Skip to main content

wrapup/
lib.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Datelike, Local, Timelike, Utc};
3use contrail_types::MasterLog;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::fs::File;
8use std::io::{BufRead, BufReader, Write};
9use std::path::{Path, PathBuf};
10
11mod report;
12
13#[derive(Debug, Default)]
14struct SessionAgg {
15    source_tool: String,
16    session_id: String,
17    project_counts: HashMap<String, usize>,
18    started_at: Option<DateTime<Utc>>,
19    ended_at: Option<DateTime<Utc>>,
20    turns: usize,
21    interrupted: bool,
22    clipboard_hits: usize,
23    file_effects: usize,
24    models: HashSet<String>,
25    git_branches: HashSet<String>,
26    token_cumulative_total_max: u64,
27    token_cumulative_prompt_max: u64,
28    token_cumulative_completion_max: u64,
29    token_cumulative_cached_input_max: u64,
30    token_cumulative_reasoning_output_max: u64,
31    saw_token_cumulative: bool,
32    token_sum_prompt: u64,
33    token_sum_completion: u64,
34    token_sum_cached_input: u64,
35    token_sum_cache_creation: u64,
36    saw_token_per_turn: bool,
37}
38
39#[derive(Debug, Serialize)]
40pub struct TopEntry {
41    pub key: String,
42    pub count: u64,
43}
44
45#[derive(Debug, Serialize, Clone)]
46pub struct LongestSession {
47    pub source_tool: String,
48    pub session_id: String,
49    pub project_context: String,
50    pub started_at: DateTime<Utc>,
51    pub ended_at: DateTime<Utc>,
52    pub duration_seconds: i64,
53    pub turns: u64,
54}
55
56#[derive(Debug, Serialize)]
57pub struct TokensSummary {
58    pub sessions_with_token_counts: u64,
59    pub total_tokens: u64,
60    pub prompt_tokens: u64,
61    pub completion_tokens: u64,
62    pub cached_input_tokens: u64,
63    pub reasoning_output_tokens: u64,
64}
65
66#[derive(Debug, Serialize)]
67pub struct CursorUsageSummary {
68    pub team_id: u32,
69    pub start: DateTime<Utc>,
70    pub end: DateTime<Utc>,
71    pub total_input_tokens: u64,
72    pub total_output_tokens: u64,
73    pub total_cache_write_tokens: u64,
74    pub total_cache_read_tokens: u64,
75    pub total_cost_cents: Option<f64>,
76    pub by_model: Vec<CursorModelUsage>,
77}
78
79#[derive(Debug, Serialize)]
80pub struct CursorModelUsage {
81    pub model_intent: String,
82    pub input_tokens: u64,
83    pub output_tokens: u64,
84    pub cache_write_tokens: u64,
85    pub cache_read_tokens: u64,
86    pub total_cents: Option<f64>,
87    pub request_cost: Option<f64>,
88    pub tier: Option<u32>,
89}
90
91#[derive(Debug, Serialize)]
92pub struct Wrapup {
93    pub year: i32,
94    pub range_start: Option<DateTime<Utc>>,
95    pub range_end: Option<DateTime<Utc>>,
96    pub turns_total: u64,
97    pub sessions_total: u64,
98    pub turns_by_tool: Vec<TopEntry>,
99    pub sessions_by_tool: Vec<TopEntry>,
100    pub roles: Vec<TopEntry>,
101    pub active_days: u64,
102    pub longest_streak_days: u64,
103    pub busiest_day: Option<String>,
104    pub busiest_day_turns: Option<u64>,
105    pub peak_hour_local: Option<u32>,
106    pub peak_hour_turns: Option<u64>,
107    pub top_projects_by_turns: Vec<TopEntry>,
108    pub top_projects_by_sessions: Vec<TopEntry>,
109    pub top_models: Vec<TopEntry>,
110    pub tokens: TokensSummary,
111    pub cursor_usage: Option<CursorUsageSummary>,
112    pub redacted_turns: u64,
113    pub redacted_labels: Vec<TopEntry>,
114    pub clipboard_hits: u64,
115    pub file_effects: u64,
116    pub function_calls: u64,
117    pub function_call_outputs: u64,
118    pub apply_patch_calls: u64,
119    pub antigravity_images: u64,
120    pub unique_projects: u64,
121    pub longest_session_by_duration: Option<LongestSession>,
122    pub longest_session_by_turns: Option<LongestSession>,
123    pub user_turns: u64,
124    pub user_avg_words: Option<f64>,
125    pub user_question_rate: Option<f64>,
126    pub user_code_hint_rate: Option<f64>,
127    pub hourly_activity: Vec<u64>,
128    pub daily_activity: Vec<(String, u64)>,
129    pub total_interrupts: u64,
130    pub languages: Vec<TopEntry>,
131}
132
133pub fn run() -> Result<()> {
134    let mut year: Option<i32> = None;
135    let mut start: Option<DateTime<Utc>> = None;
136    let mut end: Option<DateTime<Utc>> = None;
137    let mut last_days: Option<i64> = None;
138    let mut include_cursor_usage = false;
139    let mut log_path: Option<PathBuf> = None;
140    let mut out_path: Option<PathBuf> = None;
141    let mut top_n: usize = 10;
142
143    let mut args = std::env::args().skip(1).peekable();
144    let mut html_path: Option<PathBuf> = None;
145
146    while let Some(arg) = args.next() {
147        match arg.as_str() {
148            "--help" | "-h" => {
149                print_help();
150                return Ok(());
151            }
152            "--year" => {
153                let val = args.next().context("--year requires YYYY")?;
154                year = Some(val.parse::<i32>().context("invalid --year")?);
155            }
156            "--start" => {
157                let val = args
158                    .next()
159                    .context("--start requires DATE (YYYY-MM-DD) or RFC3339")?;
160                start = Some(parse_date_arg(&val, DateBoundary::Start)?);
161            }
162            "--end" => {
163                let val = args
164                    .next()
165                    .context("--end requires DATE (YYYY-MM-DD) or RFC3339")?;
166                end = Some(parse_date_arg(&val, DateBoundary::End)?);
167            }
168            "--last-days" => {
169                let val = args.next().context("--last-days requires N")?;
170                last_days = Some(val.parse::<i64>().context("invalid --last-days")?);
171            }
172            "--cursor-usage" => {
173                include_cursor_usage = true;
174            }
175            "--log" => {
176                let val = args.next().context("--log requires PATH")?;
177                log_path = Some(PathBuf::from(val));
178            }
179            "--out" => {
180                let val = args.next().context("--out requires PATH")?;
181                out_path = Some(PathBuf::from(val));
182            }
183            "--html" => {
184                let val = args.next().context("--html requires PATH")?;
185                html_path = Some(PathBuf::from(val));
186            }
187            "--top" => {
188                let val = args.next().context("--top requires N")?;
189                top_n = val.parse::<usize>().context("invalid --top")?;
190            }
191            other => {
192                anyhow::bail!("unknown arg: {other} (use --help)");
193            }
194        }
195    }
196
197    if last_days.is_some() && (start.is_some() || end.is_some()) {
198        anyhow::bail!("--last-days cannot be combined with --start/--end");
199    }
200
201    if last_days.is_some() && year.is_some() {
202        anyhow::bail!("--last-days cannot be combined with --year");
203    }
204
205    if (start.is_some() || end.is_some()) && year.is_some() {
206        anyhow::bail!("--start/--end cannot be combined with --year");
207    }
208
209    if let Some(days) = last_days {
210        if days <= 0 {
211            anyhow::bail!("--last-days must be a positive integer");
212        }
213        let range_end = Utc::now();
214        let range_start = range_end - chrono::Duration::days(days);
215        start = Some(range_start);
216        end = Some(range_end);
217    }
218
219    let year = year.unwrap_or_else(|| {
220        end.as_ref()
221            .map(|d| d.year())
222            .or_else(|| start.as_ref().map(|d| d.year()))
223            .unwrap_or_else(|| Local::now().year())
224    });
225    let log_path = log_path.unwrap_or_else(default_log_path);
226    let start_filter = start;
227    let end_filter = end;
228    let mut wrapup = compute_wrapup(&log_path, year, start_filter, end_filter, top_n)?;
229
230    if include_cursor_usage {
231        let (cursor_start, cursor_end) = resolve_cursor_usage_range(
232            year,
233            start_filter,
234            end_filter,
235            wrapup.range_start,
236            wrapup.range_end,
237        )?;
238        let cursor_usage = fetch_cursor_usage(cursor_start, cursor_end)?;
239
240        wrapup.tokens.total_tokens = wrapup
241            .tokens
242            .total_tokens
243            .saturating_add(cursor_usage.total_input_tokens)
244            .saturating_add(cursor_usage.total_output_tokens);
245        wrapup.tokens.prompt_tokens = wrapup
246            .tokens
247            .prompt_tokens
248            .saturating_add(cursor_usage.total_input_tokens);
249        wrapup.tokens.completion_tokens = wrapup
250            .tokens
251            .completion_tokens
252            .saturating_add(cursor_usage.total_output_tokens);
253        wrapup.tokens.cached_input_tokens = wrapup
254            .tokens
255            .cached_input_tokens
256            .saturating_add(cursor_usage.total_cache_read_tokens);
257
258        wrapup.cursor_usage = Some(cursor_usage);
259    }
260
261    if let Some(ref html_path) = html_path {
262        let html = report::generate_html_report(&wrapup);
263        if let Some(dir) = html_path.parent() {
264            std::fs::create_dir_all(dir)
265                .with_context(|| format!("create html output dir {:?}", dir))?;
266        }
267        let mut file = File::create(html_path).with_context(|| format!("write {:?}", html_path))?;
268        file.write_all(html.as_bytes())?;
269        println!("Wrote HTML wrapup to {:?}", html_path);
270    }
271
272    let out = serde_json::to_string_pretty(&wrapup)?;
273    if let Some(out_path) = out_path {
274        if let Some(dir) = out_path.parent() {
275            std::fs::create_dir_all(dir).with_context(|| format!("create output dir {:?}", dir))?;
276        }
277        let mut file = File::create(&out_path).with_context(|| format!("write {:?}", out_path))?;
278        file.write_all(out.as_bytes())?;
279        file.write_all(b"\n")?;
280        println!("Wrote JSON wrapup to {:?}", out_path);
281    } else if out_path.is_none() && html_path.is_none() {
282        println!("{out}");
283    }
284    Ok(())
285}
286
287fn print_help() {
288    println!(
289        r#"contrail wrapup
290
291Usage:
292  cargo run -p wrapup -- --year 2025
293  cargo run -p wrapup -- --last-days 30
294
295Options:
296  --year YYYY     Year filter (default: current year)
297  --start DATE    Range start (YYYY-MM-DD or RFC3339); cannot combine with --year/--last-days
298  --end DATE      Range end (YYYY-MM-DD or RFC3339); cannot combine with --year/--last-days
299  --last-days N   Range end=now, start=now-N days; cannot combine with --year/--start/--end
300  --cursor-usage  Fetch Cursor token usage from Cursor backend API (requires Cursor login; uses local access token)
301  --log PATH      Master log path (default: ~/.contrail/logs/master_log.jsonl or $CONTRAIL_LOG_PATH)
302  --out PATH      Write JSON output to a file (default: stdout)
303  --html PATH     Write HTML report to a file
304  --top N         Top-N lists size (default: 10)
305"#
306    );
307}
308
309#[derive(Clone, Copy)]
310enum DateBoundary {
311    Start,
312    End,
313}
314
315fn parse_date_arg(input: &str, boundary: DateBoundary) -> Result<DateTime<Utc>> {
316    if let Ok(ts) = DateTime::parse_from_rfc3339(input) {
317        return Ok(ts.with_timezone(&Utc));
318    }
319
320    let date = chrono::NaiveDate::parse_from_str(input, "%Y-%m-%d").context("invalid date")?;
321    let time = match boundary {
322        DateBoundary::Start => chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
323        DateBoundary::End => chrono::NaiveTime::from_hms_nano_opt(23, 59, 59, 999_999_999).unwrap(),
324    };
325
326    Ok(DateTime::<Utc>::from_naive_utc_and_offset(
327        chrono::NaiveDateTime::new(date, time),
328        Utc,
329    ))
330}
331
332fn default_log_path() -> PathBuf {
333    if let Ok(path) = std::env::var("CONTRAIL_LOG_PATH")
334        && !path.trim().is_empty()
335    {
336        return PathBuf::from(path);
337    }
338    let home = dirs::home_dir().expect("Could not find home directory");
339    home.join(".contrail/logs/master_log.jsonl")
340}
341
342fn compute_wrapup(
343    log_path: &Path,
344    year: i32,
345    start: Option<DateTime<Utc>>,
346    end: Option<DateTime<Utc>>,
347    top_n: usize,
348) -> Result<Wrapup> {
349    let file = File::open(log_path).with_context(|| format!("open {:?}", log_path))?;
350    let reader = BufReader::new(file);
351
352    let mut turns_total: u64 = 0;
353    let mut roles: HashMap<String, u64> = HashMap::new();
354    let mut turns_by_tool: HashMap<String, u64> = HashMap::new();
355    let mut daily_turns: BTreeMap<chrono::NaiveDate, u64> = BTreeMap::new();
356    let mut hourly: HashMap<u32, u64> = HashMap::new();
357    let mut model_counts: HashMap<String, u64> = HashMap::new();
358    let mut project_turns_by_session: HashMap<String, u64> = HashMap::new();
359    let mut redacted_turns: u64 = 0;
360    let mut redacted_labels: HashMap<String, u64> = HashMap::new();
361    let mut clipboard_hits: u64 = 0;
362    let mut file_effects: u64 = 0;
363    let mut function_calls: u64 = 0;
364    let mut function_call_outputs: u64 = 0;
365    let mut apply_patch_calls: u64 = 0;
366    let mut antigravity_images: u64 = 0;
367    let mut language_counts: HashMap<String, u64> = HashMap::new();
368
369    let mut user_turns: u64 = 0;
370    let mut user_words: u64 = 0;
371    let mut user_questions: u64 = 0;
372    let mut user_code_hints: u64 = 0;
373
374    let mut range_start: Option<DateTime<Utc>> = None;
375    let mut range_end: Option<DateTime<Utc>> = None;
376
377    let mut sessions: HashMap<(String, String), SessionAgg> = HashMap::new();
378
379    let mut last_seen_map: HashMap<(String, String), DateTime<Utc>> = HashMap::new();
380    let mut sub_session_index_map: HashMap<(String, String), usize> = HashMap::new();
381
382    for line in reader.lines() {
383        let line = line?;
384        let log = match serde_json::from_str::<MasterLog>(&line) {
385            Ok(v) => v,
386            Err(_) => continue,
387        };
388
389        if start.is_some() || end.is_some() {
390            if start.is_some_and(|s| log.timestamp < s) {
391                continue;
392            }
393            if end.is_some_and(|e| log.timestamp > e) {
394                continue;
395            }
396        } else if log.timestamp.year() != year {
397            continue;
398        }
399
400        let raw_key = (log.source_tool.clone(), log.session_id.clone());
401        let last_ts = *last_seen_map.get(&raw_key).unwrap_or(&log.timestamp);
402
403        let gap = log.timestamp.signed_duration_since(last_ts);
404        if gap > chrono::Duration::minutes(30) {
405            *sub_session_index_map.entry(raw_key.clone()).or_insert(0) += 1;
406        }
407        last_seen_map.insert(raw_key.clone(), log.timestamp);
408
409        let sub_idx = *sub_session_index_map.get(&raw_key).unwrap_or(&0);
410        let effective_session_id = if sub_idx > 0 {
411            format!("{}#{}", log.session_id, sub_idx)
412        } else {
413            log.session_id.clone()
414        };
415
416        turns_total += 1;
417        let local_ts = log.timestamp.with_timezone(&Local);
418        *daily_turns.entry(local_ts.date_naive()).or_insert(0) += 1;
419        *hourly.entry(local_ts.hour()).or_insert(0) += 1;
420
421        range_start = Some(range_start.map_or(log.timestamp, |v| v.min(log.timestamp)));
422        range_end = Some(range_end.map_or(log.timestamp, |v| v.max(log.timestamp)));
423
424        *turns_by_tool.entry(log.source_tool.clone()).or_insert(0) += 1;
425        *roles.entry(log.interaction.role.clone()).or_insert(0) += 1;
426
427        if log.security_flags.has_pii {
428            redacted_turns += 1;
429        }
430        for label in &log.security_flags.redacted_secrets {
431            *redacted_labels.entry(label.clone()).or_insert(0) += 1;
432        }
433
434        let meta_obj = log.metadata.as_object();
435        if let Some(obj) = meta_obj {
436            if obj
437                .get("copied_to_clipboard")
438                .and_then(Value::as_bool)
439                .unwrap_or(false)
440            {
441                clipboard_hits += 1;
442            }
443            if let Some(arr) = obj.get("file_effects").and_then(Value::as_array) {
444                file_effects += arr.len() as u64;
445                for effect in arr {
446                    let path_str = effect
447                        .as_str()
448                        .or_else(|| effect.get("path").and_then(Value::as_str));
449
450                    if let Some(path) = path_str
451                        && let Some(ext) = Path::new(path).extension().and_then(|e| e.to_str())
452                    {
453                        let ext = ext.to_lowercase();
454                        if !matches!(
455                            ext.as_str(),
456                            "json" | "md" | "txt" | "csv" | "png" | "jpg" | "lock"
457                        ) {
458                            *language_counts.entry(ext).or_insert(0) += 1;
459                        }
460                    }
461                }
462            }
463            if obj
464                .get("interrupted")
465                .and_then(Value::as_bool)
466                .unwrap_or(false)
467            {
468                let key = (log.source_tool.clone(), effective_session_id.clone());
469                let sess = sessions.entry(key).or_insert_with(|| SessionAgg {
470                    source_tool: log.source_tool.clone(),
471                    session_id: effective_session_id.clone(),
472                    ..Default::default()
473                });
474                sess.interrupted = true;
475            }
476            if let Some(model) = obj.get("model").and_then(Value::as_str) {
477                let model = model.trim();
478                if !model.is_empty() {
479                    *model_counts.entry(model.to_string()).or_insert(0) += 1;
480                }
481            }
482
483            if log.source_tool == "antigravity"
484                && let Some(n) = obj
485                    .get("antigravity_image_count")
486                    .and_then(Value::as_u64)
487                    .or_else(|| {
488                        obj.get("antigravity_image_count")
489                            .and_then(Value::as_i64)
490                            .and_then(|v| u64::try_from(v).ok())
491                    })
492            {
493                antigravity_images = antigravity_images.saturating_add(n);
494            }
495        }
496
497        if log.interaction.role == "user" {
498            user_turns += 1;
499            user_words += word_count(&log.interaction.content) as u64;
500            if log.interaction.content.contains('?') {
501                user_questions += 1;
502            }
503            if looks_like_code(&log.interaction.content) {
504                user_code_hints += 1;
505            }
506        }
507
508        let key = (log.source_tool.clone(), effective_session_id.clone());
509        let sess = sessions.entry(key).or_insert_with(|| SessionAgg {
510            source_tool: log.source_tool.clone(),
511            session_id: effective_session_id.clone(),
512            ..Default::default()
513        });
514        sess.turns += 1;
515        *sess
516            .project_counts
517            .entry(log.project_context.clone())
518            .or_insert(0) += 1;
519        sess.started_at = Some(
520            sess.started_at
521                .map_or(log.timestamp, |v| v.min(log.timestamp)),
522        );
523        sess.ended_at = Some(
524            sess.ended_at
525                .map_or(log.timestamp, |v| v.max(log.timestamp)),
526        );
527
528        if let Some(obj) = meta_obj {
529            if let Some(arr) = obj.get("file_effects").and_then(Value::as_array) {
530                sess.file_effects += arr.len();
531            }
532            if obj
533                .get("copied_to_clipboard")
534                .and_then(Value::as_bool)
535                .unwrap_or(false)
536            {
537                sess.clipboard_hits += 1;
538            }
539            if let Some(branch) = obj.get("git_branch").and_then(Value::as_str) {
540                let branch = branch.trim();
541                if !branch.is_empty() {
542                    sess.git_branches.insert(branch.to_string());
543                }
544            }
545            if let Some(model) = obj.get("model").and_then(Value::as_str) {
546                let model = model.trim();
547                if !model.is_empty() {
548                    sess.models.insert(model.to_string());
549                }
550            }
551
552            update_cumulative_tokens_from_metadata(sess, obj);
553        }
554
555        if log.source_tool == "codex-cli"
556            && log.interaction.content.contains("\"token_count\"")
557            && let Some(usage) = extract_token_count_from_content(&log.interaction.content)
558        {
559            sess.saw_token_cumulative = true;
560            sess.token_cumulative_total_max = sess.token_cumulative_total_max.max(usage.total);
561            sess.token_cumulative_prompt_max = sess.token_cumulative_prompt_max.max(usage.prompt);
562            sess.token_cumulative_completion_max =
563                sess.token_cumulative_completion_max.max(usage.completion);
564            sess.token_cumulative_cached_input_max = sess
565                .token_cumulative_cached_input_max
566                .max(usage.cached_input);
567            sess.token_cumulative_reasoning_output_max = sess
568                .token_cumulative_reasoning_output_max
569                .max(usage.reasoning_output);
570        }
571
572        if log.source_tool == "codex-cli"
573            && log.interaction.content.contains("\"type\"")
574            && let Ok(value) = serde_json::from_str::<Value>(&log.interaction.content)
575        {
576            if value.get("type").and_then(Value::as_str) == Some("function_call_output") {
577                function_call_outputs += 1;
578            }
579            if value.get("type").and_then(Value::as_str) == Some("function_call") {
580                function_calls += 1;
581                if let Some(args) = value.get("arguments").and_then(Value::as_str)
582                    && args.contains("apply_patch")
583                {
584                    apply_patch_calls += 1;
585                }
586            }
587        }
588    }
589
590    let sessions_total = sessions.len() as u64;
591
592    let sessions_by_tool = top_entries(
593        sessions
594            .values()
595            .fold(HashMap::<String, u64>::new(), |mut acc, sess| {
596                *acc.entry(sess.source_tool.clone()).or_insert(0) += 1;
597                acc
598            }),
599        top_n,
600    );
601    let turns_by_tool = top_entries(turns_by_tool, top_n);
602    let roles = top_entries(roles, top_n);
603    let top_models = top_entries(model_counts, top_n);
604    let redacted_labels = top_entries(redacted_labels, top_n);
605
606    let active_days = daily_turns.len() as u64;
607    let longest_streak_days = longest_streak(daily_turns.keys().copied().collect::<Vec<_>>());
608
609    let (busiest_day, busiest_day_turns) = daily_turns
610        .iter()
611        .max_by_key(|(_, c)| *c)
612        .map(|(d, c)| (Some(d.to_string()), Some(*c)))
613        .unwrap_or((None, None));
614
615    let (peak_hour_local, peak_hour_turns) = hourly
616        .iter()
617        .max_by_key(|(_, c)| *c)
618        .map(|(h, c)| (Some(*h), Some(*c)))
619        .unwrap_or((None, None));
620
621    let mut project_sessions: HashMap<String, u64> = HashMap::new();
622    for sess in sessions.values() {
623        let project = pick_project_context(&sess.project_counts);
624        if is_generic_project_context(&project) {
625            continue;
626        }
627        *project_sessions.entry(project.clone()).or_insert(0) += 1;
628        *project_turns_by_session.entry(project).or_insert(0) += sess.turns as u64;
629    }
630
631    let unique_projects = project_turns_by_session.len() as u64;
632    let top_projects_by_turns = top_entries(project_turns_by_session, top_n);
633    let top_projects_by_sessions = top_entries(project_sessions, top_n);
634
635    let (longest_session_by_duration, longest_session_by_turns) =
636        compute_longest_sessions(&sessions);
637
638    let tokens = summarize_tokens(&sessions);
639
640    let total_interrupts = sessions.values().filter(|s| s.interrupted).count() as u64;
641
642    let mut hourly_activity = vec![0u64; 24];
643    for (hour, count) in hourly {
644        if hour < 24 {
645            hourly_activity[hour as usize] = count;
646        }
647    }
648
649    let daily_activity: Vec<(String, u64)> = daily_turns
650        .into_iter()
651        .map(|(d, c)| (d.format("%Y-%m-%d").to_string(), c))
652        .collect();
653
654    Ok(Wrapup {
655        year,
656        range_start,
657        range_end,
658        turns_total,
659        sessions_total,
660        turns_by_tool,
661        sessions_by_tool,
662        roles,
663        active_days,
664        longest_streak_days,
665        busiest_day,
666        busiest_day_turns,
667        peak_hour_local,
668        peak_hour_turns,
669        top_projects_by_turns,
670        top_projects_by_sessions,
671        top_models,
672        tokens,
673        cursor_usage: None,
674        redacted_turns,
675        redacted_labels,
676        clipboard_hits,
677        file_effects,
678        function_calls,
679        function_call_outputs,
680        apply_patch_calls,
681        antigravity_images,
682        unique_projects,
683        longest_session_by_duration,
684        longest_session_by_turns,
685        user_turns,
686        user_avg_words: rate(user_words, user_turns),
687        user_question_rate: pct(user_questions, user_turns),
688        user_code_hint_rate: pct(user_code_hints, user_turns),
689        hourly_activity,
690        daily_activity,
691        total_interrupts,
692        languages: top_entries(language_counts, top_n),
693    })
694}
695
696fn resolve_cursor_usage_range(
697    year: i32,
698    requested_start: Option<DateTime<Utc>>,
699    requested_end: Option<DateTime<Utc>>,
700    observed_start: Option<DateTime<Utc>>,
701    observed_end: Option<DateTime<Utc>>,
702) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
703    if let (Some(start), Some(end)) = (requested_start, requested_end) {
704        anyhow::ensure!(end >= start, "cursor usage range end must be >= start");
705        return Ok((start, end));
706    }
707
708    if requested_start.is_some() || requested_end.is_some() {
709        anyhow::bail!("--cursor-usage requires both --start and --end (or use --last-days)");
710    }
711
712    if let (Some(start), Some(end)) = (observed_start, observed_end) {
713        anyhow::ensure!(end >= start, "cursor usage range end must be >= start");
714        return Ok((start, end));
715    }
716
717    let start = DateTime::<Utc>::from_naive_utc_and_offset(
718        chrono::NaiveDate::from_ymd_opt(year, 1, 1)
719            .context("invalid year")?
720            .and_hms_opt(0, 0, 0)
721            .unwrap(),
722        Utc,
723    );
724    let end = DateTime::<Utc>::from_naive_utc_and_offset(
725        chrono::NaiveDate::from_ymd_opt(year, 12, 31)
726            .context("invalid year")?
727            .and_hms_nano_opt(23, 59, 59, 999_999_999)
728            .unwrap(),
729        Utc,
730    );
731    Ok((start, end))
732}
733
734#[derive(Debug, Deserialize)]
735struct CursorAggregatedUsageResponse {
736    #[serde(default)]
737    aggregations: Vec<CursorAggregatedModelUsage>,
738    #[serde(default, rename = "totalInputTokens")]
739    total_input_tokens: String,
740    #[serde(default, rename = "totalOutputTokens")]
741    total_output_tokens: String,
742    #[serde(default, rename = "totalCacheWriteTokens")]
743    total_cache_write_tokens: String,
744    #[serde(default, rename = "totalCacheReadTokens")]
745    total_cache_read_tokens: String,
746    #[serde(default, rename = "totalCostCents")]
747    total_cost_cents: Option<f64>,
748}
749
750#[derive(Debug, Deserialize)]
751struct CursorAggregatedModelUsage {
752    #[serde(default, rename = "modelIntent")]
753    model_intent: String,
754    #[serde(default, rename = "inputTokens")]
755    input_tokens: Option<String>,
756    #[serde(default, rename = "outputTokens")]
757    output_tokens: Option<String>,
758    #[serde(default, rename = "cacheWriteTokens")]
759    cache_write_tokens: Option<String>,
760    #[serde(default, rename = "cacheReadTokens")]
761    cache_read_tokens: Option<String>,
762    #[serde(default, rename = "totalCents")]
763    total_cents: Option<f64>,
764    #[serde(default, rename = "requestCost")]
765    request_cost: Option<f64>,
766    #[serde(default)]
767    tier: Option<u32>,
768}
769
770fn fetch_cursor_usage(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<CursorUsageSummary> {
771    let token = read_cursor_access_token()?;
772    let client = reqwest::blocking::Client::new();
773
774    let resp = client
775        .post("https://api2.cursor.sh/aiserver.v1.DashboardService/GetAggregatedUsageEvents")
776        .bearer_auth(token)
777        .header("Connect-Protocol-Version", "1")
778        .json(&serde_json::json!({
779            "teamId": 0,
780            "startDate": start.timestamp_millis().to_string(),
781            "endDate": end.timestamp_millis().to_string(),
782        }))
783        .send()
784        .context("Cursor usage request failed")?;
785
786    if !resp.status().is_success() {
787        anyhow::bail!("Cursor usage request failed: HTTP {}", resp.status());
788    }
789
790    let parsed: CursorAggregatedUsageResponse = resp.json().context("parse Cursor usage JSON")?;
791
792    let by_model = parsed
793        .aggregations
794        .into_iter()
795        .map(|m| CursorModelUsage {
796            model_intent: m.model_intent,
797            input_tokens: parse_u64_opt(m.input_tokens),
798            output_tokens: parse_u64_opt(m.output_tokens),
799            cache_write_tokens: parse_u64_opt(m.cache_write_tokens),
800            cache_read_tokens: parse_u64_opt(m.cache_read_tokens),
801            total_cents: m.total_cents,
802            request_cost: m.request_cost,
803            tier: m.tier,
804        })
805        .collect();
806
807    Ok(CursorUsageSummary {
808        team_id: 0,
809        start,
810        end,
811        total_input_tokens: parse_u64(&parsed.total_input_tokens),
812        total_output_tokens: parse_u64(&parsed.total_output_tokens),
813        total_cache_write_tokens: parse_u64(&parsed.total_cache_write_tokens),
814        total_cache_read_tokens: parse_u64(&parsed.total_cache_read_tokens),
815        total_cost_cents: parsed.total_cost_cents,
816        by_model,
817    })
818}
819
820fn read_cursor_access_token() -> Result<String> {
821    let home = dirs::home_dir().context("could not resolve home directory")?;
822    let db_path = home.join("Library/Application Support/Cursor/User/globalStorage/state.vscdb");
823
824    let conn = rusqlite::Connection::open(&db_path)
825        .with_context(|| format!("open Cursor globalStorage DB: {:?}", db_path))?;
826
827    let mut stmt = conn
828        .prepare("SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'")
829        .context("prepare Cursor access token query")?;
830
831    let token = stmt
832        .query_row([], |row| {
833            use rusqlite::types::ValueRef;
834            let value = row.get_ref(0)?;
835            let data_type = value.data_type();
836            match value {
837                ValueRef::Text(s) => Ok(String::from_utf8_lossy(s).into_owned()),
838                ValueRef::Blob(b) => Ok(String::from_utf8_lossy(b).into_owned()),
839                _ => Err(rusqlite::Error::InvalidColumnType(
840                    0,
841                    "value".to_string(),
842                    data_type,
843                )),
844            }
845        })
846        .context("cursorAuth/accessToken not found (are you logged into Cursor?)")?;
847
848    anyhow::ensure!(!token.trim().is_empty(), "cursorAuth/accessToken was empty");
849
850    Ok(token)
851}
852
853fn parse_u64(s: &str) -> u64 {
854    s.trim().parse::<u64>().unwrap_or(0)
855}
856
857fn parse_u64_opt(s: Option<String>) -> u64 {
858    s.as_deref().map(parse_u64).unwrap_or(0)
859}
860
861fn is_generic_project_context(project_context: &str) -> bool {
862    matches!(
863        project_context,
864        "Imported History" | "Codex Session" | "Unknown" | "Claude Global" | "Antigravity Brain"
865    )
866}
867
868fn top_entries(map: HashMap<String, u64>, top_n: usize) -> Vec<TopEntry> {
869    let mut items: Vec<(String, u64)> = map.into_iter().collect();
870    items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
871    items
872        .into_iter()
873        .take(top_n)
874        .map(|(k, v)| TopEntry { key: k, count: v })
875        .collect()
876}
877
878fn longest_streak(mut dates: Vec<chrono::NaiveDate>) -> u64 {
879    if dates.is_empty() {
880        return 0;
881    }
882    dates.sort();
883    let mut best = 1u64;
884    let mut current = 1u64;
885    for w in dates.windows(2) {
886        let prev = w[0];
887        let next = w[1];
888        if next == prev + chrono::Days::new(1) {
889            current += 1;
890        } else {
891            best = best.max(current);
892            current = 1;
893        }
894    }
895    best.max(current)
896}
897
898fn pick_project_context(counts: &HashMap<String, usize>) -> String {
899    const GENERIC: &[&str] = &[
900        "Imported History",
901        "Codex Session",
902        "Unknown",
903        "Claude Global",
904        "Antigravity Brain",
905    ];
906
907    let mut entries: Vec<(&String, &usize)> = counts.iter().collect();
908    entries.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
909
910    for (ctx, _) in &entries {
911        if !GENERIC.contains(&ctx.as_str()) {
912            return (*ctx).clone();
913        }
914    }
915    entries
916        .first()
917        .map(|(ctx, _)| (*ctx).clone())
918        .unwrap_or_else(|| "Unknown".to_string())
919}
920
921fn compute_longest_sessions(
922    sessions: &HashMap<(String, String), SessionAgg>,
923) -> (Option<LongestSession>, Option<LongestSession>) {
924    let mut best_duration: Option<LongestSession> = None;
925    let mut best_turns: Option<LongestSession> = None;
926
927    for sess in sessions.values() {
928        let (Some(start), Some(end)) = (sess.started_at, sess.ended_at) else {
929            continue;
930        };
931        let duration_seconds = (end - start).num_seconds();
932        let project = pick_project_context(&sess.project_counts);
933
934        let candidate = LongestSession {
935            source_tool: sess.source_tool.clone(),
936            session_id: sess.session_id.clone(),
937            project_context: project,
938            started_at: start,
939            ended_at: end,
940            duration_seconds,
941            turns: sess.turns as u64,
942        };
943
944        if best_duration
945            .as_ref()
946            .is_none_or(|b| candidate.duration_seconds > b.duration_seconds)
947        {
948            best_duration = Some(candidate.clone());
949        }
950        if best_turns
951            .as_ref()
952            .is_none_or(|b| candidate.turns > b.turns)
953        {
954            best_turns = Some(candidate);
955        }
956    }
957
958    (best_duration, best_turns)
959}
960
961fn summarize_tokens(sessions: &HashMap<(String, String), SessionAgg>) -> TokensSummary {
962    let mut sessions_with = 0u64;
963    let mut total = 0u64;
964    let mut prompt = 0u64;
965    let mut completion = 0u64;
966    let mut cached_input = 0u64;
967    let mut reasoning_output = 0u64;
968
969    for sess in sessions.values() {
970        if sess.saw_token_cumulative && sess.token_cumulative_total_max > 0 {
971            sessions_with += 1;
972            total += sess.token_cumulative_total_max;
973            prompt += sess.token_cumulative_prompt_max;
974            completion += sess.token_cumulative_completion_max;
975            cached_input += sess.token_cumulative_cached_input_max;
976            reasoning_output += sess.token_cumulative_reasoning_output_max;
977        } else if sess.saw_token_per_turn
978            && (sess.token_sum_prompt > 0 || sess.token_sum_completion > 0)
979        {
980            sessions_with += 1;
981            let session_total = sess.token_sum_prompt + sess.token_sum_completion;
982            total += session_total;
983            prompt += sess.token_sum_prompt;
984            completion += sess.token_sum_completion;
985            cached_input += sess.token_sum_cached_input;
986        }
987    }
988
989    TokensSummary {
990        sessions_with_token_counts: sessions_with,
991        total_tokens: total,
992        prompt_tokens: prompt,
993        completion_tokens: completion,
994        cached_input_tokens: cached_input,
995        reasoning_output_tokens: reasoning_output,
996    }
997}
998
999fn update_cumulative_tokens_from_metadata(
1000    sess: &mut SessionAgg,
1001    meta: &serde_json::Map<String, Value>,
1002) {
1003    let read_u64 = |key: &str| {
1004        meta.get(key).and_then(Value::as_u64).or_else(|| {
1005            meta.get(key)
1006                .and_then(Value::as_i64)
1007                .and_then(|n| u64::try_from(n).ok())
1008        })
1009    };
1010
1011    let total = read_u64("usage_cumulative_total_tokens").unwrap_or(0);
1012    if total > 0 {
1013        sess.saw_token_cumulative = true;
1014        sess.token_cumulative_total_max = sess.token_cumulative_total_max.max(total);
1015        sess.token_cumulative_prompt_max = sess
1016            .token_cumulative_prompt_max
1017            .max(read_u64("usage_cumulative_prompt_tokens").unwrap_or(0));
1018        sess.token_cumulative_completion_max = sess
1019            .token_cumulative_completion_max
1020            .max(read_u64("usage_cumulative_completion_tokens").unwrap_or(0));
1021        sess.token_cumulative_cached_input_max = sess
1022            .token_cumulative_cached_input_max
1023            .max(read_u64("usage_cumulative_cached_input_tokens").unwrap_or(0));
1024        sess.token_cumulative_reasoning_output_max = sess
1025            .token_cumulative_reasoning_output_max
1026            .max(read_u64("usage_cumulative_reasoning_output_tokens").unwrap_or(0));
1027    }
1028
1029    let prompt_turn = read_u64("usage_prompt_tokens").unwrap_or(0);
1030    let completion_turn = read_u64("usage_completion_tokens").unwrap_or(0);
1031    if prompt_turn > 0 || completion_turn > 0 {
1032        sess.saw_token_per_turn = true;
1033        sess.token_sum_prompt += prompt_turn;
1034        sess.token_sum_completion += completion_turn;
1035        sess.token_sum_cached_input += read_u64("usage_cached_input_tokens").unwrap_or(0);
1036        sess.token_sum_cache_creation += read_u64("usage_cache_creation_tokens").unwrap_or(0);
1037    }
1038}
1039
1040#[derive(Debug)]
1041struct TokenCountUsage {
1042    total: u64,
1043    prompt: u64,
1044    completion: u64,
1045    cached_input: u64,
1046    reasoning_output: u64,
1047}
1048
1049fn extract_token_count_from_content(content: &str) -> Option<TokenCountUsage> {
1050    let value = serde_json::from_str::<Value>(content).ok()?;
1051    if value.get("type").and_then(Value::as_str)? != "event_msg" {
1052        return None;
1053    }
1054    if value.pointer("/payload/type").and_then(Value::as_str)? != "token_count" {
1055        return None;
1056    }
1057    let total = value
1058        .pointer("/payload/info/total_token_usage/total_tokens")
1059        .and_then(Value::as_u64)
1060        .or_else(|| {
1061            value
1062                .pointer("/payload/info/total_token_usage/total_tokens")
1063                .and_then(Value::as_i64)
1064                .and_then(|n| u64::try_from(n).ok())
1065        })?;
1066
1067    let prompt = value
1068        .pointer("/payload/info/total_token_usage/input_tokens")
1069        .and_then(Value::as_u64)
1070        .unwrap_or(0);
1071    let completion = value
1072        .pointer("/payload/info/total_token_usage/output_tokens")
1073        .and_then(Value::as_u64)
1074        .unwrap_or(0);
1075    let cached_input = value
1076        .pointer("/payload/info/total_token_usage/cached_input_tokens")
1077        .and_then(Value::as_u64)
1078        .unwrap_or(0);
1079    let reasoning_output = value
1080        .pointer("/payload/info/total_token_usage/reasoning_output_tokens")
1081        .and_then(Value::as_u64)
1082        .unwrap_or(0);
1083
1084    Some(TokenCountUsage {
1085        total,
1086        prompt,
1087        completion,
1088        cached_input,
1089        reasoning_output,
1090    })
1091}
1092
1093fn word_count(text: &str) -> usize {
1094    text.split_whitespace().count()
1095}
1096
1097fn looks_like_code(text: &str) -> bool {
1098    if text.contains("```") {
1099        return true;
1100    }
1101    if text.contains("\n    ") || text.contains("\n\t") {
1102        return true;
1103    }
1104    for token in ["::", "->", "=>", "{", "}", ";", "&&", "||", "==", "!="] {
1105        if text.contains(token) {
1106            return true;
1107        }
1108    }
1109    false
1110}
1111
1112fn rate(total_words: u64, n: u64) -> Option<f64> {
1113    if n == 0 {
1114        return None;
1115    }
1116    Some(total_words as f64 / n as f64)
1117}
1118
1119fn pct(n: u64, d: u64) -> Option<f64> {
1120    if d == 0 {
1121        return None;
1122    }
1123    Some(100.0 * n as f64 / d as f64)
1124}