Skip to main content

vct_core/session/
cursor.rs

1//! Cursor session reader (local SQLite blob stores).
2//!
3//! Cursor keeps session data in two places under `~/.cursor`:
4//!
5//! - `ai-tracking/ai-code-tracking.db` — one row per AI-authored code line,
6//!   carrying the `model` that wrote it. Used to attribute each conversation to
7//!   a model for the `analysis` view (`conversationId -> model`).
8//! - `chats/<projectHash>/<conversationId>/store.db` — a content-addressed blob
9//!   store holding the whole conversation. Assistant turns live in binary
10//!   protobuf DAG nodes (`field 4` = the message JSON, `field 26` = timestamp,
11//!   `field 5` = the running context-window gauge); tool results live in
12//!   standalone JSON blobs. Parsed for `analysis` tool-call metrics.
13//!
14//! Cursor does **not** persist real billing tokens locally (only the context
15//! gauge), so the `usage` view is a deliberately-rough **local estimate** from
16//! that gauge (there is no dashboard-API path here; see `docs/quota.md` for
17//! the raw endpoint if it is ever reintroduced), keeping Cursor consistent with
18//! the other providers whose `usage` is likewise computed from local session
19//! data.
20//!
21//! Both entry points return the same `(local YYYY-MM-DD, CodeAnalysis[, cost])`
22//! shape the OpenCode reader produces, so the `usage` / `analysis` aggregators
23//! fold Cursor in exactly like the other providers.
24
25use 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
43// ===========================================================================
44// Public entry points
45// ===========================================================================
46
47/// Reads per-model token usage for Cursor.
48///
49/// Cursor does not persist real billing tokens locally (only a context gauge),
50/// so this is a deliberately-rough estimate from the chat stores: the context
51/// gauge is counted as cache-read tokens. The caller applies the shared
52/// LiteLLM pricing map so a TUI refresh does not rebuild it per reader call.
53///
54/// Each returned tuple is `(local YYYY-MM-DD, CodeAnalysis, 0.0)` with the
55/// analysis carrying one model's `conversation_usage`, matching the shape of
56/// [`crate::session::read_opencode_usage`].
57///
58/// # Errors
59///
60/// Returns an error only if reading the local chat stores fails.
61pub 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
89/// Per-store Cursor usage result used by diagnostics-aware collection.
90pub(crate) struct CursorUsageRead {
91    /// Successfully aggregated date/model rows.
92    pub rows: Vec<UsageContribution>,
93    /// Number of discovered stores, or one traversal candidate on failure.
94    pub candidates: usize,
95    /// Stores decoded successfully, including valid empty stores.
96    pub parsed: usize,
97    /// Store-level open or decode failures.
98    pub failures: Vec<CursorAnalysisFailure>,
99}
100
101/// Reads Cursor usage while preserving partial store failures.
102pub(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
116/// Reads per-model file-operation metrics for Cursor from the chat stores.
117///
118/// Walks every `chats/*/*/store.db`, extracts each assistant turn's tool calls
119/// (`Read` / `Write` / `StrReplace`→edit / `Shell`→bash / `TodoWrite`), and
120/// attributes them to the conversation's model (from `ai-code-tracking.db`,
121/// falling back to the store's `lastUsedModel`). Records are bucketed by the
122/// assistant turn's local date and filtered by `time_range`.
123///
124/// # Errors
125///
126/// A bad store is logged and skipped when any other store succeeds. Returns an
127/// error when stores exist but none can be read, allowing batch diagnostics to
128/// distinguish total parser failure from an empty Cursor history.
129pub 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
156/// One Cursor store that could not be decoded.
157pub(crate) struct CursorAnalysisFailure {
158    /// Store path used for diagnostics.
159    pub path: PathBuf,
160    /// SQLite or schema error without any chat payload.
161    pub error: String,
162}
163
164/// Accessible Cursor stores plus traversal failures discovered beside them.
165pub(crate) struct CursorStoreDiscovery {
166    pub stores: Vec<PathBuf>,
167    pub failures: Vec<CursorAnalysisFailure>,
168}
169
170/// Per-store Cursor analysis result used by the batch collector.
171pub(crate) struct CursorAnalysisRead {
172    /// Successfully decoded date/session rows.
173    pub rows: Vec<DatabaseAnalysisRow>,
174    /// Number of discovered `store.db` files.
175    pub candidates: usize,
176    /// Number of stores decoded successfully, including valid empty stores.
177    pub parsed: usize,
178    /// Store-level failures retained for noninteractive diagnostics.
179    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
188/// Reads Cursor stores while retaining one diagnostic per store.
189pub(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// ===========================================================================
261// usage: local estimate
262// ===========================================================================
263
264/// One usage aggregation row keyed by `(date, model)`, so any time range can
265/// filter it locally. A purely in-memory intermediate — never serialized.
266#[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
285/// Turns usage events into compact summary contributions.
286fn 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
304/// Reads one Cursor store into compact usage contributions.
305///
306/// The caller owns discovery and the shared tracking index so an incremental
307/// scan can fingerprint and refresh each store independently.
308pub(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
338// ===========================================================================
339// usage: local estimate
340// ===========================================================================
341
342/// Builds all-time usage-estimate events from the local context gauge.
343///
344/// Cursor stores only the running context-window size per assistant turn, not
345/// billed tokens. Each turn re-sends (and prompt-cache-reads) the accumulated
346/// context, so summing the gauge across a conversation's turns approximates the
347/// **cache-read** token volume — reported in the cache-read bucket both because
348/// that is the honest bucket and because it is then priced at the much cheaper
349/// cache rate rather than a wildly-inflated full-input rate. Input/output are
350/// unknown (`0`) and the stored cost is `0` (models Cursor prices itself, e.g.
351/// `composer-*`, have no LiteLLM entry and stay `$0`). Deliberately rough.
352/// Returns all dates; the caller filters by time range.
353fn 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    // (date, model) -> (summed context-window gauge, latest timestamp)
365    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
435// ===========================================================================
436// analysis: store.db parsing
437// ===========================================================================
438
439/// Enumerates every `chats/<projectHash>/<conversationId>/store.db` under the
440/// chats root (exactly two directory levels deep).
441pub(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
530/// The conversationId is the store.db's parent directory name.
531fn 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
539/// Loads `conversationId -> model` from `ai-code-tracking.db`.
540///
541/// Each conversation is authored by a single model in practice; when more than
542/// one appears, the one with the most tracked lines wins. Returns an empty map
543/// when the DB is absent; read and schema failures remain retryable errors.
544pub(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                // Rows are ordered by descending line count, so the first model seen
570                // for a conversation is its dominant one.
571                m.entry(row.0).or_insert(row.1);
572            }
573            Ok(m)
574        },
575    )
576}
577
578/// Model attribution read from one stable tracking-database fingerprint.
579pub(crate) struct ConversationModelSnapshot {
580    pub(crate) models: FastHashMap<String, String>,
581    pub(crate) fingerprint: Option<DatabaseFingerprint>,
582}
583
584/// Loads Cursor model attribution without pairing map A with fingerprint B.
585pub(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
609/// Parses one chat store into per-(date) analysis records for its model.
610pub(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        // Pass 1: index Read tool results by tool-call id so their file lines can
627        // be attributed to the matching Read call in pass 2.
628        let read_results = collect_read_results(&blobs);
629        let mut failed_payloads = 0usize;
630
631        // Pass 2: fold each assistant turn's tool calls into a per-date state.
632        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                // Cursor also writes aggregate DAG nodes with the normal
641                // context-gauge field and a timestamp but no message. They are
642                // not assistant turns. A timestamped node with neither the
643                // message nor the known gauge remains suspicious schema drift.
644                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            // Only assistant turns carry tool calls. Guard before creating a
666            // date bucket so a non-assistant node never emits a zero-metric row
667            // or inflates the Cursor active-day count.
668            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            // The analysis aggregator only reads the model key; the value is a
705            // placeholder (real tokens come from the usage API path).
706            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
719/// Reads a store's per-turn context-occupancy gauge for the usage approximation.
720///
721/// Returns the conversation's model plus `(timestamp_ms, context_tokens)` for
722/// every assistant turn that carries the gauge.
723struct 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        // The gauge scan only reads binary DAG nodes; filtering in SQL keeps
739        // the (potentially large) JSON tool-result payloads from ever being
740        // materialized on the usage cheap path.
741        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            // Only assistant turns represent a real per-request context. Cursor
755            // also stores intermediate DAG nodes that carry the running gauge but
756            // no assistant message; counting those would roughly double the
757            // approximation's tokens and inflate its active-day count.
758            let role = message_role(msg_bytes);
759            match role.as_deref() {
760                Some("assistant") => {
761                    // An empty gauge payload is an observed benign variant (a
762                    // turn recorded before any context reading, e.g. aborted);
763                    // only a non-empty payload we cannot decode is drift.
764                    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
787/// Resolves a store's model: the tracking DB attribution, else the store's own
788/// `lastUsedModel`, else `"unknown"`.
789fn 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
801/// Reads `lastUsedModel` from the store's `meta` row.
802///
803/// The `meta.value` is a hex-encoded JSON string; decode then read the field.
804/// Tolerates a plain-JSON value too, in case a future build stops hex-encoding.
805fn 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
817/// Loads every blob's raw bytes from a store.
818fn 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
824/// Loads only the binary DAG-node blobs (first byte `0x0A`).
825///
826/// The usage gauge path never inspects the JSON message/tool-result blobs the
827/// analysis path joins by `toolCallId`, so it skips them at the SQL layer.
828fn 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/// Returns `(assistant message JSON bytes, timestamp_ms)` for a binary DAG node.
836///
837/// Binary nodes start with `field 1` (`0x0A`) and embed exactly one assistant
838/// message in `field 4`; `field 26` is the epoch-ms timestamp. Non-node blobs
839/// (JSON messages) return `None`, as do nodes missing the timestamp — an
840/// undateable turn is skipped rather than mis-bucketed to the epoch (1970).
841#[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
850/// Whether a message JSON blob is an assistant turn.
851fn 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
862/// Applies one assistant message's tool calls to `state`.
863fn 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                        // A read whose result we could not recover still counts as a
931                        // read invocation, matching the OpenCode reader.
932                        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            // Grep / Glob / Delete etc. are not part of the tracked tool set.
946            _ => {}
947        }
948    }
949    Ok(failures)
950}
951
952enum CursorReadResult {
953    Content(String),
954    Unsupported,
955}
956
957/// Indexes `Read` tool results by tool-call id, with line-number prefixes
958/// stripped so the recovered content is the file's own lines.
959fn 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        // Assistant messages are also stored as standalone content-addressed
970        // JSON blobs and referenced from binary DAG nodes. Pass 2 reads the
971        // dated node, so this undated payload copy is expected and ignored.
972        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
1012/// Strips Cursor's `"<spaces><digits>|"` read-output line prefixes, keeping only
1013/// the numbered content lines so the recovered text has the file's line count.
1014fn 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
1024/// Returns the content after a `"<spaces><digits>|"` prefix, or `None` when the
1025/// line has no such prefix.
1026fn 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// ===========================================================================
1039// protobuf DAG node decoding
1040// ===========================================================================
1041
1042/// The subset of a binary DAG node fields the reader needs.
1043#[derive(Default)]
1044struct NodeFields<'a> {
1045    /// `field 4`: the embedded message JSON bytes.
1046    msg: Option<&'a [u8]>,
1047    /// `field 26`: epoch-ms timestamp.
1048    ts: Option<i64>,
1049    /// `field 5`: the nested context-gauge message bytes.
1050    ctx_msg: Option<&'a [u8]>,
1051}
1052
1053/// Walks a node's top-level protobuf fields, extracting `field 4/5/26`.
1054///
1055/// Deliberately does not traverse the DAG (child hash refs in `field 1/3`); it
1056/// only reads the three fields the reader cares about, so it stays robust to
1057/// unrelated schema additions.
1058fn 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
1101/// Reads the context-occupancy value (`field 1`) out of a `field 5` gauge message.
1102fn 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
1130/// Reads a base-128 varint at `pos`, returning `(value, next_pos)`.
1131///
1132/// Bounded to 10 bytes and to the slice length so a truncated blob can never
1133/// spin or read out of bounds.
1134fn 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
1150// ===========================================================================
1151// shared helpers
1152// ===========================================================================
1153
1154/// Builds the Claude-style flat usage value the token extractor understands.
1155fn 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
1170/// Wraps a record into a Cursor-tagged [`CodeAnalysis`].
1171fn 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
1181/// Converts a millisecond epoch timestamp into a local `YYYY-MM-DD` date.
1182fn 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
1190/// Pre-computes the `YYYY-MM-DD` cutoff for `time_range`, if any.
1191fn 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
1197/// Returns `true` when `date` is strictly before the cutoff (should be skipped).
1198fn is_before_cutoff(date: &str, cutoff: &Option<String>) -> bool {
1199    matches!(cutoff, Some(c) if date < c.as_str())
1200}
1201
1202/// Decodes an even-length ASCII hex string into bytes, or `None` when it is not
1203/// valid hex.
1204fn 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()); // odd length
1237    }
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        // Only numbered content lines survive, so the recovered line count is the
1251        // file's line count.
1252        let raw = "header without number\n     1|real line";
1253        assert_eq!(strip_cursor_line_numbers(raw), "real line");
1254    }
1255
1256    /// Builds a minimal binary DAG node: `field 4` = message JSON, `field 26` =
1257    /// timestamp varint, optional `field 5` = context gauge.
1258    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        // A protobuf tag is itself a varint `(field << 3) | wire`; field 26's tag
1273        // (208) needs two bytes, so encode every tag as a varint.
1274        fn tag(field: u64, wire: u64, out: &mut Vec<u8>) {
1275            varint((field << 3) | wire, out);
1276        }
1277        let mut out = Vec::new();
1278        // field 1 marker (a dummy 1-byte child ref) so the blob starts with 0x0A.
1279        tag(1, 2, &mut out);
1280        varint(1, &mut out);
1281        out.push(0x00);
1282        // field 4 (message JSON)
1283        tag(4, 2, &mut out);
1284        varint(msg.len() as u64, &mut out);
1285        out.extend_from_slice(msg.as_bytes());
1286        // field 5 (context gauge: field 1 = ctx)
1287        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        // field 26 (timestamp)
1296        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                // Cursor also emits the read tool as `ReadFile` in some versions.
1378                {"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        // Read + ReadFile both count as reads.
1398        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        // Daily cutoff drops the ancient 2000 row but keeps the far-future one.
1429        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    /// Builds a temp `store.db` with the given binary nodes and JSON blobs.
1439    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        // A non-assistant node must NOT create a date bucket or an active day.
1483        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        // Exactly one assistant turn -> one (date) record; the user node is dropped.
1501        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        // Read result lines were recovered and prefix-stripped (2 numbered lines).
1510        assert_eq!(rec.total_read_lines, 2);
1511        // No tracking DB / meta -> model falls back to "unknown".
1512        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        // A gauge-bearing node that is NOT an assistant turn must be excluded so
1528        // the offline approximation does not double-count context.
1529        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}