1use crate::VERSION;
26use crate::constants::FastHashMap;
27use crate::models::TimeRange;
28use crate::models::{CodeAnalysis, CodeAnalysisRecord, ExtensionType};
29use crate::session::diagnostics::{
30 DatabaseAnalysisRow, DatabaseUsageRead, UsageContribution, UsageTokenContribution,
31};
32use crate::session::sqlite::{
33 DatabaseFingerprint, optional_database_fingerprint, with_readonly_connection,
34};
35use crate::session::state::{ParseMode, SessionParseState};
36use crate::utils::{get_current_user, get_machine_id};
37use anyhow::{Result, anyhow};
38use rusqlite::Connection;
39use serde_json::{Value, json};
40use std::collections::HashMap;
41use std::path::{Path, PathBuf};
42
43pub fn read_cursor_usage(
62 chats_dir: &Path,
63 tracking_db: &Path,
64 time_range: TimeRange,
65) -> Result<Vec<(String, CodeAnalysis, f64)>> {
66 let result = read_cursor_usage_with_diagnostics(chats_dir, tracking_db, time_range);
67 for failure in &result.failures {
68 log::warn!(
69 "failed to read Cursor usage store {}: {}",
70 failure.path.display(),
71 failure.error
72 );
73 }
74 if result.candidates > 0 && result.parsed == 0 {
75 return Err(anyhow!(
76 "failed to read all {} Cursor usage stores",
77 result.candidates
78 ));
79 }
80 let user = get_current_user();
81 let machine = get_machine_id().to_string();
82 Ok(result
83 .rows
84 .into_iter()
85 .map(|row| row.into_public_row(ExtensionType::Cursor, &user, &machine))
86 .collect())
87}
88
89pub(crate) struct CursorUsageRead {
91 pub rows: Vec<UsageContribution>,
93 pub candidates: usize,
95 pub parsed: usize,
97 pub failures: Vec<CursorAnalysisFailure>,
99}
100
101pub(crate) fn read_cursor_usage_with_diagnostics(
103 chats_dir: &Path,
104 tracking_db: &Path,
105 time_range: TimeRange,
106) -> CursorUsageRead {
107 let read = approximation_events(chats_dir, tracking_db);
108 CursorUsageRead {
109 rows: aggregate_events(&read.events, time_range),
110 candidates: read.candidates,
111 parsed: read.parsed,
112 failures: read.failures,
113 }
114}
115
116pub fn read_cursor_analysis(
130 chats_dir: &Path,
131 tracking_db: &Path,
132 time_range: TimeRange,
133 mode: ParseMode,
134) -> Result<Vec<(String, CodeAnalysis)>> {
135 let result = read_cursor_analysis_with_diagnostics(chats_dir, tracking_db, time_range, mode);
136 for failure in &result.failures {
137 log::warn!(
138 "failed to read Cursor store {}: {}",
139 failure.path.display(),
140 failure.error
141 );
142 }
143 if result.candidates > 0 && result.parsed == 0 {
144 return Err(anyhow!(
145 "failed to read all {} Cursor chat stores",
146 result.candidates
147 ));
148 }
149 Ok(result
150 .rows
151 .into_iter()
152 .map(|row| (row.date, row.analysis))
153 .collect())
154}
155
156pub(crate) struct CursorAnalysisFailure {
158 pub path: PathBuf,
160 pub error: String,
162}
163
164pub(crate) struct CursorStoreDiscovery {
166 pub stores: Vec<PathBuf>,
167 pub failures: Vec<CursorAnalysisFailure>,
168}
169
170pub(crate) struct CursorAnalysisRead {
172 pub rows: Vec<DatabaseAnalysisRow>,
174 pub candidates: usize,
176 pub parsed: usize,
178 pub failures: Vec<CursorAnalysisFailure>,
180}
181
182pub(crate) struct CursorStoreAnalysis {
183 pub(crate) rows: Vec<(String, CodeAnalysis)>,
184 pub(crate) normalized_messages: usize,
185 pub(crate) failed_payloads: usize,
186}
187
188pub(crate) fn read_cursor_analysis_with_diagnostics(
190 chats_dir: &Path,
191 tracking_db: &Path,
192 time_range: TimeRange,
193 mode: ParseMode,
194) -> CursorAnalysisRead {
195 let (conv_models, tracking_failure) = match load_conversation_models(tracking_db) {
196 Ok(models) => (models, None),
197 Err(error) => (
198 FastHashMap::default(),
199 Some(CursorAnalysisFailure {
200 path: tracking_db.to_path_buf(),
201 error: error.to_string(),
202 }),
203 ),
204 };
205 let user = get_current_user();
206 let machine = get_machine_id().to_string();
207
208 let discovery = discover_cursor_store_dbs(chats_dir);
209 let candidates = discovery.stores.len() + discovery.failures.len();
210 let mut parsed = 0usize;
211 let mut out = Vec::new();
212 let mut failures = discovery.failures;
213 failures.extend(tracking_failure);
214 for store_db in discovery.stores {
215 match read_store_analysis(&store_db, &conv_models, time_range, mode, &user, &machine) {
216 Ok(store) if store.normalized_messages == 0 && store.failed_payloads > 0 => {
217 failures.push(CursorAnalysisFailure {
218 path: store_db,
219 error: format!(
220 "none of {} analyzer payloads used a supported schema",
221 store.failed_payloads
222 ),
223 });
224 }
225 Ok(store) => {
226 parsed += 1;
227 for (date, analysis) in store.rows {
228 out.push(DatabaseAnalysisRow {
229 source_id: store_db.to_string_lossy().into_owned(),
230 date,
231 analysis,
232 });
233 }
234 if store.failed_payloads > 0 {
235 failures.push(CursorAnalysisFailure {
236 path: store_db,
237 error: format!(
238 "{} analyzer payloads used an unsupported schema",
239 store.failed_payloads
240 ),
241 });
242 }
243 }
244 Err(err) => {
245 failures.push(CursorAnalysisFailure {
246 path: store_db,
247 error: err.to_string(),
248 });
249 }
250 }
251 }
252 CursorAnalysisRead {
253 rows: out,
254 candidates,
255 parsed,
256 failures,
257 }
258}
259
260#[derive(Debug)]
267struct UsageEvent {
268 date: String,
269 timestamp_ms: i64,
270 model: String,
271 input: i64,
272 output: i64,
273 cache_read: i64,
274 cache_write: i64,
275 cost: f64,
276}
277
278struct CursorUsageEvents {
279 events: Vec<UsageEvent>,
280 candidates: usize,
281 parsed: usize,
282 failures: Vec<CursorAnalysisFailure>,
283}
284
285fn aggregate_events(events: &[UsageEvent], time_range: TimeRange) -> Vec<UsageContribution> {
287 let cutoff = cutoff_string(time_range);
288 let mut out = Vec::new();
289 for e in events {
290 if is_before_cutoff(&e.date, &cutoff) {
291 continue;
292 }
293 out.push(UsageContribution::single_model(
294 e.date.clone(),
295 e.timestamp_ms,
296 e.model.clone(),
297 cursor_usage_value(e.input, e.output, e.cache_read, e.cache_write),
298 e.cost,
299 ));
300 }
301 out
302}
303
304pub(crate) fn read_cursor_usage_store(
309 store_db: &Path,
310 conv_models: &FastHashMap<String, String>,
311 time_range: TimeRange,
312) -> Result<DatabaseUsageRead> {
313 let conv_id = conversation_id_from_path(store_db);
314 let read = read_store_context(store_db, conv_models, &conv_id)?;
315 let events = read
316 .turns
317 .into_iter()
318 .filter_map(|(timestamp_ms, cache_read)| {
319 ms_to_local_date(timestamp_ms).map(|date| UsageEvent {
320 date,
321 timestamp_ms,
322 model: read.model.clone(),
323 input: 0,
324 output: 0,
325 cache_read,
326 cache_write: 0,
327 cost: 0.0,
328 })
329 })
330 .collect::<Vec<_>>();
331 Ok(DatabaseUsageRead {
332 rows: aggregate_events(&events, time_range),
333 expected_records: read.expected_records,
334 parsed_records: read.parsed_records,
335 })
336}
337
338fn approximation_events(chats_dir: &Path, tracking_db: &Path) -> CursorUsageEvents {
354 let (conv_models, tracking_failure) = match load_conversation_models(tracking_db) {
355 Ok(models) => (models, None),
356 Err(error) => (
357 FastHashMap::default(),
358 Some(CursorAnalysisFailure {
359 path: tracking_db.to_path_buf(),
360 error: error.to_string(),
361 }),
362 ),
363 };
364 let mut agg: HashMap<(String, String), (i64, i64)> = HashMap::new();
366 let discovery = discover_cursor_store_dbs(chats_dir);
367 let candidates = discovery.stores.len() + discovery.failures.len();
368 let mut parsed = 0usize;
369 let mut failures = discovery.failures;
370 failures.extend(tracking_failure);
371 for store_db in discovery.stores {
372 let conv_id = conversation_id_from_path(&store_db);
373 let read = match read_store_context(&store_db, &conv_models, &conv_id) {
374 Ok(read) if read.expected_records > 0 && read.parsed_records == 0 => {
375 failures.push(CursorAnalysisFailure {
376 path: store_db,
377 error: format!(
378 "none of {} Cursor usage payloads used a supported schema",
379 read.expected_records
380 ),
381 });
382 continue;
383 }
384 Ok(read) => {
385 parsed += 1;
386 if read.expected_records > read.parsed_records {
387 failures.push(CursorAnalysisFailure {
388 path: store_db.clone(),
389 error: format!(
390 "{} Cursor usage payloads used an unsupported schema",
391 read.expected_records - read.parsed_records
392 ),
393 });
394 }
395 read
396 }
397 Err(error) => {
398 failures.push(CursorAnalysisFailure {
399 path: store_db,
400 error: error.to_string(),
401 });
402 continue;
403 }
404 };
405 for (ts, ctx) in read.turns {
406 let Some(date) = ms_to_local_date(ts) else {
407 continue;
408 };
409 let entry = agg.entry((date, read.model.clone())).or_insert((0, ts));
410 entry.0 += ctx;
411 entry.1 = entry.1.max(ts);
412 }
413 }
414
415 CursorUsageEvents {
416 events: agg
417 .into_iter()
418 .map(|((date, model), (ctx, timestamp_ms))| UsageEvent {
419 date,
420 timestamp_ms,
421 model,
422 input: 0,
423 output: 0,
424 cache_read: ctx,
425 cache_write: 0,
426 cost: 0.0,
427 })
428 .collect(),
429 candidates,
430 parsed,
431 failures,
432 }
433}
434
435pub(crate) fn discover_cursor_store_dbs(chats_dir: &Path) -> CursorStoreDiscovery {
442 let mut dbs = Vec::new();
443 let mut failures = Vec::new();
444 let projects = match std::fs::read_dir(chats_dir) {
445 Ok(projects) => projects,
446 Err(error) => {
447 return CursorStoreDiscovery {
448 stores: dbs,
449 failures: vec![CursorAnalysisFailure {
450 path: chats_dir.to_path_buf(),
451 error: error.to_string(),
452 }],
453 };
454 }
455 };
456 for project in projects {
457 let project = match project {
458 Ok(project) => project,
459 Err(error) => {
460 failures.push(CursorAnalysisFailure {
461 path: chats_dir.to_path_buf(),
462 error: error.to_string(),
463 });
464 continue;
465 }
466 };
467 match project.file_type() {
468 Ok(kind) if !kind.is_dir() => continue,
469 Ok(_) => {}
470 Err(error) => {
471 failures.push(CursorAnalysisFailure {
472 path: project.path(),
473 error: error.to_string(),
474 });
475 continue;
476 }
477 }
478 let conversations = match std::fs::read_dir(project.path()) {
479 Ok(conversations) => conversations,
480 Err(error) => {
481 failures.push(CursorAnalysisFailure {
482 path: project.path(),
483 error: error.to_string(),
484 });
485 continue;
486 }
487 };
488 for conv in conversations {
489 let conv = match conv {
490 Ok(conv) => conv,
491 Err(error) => {
492 failures.push(CursorAnalysisFailure {
493 path: project.path(),
494 error: error.to_string(),
495 });
496 continue;
497 }
498 };
499 match conv.file_type() {
500 Ok(kind) if !kind.is_dir() => continue,
501 Ok(_) => {}
502 Err(error) => {
503 failures.push(CursorAnalysisFailure {
504 path: conv.path(),
505 error: error.to_string(),
506 });
507 continue;
508 }
509 }
510 let db = conv.path().join("store.db");
511 match std::fs::metadata(&db) {
512 Ok(metadata) if metadata.is_file() => dbs.push(db),
513 Ok(_) => {}
514 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
515 Err(error) => failures.push(CursorAnalysisFailure {
516 path: db,
517 error: error.to_string(),
518 }),
519 }
520 }
521 }
522 dbs.sort_unstable();
523 failures.sort_by(|left, right| left.path.cmp(&right.path));
524 CursorStoreDiscovery {
525 stores: dbs,
526 failures,
527 }
528}
529
530fn conversation_id_from_path(store_db: &Path) -> String {
532 store_db
533 .parent()
534 .and_then(|p| p.file_name())
535 .map(|s| s.to_string_lossy().into_owned())
536 .unwrap_or_default()
537}
538
539pub(crate) fn load_conversation_models(tracking_db: &Path) -> Result<FastHashMap<String, String>> {
545 match std::fs::metadata(tracking_db) {
546 Ok(_) => {}
547 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
548 return Ok(FastHashMap::default());
549 }
550 Err(error) => return Err(error.into()),
551 }
552 with_readonly_connection(
553 tracking_db,
554 "ai_code_hashes",
555 "vct-cursor-",
556 "Cursor",
557 |conn| {
558 let mut stmt = conn.prepare(
559 "SELECT conversationId, model, COUNT(*) AS c FROM ai_code_hashes \
560 WHERE conversationId IS NOT NULL AND conversationId != '' \
561 AND model IS NOT NULL AND model != '' \
562 GROUP BY conversationId, model ORDER BY c DESC, model ASC",
563 )?;
564 let rows =
565 stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
566 let mut m: FastHashMap<String, String> = FastHashMap::default();
567 for row in rows {
568 let row = row?;
569 m.entry(row.0).or_insert(row.1);
572 }
573 Ok(m)
574 },
575 )
576}
577
578pub(crate) struct ConversationModelSnapshot {
580 pub(crate) models: FastHashMap<String, String>,
581 pub(crate) fingerprint: Option<DatabaseFingerprint>,
582}
583
584pub(crate) fn load_conversation_model_snapshot(
586 tracking_db: &Path,
587) -> Result<ConversationModelSnapshot> {
588 const MAX_ATTEMPTS: usize = 3;
589 for attempt in 0..MAX_ATTEMPTS {
590 let before = optional_database_fingerprint(tracking_db)?;
591 let models = load_conversation_models(tracking_db)?;
592 let after = optional_database_fingerprint(tracking_db)?;
593 if before == after {
594 return Ok(ConversationModelSnapshot {
595 models,
596 fingerprint: after,
597 });
598 }
599 if attempt + 1 == MAX_ATTEMPTS {
600 anyhow::bail!(
601 "Cursor tracking DB changed while being read after {MAX_ATTEMPTS} attempts: {}",
602 tracking_db.display()
603 );
604 }
605 }
606 unreachable!("tracking snapshot loop always returns")
607}
608
609pub(crate) fn read_store_analysis(
611 store_db: &Path,
612 conv_models: &FastHashMap<String, String>,
613 time_range: TimeRange,
614 mode: ParseMode,
615 user: &str,
616 machine: &str,
617) -> Result<CursorStoreAnalysis> {
618 let conv_id = conversation_id_from_path(store_db);
619 let cutoff = cutoff_string(time_range);
620
621 with_readonly_connection(store_db, "blobs", "vct-cursor-", "Cursor", |conn| {
622 let transaction = conn.unchecked_transaction()?;
623 let model = resolve_store_model(&transaction, conv_models, &conv_id);
624 let blobs = load_blobs(&transaction)?;
625
626 let read_results = collect_read_results(&blobs);
629 let mut failed_payloads = 0usize;
630
631 let mut per_date: HashMap<String, SessionParseState> = HashMap::new();
633 let mut normalized_messages = 0usize;
634 for data in &blobs {
635 if data.first() != Some(&0x0A) {
636 continue;
637 }
638 let node = walk_node(data);
639 let Some(msg_bytes) = node.msg else {
640 if node.ctx_msg.is_none()
645 && let Some(ts) = node.ts
646 && ms_to_local_date(ts).is_some_and(|date| !is_before_cutoff(&date, &cutoff))
647 {
648 failed_payloads += 1;
649 }
650 continue;
651 };
652 if let Some(ts) = node.ts
653 && ms_to_local_date(ts).is_some_and(|date| is_before_cutoff(&date, &cutoff))
654 {
655 continue;
656 }
657 let Ok(msg) = serde_json::from_slice::<Value>(msg_bytes) else {
658 failed_payloads += 1;
659 continue;
660 };
661 let Some(role) = msg.get("role").and_then(Value::as_str) else {
662 failed_payloads += 1;
663 continue;
664 };
665 if role != "assistant" {
669 continue;
670 }
671 let Some(ts) = node.ts else {
672 failed_payloads += 1;
673 continue;
674 };
675 let Some(date) = ms_to_local_date(ts) else {
676 failed_payloads += 1;
677 continue;
678 };
679 if is_before_cutoff(&date, &cutoff) {
680 continue;
681 }
682 if msg.get("content").and_then(Value::as_array).is_none() {
683 failed_payloads += 1;
684 continue;
685 }
686 let state = per_date.entry(date).or_insert_with(|| {
687 let mut s = SessionParseState::with_mode(mode);
688 s.task_id = conv_id.clone();
689 s
690 });
691 state.last_ts = ts.max(state.last_ts);
692 match apply_assistant_tools(state, &msg, &read_results, ts) {
693 Ok(tool_failures) => {
694 normalized_messages += 1;
695 failed_payloads += tool_failures;
696 }
697 Err(()) => failed_payloads += 1,
698 }
699 }
700
701 let mut out = Vec::with_capacity(per_date.len());
702 for (date, state) in per_date {
703 let mut usage = FastHashMap::default();
704 usage.insert(model.clone(), json!({}));
707 let record = state.into_record(usage);
708 out.push((date, wrap_record(record, user, machine)));
709 }
710 transaction.commit()?;
711 Ok(CursorStoreAnalysis {
712 rows: out,
713 normalized_messages,
714 failed_payloads,
715 })
716 })
717}
718
719struct CursorStoreContextRead {
724 model: String,
725 turns: Vec<(i64, i64)>,
726 expected_records: usize,
727 parsed_records: usize,
728}
729
730fn read_store_context(
731 store_db: &Path,
732 conv_models: &FastHashMap<String, String>,
733 conv_id: &str,
734) -> Result<CursorStoreContextRead> {
735 with_readonly_connection(store_db, "blobs", "vct-cursor-", "Cursor", |conn| {
736 let transaction = conn.unchecked_transaction()?;
737 let model = resolve_store_model(&transaction, conv_models, conv_id);
738 let blobs = load_node_blobs(&transaction)?;
742 let mut turns = Vec::new();
743 let mut expected_records = 0usize;
744 let mut parsed_records = 0usize;
745 for data in &blobs {
746 if data.first() != Some(&0x0A) {
747 continue;
748 }
749 let node = walk_node(data);
750 let (Some(msg_bytes), Some(ts), Some(ctx_msg)) = (node.msg, node.ts, node.ctx_msg)
751 else {
752 continue;
753 };
754 let role = message_role(msg_bytes);
759 match role.as_deref() {
760 Some("assistant") => {
761 if ctx_msg.is_empty() {
765 continue;
766 }
767 expected_records += 1;
768 if let Some(ctx) = context_tokens(ctx_msg) {
769 turns.push((ts, ctx));
770 parsed_records += 1;
771 }
772 }
773 Some("user" | "tool" | "system") => {}
774 Some(_) | None => expected_records += 1,
775 }
776 }
777 transaction.commit()?;
778 Ok(CursorStoreContextRead {
779 model,
780 turns,
781 expected_records,
782 parsed_records,
783 })
784 })
785}
786
787fn resolve_store_model(
790 conn: &Connection,
791 conv_models: &FastHashMap<String, String>,
792 conv_id: &str,
793) -> String {
794 conv_models
795 .get(conv_id)
796 .cloned()
797 .or_else(|| store_meta_model(conn))
798 .unwrap_or_else(|| "unknown".to_string())
799}
800
801fn store_meta_model(conn: &Connection) -> Option<String> {
806 let value: String = conn
807 .query_row("SELECT value FROM meta LIMIT 1", [], |r| r.get(0))
808 .ok()?;
809 let bytes = decode_hex(&value).unwrap_or_else(|| value.clone().into_bytes());
810 let json: Value = serde_json::from_slice(&bytes).ok()?;
811 json.get("lastUsedModel")
812 .and_then(|m| m.as_str())
813 .filter(|s| !s.is_empty())
814 .map(|s| s.to_string())
815}
816
817fn load_blobs(conn: &Connection) -> Result<Vec<Vec<u8>>> {
819 let mut stmt = conn.prepare("SELECT data FROM blobs ORDER BY id")?;
820 let rows = stmt.query_map([], |r| r.get::<_, Vec<u8>>(0))?;
821 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
822}
823
824fn load_node_blobs(conn: &Connection) -> Result<Vec<Vec<u8>>> {
829 let mut stmt =
830 conn.prepare("SELECT data FROM blobs WHERE substr(data, 1, 1) = X'0A' ORDER BY id")?;
831 let rows = stmt.query_map([], |r| r.get::<_, Vec<u8>>(0))?;
832 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
833}
834
835#[cfg(test)]
842fn assistant_node(data: &[u8]) -> Option<(&[u8], i64)> {
843 if data.first() != Some(&0x0A) {
844 return None;
845 }
846 let node = walk_node(data);
847 Some((node.msg?, node.ts?))
848}
849
850fn message_role(bytes: &[u8]) -> Option<String> {
852 serde_json::from_slice::<Value>(bytes)
853 .ok()
854 .and_then(|message| {
855 message
856 .get("role")
857 .and_then(Value::as_str)
858 .map(str::to_owned)
859 })
860}
861
862fn apply_assistant_tools(
864 state: &mut SessionParseState,
865 msg: &Value,
866 read_results: &HashMap<String, CursorReadResult>,
867 ts: i64,
868) -> std::result::Result<usize, ()> {
869 if msg.get("role").and_then(|v| v.as_str()) != Some("assistant") {
870 return Err(());
871 }
872 let Some(content) = msg.get("content").and_then(|v| v.as_array()) else {
873 return Err(());
874 };
875 let mut failures = 0usize;
876 for c in content {
877 if c.get("type").and_then(|v| v.as_str()) != Some("tool-call") {
878 continue;
879 }
880 let Some(tool) = c
881 .get("toolName")
882 .and_then(Value::as_str)
883 .filter(|tool| !tool.is_empty())
884 else {
885 failures += 1;
886 continue;
887 };
888 let args = c.get("args").and_then(Value::as_object);
889 let arg = |key: &str| -> Option<&str> { args?.get(key)?.as_str() };
890 match tool {
891 "Write" => {
892 let (Some(path), Some(contents)) =
893 (arg("path").filter(|path| !path.is_empty()), arg("contents"))
894 else {
895 failures += 1;
896 continue;
897 };
898 state.add_write_detail(path, contents, ts);
899 }
900 "StrReplace" => {
901 let (Some(path), Some(old), Some(new)) = (
902 arg("path").filter(|path| !path.is_empty()),
903 arg("old_string"),
904 arg("new_string"),
905 ) else {
906 failures += 1;
907 continue;
908 };
909 state.add_edit_detail(path, old, new, ts);
910 }
911 "Read" | "ReadFile" => {
912 let (Some(path), Some(tool_call_id)) = (
913 arg("path").filter(|path| !path.is_empty()),
914 c.get("toolCallId")
915 .and_then(Value::as_str)
916 .filter(|id| !id.is_empty()),
917 ) else {
918 failures += 1;
919 continue;
920 };
921 match read_results.get(tool_call_id) {
922 Some(CursorReadResult::Content(content)) if !content.is_empty() => {
923 state.add_read_detail(path, content, ts);
924 }
925 Some(CursorReadResult::Unsupported) => {
926 failures += 1;
927 state.tool_counts.read += 1;
928 }
929 _ => {
930 state.tool_counts.read += 1;
933 }
934 }
935 }
936 "Shell" => {
937 let Some(command) = arg("command").filter(|command| !command.trim().is_empty())
938 else {
939 failures += 1;
940 continue;
941 };
942 state.add_run_command(command, arg("description").unwrap_or(""), ts);
943 }
944 "TodoWrite" => state.tool_counts.todo_write += 1,
945 _ => {}
947 }
948 }
949 Ok(failures)
950}
951
952enum CursorReadResult {
953 Content(String),
954 Unsupported,
955}
956
957fn collect_read_results(blobs: &[Vec<u8>]) -> HashMap<String, CursorReadResult> {
960 let mut map = HashMap::new();
961 for data in blobs {
962 if data.first() != Some(&b'{') {
963 continue;
964 }
965 let Ok(msg) = serde_json::from_slice::<Value>(data) else {
966 continue;
967 };
968 let role = msg.get("role").and_then(Value::as_str);
969 if role == Some("assistant") {
973 continue;
974 }
975 if role != Some("tool") {
976 continue;
977 }
978 let Some(content) = msg.get("content").and_then(|v| v.as_array()) else {
979 continue;
980 };
981 for c in content {
982 if c.get("type").and_then(|v| v.as_str()) != Some("tool-result") {
983 continue;
984 }
985 if !matches!(
986 c.get("toolName").and_then(|v| v.as_str()),
987 Some("Read" | "ReadFile")
988 ) {
989 continue;
990 }
991 let Some(id) = c
992 .get("toolCallId")
993 .and_then(|v| v.as_str())
994 .filter(|id| !id.is_empty())
995 else {
996 continue;
997 };
998 if let Some(result) = c.get("result").and_then(|v| v.as_str()) {
999 map.insert(
1000 id.to_string(),
1001 CursorReadResult::Content(strip_cursor_line_numbers(result)),
1002 );
1003 } else {
1004 map.entry(id.to_string())
1005 .or_insert(CursorReadResult::Unsupported);
1006 }
1007 }
1008 }
1009 map
1010}
1011
1012fn strip_cursor_line_numbers(text: &str) -> String {
1015 let mut lines = Vec::new();
1016 for line in text.split('\n') {
1017 if let Some(content) = strip_line_number_prefix(line) {
1018 lines.push(content);
1019 }
1020 }
1021 lines.join("\n")
1022}
1023
1024fn strip_line_number_prefix(line: &str) -> Option<&str> {
1027 let trimmed = line.trim_start_matches(' ');
1028 let digits_end = trimmed
1029 .find(|c: char| !c.is_ascii_digit())
1030 .unwrap_or(trimmed.len());
1031 if digits_end > 0 && trimmed[digits_end..].starts_with('|') {
1032 Some(&trimmed[digits_end + 1..])
1033 } else {
1034 None
1035 }
1036}
1037
1038#[derive(Default)]
1044struct NodeFields<'a> {
1045 msg: Option<&'a [u8]>,
1047 ts: Option<i64>,
1049 ctx_msg: Option<&'a [u8]>,
1051}
1052
1053fn walk_node(data: &[u8]) -> NodeFields<'_> {
1059 let mut nf = NodeFields::default();
1060 let mut i = 0;
1061 while i < data.len() {
1062 let Some((tag, ni)) = read_varint(data, i) else {
1063 break;
1064 };
1065 i = ni;
1066 let field = tag >> 3;
1067 let wire = tag & 7;
1068 match wire {
1069 0 => {
1070 let Some((v, ni)) = read_varint(data, i) else {
1071 break;
1072 };
1073 i = ni;
1074 if field == 26 {
1075 nf.ts = Some(v as i64);
1076 }
1077 }
1078 2 => {
1079 let Some((len, ni)) = read_varint(data, i) else {
1080 break;
1081 };
1082 i = ni;
1083 let Some(end) = i.checked_add(len as usize).filter(|e| *e <= data.len()) else {
1084 break;
1085 };
1086 match field {
1087 4 => nf.msg = Some(&data[i..end]),
1088 5 => nf.ctx_msg = Some(&data[i..end]),
1089 _ => {}
1090 }
1091 i = end;
1092 }
1093 5 => i = i.saturating_add(4),
1094 1 => i = i.saturating_add(8),
1095 _ => break,
1096 }
1097 }
1098 nf
1099}
1100
1101fn context_tokens(ctx_msg: &[u8]) -> Option<i64> {
1103 let mut i = 0;
1104 while i < ctx_msg.len() {
1105 let (tag, ni) = read_varint(ctx_msg, i)?;
1106 i = ni;
1107 let field = tag >> 3;
1108 let wire = tag & 7;
1109 match wire {
1110 0 => {
1111 let (v, ni) = read_varint(ctx_msg, i)?;
1112 i = ni;
1113 if field == 1 {
1114 return Some(v as i64);
1115 }
1116 }
1117 2 => {
1118 let (len, ni) = read_varint(ctx_msg, i)?;
1119 i = ni;
1120 i = i.checked_add(len as usize)?;
1121 }
1122 5 => i = i.checked_add(4)?,
1123 1 => i = i.checked_add(8)?,
1124 _ => break,
1125 }
1126 }
1127 None
1128}
1129
1130fn read_varint(data: &[u8], pos: usize) -> Option<(u64, usize)> {
1135 let mut result: u64 = 0;
1136 let mut shift = 0u32;
1137 let mut i = pos;
1138 while i < data.len() && shift < 64 {
1139 let byte = data[i];
1140 result |= u64::from(byte & 0x7f) << shift;
1141 i += 1;
1142 if byte & 0x80 == 0 {
1143 return Some((result, i));
1144 }
1145 shift += 7;
1146 }
1147 None
1148}
1149
1150fn cursor_usage_value(
1156 input: i64,
1157 output: i64,
1158 cache_read: i64,
1159 cache_write: i64,
1160) -> UsageTokenContribution {
1161 UsageTokenContribution {
1162 input_tokens: input,
1163 output_tokens: output,
1164 reasoning_tokens: 0,
1165 cache_read_tokens: cache_read,
1166 cache_creation_tokens: cache_write,
1167 }
1168}
1169
1170fn wrap_record(record: CodeAnalysisRecord, user: &str, machine: &str) -> CodeAnalysis {
1172 CodeAnalysis {
1173 user: user.to_string(),
1174 extension_name: ExtensionType::Cursor.to_string(),
1175 insights_version: VERSION.to_string(),
1176 machine_id: machine.to_string(),
1177 records: vec![record],
1178 }
1179}
1180
1181fn ms_to_local_date(ms: i64) -> Option<String> {
1183 chrono::DateTime::from_timestamp_millis(ms).map(|dt| {
1184 dt.with_timezone(&chrono::Local)
1185 .format("%Y-%m-%d")
1186 .to_string()
1187 })
1188}
1189
1190fn cutoff_string(time_range: TimeRange) -> Option<String> {
1192 time_range
1193 .cutoff_date()
1194 .map(|d| d.format("%Y-%m-%d").to_string())
1195}
1196
1197fn is_before_cutoff(date: &str, cutoff: &Option<String>) -> bool {
1199 matches!(cutoff, Some(c) if date < c.as_str())
1200}
1201
1202fn decode_hex(s: &str) -> Option<Vec<u8>> {
1205 let s = s.trim();
1206 if s.is_empty() || !s.len().is_multiple_of(2) {
1207 return None;
1208 }
1209 let mut out = Vec::with_capacity(s.len() / 2);
1210 let bytes = s.as_bytes();
1211 let mut i = 0;
1212 while i < bytes.len() {
1213 let hi = (bytes[i] as char).to_digit(16)?;
1214 let lo = (bytes[i + 1] as char).to_digit(16)?;
1215 out.push((hi * 16 + lo) as u8);
1216 i += 2;
1217 }
1218 Some(out)
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224
1225 #[test]
1226 fn hex_decode_roundtrips_json() {
1227 let json = r#"{"lastUsedModel":"composer-2"}"#;
1228 let hex: String = json.bytes().map(|b| format!("{b:02x}")).collect();
1229 let decoded = decode_hex(&hex).unwrap();
1230 assert_eq!(decoded, json.as_bytes());
1231 }
1232
1233 #[test]
1234 fn hex_decode_rejects_non_hex() {
1235 assert!(decode_hex("not-hex!!").is_none());
1236 assert!(decode_hex("abc").is_none()); }
1238
1239 #[test]
1240 fn strips_cursor_line_number_prefixes() {
1241 let raw = " 1|fn main() {\n 2| let x = 1;\n 3|}";
1242 assert_eq!(
1243 strip_cursor_line_numbers(raw),
1244 "fn main() {\n let x = 1;\n}"
1245 );
1246 }
1247
1248 #[test]
1249 fn strip_ignores_unnumbered_lines() {
1250 let raw = "header without number\n 1|real line";
1253 assert_eq!(strip_cursor_line_numbers(raw), "real line");
1254 }
1255
1256 fn make_node(msg: &str, ts: i64, ctx: Option<i64>) -> Vec<u8> {
1259 fn varint(mut v: u64, out: &mut Vec<u8>) {
1260 loop {
1261 let mut b = (v & 0x7f) as u8;
1262 v >>= 7;
1263 if v != 0 {
1264 b |= 0x80;
1265 }
1266 out.push(b);
1267 if v == 0 {
1268 break;
1269 }
1270 }
1271 }
1272 fn tag(field: u64, wire: u64, out: &mut Vec<u8>) {
1275 varint((field << 3) | wire, out);
1276 }
1277 let mut out = Vec::new();
1278 tag(1, 2, &mut out);
1280 varint(1, &mut out);
1281 out.push(0x00);
1282 tag(4, 2, &mut out);
1284 varint(msg.len() as u64, &mut out);
1285 out.extend_from_slice(msg.as_bytes());
1286 if let Some(ctx) = ctx {
1288 let mut inner = Vec::new();
1289 tag(1, 0, &mut inner);
1290 varint(ctx as u64, &mut inner);
1291 tag(5, 2, &mut out);
1292 varint(inner.len() as u64, &mut out);
1293 out.extend_from_slice(&inner);
1294 }
1295 tag(26, 0, &mut out);
1297 varint(ts as u64, &mut out);
1298 out
1299 }
1300
1301 #[test]
1302 fn assistant_node_extracts_message_and_ts() {
1303 let node = make_node(
1304 r#"{"role":"assistant","content":[]}"#,
1305 1_700_000_000_000,
1306 None,
1307 );
1308 let (msg, ts) = assistant_node(&node).unwrap();
1309 assert_eq!(ts, 1_700_000_000_000);
1310 let parsed: Value = serde_json::from_slice(msg).unwrap();
1311 assert_eq!(parsed["role"], "assistant");
1312 }
1313
1314 #[test]
1315 fn node_context_gauge_decodes() {
1316 let node = make_node(r#"{"role":"assistant"}"#, 1, Some(568_964));
1317 let nf = walk_node(&node);
1318 assert_eq!(context_tokens(nf.ctx_msg.unwrap()), Some(568_964));
1319 }
1320
1321 #[test]
1322 fn json_blob_is_not_an_assistant_node() {
1323 let blob = br#"{"role":"assistant","content":[]}"#.to_vec();
1324 assert!(assistant_node(&blob).is_none());
1325 }
1326
1327 #[test]
1328 fn load_blobs_uses_stable_id_order() {
1329 let conn = Connection::open_in_memory().unwrap();
1330 conn.execute_batch("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)")
1331 .unwrap();
1332 conn.execute("INSERT INTO blobs VALUES ('z', X'02')", [])
1333 .unwrap();
1334 conn.execute("INSERT INTO blobs VALUES ('a', X'01')", [])
1335 .unwrap();
1336
1337 assert_eq!(load_blobs(&conn).unwrap(), vec![vec![1], vec![2]]);
1338 }
1339
1340 #[test]
1341 fn dominant_model_ties_use_lexical_order() {
1342 let dir = tempfile::tempdir().unwrap();
1343 let path = dir.path().join("tracking.db");
1344 let conn = Connection::open(&path).unwrap();
1345 conn.execute_batch(
1346 "CREATE TABLE ai_code_hashes (conversationId TEXT, model TEXT); \
1347 INSERT INTO ai_code_hashes VALUES ('conversation', 'z-model'); \
1348 INSERT INTO ai_code_hashes VALUES ('conversation', 'a-model');",
1349 )
1350 .unwrap();
1351 drop(conn);
1352
1353 let models = load_conversation_models(&path).unwrap();
1354 assert_eq!(
1355 models.get("conversation").map(String::as_str),
1356 Some("a-model")
1357 );
1358 }
1359
1360 #[test]
1361 fn apply_tools_counts_and_lines() {
1362 let mut state = SessionParseState::with_mode(ParseMode::Full);
1363 let msg = json!({
1364 "role": "assistant",
1365 "content": [
1366 {"type": "tool-call", "toolName": "Write", "toolCallId": "a",
1367 "args": {"path": "/repo/x.rs", "contents": "line1\nline2"}},
1368 {"type": "tool-call", "toolName": "StrReplace", "toolCallId": "b",
1369 "args": {"path": "/repo/y.rs", "old_string": "old", "new_string": "new1\nnew2"}},
1370 {"type": "tool-call", "toolName": "Shell", "toolCallId": "c",
1371 "args": {"command": "ls -la", "description": "list"}},
1372 {"type": "tool-call", "toolName": "TodoWrite", "toolCallId": "d", "args": {}},
1373 {"type": "tool-call", "toolName": "Read", "toolCallId": "e",
1374 "args": {"path": "/repo/z.rs"}},
1375 {"type": "tool-call", "toolName": "Grep", "toolCallId": "f",
1376 "args": {"pattern": "foo"}},
1377 {"type": "tool-call", "toolName": "ReadFile", "toolCallId": "g",
1379 "args": {"path": "/repo/w.rs"}},
1380 ]
1381 });
1382 let mut reads = HashMap::new();
1383 reads.insert(
1384 "e".to_string(),
1385 CursorReadResult::Content("r1\nr2\nr3".to_string()),
1386 );
1387 reads.insert(
1388 "g".to_string(),
1389 CursorReadResult::Content("w1\nw2".to_string()),
1390 );
1391 apply_assistant_tools(&mut state, &msg, &reads, 42).unwrap();
1392
1393 assert_eq!(state.tool_counts.write, 1);
1394 assert_eq!(state.tool_counts.edit, 1);
1395 assert_eq!(state.tool_counts.bash, 1);
1396 assert_eq!(state.tool_counts.todo_write, 1);
1397 assert_eq!(state.tool_counts.read, 2);
1399 assert_eq!(state.total_write_lines, 2);
1400 assert_eq!(state.total_edit_lines, 2);
1401 assert_eq!(state.total_read_lines, 5);
1402 }
1403
1404 #[test]
1405 fn aggregate_events_filters_and_builds_records() {
1406 let events = vec![
1407 UsageEvent {
1408 date: "2999-01-01".to_string(),
1409 timestamp_ms: 32_470_920_000_000,
1410 model: "claude-sonnet-4.6".to_string(),
1411 input: 100,
1412 output: 20,
1413 cache_read: 50,
1414 cache_write: 10,
1415 cost: 1.5,
1416 },
1417 UsageEvent {
1418 date: "2000-01-01".to_string(),
1419 timestamp_ms: 946_684_800_000,
1420 model: "composer-2".to_string(),
1421 input: 5,
1422 output: 5,
1423 cache_read: 0,
1424 cache_write: 0,
1425 cost: 0.0,
1426 },
1427 ];
1428 let rows = aggregate_events(&events, TimeRange::Daily);
1430 assert_eq!(rows.len(), 1);
1431 let row = &rows[0];
1432 assert_eq!(row.date, "2999-01-01");
1433 assert_eq!(row.timestamp_ms, 32_470_920_000_000);
1434 assert!((row.stored_cost - 1.5).abs() < 1e-9);
1435 assert_eq!(row.model, "claude-sonnet-4.6");
1436 }
1437
1438 fn make_store_db(nodes: &[Vec<u8>], json_blobs: &[&str]) -> (tempfile::TempDir, PathBuf) {
1440 let dir = tempfile::tempdir().unwrap();
1441 let path = dir.path().join("store.db");
1442 write_store_db(&path, nodes, json_blobs);
1443 (dir, path)
1444 }
1445
1446 fn write_store_db(path: &Path, nodes: &[Vec<u8>], json_blobs: &[&str]) {
1447 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1448 let conn = Connection::open(path).unwrap();
1449 conn.execute_batch(
1450 "CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB); \
1451 CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);",
1452 )
1453 .unwrap();
1454 for (i, n) in nodes.iter().enumerate() {
1455 conn.execute(
1456 "INSERT INTO blobs (id, data) VALUES (?1, ?2)",
1457 rusqlite::params![format!("n{i}"), n],
1458 )
1459 .unwrap();
1460 }
1461 for (i, j) in json_blobs.iter().enumerate() {
1462 conn.execute(
1463 "INSERT INTO blobs (id, data) VALUES (?1, ?2)",
1464 rusqlite::params![format!("j{i}"), j.as_bytes()],
1465 )
1466 .unwrap();
1467 }
1468 drop(conn);
1469 }
1470
1471 #[test]
1472 fn read_store_analysis_over_real_db_counts_tools_and_ignores_non_assistant() {
1473 let assistant = make_node(
1474 r#"{"role":"assistant","content":[
1475 {"type":"tool-call","toolName":"Write","toolCallId":"a","args":{"path":"/r/x.rs","contents":"l1\nl2"}},
1476 {"type":"tool-call","toolName":"Shell","toolCallId":"b","args":{"command":"ls","description":"d"}},
1477 {"type":"tool-call","toolName":"Read","toolCallId":"z","args":{"path":"/r/y.rs"}}
1478 ]}"#,
1479 1_700_000_000_000,
1480 Some(50_000),
1481 );
1482 let user_node = make_node(r#"{"role":"user","content":[]}"#, 1_700_000_100_000, None);
1484 let tool_result = r#"{"role":"tool","content":[{"type":"tool-result","toolName":"Read","toolCallId":"z","result":" 1|line one\n 2|line two"}]}"#;
1485 let assistant_payload = r#"{"role":"assistant","content":[]}"#;
1486 let (_dir, path) =
1487 make_store_db(&[assistant, user_node], &[tool_result, assistant_payload]);
1488
1489 let conv_models = FastHashMap::default();
1490 let store = read_store_analysis(
1491 &path,
1492 &conv_models,
1493 TimeRange::All,
1494 ParseMode::Full,
1495 "u",
1496 "m",
1497 )
1498 .unwrap();
1499
1500 assert_eq!(store.rows.len(), 1);
1502 assert_eq!(store.normalized_messages, 1);
1503 assert_eq!(store.failed_payloads, 0);
1504 let rec = &store.rows[0].1.records[0];
1505 assert_eq!(rec.tool_call_counts.write, 1);
1506 assert_eq!(rec.tool_call_counts.bash, 1);
1507 assert_eq!(rec.tool_call_counts.read, 1);
1508 assert_eq!(rec.total_write_lines, 2);
1509 assert_eq!(rec.total_read_lines, 2);
1511 assert!(rec.conversation_usage.contains_key("unknown"));
1513 }
1514
1515 #[test]
1516 fn read_store_context_recovers_gauge_per_assistant_turn_only() {
1517 let a = make_node(
1518 r#"{"role":"assistant","content":[]}"#,
1519 1_700_000_000_000,
1520 Some(42_000),
1521 );
1522 let b = make_node(
1523 r#"{"role":"assistant","content":[]}"#,
1524 1_700_000_500_000,
1525 Some(88_000),
1526 );
1527 let non_assistant = make_node(
1530 r#"{"role":"user","content":[]}"#,
1531 1_700_000_600_000,
1532 Some(99_999),
1533 );
1534 let (_dir, path) = make_store_db(&[a, b, non_assistant], &[]);
1535
1536 let conv_models = FastHashMap::default();
1537 let mut read = read_store_context(&path, &conv_models, "conv").unwrap();
1538 read.turns.sort();
1539 assert_eq!(read.model, "unknown");
1540 assert_eq!(
1541 read.turns,
1542 vec![(1_700_000_000_000, 42_000), (1_700_000_500_000, 88_000)]
1543 );
1544 assert_eq!(read.expected_records, 2);
1545 assert_eq!(read.parsed_records, 2);
1546 }
1547
1548 #[test]
1549 fn cursor_usage_reports_unknown_message_schema() {
1550 for message in ["not json", r#"{"content":[]}"#] {
1551 let dir = tempfile::tempdir().unwrap();
1552 let chats = dir.path().join("chats");
1553 let store = chats.join("project/conversation/store.db");
1554 let node = make_node(message, 1_700_000_000_000, Some(123));
1555 write_store_db(&store, &[node], &[]);
1556
1557 let result = read_cursor_usage_with_diagnostics(
1558 &chats,
1559 &dir.path().join("tracking.db"),
1560 TimeRange::All,
1561 );
1562 assert_eq!(result.candidates, 1);
1563 assert_eq!(result.parsed, 0);
1564 assert!(result.rows.is_empty());
1565 assert_eq!(result.failures.len(), 1);
1566 assert!(result.failures[0].error.contains("none of 1"));
1567 }
1568
1569 let dir = tempfile::tempdir().unwrap();
1570 let chats = dir.path().join("chats");
1571 let store = chats.join("project/conversation/store.db");
1572 let valid = make_node(
1573 r#"{"role":"assistant","content":[]}"#,
1574 1_700_000_000_000,
1575 Some(123),
1576 );
1577 let invalid = make_node(r#"{"content":[]}"#, 1_700_000_001_000, Some(456));
1578 write_store_db(&store, &[valid, invalid], &[]);
1579 let result = read_cursor_usage_with_diagnostics(
1580 &chats,
1581 &dir.path().join("tracking.db"),
1582 TimeRange::All,
1583 );
1584 assert_eq!(result.candidates, 1);
1585 assert_eq!(result.parsed, 1);
1586 assert_eq!(result.rows.len(), 1);
1587 assert_eq!(result.failures.len(), 1);
1588 assert!(result.failures[0].error.contains("1 Cursor usage payload"));
1589 }
1590
1591 #[test]
1592 fn cursor_store_diagnostics_reject_undecodable_assistant_messages() {
1593 for message in ["not json", r#"{"role":"assistant","futureContent":[]}"#] {
1594 let dir = tempfile::tempdir().unwrap();
1595 let chats = dir.path().join("chats");
1596 let store = chats.join("project/conversation/store.db");
1597 let node = make_node(message, 1_700_000_000_000, None);
1598 write_store_db(&store, &[node], &[]);
1599
1600 let result = read_cursor_analysis_with_diagnostics(
1601 &chats,
1602 &dir.path().join("tracking.db"),
1603 TimeRange::All,
1604 ParseMode::Full,
1605 );
1606 assert_eq!(result.candidates, 1);
1607 assert_eq!(result.parsed, 0);
1608 assert!(result.rows.is_empty());
1609 assert_eq!(result.failures.len(), 1);
1610 assert!(result.failures[0].error.contains("none of 1"));
1611 }
1612
1613 let dir = tempfile::tempdir().unwrap();
1614 let chats = dir.path().join("chats");
1615 let store = chats.join("project/conversation/store.db");
1616 let mut unknown_node = make_node(
1617 r#"{"role":"assistant","content":[]}"#,
1618 1_700_000_000_000,
1619 None,
1620 );
1621 assert_eq!(unknown_node[3], 0x22);
1622 unknown_node[3] = 0x32;
1623 write_store_db(&store, &[unknown_node], &[]);
1624
1625 let result = read_cursor_analysis_with_diagnostics(
1626 &chats,
1627 &dir.path().join("tracking.db"),
1628 TimeRange::All,
1629 ParseMode::Full,
1630 );
1631 assert_eq!(result.candidates, 1);
1632 assert_eq!(result.parsed, 0);
1633 assert_eq!(result.failures.len(), 1);
1634 }
1635
1636 #[test]
1637 fn cursor_store_diagnostics_ignore_context_aggregate_nodes() {
1638 let dir = tempfile::tempdir().unwrap();
1639 let chats = dir.path().join("chats");
1640 let store = chats.join("project/conversation/store.db");
1641 let message = r#"{"role":"assistant","content":[]}"#;
1642 let mut aggregate = make_node(message, 1_700_000_000_000, Some(42_000));
1643 aggregate.drain(3..5 + message.len());
1644 write_store_db(&store, &[aggregate], &[]);
1645
1646 let result = read_cursor_analysis_with_diagnostics(
1647 &chats,
1648 &dir.path().join("tracking.db"),
1649 TimeRange::All,
1650 ParseMode::Full,
1651 );
1652 assert_eq!(result.candidates, 1);
1653 assert_eq!(result.parsed, 1);
1654 assert!(result.rows.is_empty());
1655 assert!(result.failures.is_empty());
1656 }
1657
1658 #[test]
1659 fn cursor_store_diagnostics_report_known_tool_schema_drift() {
1660 let dir = tempfile::tempdir().unwrap();
1661 let chats = dir.path().join("chats");
1662 let store = chats.join("project/conversation/store.db");
1663 let assistant = make_node(
1664 r#"{"role":"assistant","content":[
1665 {"type":"tool-call","toolName":"Write","toolCallId":"w","args":{"futurePath":"/repo/a","futureContents":"text"}},
1666 {"type":"tool-call","toolName":"Read","toolCallId":"r","args":{"path":"/repo/b"}}
1667 ]}"#,
1668 1_700_000_000_000,
1669 None,
1670 );
1671 let read_result = r#"{"role":"tool","content":[{"type":"tool-result","toolName":"Read","toolCallId":"r","futureResult":"text"}]}"#;
1672 write_store_db(&store, &[assistant], &[read_result]);
1673
1674 let result = read_cursor_analysis_with_diagnostics(
1675 &chats,
1676 &dir.path().join("tracking.db"),
1677 TimeRange::All,
1678 ParseMode::Full,
1679 );
1680 assert_eq!(result.candidates, 1);
1681 assert_eq!(result.parsed, 1);
1682 assert_eq!(result.failures.len(), 1);
1683 assert!(result.failures[0].error.contains("2 analyzer payloads"));
1684 assert_eq!(result.rows.len(), 1);
1685 let record = &result.rows[0].analysis.records[0];
1686 assert_eq!(record.tool_call_counts.write, 0);
1687 assert_eq!(record.tool_call_counts.read, 1);
1688 assert_eq!(record.total_read_lines, 0);
1689 }
1690
1691 #[test]
1692 fn cursor_store_diagnostics_ignore_out_of_range_malformed_read_results() {
1693 let dir = tempfile::tempdir().unwrap();
1694 let chats = dir.path().join("chats");
1695 let store = chats.join("project/conversation/store.db");
1696 let old_assistant = make_node(
1697 r#"{"role":"assistant","content":[
1698 {"type":"tool-call","toolName":"Read","toolCallId":"old","args":{"path":"/repo/old"}}
1699 ]}"#,
1700 946_684_800_000,
1701 None,
1702 );
1703 let malformed_result = r#"{"role":"tool","content":[{"type":"tool-result","toolName":"Read","toolCallId":"old","futureResult":"text"}]}"#;
1704 write_store_db(&store, &[old_assistant], &[malformed_result]);
1705
1706 let result = read_cursor_analysis_with_diagnostics(
1707 &chats,
1708 &dir.path().join("tracking.db"),
1709 TimeRange::Daily,
1710 ParseMode::Full,
1711 );
1712 assert_eq!(result.candidates, 1);
1713 assert_eq!(result.parsed, 1);
1714 assert!(result.rows.is_empty());
1715 assert!(result.failures.is_empty());
1716 }
1717
1718 #[test]
1719 fn out_of_range_malformed_read_result_does_not_taint_current_turn() {
1720 let dir = tempfile::tempdir().unwrap();
1721 let chats = dir.path().join("chats");
1722 let store = chats.join("project/conversation/store.db");
1723 let old_assistant = make_node(
1724 r#"{"role":"assistant","content":[
1725 {"type":"tool-call","toolName":"Read","toolCallId":"old","args":{"path":"/repo/old"}}
1726 ]}"#,
1727 946_684_800_000,
1728 None,
1729 );
1730 let current_assistant = make_node(
1731 r#"{"role":"assistant","content":[]}"#,
1732 4_102_444_800_000,
1733 None,
1734 );
1735 let malformed_result = r#"{"role":"tool","content":[{"type":"tool-result","toolName":"Read","toolCallId":"old","futureResult":"text"}]}"#;
1736 write_store_db(
1737 &store,
1738 &[old_assistant, current_assistant],
1739 &[malformed_result],
1740 );
1741
1742 let result = read_cursor_analysis_with_diagnostics(
1743 &chats,
1744 &dir.path().join("tracking.db"),
1745 TimeRange::Daily,
1746 ParseMode::Full,
1747 );
1748 assert_eq!(result.parsed, 1);
1749 assert_eq!(result.rows.len(), 1);
1750 assert!(result.failures.is_empty());
1751 }
1752
1753 #[test]
1754 fn in_range_malformed_read_result_remains_a_partial_failure() {
1755 let dir = tempfile::tempdir().unwrap();
1756 let chats = dir.path().join("chats");
1757 let store = chats.join("project/conversation/store.db");
1758 let assistant = make_node(
1759 r#"{"role":"assistant","content":[
1760 {"type":"tool-call","toolName":"Read","toolCallId":"current","args":{"path":"/repo/current"}}
1761 ]}"#,
1762 4_102_444_800_000,
1763 None,
1764 );
1765 let malformed_result = r#"{"role":"tool","content":[{"type":"tool-result","toolName":"Read","toolCallId":"current","futureResult":"text"}]}"#;
1766 write_store_db(&store, &[assistant], &[malformed_result]);
1767
1768 let result = read_cursor_analysis_with_diagnostics(
1769 &chats,
1770 &dir.path().join("tracking.db"),
1771 TimeRange::Daily,
1772 ParseMode::Full,
1773 );
1774 assert_eq!(result.parsed, 1);
1775 assert_eq!(result.failures.len(), 1);
1776 assert!(result.failures[0].error.contains("1 analyzer payload"));
1777 let record = &result.rows[0].analysis.records[0];
1778 assert_eq!(record.tool_call_counts.read, 1);
1779 assert_eq!(record.total_read_lines, 0);
1780 }
1781
1782 #[test]
1783 fn unkeyed_tool_blobs_do_not_taint_an_in_range_read() {
1784 let dir = tempfile::tempdir().unwrap();
1785 let chats = dir.path().join("chats");
1786 let store = chats.join("project/conversation/store.db");
1787 let assistant = make_node(
1788 r#"{"role":"assistant","content":[
1789 {"type":"tool-call","toolName":"Read","toolCallId":"missing","args":{"path":"/repo/current"}}
1790 ]}"#,
1791 4_102_444_800_000,
1792 None,
1793 );
1794 let non_array = r#"{"role":"tool","content":"future"}"#;
1795 let missing_id = r#"{"role":"tool","content":[{"type":"tool-result","toolName":"Read","result":"text"}]}"#;
1796 write_store_db(&store, &[assistant], &[non_array, missing_id]);
1797
1798 let result = read_cursor_analysis_with_diagnostics(
1799 &chats,
1800 &dir.path().join("tracking.db"),
1801 TimeRange::Daily,
1802 ParseMode::Full,
1803 );
1804 assert_eq!(result.parsed, 1);
1805 assert!(result.failures.is_empty());
1806 let record = &result.rows[0].analysis.records[0];
1807 assert_eq!(record.tool_call_counts.read, 1);
1808 assert_eq!(record.total_read_lines, 0);
1809 }
1810
1811 #[test]
1812 fn read_cursor_analysis_errors_when_every_store_is_unreadable() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let chats = dir.path().join("chats");
1815 let store = chats.join("project/conversation/store.db");
1816 std::fs::create_dir_all(store.parent().unwrap()).unwrap();
1817 let conn = Connection::open(&store).unwrap();
1818 conn.execute_batch(
1819 "CREATE TABLE blobs (id TEXT PRIMARY KEY, data TEXT); \
1820 CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); \
1821 INSERT INTO blobs VALUES ('bad', 'not a blob');",
1822 )
1823 .unwrap();
1824 drop(conn);
1825
1826 let error = read_cursor_analysis(
1827 &chats,
1828 &dir.path().join("tracking.db"),
1829 TimeRange::All,
1830 ParseMode::Full,
1831 )
1832 .unwrap_err();
1833 assert!(
1834 error
1835 .to_string()
1836 .contains("failed to read all 1 Cursor chat stores")
1837 );
1838 }
1839
1840 #[test]
1841 fn cursor_store_diagnostics_preserve_partial_failures() {
1842 let dir = tempfile::tempdir().unwrap();
1843 let chats = dir.path().join("chats");
1844
1845 let valid = chats.join("project/valid/store.db");
1846 std::fs::create_dir_all(valid.parent().unwrap()).unwrap();
1847 let conn = Connection::open(&valid).unwrap();
1848 conn.execute_batch(
1849 "CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB); \
1850 CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);",
1851 )
1852 .unwrap();
1853 drop(conn);
1854
1855 let invalid = chats.join("project/invalid/store.db");
1856 std::fs::create_dir_all(invalid.parent().unwrap()).unwrap();
1857 let conn = Connection::open(&invalid).unwrap();
1858 conn.execute_batch(
1859 "CREATE TABLE blobs (id TEXT PRIMARY KEY, data TEXT); \
1860 CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); \
1861 INSERT INTO blobs VALUES ('bad', 'not a blob');",
1862 )
1863 .unwrap();
1864 drop(conn);
1865
1866 let result = read_cursor_analysis_with_diagnostics(
1867 &chats,
1868 &dir.path().join("tracking.db"),
1869 TimeRange::All,
1870 ParseMode::Full,
1871 );
1872 assert_eq!(result.candidates, 2);
1873 assert_eq!(result.parsed, 1);
1874 assert_eq!(result.failures.len(), 1);
1875 assert_eq!(result.failures[0].path, invalid);
1876 }
1877}