diff --git a/src/adapters/AGENTS.md b/src/adapters/AGENTS.md
index 334f16c..f25ca55 100644
--- a/src/adapters/AGENTS.md
+++ b/src/adapters/AGENTS.md
@@ -14,9 +14,14 @@ apply when working here.
wires the adapter into sync, search, the TUI source filter, and the CLI
`--source` flag. No schema change is needed — `sessions.source` is a value,
not a column per tool.
-- `events.rs`, `file_scan.rs`, and `sync_state.rs` are shared helpers, not
- adapters. Prefer `file_scan::run_file_scan_with_options` over hand-rolled
- mtime tracking for file-based sources.
+- `events.rs`, `file_scan.rs`, `sync_state.rs`, `json_util.rs`, and `paths.rs`
+ are shared helpers, not adapters. Prefer them over hand-rolling:
+ `file_scan::run_file_scan_with_options` for mtime tracking,
+ `json_util::jsonl_indexed` for per-line JSONL read loops,
+ `json_util::rfc3339_ms` and `json_util::json_i64` for timestamp/number
+ coercion, `paths::resolve_home_dir` for `~/…` directory resolution, and
+ `first_timestamp`/`last_timestamp` (`mod.rs`) for started_at/updated_at
+ fallback chains.
- Usage and session events are extensions on the same `RawSession`
(`with_usage`, `with_events`), each with its own parser version. Bump the
parser version when parsing changes — that is what triggers backfill for
diff --git a/src/adapters/antigravity.rs b/src/adapters/antigravity.rs
index e76c207..afc901a 100644
--- a/src/adapters/antigravity.rs
+++ b/src/adapters/antigravity.rs
@@ -8,6 +8,7 @@ use tracing::debug;
use walkdir::WalkDir;
use crate::adapters::file_scan::{self, FileScanEntry};
+use crate::adapters::paths::resolve_home_dir;
use crate::adapters::{
RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats,
};
@@ -65,13 +66,10 @@ impl SourceAdapter for AntigravityAdapter {
}
fn resolve_antigravity_dir() -> anyhow::Result> {
- let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
- let dir = home.join(".gemini/antigravity-cli");
- if !dir.exists() {
- debug!("~/.gemini/antigravity-cli not found, skipping Antigravity CLI");
- return Ok(None);
- }
- Ok(Some(dir))
+ resolve_home_dir(
+ ".gemini/antigravity-cli",
+ "~/.gemini/antigravity-cli not found, skipping Antigravity CLI",
+ )
}
fn scan_for_sync_impl(
diff --git a/src/adapters/claude_code.rs b/src/adapters/claude_code.rs
index fbcedb1..79a8a69 100644
--- a/src/adapters/claude_code.rs
+++ b/src/adapters/claude_code.rs
@@ -9,8 +9,11 @@ use walkdir::WalkDir;
use crate::adapters::events;
use crate::adapters::file_scan::{self, FileScanEntry};
+use crate::adapters::json_util::{jsonl_indexed, rfc3339_ms};
+use crate::adapters::paths::resolve_home_dir;
use crate::adapters::{
RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats,
+ first_timestamp,
};
use crate::db::store::Store;
use crate::types::{RawSessionEvent, RawUsageEvent, Role, TokenSource};
@@ -92,13 +95,7 @@ fn load_session_indexes(claude_dir: &Path) -> SessionIndexes {
}
fn resolve_claude_dir() -> anyhow::Result > {
- let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
- let dir = home.join(".claude");
- if !dir.exists() {
- debug!("~/.claude not found, skipping Claude Code");
- return Ok(None);
- }
- Ok(Some(dir))
+ resolve_home_dir(".claude", "~/.claude not found, skipping Claude Code")
}
fn scan_for_sync_impl(
@@ -302,11 +299,13 @@ fn parse_claude_session_file(
}
let meta = indexes.live.get(&entry.session_id);
- let started_at = meta
- .and_then(|m| m.started_at)
- .or_else(|| parsed.messages.first().and_then(|m| m.timestamp))
- .or_else(|| parsed.usage_events.first().map(|event| event.timestamp))
- .unwrap_or(0);
+ let started_at = first_timestamp(
+ meta.and_then(|m| m.started_at),
+ &parsed.messages,
+ &parsed.usage_events,
+ &[],
+ )
+ .unwrap_or(0);
let directory =
meta.and_then(|m| m.cwd.clone()).or_else(|| parsed.cwd.clone()).or(entry.directory);
let entrypoint = meta.and_then(|m| m.entrypoint.clone());
@@ -364,16 +363,8 @@ fn parse_conversation_jsonl(
let mut last_ts: Option = None;
let source_path = path.to_string_lossy().to_string();
- for (line_index, line) in reader.lines().enumerate() {
- let line = line?;
- let line = line.trim();
- if line.is_empty() {
- continue;
- }
- let v: Value = match serde_json::from_str(line) {
- Ok(v) => v,
- Err(_) => continue,
- };
+ for item in jsonl_indexed(reader.lines()) {
+ let (line_index, v) = item?;
if cwd.is_none()
&& let Some(c) = v.get("cwd").and_then(|s| s.as_str())
@@ -421,11 +412,7 @@ fn parse_conversation_jsonl(
};
let text = extract_content(message.get("content"));
- let timestamp = v
- .get("timestamp")
- .and_then(|t| t.as_str())
- .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
- .map(|dt| dt.timestamp_millis());
+ let timestamp = rfc3339_ms(v.get("timestamp"));
let message_seq =
if !is_machinery && !text.is_empty() { Some(messages.len() as u32) } else { None };
diff --git a/src/adapters/cline.rs b/src/adapters/cline.rs
index 6735867..8e03ff4 100644
--- a/src/adapters/cline.rs
+++ b/src/adapters/cline.rs
@@ -5,6 +5,7 @@ use serde_json::Value;
use tracing::debug;
use crate::adapters::file_scan::{self, FileScanEntry};
+use crate::adapters::paths::resolve_home_dir;
use crate::adapters::{
RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats,
};
@@ -67,14 +68,10 @@ fn scan_for_sync_impl(
}
fn resolve_tasks_dir() -> anyhow::Result> {
- let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
- let dir = home
- .join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks");
- if !dir.exists() {
- debug!("Cline tasks directory not found, skipping Cline");
- return Ok(None);
- }
- Ok(Some(dir))
+ resolve_home_dir(
+ "Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks",
+ "Cline tasks directory not found, skipping Cline",
+ )
}
fn collect_cline_entries(tasks_dir: &Path) -> Vec {
diff --git a/src/adapters/codex.rs b/src/adapters/codex.rs
index d4c06f4..190e1cd 100644
--- a/src/adapters/codex.rs
+++ b/src/adapters/codex.rs
@@ -8,8 +8,11 @@ use walkdir::WalkDir;
use crate::adapters::events;
use crate::adapters::file_scan::{self, FileScanEntry};
+use crate::adapters::json_util::{jsonl_indexed, rfc3339_ms};
+use crate::adapters::paths::resolve_home_dir;
use crate::adapters::{
RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats,
+ first_timestamp, last_timestamp,
};
use crate::db::store::Store;
use crate::types::{RawSessionEvent, RawUsageEvent, Role, TokenSource};
@@ -98,13 +101,7 @@ fn open_url_command(url: String) -> ResumeCommand {
}
fn resolve_codex_dir() -> anyhow::Result> {
- let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
- let dir = home.join(".codex");
- if !dir.exists() {
- debug!("~/.codex not found, skipping Codex");
- return Ok(None);
- }
- Ok(Some(dir))
+ resolve_home_dir(".codex", "~/.codex not found, skipping Codex")
}
fn scan_for_sync_impl(
@@ -226,16 +223,8 @@ fn parse_codex_session_with_options(
let mut forked_child_inherited_reported_total: Option = None;
let source_path = path.to_string_lossy().to_string();
- for (line_index, line) in reader.lines().enumerate() {
- let line = line?;
- let line = line.trim();
- if line.is_empty() {
- continue;
- }
- let v: Value = match serde_json::from_str(line) {
- Ok(v) => v,
- Err(_) => continue,
- };
+ for item in jsonl_indexed(reader.lines()) {
+ let (line_index, v) = item?;
let msg_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
@@ -290,11 +279,7 @@ fn parse_codex_session_with_options(
&model,
);
}
- meta_timestamp = payload
- .get("timestamp")
- .and_then(|t| t.as_str())
- .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
- .map(|dt| dt.timestamp_millis());
+ meta_timestamp = rfc3339_ms(payload.get("timestamp"));
if payload.get("forked_from_id").and_then(|s| s.as_str()).is_some() {
forked_child_waiting_for_turn_context = true;
forked_child_inherited_baseline = None;
@@ -425,21 +410,14 @@ fn parse_codex_session_with_options(
path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string()
});
- let started_at = meta_timestamp
- .or_else(|| messages.first().and_then(|m| m.timestamp))
- .or_else(|| usage_events.first().map(|event| event.timestamp))
- .or_else(|| events.first().and_then(|event| event.timestamp))
- .unwrap_or(0);
+ let started_at =
+ first_timestamp(meta_timestamp, &messages, &usage_events, &events).unwrap_or(0);
Ok(Some(RawSession {
source_id,
directory: meta_cwd,
started_at,
- updated_at: messages
- .last()
- .and_then(|m| m.timestamp)
- .or_else(|| usage_events.last().map(|event| event.timestamp))
- .or_else(|| events.last().and_then(|event| event.timestamp)),
+ updated_at: last_timestamp(None, &messages, &usage_events, &events),
entrypoint: None,
messages,
usage_events,
@@ -850,10 +828,7 @@ fn apply_pending_codex_model(
}
fn parse_timestamp(v: &Value) -> Option {
- v.get("timestamp")
- .and_then(|t| t.as_str())
- .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
- .map(|dt| dt.timestamp_millis())
+ rfc3339_ms(v.get("timestamp"))
}
fn push_codex_message(
diff --git a/src/adapters/copilot.rs b/src/adapters/copilot.rs
index def06d8..af98720 100644
--- a/src/adapters/copilot.rs
+++ b/src/adapters/copilot.rs
@@ -8,8 +8,11 @@ use tracing::debug;
use crate::adapters::events;
use crate::adapters::file_scan::{self, FileScanEntry, FileScanOptions};
+use crate::adapters::json_util::{jsonl_indexed, rfc3339_ms};
+use crate::adapters::paths::resolve_home_dir;
use crate::adapters::{
RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats,
+ first_timestamp, last_timestamp,
};
use crate::db::store::Store;
use crate::types::{RawSessionEvent, Role};
@@ -65,13 +68,10 @@ impl SourceAdapter for CopilotAdapter {
}
fn resolve_copilot_dir() -> anyhow::Result> {
- let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
- let dir = home.join(".copilot/session-state");
- if !dir.exists() {
- debug!("~/.copilot/session-state not found, skipping Copilot CLI");
- return Ok(None);
- }
- Ok(Some(dir))
+ resolve_home_dir(
+ ".copilot/session-state",
+ "~/.copilot/session-state not found, skipping Copilot CLI",
+ )
}
fn scan_for_sync_impl(
@@ -212,16 +212,8 @@ where
let mut messages = Vec::new();
let mut session_events = Vec::new();
- for line in lines {
- let line = line?;
- let line = line.trim();
- if line.is_empty() {
- continue;
- }
- let v: Value = match serde_json::from_str(line) {
- Ok(v) => v,
- Err(_) => continue,
- };
+ for item in jsonl_indexed(lines) {
+ let (_, v) = item?;
let event_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
let timestamp = parse_timestamp(&v);
@@ -231,11 +223,7 @@ where
"session.start" => {
if let Some(data) = v.get("data") {
session_id = data.get("sessionId").and_then(|s| s.as_str()).map(String::from);
- meta_started_at = data
- .get("startTime")
- .and_then(|t| t.as_str())
- .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
- .map(|dt| dt.timestamp_millis());
+ meta_started_at = rfc3339_ms(data.get("startTime"));
directory = data
.get("context")
.and_then(|c| c.get("cwd"))
@@ -349,9 +337,8 @@ where
}
let source_id = session_id.unwrap_or_else(|| fallback_id.to_string());
- let started_at =
- meta_started_at.or_else(|| messages.first().and_then(|m| m.timestamp)).unwrap_or(0);
- let updated_at = messages.last().and_then(|m| m.timestamp);
+ let started_at = first_timestamp(meta_started_at, &messages, &[], &[]).unwrap_or(0);
+ let updated_at = last_timestamp(None, &messages, &[], &[]);
let mut session =
RawSession::search_only(source_id, directory, started_at, updated_at, None, messages);
@@ -411,10 +398,7 @@ fn extract_tool_requests(tool_requests: Option<&Value>) -> String {
}
fn parse_timestamp(v: &Value) -> Option {
- v.get("timestamp")
- .and_then(|t| t.as_str())
- .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
- .map(|dt| dt.timestamp_millis())
+ rfc3339_ms(v.get("timestamp"))
}
#[cfg(test)]
diff --git a/src/adapters/cursor.rs b/src/adapters/cursor.rs
index 926bad7..a01c67f 100644
--- a/src/adapters/cursor.rs
+++ b/src/adapters/cursor.rs
@@ -10,8 +10,11 @@ use tracing::debug;
use walkdir::WalkDir;
use crate::adapters::events;
+use crate::adapters::json_util::json_i64;
+use crate::adapters::paths::resolve_home_dir;
use crate::adapters::{
RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats,
+ first_timestamp, last_timestamp,
};
use crate::db::store::{EventSessionStateMeta, Store, UsageSessionStateMeta};
use crate::types::{RawSessionEvent, RawUsageEvent, Role, TokenSource};
@@ -333,14 +336,17 @@ fn parse_composer_session(
return Ok(None);
}
- let started_at = json_i64(data.get("createdAt"))
- .or(meta.created_at)
- .or_else(|| messages.first().and_then(|msg| msg.timestamp))
- .unwrap_or(0);
- let updated_at = json_i64(data.get("lastUpdatedAt"))
- .or(json_i64(data.get("conversationCheckpointLastUpdatedAt")))
- .or(meta.last_updated_at)
- .or_else(|| messages.last().and_then(|msg| msg.timestamp));
+ let started_at =
+ first_timestamp(json_i64(data.get("createdAt")).or(meta.created_at), &messages, &[], &[])
+ .unwrap_or(0);
+ let updated_at = last_timestamp(
+ json_i64(data.get("lastUpdatedAt"))
+ .or(json_i64(data.get("conversationCheckpointLastUpdatedAt")))
+ .or(meta.last_updated_at),
+ &messages,
+ &[],
+ &[],
+ );
Ok(Some(ParsedComposerSession {
messages,
@@ -1005,13 +1011,7 @@ fn collect_cursor_project_key_matches(
}
fn resolve_projects_dir() -> anyhow::Result> {
- let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
- let dir = home.join(".cursor").join("projects");
- if !dir.exists() {
- debug!("~/.cursor/projects not found, skipping Cursor");
- return Ok(None);
- }
- Ok(Some(dir))
+ resolve_home_dir(".cursor/projects", "~/.cursor/projects not found, skipping Cursor")
}
fn resolve_global_state_db_path() -> Option {
@@ -1155,14 +1155,6 @@ fn render_json_fragment(value: &Value) -> Option {
}
}
-fn json_i64(value: Option<&Value>) -> Option {
- value.and_then(|value| {
- value
- .as_i64()
- .or_else(|| value.as_u64().map(|value| value as i64))
- .or_else(|| value.as_f64().map(|value| value as i64))
- })
-}
#[cfg(test)]
mod tests {
use std::io::Write;
diff --git a/src/adapters/gemini.rs b/src/adapters/gemini.rs
index 603e8ed..ab223e6 100644
--- a/src/adapters/gemini.rs
+++ b/src/adapters/gemini.rs
@@ -6,6 +6,7 @@ use serde_json::Value;
use tracing::debug;
use walkdir::WalkDir;
+use crate::adapters::json_util::json_i64;
use crate::adapters::{RawMessage, RawSession, ResumeCommand, SourceAdapter};
use crate::types::{RawUsageEvent, Role, TokenSource};
@@ -217,15 +218,6 @@ fn extract_gemini_usage_event(
})
}
-fn json_i64(value: Option<&Value>) -> Option {
- value.and_then(|value| {
- value
- .as_i64()
- .or_else(|| value.as_u64().map(|v| v as i64))
- .or_else(|| value.as_f64().map(|v| v as i64))
- })
-}
-
fn extract_tool_calls(tool_calls: Option<&Value>) -> String {
let Some(arr) = tool_calls.and_then(|v| v.as_array()) else {
return String::new();
diff --git a/src/adapters/grok.rs b/src/adapters/grok.rs
index 2a346d3..b508d7b 100644
--- a/src/adapters/grok.rs
+++ b/src/adapters/grok.rs
@@ -7,6 +7,8 @@ use serde_json::Value;
use tracing::debug;
use crate::adapters::file_scan::{self, FileScanEntry, FileScanOptions};
+use crate::adapters::json_util::{json_i64, jsonl_indexed, rfc3339_ms};
+use crate::adapters::paths::resolve_home_dir;
use crate::adapters::{
RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats,
};
@@ -102,13 +104,7 @@ struct PendingAgentMessage {
}
fn resolve_grok_sessions_dir() -> anyhow::Result> {
- let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
- let dir = home.join(".grok").join("sessions");
- if !dir.exists() {
- debug!("~/.grok/sessions not found, skipping Grok");
- return Ok(None);
- }
- Ok(Some(dir))
+ resolve_home_dir(".grok/sessions", "~/.grok/sessions not found, skipping Grok")
}
fn scan_for_sync_impl(
@@ -332,8 +328,8 @@ fn load_grok_summary(session_dir: &Path, fallback_id: &str) -> anyhow::Result(
let mut prompts: Vec = Vec::new();
let mut prompt_index: HashMap = HashMap::new();
let mut last_model: Option = None;
- for line in reader.lines() {
- let line = line?;
- let line = line.trim();
- if line.is_empty() {
- continue;
- }
-
- let Ok(doc) = serde_json::from_str::(line) else {
- continue;
- };
+ for item in jsonl_indexed(reader.lines()) {
+ let (_, doc) = item?;
let params = match doc.get("params") {
Some(params) => params,
@@ -373,7 +361,7 @@ fn parse_grok_updates_reader(
};
let session_update =
update.get("sessionUpdate").and_then(|value| value.as_str()).unwrap_or("");
- let timestamp_ms = doc.get("timestamp").and_then(json_i64).map(|ts| ts * 1000);
+ let timestamp_ms = json_i64(doc.get("timestamp")).map(|ts| ts * 1000);
track_prompt_tokens(params, timestamp_ms, &mut prompts, &mut prompt_index, &mut last_model);
match session_update {
@@ -448,7 +436,7 @@ fn track_prompt_tokens(
else {
return;
};
- let Some(total_tokens) = meta.get("totalTokens").and_then(json_i64) else {
+ let Some(total_tokens) = json_i64(meta.get("totalTokens")) else {
return;
};
let index = match prompt_index.get(prompt_id) {
@@ -512,7 +500,7 @@ fn agent_chunk_key(params: &Value) -> AgentChunkKey {
.and_then(|prompt| prompt.as_str())
.filter(|prompt| !prompt.is_empty())
.map(str::to_string),
- turn_start_ms: meta.and_then(|meta| meta.get("turnStartMs")).and_then(json_i64),
+ turn_start_ms: json_i64(meta.and_then(|meta| meta.get("turnStartMs"))),
}
}
@@ -625,18 +613,6 @@ fn format_tool_call_result(update: &Value) -> Option {
Some(out)
}
-fn parse_rfc3339_ms(value: Option<&Value>) -> Option {
- let text = value.and_then(|value| value.as_str())?;
- chrono::DateTime::parse_from_rfc3339(text).ok().map(|dt| dt.timestamp_millis())
-}
-
-fn json_i64(value: &Value) -> Option {
- value
- .as_i64()
- .or_else(|| value.as_u64().map(|value| value as i64))
- .or_else(|| value.as_f64().map(|value| value as i64))
-}
-
#[cfg(test)]
mod tests {
use std::io::Cursor;
diff --git a/src/adapters/json_util.rs b/src/adapters/json_util.rs
new file mode 100644
index 0000000..b74dcd5
--- /dev/null
+++ b/src/adapters/json_util.rs
@@ -0,0 +1,32 @@
+use std::io;
+
+use serde_json::Value;
+
+pub(crate) fn rfc3339_ms(value: Option<&Value>) -> Option {
+ let text = value?.as_str()?;
+ chrono::DateTime::parse_from_rfc3339(text).ok().map(|dt| dt.timestamp_millis())
+}
+
+pub(crate) fn json_i64(value: Option<&Value>) -> Option {
+ let value = value?;
+ value
+ .as_i64()
+ .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
+ .or_else(|| value.as_f64().map(|value| value as i64))
+}
+
+pub(crate) fn jsonl_indexed(
+ lines: impl IntoIterator- >,
+) -> impl Iterator
- > {
+ lines.into_iter().enumerate().filter_map(|(index, line)| match line {
+ Ok(line) => {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ serde_json::from_str::
(trimmed).ok().map(|value| Ok((index, value)))
+ }
+ }
+ Err(error) => Some(Err(error)),
+ })
+}
diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs
index d3c86fd..dd89569 100644
--- a/src/adapters/mod.rs
+++ b/src/adapters/mod.rs
@@ -8,8 +8,10 @@ pub(crate) mod events;
pub(crate) mod file_scan;
pub(crate) mod gemini;
pub(crate) mod grok;
+pub(crate) mod json_util;
pub(crate) mod kiro;
pub(crate) mod opencode;
+pub(crate) mod paths;
pub(crate) mod pi;
pub(crate) mod sync_state;
@@ -110,6 +112,28 @@ pub(crate) struct RawMessage {
pub(crate) timestamp: Option,
}
+pub(crate) fn first_timestamp(
+ meta: Option,
+ messages: &[RawMessage],
+ usage_events: &[RawUsageEvent],
+ events: &[RawSessionEvent],
+) -> Option {
+ meta.or_else(|| messages.first().and_then(|message| message.timestamp))
+ .or_else(|| usage_events.first().map(|event| event.timestamp))
+ .or_else(|| events.first().and_then(|event| event.timestamp))
+}
+
+pub(crate) fn last_timestamp(
+ meta: Option,
+ messages: &[RawMessage],
+ usage_events: &[RawUsageEvent],
+ events: &[RawSessionEvent],
+) -> Option {
+ meta.or_else(|| messages.last().and_then(|message| message.timestamp))
+ .or_else(|| usage_events.last().map(|event| event.timestamp))
+ .or_else(|| events.last().and_then(|event| event.timestamp))
+}
+
#[derive(Default)]
pub(crate) struct SyncScanStats {
pub(crate) skipped_sessions: u32,
diff --git a/src/adapters/paths.rs b/src/adapters/paths.rs
new file mode 100644
index 0000000..260f895
--- /dev/null
+++ b/src/adapters/paths.rs
@@ -0,0 +1,14 @@
+use std::path::PathBuf;
+
+pub(crate) fn resolve_home_dir(
+ relative: &str,
+ missing_message: &str,
+) -> anyhow::Result> {
+ let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
+ let dir = home.join(relative);
+ if !dir.exists() {
+ tracing::debug!("{missing_message}");
+ return Ok(None);
+ }
+ Ok(Some(dir))
+}
diff --git a/src/adapters/pi.rs b/src/adapters/pi.rs
index 23135b0..8da9ad7 100644
--- a/src/adapters/pi.rs
+++ b/src/adapters/pi.rs
@@ -8,8 +8,10 @@ use tracing::debug;
use walkdir::WalkDir;
use crate::adapters::file_scan::{self, FileScanEntry};
+use crate::adapters::json_util::{json_i64, jsonl_indexed, rfc3339_ms};
use crate::adapters::{
RawMessage, RawSession, ResumeCommand, SourceAdapter, SyncScanResult, SyncScanStats,
+ first_timestamp,
};
use crate::db::store::Store;
use crate::types::{RawUsageEvent, Role, TokenSource};
@@ -259,11 +261,9 @@ fn parse_pi_session_file(
return Ok(None);
}
- let started_at = parsed
- .started_at
- .or_else(|| parsed.messages.first().and_then(|message| message.timestamp))
- .or_else(|| parsed.usage_events.first().map(|event| event.timestamp))
- .unwrap_or(0);
+ let started_at =
+ first_timestamp(parsed.started_at, &parsed.messages, &parsed.usage_events, &[])
+ .unwrap_or(0);
Ok(Some(RawSession {
source_id: parsed.session_id.unwrap_or(entry.session_id),
@@ -297,17 +297,8 @@ fn parse_pi_session(path: &Path, fallback_timestamp: i64) -> anyhow::Result entry,
- Err(_) => continue,
- };
+ for item in jsonl_indexed(reader.lines()) {
+ let (line_index, entry) = item?;
match entry.get("type").and_then(|value| value.as_str()).unwrap_or("") {
"session" => {
@@ -407,9 +398,7 @@ fn parse_pi_message(
messages: &mut Vec,
usage_events: &mut Vec,
) {
- let timestamp = message
- .get("timestamp")
- .and_then(number_to_i64)
+ let timestamp = json_i64(message.get("timestamp"))
.or_else(|| parse_entry_timestamp(entry))
.unwrap_or(fallback_timestamp);
@@ -551,7 +540,7 @@ fn non_empty_str(value: Option<&Value>) -> Option<&str> {
}
fn usage_count(usage: &Value, keys: &[&str]) -> i64 {
- keys.iter().find_map(|key| usage.get(*key).and_then(number_to_i64)).unwrap_or(0).max(0)
+ keys.iter().find_map(|key| json_i64(usage.get(*key))).unwrap_or(0).max(0)
}
fn extract_content(content: Option<&Value>) -> String {
@@ -630,18 +619,7 @@ fn extract_bash_execution_content(message: &Value) -> String {
}
fn parse_entry_timestamp(entry: &Value) -> Option {
- entry
- .get("timestamp")
- .and_then(|value| value.as_str())
- .and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(timestamp).ok())
- .map(|timestamp| timestamp.timestamp_millis())
-}
-
-fn number_to_i64(value: &Value) -> Option {
- value
- .as_i64()
- .or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
- .or_else(|| value.as_f64().map(|value| value as i64))
+ rfc3339_ms(entry.get("timestamp"))
}
#[cfg(test)]