diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 9447f76..95d5686 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -1405,9 +1405,9 @@ fn build_cursor_session( // Reuse the existing id when present, otherwise pre-create a // workspaceStorage entry so Cursor adopts ours on next open. let resolver = PathResolver::new(); - if let Ok(ensured) = resolver.ensure_workspace_storage_entry(&canonical, |path| { - stable_workspace_id_for(path) - }) { + if let Ok(ensured) = resolver + .ensure_workspace_storage_entry(&canonical, |path| stable_workspace_id_for(path)) + { projector = projector.with_workspace_id(ensured.id); if ensured.created { eprintln!( @@ -1535,13 +1535,8 @@ fn cursor_open_hints(workspace: &std::path::Path) -> Vec { } } - #[cfg(not(target_os = "emscripten"))] -fn upsert_cursor_kv( - tx: &rusqlite::Transaction<'_>, - key: &str, - value: &str, -) -> Result<()> { +fn upsert_cursor_kv(tx: &rusqlite::Transaction<'_>, key: &str, value: &str) -> Result<()> { tx.execute( "INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES (?1, ?2)", rusqlite::params![key, value], diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 7dac653..40fc84e 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -1144,16 +1144,15 @@ fn derive_cursor( Ok(toolpath_cursor::derive_path(&s, &cfg)) }; - let workspace_filter = project.as_deref().map(|p| { - std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p)) - }); + let workspace_filter = project + .as_deref() + .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| PathBuf::from(p))); let workspace_match = |m: &toolpath_cursor::CursorSessionMetadata| -> bool { match (&workspace_filter, &m.workspace_path) { (None, _) => true, (Some(_), None) => false, (Some(want), Some(have)) => { - let canonical = - std::fs::canonicalize(have).unwrap_or_else(|_| have.clone()); + let canonical = std::fs::canonicalize(have).unwrap_or_else(|_| have.clone()); &canonical == want } } diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index fba314e..bb5ba95 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -836,10 +836,7 @@ fn harness_status_pi(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> } } -fn harness_status_cursor( - bundle: &HarnessBundle, - home: Option<&std::path::Path>, -) -> HarnessStatus { +fn harness_status_cursor(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus { let Some(mgr) = &bundle.cursor else { return HarnessStatus::unresolved(); }; diff --git a/crates/toolpath-claude/src/project.rs b/crates/toolpath-claude/src/project.rs index 85969d7..5435649 100644 --- a/crates/toolpath-claude/src/project.rs +++ b/crates/toolpath-claude/src/project.rs @@ -1137,7 +1137,10 @@ mod tests { // Wire: the total is stamped on every line of the split, each tagged // with the shared message.id. - for entry in content_entries(&convo).iter().filter(|e| e.entry_type == "assistant") { + for entry in content_entries(&convo) + .iter() + .filter(|e| e.entry_type == "assistant") + { let msg = entry.message.as_ref().unwrap(); assert_eq!(msg.id.as_deref(), Some("msg_A")); assert_eq!(msg.usage.as_ref().unwrap().output_tokens, Some(164)); @@ -1145,7 +1148,11 @@ mod tests { // Re-read: total back on the final turn only; no fabricated attribution. let back = crate::provider::to_view(&convo); - let a: Vec<&Turn> = back.turns.iter().filter(|t| t.role == Role::Assistant).collect(); + let a: Vec<&Turn> = back + .turns + .iter() + .filter(|t| t.role == Role::Assistant) + .collect(); assert!(a[0].token_usage.is_none()); assert_eq!(a[1].token_usage.as_ref().unwrap().output_tokens, Some(164)); assert!(a.iter().all(|t| t.attributed_token_usage.is_none())); diff --git a/crates/toolpath-claude/src/provider.rs b/crates/toolpath-claude/src/provider.rs index 1896d1e..0e544f9 100644 --- a/crates/toolpath-claude/src/provider.rs +++ b/crates/toolpath-claude/src/provider.rs @@ -881,7 +881,10 @@ mod tests { canonicalize_message_usage(&mut turns); assert!(turns[0].token_usage.is_none(), "total only on final turn"); - assert_eq!(turns[1].token_usage.as_ref().unwrap().output_tokens, Some(164)); + assert_eq!( + turns[1].token_usage.as_ref().unwrap().output_tokens, + Some(164) + ); assert_eq!(turns[1].token_usage.as_ref().unwrap().input_tokens, Some(6)); for t in &turns { assert!( @@ -919,7 +922,10 @@ mod tests { assert!(turns[0].token_usage.is_none()); assert!(turns[1].token_usage.is_none()); - assert_eq!(turns[2].token_usage.as_ref().unwrap().output_tokens, Some(997)); + assert_eq!( + turns[2].token_usage.as_ref().unwrap().output_tokens, + Some(997) + ); for t in &turns { assert!(t.attributed_token_usage.is_none()); } diff --git a/crates/toolpath-claude/src/reader.rs b/crates/toolpath-claude/src/reader.rs index 24aae75..0fd7591 100644 --- a/crates/toolpath-claude/src/reader.rs +++ b/crates/toolpath-claude/src/reader.rs @@ -116,8 +116,7 @@ impl ConversationReader { } if !entry.timestamp.is_empty() - && let Ok(timestamp) = - entry.timestamp.parse::>() + && let Ok(timestamp) = entry.timestamp.parse::>() { if started_at.is_none() || Some(timestamp) < started_at { started_at = Some(timestamp); diff --git a/crates/toolpath-codex/src/project.rs b/crates/toolpath-codex/src/project.rs index 2ec3dd5..c2c26b8 100644 --- a/crates/toolpath-codex/src/project.rs +++ b/crates/toolpath-codex/src/project.rs @@ -168,7 +168,12 @@ fn project_view( .find(|(_, t)| matches!(t.role, Role::Assistant)) .map(|(i, t)| group_of(i, t)) .unwrap_or_else(|| view.id.clone()); - lines.push(make_turn_context_line(&first_group, &session_timestamp, &cwd, &model)); + lines.push(make_turn_context_line( + &first_group, + &session_timestamp, + &cwd, + &model, + )); let mut current_group = Some(first_group); // Running session-cumulative usage. Codex's `total_token_usage` is @@ -180,13 +185,25 @@ fn project_view( if matches!(turn.role, Role::Assistant) { let group = group_of(idx, turn); if current_group.as_deref() != Some(&group) { - lines.push(make_turn_context_line(&group, &turn.timestamp, &cwd, &model)); + lines.push(make_turn_context_line( + &group, + &turn.timestamp, + &cwd, + &model, + )); current_group = Some(group); } } let codex = codex_extras(turn).cloned().unwrap_or_default(); let is_final_assistant = Some(idx) == last_assistant_idx; - emit_turn_lines(turn, &codex, is_final_assistant, &cwd, &mut lines, &mut running); + emit_turn_lines( + turn, + &codex, + is_final_assistant, + &cwd, + &mut lines, + &mut running, + ); } Ok(crate::types::Session { @@ -234,12 +251,7 @@ fn make_session_meta_line( } } -fn make_turn_context_line( - turn_id: &str, - timestamp: &str, - cwd: &str, - model: &str, -) -> RolloutLine { +fn make_turn_context_line(turn_id: &str, timestamp: &str, cwd: &str, model: &str) -> RolloutLine { let tc = TurnContext { turn_id: turn_id.to_string(), cwd: PathBuf::from(cwd), diff --git a/crates/toolpath-codex/src/provider.rs b/crates/toolpath-codex/src/provider.rs index 00bcdf4..6f9c400 100644 --- a/crates/toolpath-codex/src/provider.rs +++ b/crates/toolpath-codex/src/provider.rs @@ -622,7 +622,11 @@ impl<'a> Builder<'a> { // A step's spend that arrived before any assistant turn existed // attaches to this, the first one. if let Some(pending) = self.pending_attributed.take() { - add_usage(turn.attributed_token_usage.get_or_insert_with(TokenUsage::default), &pending); + add_usage( + turn.attributed_token_usage + .get_or_insert_with(TokenUsage::default), + &pending, + ); } } @@ -690,9 +694,7 @@ impl<'a> Builder<'a> { let start = k; let mid = self.turns[assistants[k]].group_id.clone(); if mid.is_some() { - while k + 1 < assistants.len() - && self.turns[assistants[k + 1]].group_id == mid - { + while k + 1 < assistants.len() && self.turns[assistants[k + 1]].group_id == mid { k += 1; } } @@ -1158,14 +1160,38 @@ mod tests { let (_t, mgr, id) = setup_session_fixture(&body); let view = to_view(&mgr.read_session(&id).unwrap()); - let assistants: Vec<&Turn> = view.turns.iter().filter(|t| t.role == Role::Assistant).collect(); + let assistants: Vec<&Turn> = view + .turns + .iter() + .filter(|t| t.role == Role::Assistant) + .collect(); assert_eq!(assistants.len(), 2); // Per-step attribution: 40 then 60 — NOT 80/120 (which doubling gives). - assert_eq!(assistants[0].attributed_token_usage.as_ref().unwrap().output_tokens, Some(40)); - assert_eq!(assistants[1].attributed_token_usage.as_ref().unwrap().output_tokens, Some(60)); + assert_eq!( + assistants[0] + .attributed_token_usage + .as_ref() + .unwrap() + .output_tokens, + Some(40) + ); + assert_eq!( + assistants[1] + .attributed_token_usage + .as_ref() + .unwrap() + .output_tokens, + Some(60) + ); // Σ attributed == round total on the final turn. - assert_eq!(assistants[1].token_usage.as_ref().unwrap().output_tokens, Some(100)); - let sum: u32 = assistants.iter().filter_map(|t| t.attributed_token_usage.as_ref()?.output_tokens).sum(); + assert_eq!( + assistants[1].token_usage.as_ref().unwrap().output_tokens, + Some(100) + ); + let sum: u32 = assistants + .iter() + .filter_map(|t| t.attributed_token_usage.as_ref()?.output_tokens) + .sum(); assert_eq!(sum, 100); } @@ -1201,11 +1227,21 @@ mod tests { let (_t, mgr, id) = setup_session_fixture(&body); let view = to_view(&mgr.read_session(&id).unwrap()); - let assistants: Vec<&Turn> = view.turns.iter().filter(|t| t.role == Role::Assistant).collect(); + let assistants: Vec<&Turn> = view + .turns + .iter() + .filter(|t| t.role == Role::Assistant) + .collect(); assert_eq!(assistants.len(), 2); // Per-step reasoning deltas, NOT cumulative (100/260) and NOT doubled. - assert_eq!(reasoning_of(assistants[0].attributed_token_usage.as_ref()), Some(100)); - assert_eq!(reasoning_of(assistants[1].attributed_token_usage.as_ref()), Some(160)); + assert_eq!( + reasoning_of(assistants[0].attributed_token_usage.as_ref()), + Some(100) + ); + assert_eq!( + reasoning_of(assistants[1].attributed_token_usage.as_ref()), + Some(160) + ); // Round total breakdown is the sum of attributions. let round = assistants[1].token_usage.as_ref().unwrap(); assert_eq!(reasoning_of(Some(round)), Some(260)); @@ -1233,8 +1269,18 @@ mod tests { ].join("\n"); let (_t, mgr, id) = setup_session_fixture(&body); let view = to_view(&mgr.read_session(&id).unwrap()); - let a = view.turns.iter().find(|t| t.role == Role::Assistant).unwrap(); - assert!(a.attributed_token_usage.as_ref().unwrap().breakdowns.is_empty()); + let a = view + .turns + .iter() + .find(|t| t.role == Role::Assistant) + .unwrap(); + assert!( + a.attributed_token_usage + .as_ref() + .unwrap() + .breakdowns + .is_empty() + ); assert!(a.token_usage.as_ref().unwrap().breakdowns.is_empty()); } diff --git a/crates/toolpath-codex/tests/fixture_roundtrip.rs b/crates/toolpath-codex/tests/fixture_roundtrip.rs index d1bc593..1a5cb50 100644 --- a/crates/toolpath-codex/tests/fixture_roundtrip.rs +++ b/crates/toolpath-codex/tests/fixture_roundtrip.rs @@ -173,7 +173,10 @@ fn reasoning_breakdown_differenced_dedup_safe_against_real_fixture() { .iter() .map(|t| reasoning_of(t.attributed_token_usage.as_ref())) .sum(); - assert_eq!(attributed_reasoning, 979, "Σ attributed reasoning != cumulative"); + assert_eq!( + attributed_reasoning, 979, + "Σ attributed reasoning != cumulative" + ); // Per step, reasoning ⊆ output. for t in &view.turns { @@ -201,7 +204,10 @@ fn reasoning_breakdown_differenced_dedup_safe_against_real_fixture() { r }) .sum(); - assert_eq!(round_reasoning, 979, "Σ round-total reasoning != cumulative"); + assert_eq!( + round_reasoning, 979, + "Σ round-total reasoning != cumulative" + ); } #[test] diff --git a/crates/toolpath-convo/src/derive.rs b/crates/toolpath-convo/src/derive.rs index ad66857..acae41d 100644 --- a/crates/toolpath-convo/src/derive.rs +++ b/crates/toolpath-convo/src/derive.rs @@ -419,6 +419,13 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { last_step_id = Some(step_id); } + // A path's step IDs must be unique. A source can reuse an ID across + // distinct records — Claude Code, for one, reuses `uuid` on `attachment` + // lines, so two unrelated events arrive with the same ID — and carrying + // that through breaks any consumer that keys on the ID (e.g. a store with + // a `UNIQUE (path_id, step_id)` constraint). Resolve at generation time. + enforce_unique_step_ids(&mut steps); + let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default(); // Meta @@ -473,6 +480,43 @@ pub fn derive_path(view: &ConversationView, config: &DeriveConfig) -> Path { } } +/// Enforce the path invariant that each step ID occurs at most once. +/// +/// Some sources reuse an ID across distinct records — Claude Code reuses the +/// line `uuid` on `attachment` events — so two records can arrive with the +/// same ID and emit two steps that share one, breaking consumers that key on +/// it. Keep the first step for an ID, drop byte-identical repeats (the same +/// record emitted twice), and re-ID genuinely-distinct collisions to +/// `#` so no information is lost. Parent references resolve to the +/// first occurrence, which always retains the original ID. +fn enforce_unique_step_ids(steps: &mut Vec) { + let mut index: HashMap = HashMap::new(); + let mut out: Vec = Vec::with_capacity(steps.len()); + for step in std::mem::take(steps) { + let id = step.step.id.clone(); + let Some(&idx) = index.get(&id) else { + index.insert(id, out.len()); + out.push(step); + continue; + }; + // Seen this ID before: drop an exact repeat, else mint a fresh ID. + if serde_json::to_value(&out[idx]).ok() == serde_json::to_value(&step).ok() { + continue; + } + let mut n = 2; + let mut new_id = format!("{id}#{n}"); + while index.contains_key(&new_id) { + n += 1; + new_id = format!("{id}#{n}"); + } + let mut step = step; + step.step.id = new_id.clone(); + index.insert(new_id, out.len()); + out.push(step); + } + *steps = out; +} + fn actor_for_turn(turn: &Turn, provider: &str) -> String { match &turn.role { Role::User => "human:user".to_string(), @@ -885,6 +929,56 @@ mod tests { ); } + #[test] + fn duplicate_event_ids_are_resolved_to_unique_step_ids() { + // A source can reuse an ID across records (Claude Code reuses `uuid` + // on attachment lines). derive_path must still yield unique step IDs: + // drop a byte-identical repeat, re-ID a genuinely-distinct collision. + let mut view = view_with(vec![base_turn("t1", Role::User)]); + // Two byte-identical attachment events (same ID, parent, data) ... + for _ in 0..2 { + view.events.push(crate::ConversationEvent { + id: "dup".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + parent_id: Some("t1".into()), + event_type: "attachment".into(), + data: HashMap::from([("k".to_string(), serde_json::json!(1))]), + }); + } + // ... and a distinct event that collides on the same ID. + view.events.push(crate::ConversationEvent { + id: "dup".into(), + timestamp: "2026-01-01T00:00:00Z".into(), + parent_id: Some("t1".into()), + event_type: "attachment".into(), + data: HashMap::from([("k".to_string(), serde_json::json!(2))]), + }); + + let path = derive_path(&view, &DeriveConfig::default()); + let ids: Vec<&str> = path.steps.iter().map(|s| s.step.id.as_str()).collect(); + + // The invariant: no ID repeats within the path. + let unique: std::collections::HashSet<&&str> = ids.iter().collect(); + assert_eq!(unique.len(), ids.len(), "step IDs must be unique: {ids:?}"); + // Repeat dropped, distinct collision kept under a fresh ID — so exactly + // two `dup*` steps survive (not three, not one), losing no real data. + let dups: Vec<&&str> = ids.iter().filter(|id| id.starts_with("dup")).collect(); + assert_eq!(dups.len(), 2, "repeat dropped, distinct re-ID'd: {ids:?}"); + assert!( + ids.contains(&"dup"), + "first occurrence keeps the original ID" + ); + assert!( + ids.contains(&"dup#2"), + "distinct collision re-ID'd: {ids:?}" + ); + // The head points at a real, surviving step. + assert!( + ids.contains(&path.path.head.as_str()), + "head references a surviving step" + ); + } + #[test] fn derived_path_conforms_to_agent_coding_session_kind() { // derive_path stamps meta.kind = agent-coding-session, so its output diff --git a/crates/toolpath-cursor/examples/dump_fixture.rs b/crates/toolpath-cursor/examples/dump_fixture.rs index e6fba08..6fc8ee7 100644 --- a/crates/toolpath-cursor/examples/dump_fixture.rs +++ b/crates/toolpath-cursor/examples/dump_fixture.rs @@ -81,7 +81,9 @@ fn capture_from_db( let chosen_id = composer_override.unwrap_or_else(|| { let mut chosen: Option<(String, usize)> = None; for id in &ids { - let Ok(s) = mgr.read_session(id) else { continue }; + let Ok(s) = mgr.read_session(id) else { + continue; + }; let n = s.bubbles.len(); eprintln!(" {} → {} bubbles", id, n); if chosen.as_ref().is_none_or(|(_, prev)| n > *prev) { @@ -126,8 +128,12 @@ fn capture_from_db( fn referenced_blob_hashes(session: &CursorSession) -> std::collections::HashSet { let mut needed = std::collections::HashSet::new(); for b in &session.bubbles { - let Some(tf) = &b.tool_former_data else { continue }; - let Ok(Some(result)) = tf.parse_result() else { continue }; + let Some(tf) = &b.tool_former_data else { + continue; + }; + let Ok(Some(result)) = tf.parse_result() else { + continue; + }; for field in ["beforeContentId", "afterContentId"] { if let Some(raw) = result.get(field).and_then(|v| v.as_str()) && let Some(hash) = raw.strip_prefix(CONTENT_PREFIX) @@ -142,8 +148,7 @@ fn referenced_blob_hashes(session: &CursorSession) -> std::collections::HashSet< // ── Mode 2: from a cursor-agent CLI JSONL transcript ────────────────── fn capture_from_jsonl(path: &str) -> CursorSession { - let content = fs::read_to_string(path) - .unwrap_or_else(|e| panic!("read {path}: {e}")); + let content = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}")); let composer_id = composer_id_from_jsonl_path(path); let workspace = workspace_from_jsonl_path(path); let view = view_from_jsonl(&content, &composer_id, &workspace); @@ -271,11 +276,7 @@ fn view_from_jsonl( role, // Synthesize plausible monotonic timestamps; the // transcript carries no real ones. - timestamp: format!( - "2026-06-01T{:02}:{:02}:00Z", - line_no / 60, - line_no % 60 - ), + timestamp: format!("2026-06-01T{:02}:{:02}:00Z", line_no / 60, line_no % 60), text, thinking: None, tool_uses, @@ -298,9 +299,7 @@ fn view_from_jsonl( version: Some("cursor-agent".into()), }), base: Some(SessionBase { - working_dir: workspace - .as_ref() - .map(|w| w.to_string_lossy().into_owned()), + working_dir: workspace.as_ref().map(|w| w.to_string_lossy().into_owned()), vcs_branch: None, vcs_revision: None, vcs_remote: None, diff --git a/crates/toolpath-cursor/src/io.rs b/crates/toolpath-cursor/src/io.rs index 1d4ceaf..9b5affe 100644 --- a/crates/toolpath-cursor/src/io.rs +++ b/crates/toolpath-cursor/src/io.rs @@ -88,10 +88,7 @@ impl CursorIO { match db.load_session(&head.composer_id) { Ok(s) => out.push(to_metadata(&s)), Err(e) => { - eprintln!( - "Warning: skipping composer {}: {}", - head.composer_id, e - ); + eprintln!("Warning: skipping composer {}: {}", head.composer_id, e); } } } @@ -138,10 +135,7 @@ impl CursorIO { return None; } let slug = paths::slug_from_abs_path(&abs); - let p = self - .resolver - .transcript_path(&slug, session.id()) - .ok()?; + let p = self.resolver.transcript_path(&slug, session.id()).ok()?; p.exists().then_some(p) } } @@ -212,7 +206,10 @@ mod tests { assert_eq!(metas[0].id, "c1"); assert_eq!(metas[0].message_count, 3); assert_eq!(metas[0].first_user_message.as_deref(), Some("hello")); - assert_eq!(metas[0].workspace_path.as_deref(), Some(std::path::Path::new("/p"))); + assert_eq!( + metas[0].workspace_path.as_deref(), + Some(std::path::Path::new("/p")) + ); } #[test] diff --git a/crates/toolpath-cursor/src/paths.rs b/crates/toolpath-cursor/src/paths.rs index 91400f3..47d63e3 100644 --- a/crates/toolpath-cursor/src/paths.rs +++ b/crates/toolpath-cursor/src/paths.rs @@ -108,7 +108,10 @@ impl PathResolver { /// Path to the agent-transcripts folder for a project slug. pub fn project_transcripts_dir(&self, slug: &str) -> Result { - Ok(self.projects_dir()?.join(slug).join(AGENT_TRANSCRIPTS_SUBDIR)) + Ok(self + .projects_dir()? + .join(slug) + .join(AGENT_TRANSCRIPTS_SUBDIR)) } /// Path to the JSONL transcript file for a composer in a project. @@ -189,13 +192,10 @@ impl PathResolver { let Some(path_part) = folder_uri.strip_prefix("file://") else { continue; }; - let recorded = std::fs::canonicalize(path_part) - .unwrap_or_else(|_| PathBuf::from(path_part)); + let recorded = + std::fs::canonicalize(path_part).unwrap_or_else(|_| PathBuf::from(path_part)); if recorded == target { - let id = entry - .file_name() - .to_string_lossy() - .into_owned(); + let id = entry.file_name().to_string_lossy().into_owned(); return Ok(Some(id)); } } @@ -220,10 +220,7 @@ impl PathResolver { synthesize_id: impl FnOnce(&Path) -> String, ) -> Result { if let Some(id) = self.find_workspace_id(folder)? { - return Ok(EnsuredWorkspaceId { - id, - created: false, - }); + return Ok(EnsuredWorkspaceId { id, created: false }); } let id = synthesize_id(folder); let dir = self.workspace_storage_dir()?.join(&id); diff --git a/crates/toolpath-cursor/src/project.rs b/crates/toolpath-cursor/src/project.rs index 34bd0c7..97934fb 100644 --- a/crates/toolpath-cursor/src/project.rs +++ b/crates/toolpath-cursor/src/project.rs @@ -120,10 +120,7 @@ fn project_view( .filter(|s| !s.is_empty()) .unwrap_or_else(|| workspace_hash(&workspace_path_str)); - let title = cfg - .title - .clone() - .or_else(|| view.title(80)); + let title = cfg.title.clone().or_else(|| view.title(80)); let agent_backend = cfg .agent_backend @@ -134,14 +131,11 @@ fn project_view( .clone() .unwrap_or_else(|| DEFAULT_MODEL_NAME.to_string()); - let created_at = view - .started_at - .map(|t| t.timestamp_millis()) - .or_else(|| { - view.turns - .first() - .and_then(|t| parse_timestamp_ms(&t.timestamp)) - }); + let created_at = view.started_at.map(|t| t.timestamp_millis()).or_else(|| { + view.turns + .first() + .and_then(|t| parse_timestamp_ms(&t.timestamp)) + }); let last_updated_at = view .last_activity .map(|t| t.timestamp_millis()) @@ -295,10 +289,13 @@ fn build_bubble(turn: &Turn, content_blobs: &mut HashMap) -> Bub let model_info = if is_tool_bubble { None } else { - turn.model.as_deref().filter(|m| !m.is_empty()).map(|m| ModelInfo { - model_name: Some(m.to_string()), - extra: HashMap::new(), - }) + turn.model + .as_deref() + .filter(|m| !m.is_empty()) + .map(|m| ModelInfo { + model_name: Some(m.to_string()), + extra: HashMap::new(), + }) }; // Empty-text bubbles must omit richText — emitting an empty @@ -415,9 +412,7 @@ fn normalize_input_for_cursor(tool_id: u32, input: &Value) -> Value { }; let renames: &[(&str, &str)] = match tool_id { - crate::types::TOOL_RUN_TERMINAL_COMMAND_V2 => &[ - ("description", "commandDescription"), - ], + crate::types::TOOL_RUN_TERMINAL_COMMAND_V2 => &[("description", "commandDescription")], crate::types::TOOL_EDIT_FILE_V2 => &[ ("file_path", "relativeWorkspacePath"), ("filePath", "relativeWorkspacePath"), @@ -440,9 +435,7 @@ fn normalize_input_for_cursor(tool_id: u32, input: &Value) -> Value { ("target_directory", "path"), ("case_insensitive", "caseInsensitive"), ], - crate::types::TOOL_TASK_V2 => &[ - ("subagent_type", "subagentType"), - ], + crate::types::TOOL_TASK_V2 => &[("subagent_type", "subagentType")], _ => &[], }; @@ -450,7 +443,8 @@ fn normalize_input_for_cursor(tool_id: u32, input: &Value) -> Value { let mut renamed: std::collections::HashSet<&str> = std::collections::HashSet::new(); for (foreign, cursor) in renames { if let Some(v) = obj.get(*foreign) { - out.entry((*cursor).to_string()).or_insert_with(|| v.clone()); + out.entry((*cursor).to_string()) + .or_insert_with(|| v.clone()); renamed.insert(*foreign); } } @@ -463,11 +457,9 @@ fn normalize_input_for_cursor(tool_id: u32, input: &Value) -> Value { "parsingResult", "requestedSandboxPolicy", ]), - crate::types::TOOL_EDIT_FILE_V2 => Some(&[ - "relativeWorkspacePath", - "noCodeblock", - "cloudAgentEdit", - ]), + crate::types::TOOL_EDIT_FILE_V2 => { + Some(&["relativeWorkspacePath", "noCodeblock", "cloudAgentEdit"]) + } crate::types::TOOL_READ_FILE_V2 => Some(&[ "targetFile", "effectiveUri", @@ -476,24 +468,13 @@ fn normalize_input_for_cursor(tool_id: u32, input: &Value) -> Value { "startLineOneIndexed", "endLineOneIndexedInclusive", ]), - crate::types::TOOL_GLOB_FILE_SEARCH => Some(&[ - "globPattern", - "targetDirectory", - ]), - crate::types::TOOL_RIPGREP_RAW_SEARCH => Some(&[ - "pattern", - "path", - "caseInsensitive", - "includes", - "excludes", - ]), - crate::types::TOOL_TASK_V2 => Some(&[ - "description", - "prompt", - "subagentType", - "model", - "name", - ]), + crate::types::TOOL_GLOB_FILE_SEARCH => Some(&["globPattern", "targetDirectory"]), + crate::types::TOOL_RIPGREP_RAW_SEARCH => { + Some(&["pattern", "path", "caseInsensitive", "includes", "excludes"]) + } + crate::types::TOOL_TASK_V2 => { + Some(&["description", "prompt", "subagentType", "model", "name"]) + } _ => None, }; for (k, v) in obj { @@ -509,8 +490,10 @@ fn normalize_input_for_cursor(tool_id: u32, input: &Value) -> Value { } } if tool_id == crate::types::TOOL_EDIT_FILE_V2 { - out.entry("noCodeblock".to_string()).or_insert(Value::Bool(true)); - out.entry("cloudAgentEdit".to_string()).or_insert(Value::Bool(false)); + out.entry("noCodeblock".to_string()) + .or_insert(Value::Bool(true)); + out.entry("cloudAgentEdit".to_string()) + .or_insert(Value::Bool(false)); } Value::Object(out) } @@ -528,10 +511,7 @@ fn tool_id_and_name(tu: &ToolInvocation) -> (u32, String) { Some(ToolCategory::FileWrite) => (TOOL_EDIT_FILE_V2, "edit_file_v2".into()), Some(ToolCategory::FileRead) => (TOOL_READ_FILE_V2, "read_file_v2".into()), Some(ToolCategory::FileSearch) => (TOOL_GLOB_FILE_SEARCH, "glob_file_search".into()), - Some(ToolCategory::Network) => ( - crate::types::TOOL_WEB_SEARCH, - "web_search".into(), - ), + Some(ToolCategory::Network) => (crate::types::TOOL_WEB_SEARCH, "web_search".into()), Some(ToolCategory::Delegation) => (TOOL_TASK_V2, "task_v2".into()), None => (crate::types::TOOL_UNSPECIFIED, tu.name.clone()), } @@ -661,7 +641,9 @@ fn reconstruct_hunks_from_diff(diff: &str) -> (String, String) { fn register_blob(blobs: &mut HashMap, body: Option<&str>) -> Option { let body = body?; let hash = sha256_hex(body.as_bytes()); - blobs.entry(hash.clone()).or_insert_with(|| body.to_string()); + blobs + .entry(hash.clone()) + .or_insert_with(|| body.to_string()); Some(hash) } @@ -669,10 +651,12 @@ fn sha256_hex(bytes: &[u8]) -> String { use std::fmt::Write; let mut h = Sha256::new(); h.update(bytes); - h.finalize().iter().fold(String::with_capacity(64), |mut s, b| { - let _ = write!(s, "{b:02x}"); - s - }) + h.finalize() + .iter() + .fold(String::with_capacity(64), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) } fn workspace_hash(path: &str) -> String { @@ -684,7 +668,11 @@ fn parse_timestamp_ms(ts: &str) -> Option { DateTime::parse_from_rfc3339(ts) .ok() .map(|dt| dt.timestamp_millis()) - .or_else(|| ts.parse::>().ok().map(|dt| dt.timestamp_millis())) + .or_else(|| { + ts.parse::>() + .ok() + .map(|dt| dt.timestamp_millis()) + }) .or_else(|| ts.parse::().ok()) } @@ -1099,9 +1087,10 @@ mod tests { }]; let s = CursorProjector::new().project(&view_with(vec![t])).unwrap(); - assert!(s.content_blobs.contains_key( - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - )); + assert!( + s.content_blobs + .contains_key("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + ); let after_hash = sha256_hex("fn main() {}".as_bytes()); assert_eq!( s.content_blobs.get(&after_hash).map(String::as_str), @@ -1297,7 +1286,10 @@ mod tests { #[test] fn parse_hunk_header_basic() { assert_eq!(super::parse_hunk_header("@@ -1,3 +1,4 @@"), Some((1, 1))); - assert_eq!(super::parse_hunk_header("@@ -10,5 +20,7 @@ fn foo()"), Some((10, 20))); + assert_eq!( + super::parse_hunk_header("@@ -10,5 +20,7 @@ fn foo()"), + Some((10, 20)) + ); assert_eq!(super::parse_hunk_header("@@ -1 +1 @@"), Some((1, 1))); assert_eq!(super::parse_hunk_header("@@ -0,0 +1,3 @@"), Some((0, 1))); assert_eq!(super::parse_hunk_header("not a hunk"), None); @@ -1344,9 +1336,7 @@ mod tests { path: "/proj/x.rs".into(), tool_id: Some("tc1".into()), operation: Some("edit".into()), - raw_diff: Some( - "@@ -1,1 +1,1 @@\n-old\n+new\n".into(), - ), + raw_diff: Some("@@ -1,1 +1,1 @@\n-old\n+new\n".into()), before: None, after: None, rename_to: None, @@ -1361,8 +1351,14 @@ mod tests { assert_ne!(before_id, after_id); let before_hash = before_id.trim_start_matches("composer.content."); let after_hash = after_id.trim_start_matches("composer.content."); - assert_eq!(s.content_blobs.get(before_hash).map(String::as_str), Some("old\n")); - assert_eq!(s.content_blobs.get(after_hash).map(String::as_str), Some("new\n")); + assert_eq!( + s.content_blobs.get(before_hash).map(String::as_str), + Some("old\n") + ); + assert_eq!( + s.content_blobs.get(after_hash).map(String::as_str), + Some("new\n") + ); } #[test] @@ -1390,12 +1386,21 @@ mod tests { let s = CursorProjector::new().project(&view_with(vec![t])).unwrap(); let tf = s.bubbles[0].tool_former_data.as_ref().unwrap(); let result = tf.parse_result().unwrap().unwrap(); - let before_hash = result["beforeContentId"].as_str().unwrap() + let before_hash = result["beforeContentId"] + .as_str() + .unwrap() .trim_start_matches("composer.content."); - let after_hash = result["afterContentId"].as_str().unwrap() + let after_hash = result["afterContentId"] + .as_str() + .unwrap() .trim_start_matches("composer.content."); - assert_eq!(s.content_blobs.get(before_hash).map(String::as_str), Some("real before")); - assert_eq!(s.content_blobs.get(after_hash).map(String::as_str), Some("real after")); + assert_eq!( + s.content_blobs.get(before_hash).map(String::as_str), + Some("real before") + ); + assert_eq!( + s.content_blobs.get(after_hash).map(String::as_str), + Some("real after") + ); } - } diff --git a/crates/toolpath-cursor/src/provider.rs b/crates/toolpath-cursor/src/provider.rs index 02746ab..d1276f3 100644 --- a/crates/toolpath-cursor/src/provider.rs +++ b/crates/toolpath-cursor/src/provider.rs @@ -35,8 +35,8 @@ use crate::io::CursorIO; use crate::paths::PathResolver; use crate::reader::CONTENT_PREFIX; use crate::types::{ - Bubble, CursorSession, CursorSessionMetadata, ToolFormerData, BUBBLE_TYPE_ASSISTANT, - BUBBLE_TYPE_USER, TOOL_EDIT_FILE_V2, TOOL_RUN_TERMINAL_COMMAND_V2, tool_name_for_id, + BUBBLE_TYPE_ASSISTANT, BUBBLE_TYPE_USER, Bubble, CursorSession, CursorSessionMetadata, + TOOL_EDIT_FILE_V2, TOOL_RUN_TERMINAL_COMMAND_V2, ToolFormerData, tool_name_for_id, }; use toolpath_convo::{ ConversationMeta, ConversationProvider, ConversationView, ConvoError as ConvoTraitError, @@ -56,7 +56,9 @@ pub struct CursorConvo { impl CursorConvo { pub fn new() -> Self { - Self { io: CursorIO::new() } + Self { + io: CursorIO::new(), + } } pub fn with_resolver(resolver: PathResolver) -> Self { @@ -87,9 +89,10 @@ impl CursorConvo { // maintains roughly newest-first. Fall back to last_activity // when present so this stays right even if the header order // ever drifts. - let pick = metas - .iter() - .max_by_key(|m| m.last_activity.unwrap_or_else(chrono::DateTime::::default)); + let pick = metas.iter().max_by_key(|m| { + m.last_activity + .unwrap_or_else(chrono::DateTime::::default) + }); match pick { Some(m) => Ok(Some(self.read_session(&m.id)?)), None => Ok(None), @@ -178,24 +181,9 @@ fn category_by_name(name: &str) -> Option { // (`create_rm_files`, `save_file`, `undo_edit`, `apply_agent_diff`, // `reapply`), and the agent-side friendly aliases // `Write`/`StrReplace`/`edit`/`delete`/`Edit`. - "edit_file_v2" - | "edit_file" - | "edit" - | "Edit" - | "Write" - | "StrReplace" - | "delete_file" - | "delete" - | "new_edit" - | "new_file" - | "save_file" - | "reapply" - | "undo_edit" - | "apply_agent_diff" - | "create_rm_files" - | "add_test" - | "delete_test" - | "fix_lints" + "edit_file_v2" | "edit_file" | "edit" | "Edit" | "Write" | "StrReplace" | "delete_file" + | "delete" | "new_edit" | "new_file" | "save_file" | "reapply" | "undo_edit" + | "apply_agent_diff" | "create_rm_files" | "add_test" | "delete_test" | "fix_lints" | "fix_lints_subagent" => Some(ToolCategory::FileWrite), // ── FileRead ───────────────────────────────────────────── @@ -252,11 +240,9 @@ fn category_by_name(name: &str) -> Option { // outside the local fs / shell: web fetch + search, GitHub // PR retrieval, and MCP tool dispatch (`call_mcp_tool` // proxies a model-driven call to a remote MCP server). - "web_search" - | "web_fetch" - | "fetch_pull_request" - | "fetch" - | "call_mcp_tool" => Some(ToolCategory::Network), + "web_search" | "web_fetch" | "fetch_pull_request" | "fetch" | "call_mcp_tool" => { + Some(ToolCategory::Network) + } // ── Delegation ─────────────────────────────────────────── // `Task`/`task_v2` is the dispatch primitive. `TaskSubagent`, @@ -335,24 +321,16 @@ impl<'a> Builder<'a> { self.turns.push(turn); } - let started_at = self - .session - .started_at() - .or_else(|| { - self.session - .bubbles - .first() - .and_then(|b| b.created_at_utc()) - }); + let started_at = self.session.started_at().or_else(|| { + self.session + .bubbles + .first() + .and_then(|b| b.created_at_utc()) + }); let last_activity = self .session .last_activity() - .or_else(|| { - self.session - .bubbles - .last() - .and_then(|b| b.created_at_utc()) - }); + .or_else(|| self.session.bubbles.last().and_then(|b| b.created_at_utc())); ConversationView { id: self.session.id().to_string(), @@ -552,11 +530,7 @@ impl<'a> Builder<'a> { } } - fn file_mutation_for_edit( - &self, - tf: &ToolFormerData, - tool_id: &str, - ) -> Option { + fn file_mutation_for_edit(&self, tf: &ToolFormerData, tool_id: &str) -> Option { let params = tf.parse_params().ok()?; let path = params .get("relativeWorkspacePath") @@ -633,14 +607,11 @@ fn result_to_text(tf: &ToolFormerData, v: &Value) -> String { // ids. We surface a deterministic summary so consumers // (audit, diff inspection) get something readable while the // structured file mutation carries the real payload. - let path = tf - .parse_params() - .ok() - .and_then(|p| { - p.get("relativeWorkspacePath") - .and_then(|v| v.as_str()) - .map(str::to_string) - }); + let path = tf.parse_params().ok().and_then(|p| { + p.get("relativeWorkspacePath") + .and_then(|v| v.as_str()) + .map(str::to_string) + }); match path { Some(p) => format!("edited {p}"), None => "edited file".into(), @@ -785,7 +756,10 @@ mod tests { assert_eq!(view.turns[1].role, Role::Assistant); assert_eq!(view.turns[1].text, "hi back"); assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-7")); - assert_eq!(view.turns[1].token_usage.as_ref().unwrap().input_tokens, Some(10)); + assert_eq!( + view.turns[1].token_usage.as_ref().unwrap().input_tokens, + Some(10) + ); assert_eq!(view.turns[2].role, Role::Assistant); assert_eq!(view.turns[2].tool_uses.len(), 1); @@ -920,10 +894,22 @@ mod tests { tool_category(15, "run_terminal_command_v2"), Some(ToolCategory::Shell) ); - assert_eq!(tool_category(38, "edit_file_v2"), Some(ToolCategory::FileWrite)); - assert_eq!(tool_category(40, "read_file_v2"), Some(ToolCategory::FileRead)); - assert_eq!(tool_category(41, "ripgrep_raw_search"), Some(ToolCategory::FileSearch)); - assert_eq!(tool_category(42, "glob_file_search"), Some(ToolCategory::FileSearch)); + assert_eq!( + tool_category(38, "edit_file_v2"), + Some(ToolCategory::FileWrite) + ); + assert_eq!( + tool_category(40, "read_file_v2"), + Some(ToolCategory::FileRead) + ); + assert_eq!( + tool_category(41, "ripgrep_raw_search"), + Some(ToolCategory::FileSearch) + ); + assert_eq!( + tool_category(42, "glob_file_search"), + Some(ToolCategory::FileSearch) + ); assert_eq!(tool_category(48, "task_v2"), Some(ToolCategory::Delegation)); } @@ -932,7 +918,10 @@ mod tests { // Agent-side names from the JSONL transcript layer. assert_eq!(tool_category(9999, "Shell"), Some(ToolCategory::Shell)); assert_eq!(tool_category(9999, "Write"), Some(ToolCategory::FileWrite)); - assert_eq!(tool_category(9999, "StrReplace"), Some(ToolCategory::FileWrite)); + assert_eq!( + tool_category(9999, "StrReplace"), + Some(ToolCategory::FileWrite) + ); assert_eq!(tool_category(9999, "Read"), Some(ToolCategory::FileRead)); assert_eq!(tool_category(9999, "Glob"), Some(ToolCategory::FileSearch)); assert_eq!(tool_category(9999, "Grep"), Some(ToolCategory::FileSearch)); @@ -1093,7 +1082,10 @@ mod tests { #[test] fn tool_category_unknown_id_falls_through_to_name() { // Future numeric ids we haven't seen still classify via name. - assert_eq!(tool_category(7777, "edit_file_v2"), Some(ToolCategory::FileWrite)); + assert_eq!( + tool_category(7777, "edit_file_v2"), + Some(ToolCategory::FileWrite) + ); assert_eq!(tool_category(7777, "future_tool"), None); } } diff --git a/crates/toolpath-cursor/src/reader.rs b/crates/toolpath-cursor/src/reader.rs index 87f8f2d..e35c879 100644 --- a/crates/toolpath-cursor/src/reader.rs +++ b/crates/toolpath-cursor/src/reader.rs @@ -110,12 +110,14 @@ impl DbReader { } })?; match raw { - Some(s) => serde_json::from_str(&s) - .map(Some) - .map_err(|e| CursorError::MalformedPayload { - what: format!("composerData:{composer_id}"), - detail: e.to_string(), - }), + Some(s) => { + serde_json::from_str(&s) + .map(Some) + .map_err(|e| CursorError::MalformedPayload { + what: format!("composerData:{composer_id}"), + detail: e.to_string(), + }) + } None => Ok(None), } } @@ -143,9 +145,7 @@ impl DbReader { Some(s) => match serde_json::from_str::(&s) { Ok(b) => Ok(Some(b)), Err(e) => { - eprintln!( - "Warning: bubble {composer_id}:{bubble_id} malformed: {e}; skipping" - ); + eprintln!("Warning: bubble {composer_id}:{bubble_id} malformed: {e}; skipping"); Ok(None) } }, @@ -260,7 +260,9 @@ impl DbReader { // Load content blobs referenced by tool results. let mut content_blobs = std::collections::HashMap::new(); for b in &bubbles { - let Some(tf) = &b.tool_former_data else { continue }; + let Some(tf) = &b.tool_former_data else { + continue; + }; let Ok(Some(result)) = tf.parse_result() else { continue; }; @@ -409,7 +411,8 @@ pub(crate) mod tests { #[test] fn malformed_composer_data_surfaces_error() { - let setup = r#"INSERT INTO cursorDiskKV (key, value) VALUES ('composerData:bad', '{not json}');"#; + let setup = + r#"INSERT INTO cursorDiskKV (key, value) VALUES ('composerData:bad', '{not json}');"#; let f = fixture_db(setup); let r = DbReader::open(f.path()).unwrap(); let err = r.read_composer_data("bad").unwrap_err(); diff --git a/crates/toolpath-cursor/src/types.rs b/crates/toolpath-cursor/src/types.rs index 53d29ec..0f241d7 100644 --- a/crates/toolpath-cursor/src/types.rs +++ b/crates/toolpath-cursor/src/types.rs @@ -27,7 +27,11 @@ pub struct ComposerHead { pub subtitle: Option, #[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")] pub created_at: Option, - #[serde(rename = "lastUpdatedAt", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "lastUpdatedAt", + default, + skip_serializing_if = "Option::is_none" + )] pub last_updated_at: Option, #[serde( rename = "conversationCheckpointLastUpdatedAt", @@ -35,7 +39,11 @@ pub struct ComposerHead { skip_serializing_if = "Option::is_none" )] pub conversation_checkpoint_last_updated_at: Option, - #[serde(rename = "unifiedMode", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "unifiedMode", + default, + skip_serializing_if = "Option::is_none" + )] pub unified_mode: Option, #[serde(rename = "forceMode", default, skip_serializing_if = "Option::is_none")] pub force_mode: Option, @@ -45,17 +53,37 @@ pub struct ComposerHead { pub is_draft: bool, #[serde(rename = "hasUnreadMessages", default)] pub has_unread_messages: bool, - #[serde(rename = "totalLinesAdded", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "totalLinesAdded", + default, + skip_serializing_if = "Option::is_none" + )] pub total_lines_added: Option, - #[serde(rename = "totalLinesRemoved", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "totalLinesRemoved", + default, + skip_serializing_if = "Option::is_none" + )] pub total_lines_removed: Option, - #[serde(rename = "filesChangedCount", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "filesChangedCount", + default, + skip_serializing_if = "Option::is_none" + )] pub files_changed_count: Option, - #[serde(rename = "contextUsagePercent", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "contextUsagePercent", + default, + skip_serializing_if = "Option::is_none" + )] pub context_usage_percent: Option, #[serde(rename = "numSubComposers", default)] pub num_sub_composers: u32, - #[serde(rename = "workspaceIdentifier", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "workspaceIdentifier", + default, + skip_serializing_if = "Option::is_none" + )] pub workspace_identifier: Option, #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] pub extra: HashMap, @@ -118,29 +146,57 @@ pub struct ComposerData { pub subtitle: Option, #[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")] pub created_at: Option, - #[serde(rename = "lastUpdatedAt", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "lastUpdatedAt", + default, + skip_serializing_if = "Option::is_none" + )] pub last_updated_at: Option, #[serde(rename = "isAgentic", default)] pub is_agentic: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, - #[serde(rename = "unifiedMode", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "unifiedMode", + default, + skip_serializing_if = "Option::is_none" + )] pub unified_mode: Option, #[serde(rename = "forceMode", default, skip_serializing_if = "Option::is_none")] pub force_mode: Option, - #[serde(rename = "agentBackend", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "agentBackend", + default, + skip_serializing_if = "Option::is_none" + )] pub agent_backend: Option, - #[serde(rename = "modelConfig", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "modelConfig", + default, + skip_serializing_if = "Option::is_none" + )] pub model_config: Option, /// May contain more entries than there are `bubbleId:` rows on disk; /// don't use for iteration order. #[serde(rename = "fullConversationHeadersOnly", default)] pub full_conversation_headers_only: Vec, - #[serde(rename = "subComposerIds", default, skip_serializing_if = "Vec::is_empty")] + #[serde( + rename = "subComposerIds", + default, + skip_serializing_if = "Vec::is_empty" + )] pub sub_composer_ids: Vec, - #[serde(rename = "subagentComposerIds", default, skip_serializing_if = "Vec::is_empty")] + #[serde( + rename = "subagentComposerIds", + default, + skip_serializing_if = "Vec::is_empty" + )] pub subagent_composer_ids: Vec, - #[serde(rename = "latestChatGenerationUUID", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "latestChatGenerationUUID", + default, + skip_serializing_if = "Option::is_none" + )] pub latest_chat_generation_uuid: Option, #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] pub extra: HashMap, @@ -171,7 +227,11 @@ pub struct ModelConfig { pub model_name: Option, #[serde(rename = "maxMode", default)] pub max_mode: bool, - #[serde(rename = "selectedModels", default, skip_serializing_if = "Vec::is_empty")] + #[serde( + rename = "selectedModels", + default, + skip_serializing_if = "Vec::is_empty" + )] pub selected_models: Vec, #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] pub extra: HashMap, @@ -196,7 +256,11 @@ pub struct BubbleHeader { pub kind: u8, #[serde(default, skip_serializing_if = "Option::is_none")] pub grouping: Option, - #[serde(rename = "contentHeightHint", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "contentHeightHint", + default, + skip_serializing_if = "Option::is_none" + )] pub content_height_hint: Option, } @@ -206,11 +270,23 @@ pub struct BubbleGrouping { pub is_renderable: bool, #[serde(rename = "hasText", default, skip_serializing_if = "Option::is_none")] pub has_text: Option, - #[serde(rename = "hasThinking", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "hasThinking", + default, + skip_serializing_if = "Option::is_none" + )] pub has_thinking: Option, - #[serde(rename = "thinkingDurationMs", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "thinkingDurationMs", + default, + skip_serializing_if = "Option::is_none" + )] pub thinking_duration_ms: Option, - #[serde(rename = "capabilityType", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "capabilityType", + default, + skip_serializing_if = "Option::is_none" + )] pub capability_type: Option, #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] pub extra: HashMap, @@ -234,23 +310,47 @@ pub struct Bubble { #[serde(rename = "richText", default, skip_serializing_if = "Option::is_none")] pub rich_text: Option, /// `15` = tool, `30` = thinking, `null` = text. - #[serde(rename = "capabilityType", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "capabilityType", + default, + skip_serializing_if = "Option::is_none" + )] pub capability_type: Option, - #[serde(rename = "conversationState", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "conversationState", + default, + skip_serializing_if = "Option::is_none" + )] pub conversation_state: Option, - #[serde(rename = "unifiedMode", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "unifiedMode", + default, + skip_serializing_if = "Option::is_none" + )] pub unified_mode: Option, #[serde(rename = "isAgentic", default)] pub is_agentic: bool, #[serde(rename = "requestId", default, skip_serializing_if = "Option::is_none")] pub request_id: Option, - #[serde(rename = "checkpointId", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "checkpointId", + default, + skip_serializing_if = "Option::is_none" + )] pub checkpoint_id: Option, - #[serde(rename = "tokenCount", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "tokenCount", + default, + skip_serializing_if = "Option::is_none" + )] pub token_count: Option, #[serde(rename = "modelInfo", default, skip_serializing_if = "Option::is_none")] pub model_info: Option, - #[serde(rename = "toolFormerData", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "toolFormerData", + default, + skip_serializing_if = "Option::is_none" + )] pub tool_former_data: Option, /// Must serialize as `[]` when empty — Cursor's renderer calls /// `Object.entries(undefined)` on the thinking-blocks indexer. @@ -266,10 +366,7 @@ pub struct Bubble { impl Bubble { pub fn created_at_utc(&self) -> Option> { - self.created_at - .as_ref()? - .parse::>() - .ok() + self.created_at.as_ref()?.parse::>().ok() } pub fn is_user(&self) -> bool { @@ -297,9 +394,17 @@ pub const CAPABILITY_THINKING: u32 = 30; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TokenCount { - #[serde(rename = "inputTokens", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "inputTokens", + default, + skip_serializing_if = "Option::is_none" + )] pub input_tokens: Option, - #[serde(rename = "outputTokens", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "outputTokens", + default, + skip_serializing_if = "Option::is_none" + )] pub output_tokens: Option, #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] pub extra: HashMap, @@ -330,7 +435,11 @@ pub struct ToolFormerData { pub tool: u32, #[serde(rename = "toolIndex", default, skip_serializing_if = "Option::is_none")] pub tool_index: Option, - #[serde(rename = "modelCallId", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "modelCallId", + default, + skip_serializing_if = "Option::is_none" + )] pub model_call_id: Option, #[serde(rename = "toolCallId")] pub tool_call_id: String, @@ -343,7 +452,11 @@ pub struct ToolFormerData { pub params: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, - #[serde(rename = "additionalData", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "additionalData", + default, + skip_serializing_if = "Option::is_none" + )] pub additional_data: Option, } @@ -583,7 +696,10 @@ mod tests { assert_eq!(cd.unified_mode.as_deref(), Some("agent")); assert_eq!(cd.default_model(), Some("default")); assert_eq!(cd.full_conversation_headers_only.len(), 1); - assert_eq!(cd.extra.get("futureField"), Some(&Value::String("kept".into()))); + assert_eq!( + cd.extra.get("futureField"), + Some(&Value::String("kept".into())) + ); } #[test] diff --git a/crates/toolpath-cursor/tests/projection_roundtrip.rs b/crates/toolpath-cursor/tests/projection_roundtrip.rs index 16c8877..c4317a0 100644 --- a/crates/toolpath-cursor/tests/projection_roundtrip.rs +++ b/crates/toolpath-cursor/tests/projection_roundtrip.rs @@ -84,12 +84,17 @@ fn rebuilt_session_preserves_bubble_role_sequence() { let source = load_source(); let (_, rebuilt, _) = roundtrip(&source); - let kinds_of = |s: &CursorSession| -> Vec { - s.bubbles.iter().map(|b| b.kind).collect() - }; + let kinds_of = |s: &CursorSession| -> Vec { s.bubbles.iter().map(|b| b.kind).collect() }; let want = kinds_of(&source); assert_eq!(kinds_of(&rebuilt), want); - assert_eq!(want, vec![BUBBLE_TYPE_USER, BUBBLE_TYPE_ASSISTANT, BUBBLE_TYPE_ASSISTANT]); + assert_eq!( + want, + vec![ + BUBBLE_TYPE_USER, + BUBBLE_TYPE_ASSISTANT, + BUBBLE_TYPE_ASSISTANT + ] + ); } #[test] @@ -137,7 +142,9 @@ fn rebuilt_session_round_trips_file_content_via_blob_store() { // Empty before content uses Cursor's canonical SHA-256 sentinel. let before_id = result["beforeContentId"].as_str().unwrap(); - assert!(before_id.ends_with("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")); + assert!( + before_id.ends_with("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + ); } #[test] @@ -178,10 +185,7 @@ fn rebuilt_session_re_lifts_to_equivalent_view() { fn rebuilt_session_workspace_path_preserved() { let source = load_source(); let (_, rebuilt, _) = roundtrip(&source); - assert_eq!( - rebuilt.workspace_path().unwrap().to_string_lossy(), - "/proj" - ); + assert_eq!(rebuilt.workspace_path().unwrap().to_string_lossy(), "/proj"); } #[test] diff --git a/crates/toolpath-gemini/src/project.rs b/crates/toolpath-gemini/src/project.rs index 39bde55..5d487f3 100644 --- a/crates/toolpath-gemini/src/project.rs +++ b/crates/toolpath-gemini/src/project.rs @@ -568,7 +568,10 @@ mod tests { #[test] fn tokens_from_common_unfolds_reasoning_out_of_output() { let mut breakdowns: BTreeMap> = BTreeMap::new(); - breakdowns.insert("output".into(), BTreeMap::from([("reasoning".into(), 243u32)])); + breakdowns.insert( + "output".into(), + BTreeMap::from([("reasoning".into(), 243u32)]), + ); let usage = TokenUsage { output_tokens: Some(337), breakdowns, diff --git a/crates/toolpath-gemini/src/provider.rs b/crates/toolpath-gemini/src/provider.rs index f39da51..ead4700 100644 --- a/crates/toolpath-gemini/src/provider.rs +++ b/crates/toolpath-gemini/src/provider.rs @@ -166,7 +166,11 @@ fn tokens_to_usage(t: &Tokens) -> TokenUsage { // Fold reasoning into output (additive in Gemini — billed as // output). None only when both output and thoughts are // absent/zero, mirroring the per-field Option semantics. - output_tokens: if generated == 0 { None } else { Some(generated) }, + output_tokens: if generated == 0 { + None + } else { + Some(generated) + }, cache_read_tokens: t.cached, cache_write_tokens: None, ..Default::default() diff --git a/crates/toolpath-opencode/src/provider.rs b/crates/toolpath-opencode/src/provider.rs index 3215868..9f05349 100644 --- a/crates/toolpath-opencode/src/provider.rs +++ b/crates/toolpath-opencode/src/provider.rs @@ -604,7 +604,10 @@ fn accumulate_tokens(total: &mut TokenUsage, step: &Tokens) { // where reasoning is already inside `output`. Fold it into output_tokens // so the IR's `output` means "all generated tokens" consistently and the // session total isn't under-counted. - add_u32(&mut total.output_tokens, (step.output + step.reasoning) as u32); + add_u32( + &mut total.output_tokens, + (step.output + step.reasoning) as u32, + ); add_u32(&mut total.cache_read_tokens, step.cache.read as u32); add_u32(&mut total.cache_write_tokens, step.cache.write as u32); // Memoize the reasoning slice we just folded into output. It's @@ -1007,7 +1010,10 @@ mod tests { // The reasoning slice (5) is also memoized under // breakdowns["output"]["reasoning"] — it's the SAME number folded // into output, so Σ(inner) = 5 ≤ output (25). - assert_eq!(u.breakdowns.get("output").and_then(|m| m.get("reasoning")), Some(&5u32)); + assert_eq!( + u.breakdowns.get("output").and_then(|m| m.get("reasoning")), + Some(&5u32) + ); let total = view.total_usage.as_ref().unwrap(); assert_eq!(total.input_tokens, Some(100)); @@ -1055,7 +1061,10 @@ mod tests { let u = view.turns[0].token_usage.as_ref().unwrap(); // output total: (20+5) + (4+7) = 36; reasoning slice: 5+7 = 12. assert_eq!(u.output_tokens, Some(36)); - assert_eq!(u.breakdowns.get("output").and_then(|m| m.get("reasoning")), Some(&12u32)); + assert_eq!( + u.breakdowns.get("output").and_then(|m| m.get("reasoning")), + Some(&12u32) + ); } #[test]