use crate::usage_stats::tui::{
ScanFn, ScanMessage, ScanProgress, StatsTuiScanRequest, run_stats_tui,
};
use crate::usage_stats::{
OutputFormat, RenderOptions, SessionId, SessionRecord, StatsCliArgs, StatsJsonOutput,
StatsQuery, StatsViewResult, ToolKind, ViewKind, build_session_detail_view,
build_sessions_view, build_summary_view, build_trend_view, filter_sessions_v1,
parse_time_range, render_stats_json, render_stats_table, validate_stats_cli_args,
};
use crate::x::cursor::database::CursorDatabase;
use crate::x::cursor::models::ComposerData;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, TimeZone, Utc};
use clap::Args;
use rusqlite::{Connection, OpenFlags, OptionalExtension};
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Args, Debug, Clone)]
pub struct CursorStatsArgs {
#[command(flatten)]
pub stats: StatsCliArgs,
#[arg(long)]
pub db_path: Option<PathBuf>,
#[arg(long)]
pub global_db_path: Option<PathBuf>,
}
pub fn run_stats(args: &CursorStatsArgs) -> Result<()> {
validate_stats_cli_args(&args.stats)?;
let cwd = std::env::current_dir().context("resolve current directory")?;
let workspace_db = resolve_workspace_db_path(args.db_path.as_deref(), &cwd)?;
let global_db = resolve_global_db_path(args.global_db_path.as_deref())?;
if args.stats.tui {
let initial = StatsTuiScanRequest {
cwd: cwd.clone(),
group_by: args.stats.group_by,
range: args.stats.range.clone(),
limit: args.stats.limit,
verbose_paths: args.stats.verbose,
with_breakdown: false,
include_sidechain: true,
};
let scan_workspace_db = workspace_db.clone();
let scan_global_db = global_db.clone();
let scan_fn: ScanFn = Arc::new(move |request, tx, cancel| {
let tx_progress = tx.clone();
let mut report = move |progress: CursorScanProgress| {
let CursorScanProgress::Composers { done, total } = progress;
let _ = tx_progress.send(ScanMessage::Progress(ScanProgress {
label: "Scanning composers",
done,
total,
}));
};
let sessions = load_cursor_sessions(
&scan_workspace_db,
&scan_global_db,
&request.cwd,
Some(&mut report),
Some(cancel.as_ref()),
)?;
let time_range = parse_time_range(&request.range, Utc::now())?;
let query = StatsQuery {
view: ViewKind::Summary,
group_by: request.group_by,
time_range,
cwd: request.cwd.clone(),
limit: request.limit,
id: None,
};
Ok(filter_sessions_v1(&sessions, &query))
});
return run_stats_tui(ToolKind::Cursor, initial, scan_fn);
}
let time_range = parse_time_range(&args.stats.range, Utc::now())?;
let id = args
.stats
.id
.as_deref()
.map(|raw| SessionId(raw.to_string()));
let query = StatsQuery {
view: args.stats.view,
group_by: args.stats.group_by,
time_range,
cwd: cwd.clone(),
limit: args.stats.limit,
id,
};
let sessions = load_cursor_sessions(&workspace_db, &global_db, &cwd, None, None)?;
let sessions = filter_sessions_v1(&sessions, &query);
let view = match query.view {
ViewKind::Summary => StatsViewResult::Summary(build_summary_view(&sessions)),
ViewKind::Trend => StatsViewResult::Trend(build_trend_view(&sessions, query.group_by)),
ViewKind::Sessions => {
StatsViewResult::Sessions(build_sessions_view(&sessions, query.limit))
}
ViewKind::Session => {
let id = query.id.as_ref().expect("validated");
let Some(view) = build_session_detail_view(&sessions, id) else {
bail!("session not found for id: {id}");
};
StatsViewResult::Session(view)
}
};
let output = StatsJsonOutput {
tool: ToolKind::Cursor,
query,
view,
};
match args.stats.format {
OutputFormat::Json => {
let json = render_stats_json(&output)?;
println!("{json}");
}
OutputFormat::Table => {
let table = render_stats_table(
&output,
&RenderOptions {
verbose_paths: args.stats.verbose,
color: args.stats.color,
},
);
print!("{table}");
}
}
Ok(())
}
fn resolve_workspace_db_path(
override_path: Option<&std::path::Path>,
cwd: &std::path::Path,
) -> Result<PathBuf> {
if let Some(path) = override_path {
return Ok(path.to_path_buf());
}
let workspaces = CursorDatabase::find_all_workspaces().context("scan Cursor workspaces")?;
let Some(workspace) = workspaces
.into_iter()
.find(|w| w.project_path.as_deref() == Some(cwd))
else {
bail!("no Cursor workspace found for current directory; use --db-path");
};
Ok(workspace.db_path)
}
fn resolve_global_db_path(override_path: Option<&std::path::Path>) -> Result<PathBuf> {
if let Some(path) = override_path {
return Ok(path.to_path_buf());
}
CursorDatabase::get_global_db_path().context("resolve Cursor global state db")
}
#[derive(Debug, Clone, Copy)]
enum CursorScanProgress {
Composers { done: usize, total: usize },
}
fn load_cursor_sessions(
workspace_db: &std::path::Path,
global_db: &std::path::Path,
cwd: &std::path::Path,
mut progress: Option<&mut dyn FnMut(CursorScanProgress)>,
cancel: Option<&AtomicBool>,
) -> Result<Vec<SessionRecord>> {
let composer_data = read_composer_data(workspace_db)?;
let mut global_conn = connect_sqlite_ro(global_db)
.with_context(|| format!("open Cursor global db: {}", global_db.display()))?;
let total = composer_data.all_composers.len();
let mut done = 0usize;
let mut sessions = Vec::with_capacity(total);
for composer in composer_data.all_composers {
if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
return Ok(Vec::new());
}
done = done.saturating_add(1);
if let Some(progress) = progress.as_deref_mut() {
progress(CursorScanProgress::Composers { done, total });
}
let (token_usage, start_ts, end_ts) = read_composer_usage(
&mut global_conn,
&composer.composer_id,
composer.created_at,
composer.last_updated_at,
)?;
let Some(end_ts) = end_ts else {
continue;
};
sessions.push(SessionRecord {
tool: ToolKind::Cursor,
id: SessionId(format!("composer:{}", composer.composer_id)),
cwd: cwd.to_path_buf(),
title: composer.name.and_then(|s| {
let trimmed = s.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}),
start_ts,
end_ts,
token_usage,
is_sidechain: None,
});
}
Ok(sessions)
}
fn connect_sqlite_ro(path: &std::path::Path) -> Result<Connection> {
let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;
Connection::open_with_flags(path, flags)
.with_context(|| format!("open sqlite read-only: {}", path.display()))
}
fn read_composer_data(workspace_db: &std::path::Path) -> Result<ComposerData> {
let conn = connect_sqlite_ro(workspace_db)
.with_context(|| format!("open Cursor workspace db: {}", workspace_db.display()))?;
let raw: Option<Vec<u8>> = conn
.query_row(
"SELECT value FROM ItemTable WHERE key = 'composer.composerData' LIMIT 1",
[],
|row| row.get(0),
)
.optional()
.context("query composer.composerData")?;
let Some(raw) = raw else {
return Ok(ComposerData {
all_composers: vec![],
selected_composer_ids: None,
});
};
serde_json::from_slice(&raw).context("parse composer.composerData json")
}
#[derive(Debug, Clone, Deserialize)]
struct CursorBubbleValue {
#[serde(rename = "createdAt", default)]
created_at: Option<CursorCreatedAt>,
#[serde(rename = "tokenCount", default)]
token_count: Option<CursorTokenCount>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum CursorCreatedAt {
Rfc3339(String),
EpochMs(i64),
}
#[derive(Debug, Clone, Deserialize)]
struct CursorTokenCount {
#[serde(rename = "inputTokens", default)]
input_tokens: Option<u64>,
#[serde(rename = "outputTokens", default)]
output_tokens: Option<u64>,
}
#[derive(Default)]
struct TokenAccum {
input_any: bool,
output_any: bool,
input_sum: u64,
output_sum: u64,
}
impl TokenAccum {
fn add(&mut self, token_count: &CursorTokenCount) {
if let Some(v) = token_count.input_tokens {
self.input_any = true;
self.input_sum = self.input_sum.saturating_add(v);
}
if let Some(v) = token_count.output_tokens {
self.output_any = true;
self.output_sum = self.output_sum.saturating_add(v);
}
}
fn build(self) -> crate::usage_stats::TokenUsage {
let input = self.input_any.then_some(self.input_sum);
let output = self.output_any.then_some(self.output_sum);
let total = match (input, output) {
(Some(a), Some(b)) => Some(a.saturating_add(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
crate::usage_stats::TokenUsage {
total,
input,
output,
cache: None,
reasoning: None,
}
}
}
type CursorComposerUsage = (
crate::usage_stats::TokenUsage,
Option<DateTime<Utc>>,
Option<DateTime<Utc>>,
);
fn read_composer_usage(
conn: &mut Connection,
composer_id: &str,
composer_created_at_ms: i64,
composer_last_updated_at_ms: Option<i64>,
) -> Result<CursorComposerUsage> {
let pattern = format!("bubbleId:{composer_id}:%");
let mut stmt = conn
.prepare("SELECT rowid, key, value FROM cursorDiskKV WHERE key LIKE ?1 ORDER BY rowid")
.with_context(|| format!("prepare cursorDiskKV query for composer: {composer_id}"))?;
let rows: Vec<(i32, String, Option<Vec<u8>>)> = stmt
.query_map([&pattern], |row| {
Ok((
row.get::<_, i32>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<Vec<u8>>>(2)?,
))
})?
.filter_map(|r| r.ok())
.collect();
let mut tokens = TokenAccum::default();
let mut min_ts: Option<DateTime<Utc>> = None;
let mut max_ts: Option<DateTime<Utc>> = None;
for (_rowid, _key, value) in rows {
let Some(raw) = value else {
continue;
};
let Ok(bubble) = serde_json::from_slice::<CursorBubbleValue>(&raw) else {
continue;
};
if let Some(token_count) = &bubble.token_count {
tokens.add(token_count);
}
if let Some(created_at) = &bubble.created_at
&& let Some(ts) = parse_cursor_created_at(created_at)
{
min_ts = Some(min_ts.map_or(ts, |current| current.min(ts)));
max_ts = Some(max_ts.map_or(ts, |current| current.max(ts)));
}
}
let fallback_start = parse_epoch_ms_utc(composer_created_at_ms);
let fallback_end = composer_last_updated_at_ms
.and_then(parse_epoch_ms_utc)
.or_else(|| parse_epoch_ms_utc(composer_created_at_ms));
let start_ts = min_ts.or(fallback_start);
let end_ts = max_ts.or(fallback_end);
Ok((tokens.build(), start_ts, end_ts))
}
fn parse_cursor_created_at(raw: &CursorCreatedAt) -> Option<DateTime<Utc>> {
match raw {
CursorCreatedAt::Rfc3339(s) => DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.with_timezone(&Utc)),
CursorCreatedAt::EpochMs(ms) => parse_epoch_ms_utc(*ms),
}
}
fn parse_epoch_ms_utc(ms: i64) -> Option<DateTime<Utc>> {
Utc.timestamp_millis_opt(ms).single()
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use tempfile::TempDir;
fn create_workspace_db(path: &std::path::Path, composer_data_json: &str) {
let conn = Connection::open(path).expect("open sqlite");
conn.execute(
"CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value BLOB);",
(),
)
.expect("create ItemTable");
conn.execute(
"INSERT INTO ItemTable (key, value) VALUES (?1, ?2);",
(
"composer.composerData",
composer_data_json.as_bytes().to_vec(),
),
)
.expect("insert composerData");
}
fn create_global_db(path: &std::path::Path, rows: Vec<(&str, &str)>) {
let conn = Connection::open(path).expect("open sqlite");
conn.execute(
"CREATE TABLE cursorDiskKV (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);",
(),
)
.expect("create cursorDiskKV");
for (key, json) in rows {
conn.execute(
"INSERT INTO cursorDiskKV (key, value) VALUES (?1, ?2);",
(key, json.as_bytes().to_vec()),
)
.expect("insert bubble");
}
}
#[test]
fn load_cursor_sessions_parses_bubble_created_at_and_token_count() {
let temp = TempDir::new().expect("temp dir");
let workspace_db = temp.path().join("state.vscdb");
let global_db = temp.path().join("global.vscdb");
let composer_json = r#"{
"allComposers": [
{ "composerId": "c1", "createdAt": 1700000000000, "lastUpdatedAt": 1700000002000, "unifiedMode": "agent", "name": "One" }
]
}"#;
create_workspace_db(&workspace_db, composer_json);
create_global_db(
&global_db,
vec![
(
"bubbleId:c1:b1",
r#"{"createdAt":"2026-02-01T00:00:00.000Z","tokenCount":{"inputTokens":10,"outputTokens":5}}"#,
),
(
"bubbleId:c1:b2",
r#"{"createdAt":1760000000000,"tokenCount":{"inputTokens":3,"outputTokens":4}}"#,
),
("bubbleId:c1:b3", r#"{"createdAt":"bad","tokenCount":{}}"#),
],
);
let cwd = std::path::PathBuf::from("/p");
let sessions =
load_cursor_sessions(&workspace_db, &global_db, &cwd, None, None).expect("sessions");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id.0, "composer:c1");
assert_eq!(sessions[0].token_usage.total, Some(22));
assert!(sessions[0].start_ts.is_some());
}
#[test]
fn load_cursor_sessions_handles_empty_bubbles_with_fallback_timestamps() {
let temp = TempDir::new().expect("temp dir");
let workspace_db = temp.path().join("state.vscdb");
let global_db = temp.path().join("global.vscdb");
let composer_json = r#"{
"allComposers": [
{ "composerId": "c1", "createdAt": 1700000000000, "unifiedMode": "agent", "name": "One" }
]
}"#;
create_workspace_db(&workspace_db, composer_json);
create_global_db(&global_db, vec![]);
let cwd = std::path::PathBuf::from("/p");
let sessions =
load_cursor_sessions(&workspace_db, &global_db, &cwd, None, None).expect("sessions");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].token_usage.total, None);
assert!(sessions[0].end_ts.timestamp_millis() > 0);
}
}