Skip to main content

tokmesh_cli/
lib.rs

1mod antigravity;
2mod auth;
3mod claude_diagnostics;
4mod commands;
5mod cursor;
6mod device;
7mod leaderboard;
8mod paths;
9mod trae;
10mod tui;
11mod warp;
12
13use crate::tui::client_ui;
14use anyhow::Result;
15use clap::{Args, Parser, Subcommand, ValueEnum};
16use std::io::{self, IsTerminal, Write};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20use std::thread::{self, JoinHandle};
21use std::time::Duration;
22use tui::Tab;
23
24#[derive(Parser)]
25#[command(name = "tokmesh")]
26#[command(author, version, about = "AI token usage analytics")]
27struct Cli {
28    #[command(subcommand)]
29    command: Option<Commands>,
30
31    #[arg(short, long)]
32    theme: Option<String>,
33
34    #[arg(short, long, default_value = "0")]
35    refresh: u64,
36
37    #[arg(long)]
38    debug: bool,
39
40    #[arg(long)]
41    test_data: bool,
42
43    #[arg(long, help = "Output as JSON")]
44    json: bool,
45
46    #[arg(long, help = "Use legacy CLI table output")]
47    light: bool,
48
49    #[arg(
50        long = "write-cache",
51        requires = "light",
52        conflicts_with = "no_write_cache",
53        help = "After --light renders, atomically overwrite the TUI cache with this report's data so the next `tokmesh tui` starts from fresh data. Persists across invocations via settings.json `light.writeCache`."
54    )]
55    write_cache: bool,
56
57    #[arg(
58        long = "no-write-cache",
59        requires = "light",
60        conflicts_with = "write_cache",
61        help = "Skip cache write even if settings.json `light.writeCache` is true. Only valid with --light."
62    )]
63    no_write_cache: bool,
64
65    #[arg(
66        long = "hide-zero",
67        help = "Hide entries whose token counts, cost, and duration are all zero. Report totals still include them. Implies the static report view instead of the interactive TUI."
68    )]
69    hide_zero: bool,
70
71    #[command(flatten)]
72    clients: ClientFlags,
73
74    #[command(flatten)]
75    date: DateRangeFlags,
76
77    #[arg(
78        long,
79        value_name = "PATH",
80        global = true,
81        help = "Read local session data from this home directory for local report commands"
82    )]
83    home: Option<String>,
84
85    #[arg(long, help = "Show processing time")]
86    benchmark: bool,
87
88    #[arg(
89        long,
90        value_name = "STRATEGY",
91        default_value = "client,model",
92        help = "Grouping strategy for --light and --json output: model, client,model, client,provider,model, workspace,model, session,model, client,session,model"
93    )]
94    group_by: String,
95
96    #[arg(long, help = "Disable spinner (for AI agents and scripts)")]
97    no_spinner: bool,
98}
99
100#[derive(Subcommand)]
101enum LeaderboardCommand {
102    #[command(about = "Login (browser device flow, or --token to save an existing API token)")]
103    Login {
104        #[arg(long, help = "Save an existing API token without browser auth")]
105        token: Option<String>,
106    },
107    #[command(about = "Logout and remove stored credentials for this leaderboard")]
108    Logout,
109    #[command(about = "Show the logged-in user for this leaderboard")]
110    Whoami,
111    #[command(about = "Display saved API token as QR code")]
112    Qr {
113        #[arg(long, help = "Skip the interactive confirmation prompt")]
114        yes: bool,
115    },
116    #[command(about = "Submit local usage aggregates to this leaderboard")]
117    Submit {
118        #[command(flatten)]
119        clients: ClientFlags,
120        #[command(flatten)]
121        date: DateRangeFlags,
122        #[arg(long, help = "Print the payload without uploading")]
123        dry_run: bool,
124        #[arg(
125            long,
126            help = "With --dry-run, print the full submit JSON body to stdout"
127        )]
128        json: bool,
129        #[arg(
130            long,
131            help = "Authoritatively replace explicitly selected clients within --since/--until (tokens.ci)"
132        )]
133        replace: bool,
134    },
135    #[command(about = "Manage periodic usage submission to this leaderboard")]
136    Autosubmit {
137        #[command(subcommand)]
138        subcommand: commands::autosubmit::AutosubmitSubcommand,
139    },
140    #[command(about = "Delete all submitted usage data from this leaderboard")]
141    DeleteSubmittedData,
142}
143
144#[derive(Subcommand)]
145enum Commands {
146    #[command(about = "tokscale.ai leaderboard (login, submit, …)")]
147    Tokscale {
148        #[command(subcommand)]
149        command: LeaderboardCommand,
150    },
151    #[command(about = "tokens.ci leaderboard (login, submit, …)")]
152    Tokensci {
153        #[command(subcommand)]
154        command: LeaderboardCommand,
155    },
156    #[command(about = "Show model usage report")]
157    Models {
158        #[arg(long)]
159        json: bool,
160        #[arg(long)]
161        light: bool,
162        #[command(flatten)]
163        clients: ClientFlags,
164        #[command(flatten)]
165        date: DateRangeFlags,
166        #[arg(long, help = "Show processing time")]
167        benchmark: bool,
168        #[arg(
169            long,
170            value_name = "STRATEGY",
171            default_value = "client,model",
172            help = "Grouping strategy for --light and --json output: model, client,model, client,provider,model, workspace,model, session,model, client,session,model"
173        )]
174        group_by: String,
175        #[arg(
176            long = "write-cache",
177            requires = "light",
178            conflicts_with = "no_write_cache",
179            help = "After --light renders, atomically overwrite the TUI cache with this report's data so the next `tokmesh tui` starts from fresh data. Persists across invocations via settings.json `light.writeCache`."
180        )]
181        write_cache: bool,
182        #[arg(
183            long = "no-write-cache",
184            requires = "light",
185            conflicts_with = "write_cache",
186            help = "Skip cache write even if settings.json `light.writeCache` is true. Only valid with --light."
187        )]
188        no_write_cache: bool,
189        #[arg(
190            long = "hide-zero",
191            help = "Hide entries whose token counts, cost, and duration are all zero. Report totals still include them. Implies the static report view instead of the interactive TUI."
192        )]
193        hide_zero: bool,
194        #[arg(long, help = "Disable spinner")]
195        no_spinner: bool,
196    },
197    #[command(about = "Show monthly usage report")]
198    Monthly {
199        #[arg(long)]
200        json: bool,
201        #[arg(long)]
202        light: bool,
203        #[command(flatten)]
204        clients: ClientFlags,
205        #[command(flatten)]
206        date: DateRangeFlags,
207        #[arg(long, help = "Show processing time")]
208        benchmark: bool,
209        #[arg(
210            long = "hide-zero",
211            help = "Hide entries whose token counts and cost are all zero. Report totals still include them. Implies the static report view instead of the interactive TUI."
212        )]
213        hide_zero: bool,
214        #[arg(long, help = "Disable spinner")]
215        no_spinner: bool,
216    },
217    #[command(about = "Show hourly usage report")]
218    Hourly {
219        #[arg(long)]
220        json: bool,
221        #[arg(long)]
222        light: bool,
223        #[command(flatten)]
224        clients: ClientFlags,
225        #[command(flatten)]
226        date: DateRangeFlags,
227        #[arg(long, help = "Show processing time")]
228        benchmark: bool,
229        #[arg(
230            long = "hide-zero",
231            help = "Hide entries whose token counts and cost are all zero. Report totals still include them. Implies the static report view instead of the interactive TUI."
232        )]
233        hide_zero: bool,
234        #[arg(long, help = "Disable spinner")]
235        no_spinner: bool,
236    },
237    #[command(about = "Show pricing for a model")]
238    Pricing {
239        #[arg(help = "Model ID to look up, or `list-overrides`")]
240        model_id: String,
241        #[arg(long, help = "Output as JSON")]
242        json: bool,
243        #[arg(
244            long,
245            help = "Force specific pricing source (custom, litellm, openrouter, or models.dev)"
246        )]
247        provider: Option<String>,
248        #[arg(long, help = "Disable spinner")]
249        no_spinner: bool,
250    },
251    #[command(about = "Show local scan locations and session counts")]
252    Clients {
253        #[arg(long, help = "Output as JSON")]
254        json: bool,
255    },
256    #[command(about = "Export contribution graph data as JSON")]
257    Graph {
258        #[arg(long, help = "Write to file instead of stdout")]
259        output: Option<String>,
260        #[command(flatten)]
261        clients: ClientFlags,
262        #[command(flatten)]
263        date: DateRangeFlags,
264        #[arg(long, help = "Show processing time")]
265        benchmark: bool,
266        #[arg(long, help = "Disable spinner")]
267        no_spinner: bool,
268    },
269    #[command(
270        about = "Import historical usage from a third-party aggregate export (e.g. clawdboard) into tokmesh JSON"
271    )]
272    Import {
273        #[arg(help = "Path to the export file to import")]
274        file: String,
275        #[arg(
276            long,
277            default_value = "clawdboard",
278            help = "Export format (currently only 'clawdboard')"
279        )]
280        format: String,
281        #[arg(
282            long,
283            help = "Write normalized tokmesh JSON to this file instead of stdout"
284        )]
285        output: Option<String>,
286        #[arg(long, help = "Parse and summarize only; do not emit normalized JSON")]
287        dry_run: bool,
288    },
289    #[command(about = "Launch interactive TUI with optional filters")]
290    Tui {
291        #[command(flatten)]
292        clients: ClientFlags,
293        #[command(flatten)]
294        date: DateRangeFlags,
295    },
296    #[command(about = "Capture subprocess output for token usage tracking")]
297    Headless {
298        #[arg(help = "Source CLI (currently only 'codex' supported)")]
299        source: String,
300        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
301        args: Vec<String>,
302        #[arg(long, help = "Override output format (json or jsonl)")]
303        format: Option<String>,
304        #[arg(long, help = "Write captured output to file")]
305        output: Option<String>,
306        #[arg(long, help = "Do not auto-add JSON output flags")]
307        no_auto_flags: bool,
308    },
309    #[command(about = "Generate year-in-review wrapped image")]
310    Wrapped {
311        #[arg(long, help = "Output file path (default: tokmesh-{year}-wrapped.png)")]
312        output: Option<String>,
313        #[arg(long, help = "Year to generate (default: current year)")]
314        year: Option<String>,
315        #[command(flatten)]
316        client_flags: ClientFlags,
317        #[arg(
318            long,
319            help = "Display total tokens in abbreviated format (e.g., 7.14B)"
320        )]
321        short: bool,
322        #[arg(long, help = "Show Top OpenCode Agents (default)")]
323        agents: bool,
324        #[arg(
325            long = "clients",
326            help = "Show Top Clients instead of Top OpenCode Agents"
327        )]
328        show_clients: bool,
329        #[arg(long, help = "Disable pinning of Sisyphus agents in rankings")]
330        disable_pinned: bool,
331        #[arg(long, help = "Disable loading spinner (for scripting)")]
332        no_spinner: bool,
333    },
334    #[command(about = "Show subscription usage and quota for AI providers")]
335    Usage {
336        #[arg(long, help = "Output as JSON")]
337        json: bool,
338        #[arg(long, help = "Light terminal output (no TUI)")]
339        light: bool,
340    },
341    #[command(about = "Codex account integration commands")]
342    Codex {
343        #[command(subcommand)]
344        subcommand: CodexSubcommand,
345    },
346    #[command(about = "Cursor API cache integration commands")]
347    Cursor {
348        #[command(subcommand)]
349        subcommand: CursorSubcommand,
350    },
351    #[command(about = "Antigravity integration commands")]
352    Antigravity {
353        #[command(subcommand)]
354        subcommand: AntigravitySubcommand,
355    },
356    #[command(about = "Trae IDE integration commands")]
357    Trae {
358        #[command(subcommand)]
359        subcommand: TraeSubcommand,
360    },
361    #[command(about = "Warp/Oz aggregate usage integration commands")]
362    Warp {
363        #[command(subcommand)]
364        subcommand: WarpSubcommand,
365    },
366    #[command(
367        about = "Show session time metrics (usage time, longest continuous, max concurrent)"
368    )]
369    TimeMetrics {
370        #[arg(long)]
371        json: bool,
372        #[command(flatten)]
373        clients: ClientFlags,
374        #[command(flatten)]
375        date: DateRangeFlags,
376        #[arg(long, help = "Disable spinner")]
377        no_spinner: bool,
378    },
379    #[command(about = "Warm TUI cache in background (internal)", hide = true)]
380    WarmTuiCache,
381    #[command(about = "Task-attributed usage report")]
382    Report {
383        #[arg(long, help = "Output as JSON")]
384        json: bool,
385        #[arg(long, help = "Filter by workspace path")]
386        workspace: Option<String>,
387        #[arg(long, help = "Filter by client (opencode, claude, codex, etc.)")]
388        client: Option<String>,
389        #[command(flatten)]
390        date: DateRangeFlags,
391        #[arg(long, help = "Skip LLM summarization (show raw data only)")]
392        no_summarize: bool,
393        #[arg(
394            long,
395            default_value = "apple-fm",
396            help = "Summarizer backend: apple-fm, claude, codex, gemini, kiro"
397        )]
398        summarizer: String,
399        #[arg(long, help = "Reset all summaries and re-summarize from scratch")]
400        rebuild: bool,
401        #[arg(long, help = "Show all sessions without truncation")]
402        full: bool,
403    },
404}
405
406#[derive(Subcommand)]
407enum CursorSubcommand {
408    #[command(about = "Login to Cursor (auto-detect desktop session, or paste browser token)")]
409    Login {
410        #[arg(long, help = "Label for this Cursor account (e.g., work, personal)")]
411        name: Option<String>,
412    },
413    #[command(about = "Logout from a Cursor account")]
414    Logout {
415        #[arg(long, help = "Account label or id")]
416        name: Option<String>,
417        #[arg(long, help = "Logout from all Cursor accounts")]
418        all: bool,
419        #[arg(long, help = "Also delete cached Cursor usage")]
420        purge_cache: bool,
421    },
422    #[command(about = "Check Cursor authentication status")]
423    Status {
424        #[arg(long, help = "Account label or id")]
425        name: Option<String>,
426    },
427    #[command(about = "List saved Cursor accounts")]
428    Accounts {
429        #[arg(long, help = "Output as JSON")]
430        json: bool,
431    },
432    #[command(about = "Sync Cursor API usage into cursor-cache/usage*.csv")]
433    Sync {
434        #[arg(long, help = "Output as JSON")]
435        json: bool,
436    },
437    #[command(about = "Switch active Cursor account")]
438    Switch {
439        #[arg(help = "Account label or id")]
440        name: String,
441    },
442}
443
444#[derive(Subcommand)]
445enum CodexSubcommand {
446    #[command(about = "Import the current Codex OAuth credentials as a saved account")]
447    Import {
448        #[arg(long, help = "Label for this Codex account (e.g., work, personal)")]
449        name: Option<String>,
450    },
451    #[command(about = "List saved Codex accounts")]
452    Accounts {
453        #[arg(long, help = "Output as JSON")]
454        json: bool,
455    },
456    #[command(about = "Switch active Codex account and write Codex auth.json")]
457    Switch {
458        #[arg(help = "Account label or id")]
459        name: String,
460    },
461    #[command(about = "Remove a saved Codex account")]
462    Remove {
463        #[arg(help = "Account label or id")]
464        name: String,
465    },
466    #[command(about = "Check Codex subscription usage for an account")]
467    Status {
468        #[arg(long, help = "Account label or id")]
469        name: Option<String>,
470        #[arg(long, help = "Output as JSON")]
471        json: bool,
472    },
473    #[command(about = "Show an opt-in Codex account-activity snapshot")]
474    Activity {
475        #[arg(long, help = "Output as JSON")]
476        json: bool,
477    },
478}
479
480#[derive(Subcommand)]
481enum AntigravitySubcommand {
482    #[command(about = "Sync usage from running Antigravity language servers")]
483    Sync,
484    #[command(about = "Show Antigravity sync status")]
485    Status {
486        #[arg(long, help = "Output as JSON")]
487        json: bool,
488    },
489    #[command(about = "Delete cached Antigravity usage artifacts")]
490    PurgeCache,
491}
492
493#[derive(Subcommand)]
494enum TraeSubcommand {
495    #[command(about = "Authenticate Trae — auto-detect from desktop client or paste JWT")]
496    Login {
497        #[arg(long, help = "Paste access token directly (for manual fallback)")]
498        manual: bool,
499        #[arg(long, help = "Target Trae variant (solo, ide)")]
500        variant: Option<String>,
501    },
502    #[command(about = "Remove cached Trae credentials")]
503    Logout {
504        #[arg(long, help = "Target Trae variant (solo, ide)")]
505        variant: Option<String>,
506    },
507    #[command(about = "Show Trae authentication status")]
508    Status {
509        #[arg(long, help = "Output as JSON")]
510        json: bool,
511    },
512    #[command(about = "Sync Trae usage data into local cache")]
513    Sync {
514        #[arg(long, help = "Number of days to sync (default: 30)")]
515        since: Option<i64>,
516        #[arg(long, help = "Include auxiliary usage types (not just main chat)")]
517        include_aux: bool,
518    },
519}
520
521#[derive(Subcommand)]
522enum WarpSubcommand {
523    #[command(about = "Save Warp GraphQL authentication for aggregate usage sync")]
524    Login {
525        #[arg(long, help = "Warp bearer token or cookie header value")]
526        token: Option<String>,
527        #[arg(
528            long,
529            help = "Treat token as a Cookie header instead of a bearer token"
530        )]
531        cookie: bool,
532    },
533    #[command(about = "Remove cached Warp credentials")]
534    Logout {
535        #[arg(long, help = "Also delete cached Warp aggregate usage")]
536        purge_cache: bool,
537    },
538    #[command(about = "Show Warp aggregate sync status")]
539    Status {
540        #[arg(long, help = "Output as JSON")]
541        json: bool,
542    },
543    #[command(about = "Sync Warp aggregate usage into local cache")]
544    Sync {
545        #[arg(long, help = "Output as JSON")]
546        json: bool,
547    },
548}
549
550pub fn run() -> Result<()> {
551    use std::io::IsTerminal;
552
553    let cli = Cli::parse();
554    if let Some(name) = tui::settings::Settings::load_or_detect_bucket_timezone()? {
555        if let Some(timezone) = tokmesh_core::parse_bucket_timezone(&name) {
556            tokmesh_core::set_bucket_timezone(timezone);
557        }
558    }
559    // Install user-configured model aliases once, before any report/graph/TUI
560    // path runs, so model-name variants fold consistently across every command.
561    // Honors the global `--home` override exactly like scanner settings; an
562    // empty or absent config is a strict no-op.
563    tokmesh_core::model_alias::set_global(&tui::settings::load_model_aliases_for_home(&cli.home));
564    let opencode_model_names = tokmesh_core::opencode_model_name::load_for_home(
565        cli.home.as_deref().map(std::path::Path::new),
566    );
567    tokmesh_core::opencode_model_name::set_global(opencode_model_names);
568    let can_use_tui = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
569
570    if cli.test_data {
571        return tui::test_data_loading();
572    }
573
574    match cli.command {
575        Some(Commands::Models {
576            json,
577            light,
578            clients,
579            date,
580            benchmark,
581            group_by,
582            write_cache,
583            no_write_cache,
584            hide_zero,
585            no_spinner,
586        }) => {
587            use tokmesh_core::GroupBy;
588
589            let group_by: GroupBy = group_by.parse().unwrap_or_else(|e| {
590                eprintln!("Error: {}", e);
591                std::process::exit(1);
592            });
593            let clients = build_client_filter(clients, &cli.home);
594            if json || light || hide_zero || !can_use_tui {
595                run_models_report(
596                    json,
597                    cli.home.clone(),
598                    clients,
599                    &date,
600                    benchmark,
601                    no_spinner || !can_use_tui,
602                    group_by,
603                    write_cache,
604                    no_write_cache,
605                    hide_zero,
606                )
607            } else {
608                let (since, until) = build_date_filter(&date);
609                let year = normalize_year_filter(&date);
610                ensure_home_supported_for_tui(&cli.home)?;
611                auto_sync_cursor_before_tui(&cli.home, &clients)?;
612                tui::run(
613                    cli.theme.as_deref().unwrap_or(""),
614                    cli.refresh,
615                    cli.debug,
616                    clients,
617                    since,
618                    until,
619                    year,
620                    Some(Tab::Models),
621                )
622            }
623        }
624        Some(Commands::Monthly {
625            json,
626            light,
627            clients,
628            date,
629            benchmark,
630            hide_zero,
631            no_spinner,
632        }) => {
633            let clients = build_client_filter(clients, &cli.home);
634            if json || light || hide_zero || !can_use_tui {
635                run_monthly_report(
636                    json,
637                    cli.home.clone(),
638                    clients,
639                    &date,
640                    benchmark,
641                    no_spinner || !can_use_tui,
642                    hide_zero,
643                )
644            } else {
645                let (since, until) = build_date_filter(&date);
646                let year = normalize_year_filter(&date);
647                ensure_home_supported_for_tui(&cli.home)?;
648                auto_sync_cursor_before_tui(&cli.home, &clients)?;
649                tui::run(
650                    cli.theme.as_deref().unwrap_or(""),
651                    cli.refresh,
652                    cli.debug,
653                    clients,
654                    since,
655                    until,
656                    year,
657                    Some(Tab::Monthly),
658                )
659            }
660        }
661        Some(Commands::Hourly {
662            json,
663            light,
664            clients,
665            date,
666            benchmark,
667            hide_zero,
668            no_spinner,
669        }) => {
670            let clients = build_client_filter(clients, &cli.home);
671            if json || light || hide_zero || !can_use_tui {
672                run_hourly_report(
673                    json,
674                    cli.home.clone(),
675                    clients,
676                    &date,
677                    benchmark,
678                    no_spinner || !can_use_tui,
679                    hide_zero,
680                )
681            } else {
682                let (since, until) = build_date_filter(&date);
683                let year = normalize_year_filter(&date);
684                ensure_home_supported_for_tui(&cli.home)?;
685                auto_sync_cursor_before_tui(&cli.home, &clients)?;
686                tui::run(
687                    cli.theme.as_deref().unwrap_or(""),
688                    cli.refresh,
689                    cli.debug,
690                    clients,
691                    since,
692                    until,
693                    year,
694                    Some(Tab::Hourly),
695                )
696            }
697        }
698        Some(Commands::Pricing {
699            model_id,
700            json,
701            provider,
702            no_spinner,
703        }) => {
704            reject_unsupported_home_override(&cli.home, "pricing")?;
705            run_pricing_lookup(&model_id, json, provider.as_deref(), no_spinner)
706        }
707        Some(Commands::Clients { json }) => run_clients_command(json, cli.home.clone()),
708        Some(Commands::Tokscale { command }) => {
709            reject_unsupported_home_override(&cli.home, "tokscale")?;
710            run_leaderboard_command(leaderboard::Leaderboard::Tokscale, command, &cli.home)
711        }
712        Some(Commands::Tokensci { command }) => {
713            reject_unsupported_home_override(&cli.home, "tokensci")?;
714            run_leaderboard_command(leaderboard::Leaderboard::TokensCi, command, &cli.home)
715        }
716        Some(Commands::Graph {
717            output,
718            clients,
719            date,
720            benchmark,
721            no_spinner,
722        }) => {
723            let (since, until) = build_date_filter(&date);
724            let year = normalize_year_filter(&date);
725            let clients = build_client_filter(clients, &cli.home);
726            run_graph_command(
727                output,
728                cli.home.clone(),
729                clients,
730                since,
731                until,
732                year,
733                benchmark,
734                no_spinner,
735            )
736        }
737        Some(Commands::Import {
738            file,
739            format,
740            output,
741            dry_run,
742        }) => {
743            reject_unsupported_home_override(&cli.home, "import")?;
744            run_import_command(file, format, output, dry_run)
745        }
746        Some(Commands::Tui { clients, date }) => {
747            ensure_home_supported_for_tui(&cli.home)?;
748            let (since, until) = build_date_filter(&date);
749            let year = normalize_year_filter(&date);
750            let clients = build_client_filter(clients, &cli.home);
751            auto_sync_cursor_before_tui(&cli.home, &clients)?;
752            tui::run(
753                cli.theme.as_deref().unwrap_or(""),
754                cli.refresh,
755                cli.debug,
756                clients,
757                since,
758                until,
759                year,
760                None,
761            )
762        }
763
764        Some(Commands::Headless {
765            source,
766            args,
767            format,
768            output,
769            no_auto_flags,
770        }) => {
771            reject_unsupported_home_override(&cli.home, "headless")?;
772            run_headless_command(&source, args, format, output, no_auto_flags)
773        }
774        Some(Commands::Wrapped {
775            output,
776            year,
777            client_flags,
778            short,
779            agents,
780            show_clients,
781            disable_pinned,
782            no_spinner: _,
783        }) => {
784            reject_unsupported_home_override(&cli.home, "wrapped")?;
785            let client_filter = build_client_filter(client_flags, &cli.home);
786            run_wrapped_command(
787                output,
788                year,
789                client_filter,
790                short,
791                agents,
792                show_clients,
793                disable_pinned,
794            )
795        }
796        Some(Commands::Cursor { subcommand }) => {
797            reject_unsupported_home_override(&cli.home, "cursor")?;
798            run_cursor_command(subcommand)
799        }
800        Some(Commands::Antigravity { subcommand }) => {
801            reject_unsupported_home_override(&cli.home, "antigravity")?;
802            run_antigravity_command(subcommand)
803        }
804        Some(Commands::Usage { json, light }) => {
805            reject_unsupported_home_override(&cli.home, "usage")?;
806            commands::usage::run(json, light)
807        }
808        Some(Commands::Codex { subcommand }) => {
809            reject_unsupported_home_override(&cli.home, "codex")?;
810            run_codex_command(subcommand)
811        }
812        Some(Commands::Trae { subcommand }) => {
813            reject_unsupported_home_override(&cli.home, "trae")?;
814            run_trae_command(subcommand)
815        }
816        Some(Commands::Warp { subcommand }) => {
817            reject_unsupported_home_override(&cli.home, "warp")?;
818            run_warp_command(subcommand)
819        }
820
821        Some(Commands::TimeMetrics {
822            json,
823            clients,
824            date,
825            no_spinner,
826        }) => {
827            let (since, until) = build_date_filter(&date);
828            let year = normalize_year_filter(&date);
829            let clients = build_client_filter(clients, &cli.home);
830            run_time_metrics_report(
831                json,
832                cli.home.clone(),
833                clients,
834                since,
835                until,
836                year,
837                no_spinner,
838            )
839        }
840        Some(Commands::WarmTuiCache) => run_warm_tui_cache(),
841        Some(Commands::Report {
842            json,
843            workspace,
844            client,
845            date,
846            no_summarize,
847            summarizer,
848            rebuild,
849            full,
850        }) => {
851            let today = date.today;
852            let week = date.week;
853            let month = date.month;
854            let (since, until) = build_date_filter(&date);
855            commands::report::run_report(commands::report::ReportOptions {
856                json,
857                since,
858                until,
859                workspace,
860                client,
861                no_summarize,
862                summarizer,
863                rebuild,
864                home_dir: cli.home.clone(),
865                scanner_settings: tui::settings::load_scanner_settings(),
866                today,
867                week,
868                month,
869                full,
870            })
871        }
872        None => {
873            let clients = build_client_filter(cli.clients, &cli.home);
874            let group_by: tokmesh_core::GroupBy = cli.group_by.parse().unwrap_or_else(|e| {
875                eprintln!("Error: {}", e);
876                std::process::exit(1);
877            });
878
879            if cli.json {
880                run_models_report(
881                    cli.json,
882                    cli.home.clone(),
883                    clients,
884                    &cli.date,
885                    cli.benchmark,
886                    cli.no_spinner || cli.json,
887                    group_by,
888                    cli.write_cache,
889                    cli.no_write_cache,
890                    cli.hide_zero,
891                )
892            } else if cli.light || cli.hide_zero || !can_use_tui {
893                run_models_report(
894                    false,
895                    cli.home.clone(),
896                    clients,
897                    &cli.date,
898                    cli.benchmark,
899                    cli.no_spinner || !can_use_tui,
900                    group_by,
901                    cli.write_cache,
902                    cli.no_write_cache,
903                    cli.hide_zero,
904                )
905            } else {
906                let (since, until) = build_date_filter(&cli.date);
907                let year = normalize_year_filter(&cli.date);
908                ensure_home_supported_for_tui(&cli.home)?;
909                auto_sync_cursor_before_tui(&cli.home, &clients)?;
910                tui::run(
911                    cli.theme.as_deref().unwrap_or(""),
912                    cli.refresh,
913                    cli.debug,
914                    clients,
915                    since,
916                    until,
917                    year,
918                    None,
919                )
920            }
921        }
922    }
923}
924
925/// Client identifiers exposed via `--client`.
926///
927/// Mirrors `tokmesh_core::ClientId` plus the `Synthetic` meta-client. We
928/// duplicate the variant set on the CLI side so `tokmesh-core` stays free of
929/// CLI-parsing dependencies and so `Synthetic` (which has no scan path of its
930/// own) can be treated as a first-class filter value without changing core
931/// invariants.
932///
933/// Variant order intentionally mirrors `ClientId::ALL` declaration order so
934/// the TUI source picker, `--help`'s `[possible values: ...]` listing, and
935/// any future iteration over `ClientFilter::value_variants()` agree on a
936/// single chronological ordering. `Synthetic` is appended at the end since
937/// it has no `ClientId` counterpart.
938#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Hash)]
939#[value(rename_all = "lowercase")]
940pub enum ClientFilter {
941    Opencode,
942    Claude,
943    Codex,
944    Cursor,
945    Gemini,
946    Amp,
947    Droid,
948    Openclaw,
949    Pi,
950    Kimi,
951    Qwen,
952    Roocode,
953    Kilocode,
954    Mux,
955    Kilo,
956    Crush,
957    Hermes,
958    Copilot,
959    Goose,
960    Codebuff,
961    Antigravity,
962    Zed,
963    Kiro,
964    #[value(name = "trae")]
965    Trae,
966    Warp,
967    Cline,
968    #[value(name = "9router")]
969    NineRouter,
970    Gjc,
971    Grok,
972    Jcode,
973    Commandcode,
974    Micode,
975    #[value(name = "antigravity-cli")]
976    AntigravityCli,
977    Junie,
978    Zcode,
979    Opencodereview,
980    Codebuddy,
981    Workbuddy,
982    #[value(name = "devin-cli")]
983    DevinCli,
984    #[value(name = "devin-desktop")]
985    DevinDesktop,
986    Synthetic,
987}
988
989impl ClientFilter {
990    /// Returns the canonical lowercase identifier consumed by
991    /// `tokmesh_core` filter lists. Must match `ClientId::as_str` for every
992    /// variant that has a corresponding `ClientId`.
993    pub fn as_filter_str(&self) -> &'static str {
994        match self {
995            Self::Opencode => "opencode",
996            Self::Claude => "claude",
997            Self::Codex => "codex",
998            Self::Cursor => "cursor",
999            Self::Gemini => "gemini",
1000            Self::Amp => "amp",
1001            Self::Droid => "droid",
1002            Self::Openclaw => "openclaw",
1003            Self::Pi => "pi",
1004            Self::Kimi => "kimi",
1005            Self::Qwen => "qwen",
1006            Self::Roocode => "roocode",
1007            Self::Kilocode => "kilocode",
1008            Self::Mux => "mux",
1009            Self::Kilo => "kilo",
1010            Self::Crush => "crush",
1011            Self::Hermes => "hermes",
1012            Self::Copilot => "copilot",
1013            Self::Goose => "goose",
1014            Self::Codebuff => "codebuff",
1015            Self::Antigravity => "antigravity",
1016            Self::Zed => "zed",
1017            Self::Kiro => "kiro",
1018            Self::Trae => "trae",
1019            Self::Warp => "warp",
1020            Self::Cline => "cline",
1021            Self::Gjc => "gjc",
1022            Self::NineRouter => "9router",
1023            Self::Grok => "grok",
1024            Self::Jcode => "jcode",
1025            Self::Commandcode => "commandcode",
1026            Self::Micode => "micode",
1027            Self::AntigravityCli => "antigravity-cli",
1028            Self::Junie => "junie",
1029            Self::Zcode => "zcode",
1030            Self::Opencodereview => "opencodereview",
1031            Self::Codebuddy => "codebuddy",
1032            Self::Workbuddy => "workbuddy",
1033            Self::DevinCli => "devin-cli",
1034            Self::DevinDesktop => "devin-desktop",
1035            Self::Synthetic => "synthetic",
1036        }
1037    }
1038
1039    /// Convert to the corresponding `ClientId`, or `None` for the
1040    /// `Synthetic` meta-client which has no scan path of its own.
1041    ///
1042    /// Used at boundaries where TUI state (`HashSet<ClientFilter>`) needs
1043    /// to feed core APIs that still consume `Vec<ClientId>`.
1044    pub fn to_client_id(self) -> Option<tokmesh_core::ClientId> {
1045        use tokmesh_core::ClientId;
1046        match self {
1047            Self::Opencode => Some(ClientId::OpenCode),
1048            Self::Claude => Some(ClientId::Claude),
1049            Self::Codex => Some(ClientId::Codex),
1050            Self::Cursor => Some(ClientId::Cursor),
1051            Self::Gemini => Some(ClientId::Gemini),
1052            Self::Amp => Some(ClientId::Amp),
1053            Self::Droid => Some(ClientId::Droid),
1054            Self::Openclaw => Some(ClientId::OpenClaw),
1055            Self::Pi => Some(ClientId::Pi),
1056            Self::Kimi => Some(ClientId::Kimi),
1057            Self::Qwen => Some(ClientId::Qwen),
1058            Self::Roocode => Some(ClientId::RooCode),
1059            Self::Kilocode => Some(ClientId::KiloCode),
1060            Self::Mux => Some(ClientId::Mux),
1061            Self::Kilo => Some(ClientId::Kilo),
1062            Self::Crush => Some(ClientId::Crush),
1063            Self::Hermes => Some(ClientId::Hermes),
1064            Self::Copilot => Some(ClientId::Copilot),
1065            Self::Goose => Some(ClientId::Goose),
1066            Self::Codebuff => Some(ClientId::Codebuff),
1067            Self::Antigravity => Some(ClientId::Antigravity),
1068            Self::Zed => Some(ClientId::Zed),
1069            Self::Kiro => Some(ClientId::Kiro),
1070            Self::Trae => Some(ClientId::Trae),
1071            Self::Warp => Some(ClientId::Warp),
1072            Self::Cline => Some(ClientId::Cline),
1073            Self::Gjc => Some(ClientId::Gjc),
1074            Self::NineRouter => Some(ClientId::Gjc),
1075            Self::Grok => Some(ClientId::Grok),
1076            Self::Jcode => Some(ClientId::Jcode),
1077            Self::Commandcode => Some(ClientId::CommandCode),
1078            Self::Micode => Some(ClientId::MiMoCode),
1079            Self::AntigravityCli => Some(ClientId::AntigravityCli),
1080            Self::Junie => Some(ClientId::Junie),
1081            Self::Zcode => Some(ClientId::Zcode),
1082            Self::Opencodereview => Some(ClientId::OpenCodeReview),
1083            Self::Codebuddy => Some(ClientId::CodeBuddy),
1084            Self::Workbuddy => Some(ClientId::WorkBuddy),
1085            Self::DevinCli => Some(ClientId::DevinCli),
1086            Self::DevinDesktop => Some(ClientId::DevinDesktop),
1087            Self::Synthetic => None,
1088        }
1089    }
1090
1091    /// Lift a `ClientId` back into a `ClientFilter`. Total inverse of
1092    /// `to_client_id` for non-`Synthetic` variants.
1093    pub fn from_client_id(client: tokmesh_core::ClientId) -> Self {
1094        use tokmesh_core::ClientId;
1095        match client {
1096            ClientId::OpenCode => Self::Opencode,
1097            ClientId::Claude => Self::Claude,
1098            ClientId::Codex => Self::Codex,
1099            ClientId::Cursor => Self::Cursor,
1100            ClientId::Gemini => Self::Gemini,
1101            ClientId::Amp => Self::Amp,
1102            ClientId::Droid => Self::Droid,
1103            ClientId::OpenClaw => Self::Openclaw,
1104            ClientId::Pi => Self::Pi,
1105            ClientId::Kimi => Self::Kimi,
1106            ClientId::Qwen => Self::Qwen,
1107            ClientId::RooCode => Self::Roocode,
1108            ClientId::KiloCode => Self::Kilocode,
1109            ClientId::Mux => Self::Mux,
1110            ClientId::Kilo => Self::Kilo,
1111            ClientId::Crush => Self::Crush,
1112            ClientId::Hermes => Self::Hermes,
1113            ClientId::Copilot => Self::Copilot,
1114            ClientId::Goose => Self::Goose,
1115            ClientId::Codebuff => Self::Codebuff,
1116            ClientId::Antigravity => Self::Antigravity,
1117            ClientId::Zed => Self::Zed,
1118            ClientId::Kiro => Self::Kiro,
1119            ClientId::Trae => Self::Trae,
1120            ClientId::Warp => Self::Warp,
1121            ClientId::Cline => Self::Cline,
1122            ClientId::Gjc => Self::Gjc,
1123            ClientId::Grok => Self::Grok,
1124            ClientId::Jcode => Self::Jcode,
1125            ClientId::CommandCode => Self::Commandcode,
1126            ClientId::MiMoCode => Self::Micode,
1127            ClientId::AntigravityCli => Self::AntigravityCli,
1128            ClientId::Junie => Self::Junie,
1129            ClientId::Zcode => Self::Zcode,
1130            ClientId::OpenCodeReview => Self::Opencodereview,
1131            ClientId::CodeBuddy => Self::Codebuddy,
1132            ClientId::WorkBuddy => Self::Workbuddy,
1133            ClientId::DevinCli => Self::DevinCli,
1134            ClientId::DevinDesktop => Self::DevinDesktop,
1135        }
1136    }
1137
1138    /// Parse a canonical lowercase identifier (the same form
1139    /// `as_filter_str` returns) into a `ClientFilter`. Returns `None` for
1140    /// any unknown id so callers can drop unrecognized settings entries
1141    /// without erroring.
1142    pub fn from_filter_str(s: &str) -> Option<Self> {
1143        Self::value_variants()
1144            .iter()
1145            .copied()
1146            .find(|f| f.as_filter_str() == s)
1147    }
1148
1149    /// The "no filter" default set: every real client, with `Synthetic`
1150    /// **excluded**. Matches the pre-refactor behavior where a missing
1151    /// filter scanned every `ClientId` but did NOT post-process synthetic
1152    /// (synthetic detection has always been opt-in because it
1153    /// re-attributes messages from other clients to a different bucket).
1154    ///
1155    /// Single source of truth: every code path that needs a default
1156    /// filter (TUI launch, `submit` warm cache, etc.) must consult this
1157    /// so the cache key, the in-app state, and the loader filter all
1158    /// agree. Drift between them produces stale-cache misses on every
1159    /// launch.
1160    pub fn default_set() -> std::collections::HashSet<Self> {
1161        Self::value_variants()
1162            .iter()
1163            .copied()
1164            .filter(|f| !matches!(f, Self::Synthetic | Self::NineRouter))
1165            .collect()
1166    }
1167}
1168
1169#[derive(Args, Clone, Debug, Default)]
1170pub struct ClientFlags {
1171    /// Canonical client filter. Repeatable or comma-separated.
1172    /// Example: `--client opencode,claude` or `-c opencode -c claude`.
1173    #[arg(
1174        id = "client_filter",
1175        long = "client",
1176        short = 'c',
1177        value_name = "CLIENTS",
1178        value_enum,
1179        value_delimiter = ',',
1180        action = clap::ArgAction::Append,
1181        ignore_case = true,
1182        help = "Filter by client(s). Repeatable or comma-separated (e.g. -c opencode,claude)."
1183    )]
1184    pub clients: Vec<ClientFilter>,
1185}
1186
1187#[derive(Args, Clone, Debug, Default)]
1188pub struct DateRangeFlags {
1189    #[arg(
1190        long,
1191        help = "Show only today's usage",
1192        conflicts_with_all = ["yesterday", "week", "month", "since", "until", "year"]
1193    )]
1194    pub today: bool,
1195    #[arg(
1196        long,
1197        help = "Show only yesterday's usage",
1198        conflicts_with_all = ["week", "month", "since", "until", "year"]
1199    )]
1200    pub yesterday: bool,
1201    #[arg(
1202        long,
1203        help = "Show last 7 days",
1204        conflicts_with_all = ["month", "since", "until", "year"]
1205    )]
1206    pub week: bool,
1207    #[arg(
1208        long,
1209        help = "Show current month",
1210        conflicts_with_all = ["since", "until", "year"]
1211    )]
1212    pub month: bool,
1213    #[arg(long, help = "Start date (YYYY-MM-DD)")]
1214    pub since: Option<String>,
1215    #[arg(long, help = "End date (YYYY-MM-DD)")]
1216    pub until: Option<String>,
1217    #[arg(long, help = "Filter by year (YYYY)")]
1218    pub year: Option<String>,
1219}
1220
1221/// Builds the client filter list passed to `tokmesh_core`.
1222///
1223/// Resolution order:
1224/// 1. Collect canonical `--client/-c` values (preserves user order).
1225/// 2. If step 1 produced nothing, fall back to user-configured
1226///    `defaultClients` from `~/.config/tokmesh/settings.json` when present.
1227/// 3. Deduplicate while preserving first-seen order.
1228///
1229/// Returns `None` when no filters are active *and* no defaults configured
1230/// so the caller can scan all clients.
1231fn build_client_filter(flags: ClientFlags, home_dir: &Option<String>) -> Option<Vec<String>> {
1232    let defaults = tui::settings::load_default_clients_for_home(home_dir);
1233    build_client_filter_with_defaults(flags, &defaults)
1234}
1235
1236/// Pure variant of [`build_client_filter`] for unit-testable resolution.
1237/// `defaults` is the (already-validated) list of canonical filter ids that
1238/// should apply when no CLI flag is present.
1239fn build_client_filter_with_defaults(
1240    flags: ClientFlags,
1241    defaults: &[String],
1242) -> Option<Vec<String>> {
1243    let mut ordered: Vec<String> = Vec::new();
1244    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1245
1246    for client in &flags.clients {
1247        let id = client.as_filter_str().to_string();
1248        if seen.insert(id.clone()) {
1249            ordered.push(id);
1250        }
1251    }
1252
1253    // Defaults only apply when the user passed no canonical `--client` flags.
1254    // CLI flags always win — predictable semantics over "merge". Unknown /
1255    // typo'd ids are dropped silently so a stale settings.json entry never
1256    // breaks tokmesh.
1257    if ordered.is_empty() {
1258        for raw in defaults {
1259            if let Some(client) = ClientFilter::from_filter_str(raw) {
1260                let id = client.as_filter_str().to_string();
1261                if seen.insert(id.clone()) {
1262                    ordered.push(id);
1263                }
1264            }
1265        }
1266    }
1267
1268    if ordered.is_empty() {
1269        None
1270    } else {
1271        Some(ordered)
1272    }
1273}
1274
1275fn client_filter_includes_cursor(clients: &Option<Vec<String>>) -> bool {
1276    clients
1277        .as_ref()
1278        .is_none_or(|sources| sources.iter().any(|source| source == "cursor"))
1279}
1280
1281fn client_filter_explicitly_requests_cursor(clients: &Option<Vec<String>>) -> bool {
1282    clients
1283        .as_ref()
1284        .is_some_and(|sources| sources.iter().any(|source| source == "cursor"))
1285}
1286
1287fn client_filter_explicitly_requests_warp(clients: &Option<Vec<String>>) -> bool {
1288    clients
1289        .as_ref()
1290        .is_some_and(|sources| sources.iter().any(|source| source == "warp"))
1291}
1292
1293#[derive(Debug)]
1294struct CursorSetupState {
1295    has_credentials: bool,
1296    has_cache: bool,
1297    cache_glob: String,
1298    home_override: bool,
1299}
1300
1301fn cursor_setup_state(home_dir: &Option<String>) -> Option<CursorSetupState> {
1302    let (home_path, home_override) = match home_dir {
1303        Some(home) => (PathBuf::from(home), true),
1304        None => (dirs::home_dir()?, false),
1305    };
1306    let has_credentials = if home_override {
1307        cursor::has_active_credentials_in_home(&home_path)
1308    } else {
1309        cursor::is_cursor_logged_in()
1310    };
1311    let has_cache = cursor::has_cursor_usage_cache_in_home(&home_path);
1312    let cache_glob = if home_override {
1313        home_path
1314            .join(".config/tokmesh/cursor-cache/usage*.csv")
1315            .to_string_lossy()
1316            .to_string()
1317    } else {
1318        "~/.config/tokmesh/cursor-cache/usage*.csv".to_string()
1319    };
1320
1321    Some(CursorSetupState {
1322        has_credentials,
1323        has_cache,
1324        cache_glob,
1325        home_override,
1326    })
1327}
1328
1329fn has_cursor_usage_cache_for_report(home_dir: &Option<String>) -> bool {
1330    cursor_setup_state(home_dir).is_some_and(|state| state.has_cache)
1331}
1332
1333fn cursor_setup_warnings_for_report(
1334    home_dir: &Option<String>,
1335    clients: &Option<Vec<String>>,
1336) -> Vec<String> {
1337    if !client_filter_explicitly_requests_cursor(clients) {
1338        return Vec::new();
1339    }
1340
1341    let Some(state) = cursor_setup_state(home_dir) else {
1342        return vec![
1343            "Cursor usage requires Tokmesh's Cursor API cache, but the home directory could not be resolved. Run `tokmesh cursor login` (auto-detects Cursor desktop when signed in) and `tokmesh cursor sync --json`. Tokmesh does not parse local `~/.cursor` session data.".to_string(),
1344        ];
1345    };
1346    if state.has_cache {
1347        return Vec::new();
1348    }
1349
1350    let action = if state.home_override {
1351        "run `tokmesh cursor login` (auto-detects Cursor desktop when signed in) and `tokmesh cursor sync --json`, or populate that cache before running a report with --home"
1352    } else if state.has_credentials {
1353        "run `tokmesh cursor sync --json`"
1354    } else {
1355        "run `tokmesh cursor login` (auto-detects Cursor desktop when signed in) and `tokmesh cursor sync --json`"
1356    };
1357
1358    vec![format!(
1359        "Cursor usage requires Tokmesh's Cursor API cache at `{}`; {}. Tokmesh does not parse local `~/.cursor` session data.",
1360        state.cache_glob, action
1361    )]
1362}
1363
1364fn emit_cursor_setup_warnings(warnings: &[String]) {
1365    if warnings.is_empty() {
1366        return;
1367    }
1368
1369    use colored::Colorize;
1370    for warning in warnings {
1371        eprintln!("{}", format!("  Warning: {}", warning).yellow());
1372    }
1373}
1374
1375fn warp_setup_warnings_for_report(
1376    home_dir: &Option<String>,
1377    clients: &Option<Vec<String>>,
1378) -> Vec<String> {
1379    if !client_filter_explicitly_requests_warp(clients) {
1380        return Vec::new();
1381    }
1382
1383    let (home_path, home_override) = match home_dir {
1384        Some(home) => (PathBuf::from(home), true),
1385        None => match dirs::home_dir() {
1386            Some(home) => (home, false),
1387            None => {
1388                return vec![
1389                    "Warp usage requires Tokmesh's Warp aggregate cache, but the home directory could not be resolved. Tokmesh does not parse local Warp transcripts.".to_string(),
1390                ];
1391            }
1392        },
1393    };
1394    let has_cache = if home_override {
1395        warp::has_usage_cache_in_home(&home_path)
1396    } else {
1397        warp::load_usage_cache().is_some()
1398    };
1399    if has_cache {
1400        return Vec::new();
1401    }
1402
1403    let cache_glob = if home_override {
1404        home_path
1405            .join(".config/tokmesh/warp-cache/usage*.json")
1406            .to_string_lossy()
1407            .to_string()
1408    } else {
1409        "~/.config/tokmesh/warp-cache/usage*.json".to_string()
1410    };
1411    let action = if home_override {
1412        "run `tokmesh warp sync` for the default profile or populate that cache before running a report with --home"
1413    } else if warp::has_credentials() {
1414        "run `tokmesh warp sync`"
1415    } else {
1416        "run `tokmesh warp login` and `tokmesh warp sync`"
1417    };
1418
1419    vec![format!(
1420        "Warp usage requires Tokmesh's aggregate API cache at `{}`; {}. Tokmesh does not parse local Warp/Oz session transcripts and does not infer tokens from request counts.",
1421        cache_glob, action
1422    )]
1423}
1424
1425fn setup_warnings_for_report(
1426    home_dir: &Option<String>,
1427    clients: &Option<Vec<String>>,
1428) -> Vec<String> {
1429    let mut warnings = cursor_setup_warnings_for_report(home_dir, clients);
1430    warnings.extend(warp_setup_warnings_for_report(home_dir, clients));
1431    warnings
1432}
1433
1434fn should_auto_sync_cursor_for_local_report(
1435    home_dir: &Option<String>,
1436    clients: &Option<Vec<String>>,
1437) -> bool {
1438    home_dir.is_none() && client_filter_includes_cursor(clients)
1439}
1440
1441fn auto_sync_cursor_for_local_report(
1442    home_dir: &Option<String>,
1443    clients: &Option<Vec<String>>,
1444) -> Option<cursor::SyncCursorResult> {
1445    if !should_auto_sync_cursor_for_local_report(home_dir, clients)
1446        || !cursor::is_cursor_logged_in()
1447    {
1448        return None;
1449    }
1450
1451    // Skip the implicit refresh when each expected Cursor account cache is
1452    // recent enough — running `tokmesh models` 30× in a script must not
1453    // produce 30 Cursor API calls. The manual `tokmesh cursor sync` command
1454    // bypasses this gate.
1455    if cursor::cursor_usage_cache_is_fresh(cursor::CURSOR_AUTO_SYNC_FRESHNESS) {
1456        return None;
1457    }
1458
1459    Some(run_best_effort_cursor_sync_with_runtime_factory(
1460        tokio::runtime::Runtime::new,
1461    ))
1462}
1463
1464fn run_best_effort_cursor_sync_with_runtime_factory<F>(build_runtime: F) -> cursor::SyncCursorResult
1465where
1466    F: FnOnce() -> std::io::Result<tokio::runtime::Runtime>,
1467{
1468    match build_runtime() {
1469        Ok(rt) => rt.block_on(async { cursor::sync_cursor_cache().await }),
1470        Err(error) => cursor::SyncCursorResult {
1471            synced: false,
1472            rows: 0,
1473            error: Some(format!(
1474                "Failed to initialize Cursor sync runtime: {}",
1475                error
1476            )),
1477        },
1478    }
1479}
1480
1481fn auto_sync_cursor_before_tui(
1482    home_dir: &Option<String>,
1483    clients: &Option<Vec<String>>,
1484) -> Result<()> {
1485    let had_cursor_cache = has_cursor_usage_cache_for_report(home_dir);
1486    let explicit_cursor_filter = client_filter_explicitly_requests_cursor(clients);
1487    let cursor_sync_result = auto_sync_cursor_for_local_report(home_dir, clients);
1488    emit_cursor_sync_warning(
1489        cursor_sync_result.as_ref(),
1490        had_cursor_cache,
1491        explicit_cursor_filter,
1492    );
1493    let cursor_setup_warnings = setup_warnings_for_report(home_dir, clients);
1494    emit_cursor_setup_warnings(&cursor_setup_warnings);
1495    Ok(())
1496}
1497
1498fn emit_cursor_sync_warning(
1499    sync: Option<&cursor::SyncCursorResult>,
1500    had_cursor_cache: bool,
1501    explicit_cursor_filter: bool,
1502) {
1503    let Some(sync) = sync else {
1504        return;
1505    };
1506    let Some(error) = sync.error.as_ref() else {
1507        return;
1508    };
1509    if sync.synced || had_cursor_cache || explicit_cursor_filter {
1510        use colored::Colorize;
1511        let prefix = if sync.synced {
1512            "Cursor sync warning"
1513        } else if had_cursor_cache {
1514            "Cursor sync failed; using cached data"
1515        } else {
1516            "Cursor sync failed"
1517        };
1518        eprintln!("{}", format!("  {}: {}", prefix, error).yellow());
1519    }
1520}
1521
1522fn default_submit_clients() -> Vec<String> {
1523    let mut clients: Vec<String> = tokmesh_core::ClientId::iter()
1524        .filter(|client| client.submit_default())
1525        .map(|client| client.as_str().to_string())
1526        .collect();
1527    clients.push("synthetic".to_string());
1528    clients
1529}
1530
1531fn reject_unsupported_home_override(home_dir: &Option<String>, command: &str) -> Result<()> {
1532    if home_dir.is_some() {
1533        return Err(anyhow::anyhow!(
1534            "--home is currently supported only for local report commands. It is not supported for `{}`.",
1535            command
1536        ));
1537    }
1538
1539    Ok(())
1540}
1541
1542fn use_env_roots(home_dir: &Option<String>) -> bool {
1543    home_dir.is_none()
1544}
1545
1546fn resolve_effective_home_dir(home_dir: &Option<String>) -> Option<PathBuf> {
1547    home_dir.as_ref().map(PathBuf::from).or_else(dirs::home_dir)
1548}
1549
1550fn model_usage_includes_client(entry: &tokmesh_core::ModelUsage, client: &str) -> bool {
1551    if entry.client == client {
1552        return true;
1553    }
1554
1555    entry
1556        .merged_clients
1557        .as_deref()
1558        .is_some_and(|clients| clients.split(", ").any(|id| id == client))
1559}
1560
1561fn emit_client_diagnostics(diagnostics: &[claude_diagnostics::ClientDiagnostic]) {
1562    if diagnostics.is_empty() {
1563        return;
1564    }
1565
1566    use colored::Colorize;
1567    for diagnostic in diagnostics {
1568        eprintln!(
1569            "{}",
1570            format!("  {}: {}", diagnostic.severity, diagnostic.message).yellow()
1571        );
1572        eprintln!("{}", format!("  {}", diagnostic.help).bright_black());
1573    }
1574}
1575
1576fn ensure_home_supported_for_tui(home_dir: &Option<String>) -> Result<()> {
1577    if home_dir.is_some() {
1578        return Err(anyhow::anyhow!(
1579            "--home is currently supported for local report commands only. Use `--json`, `--light`, `models`, `monthly`, or `graph` instead of TUI mode."
1580        ));
1581    }
1582
1583    Ok(())
1584}
1585
1586fn build_date_filter(date: &DateRangeFlags) -> (Option<String>, Option<String>) {
1587    build_date_filter_for_date(date, tokmesh_core::bucket_timezone().today())
1588}
1589
1590fn build_date_filter_for_date(
1591    date: &DateRangeFlags,
1592    current_date: chrono::NaiveDate,
1593) -> (Option<String>, Option<String>) {
1594    use chrono::{Datelike, Duration};
1595
1596    if date.today {
1597        let day = current_date.format("%Y-%m-%d").to_string();
1598        return (Some(day.clone()), Some(day));
1599    }
1600
1601    if date.yesterday {
1602        let day = (current_date - Duration::days(1))
1603            .format("%Y-%m-%d")
1604            .to_string();
1605        return (Some(day.clone()), Some(day));
1606    }
1607
1608    if date.week {
1609        let start = current_date - Duration::days(6);
1610        return (
1611            Some(start.format("%Y-%m-%d").to_string()),
1612            Some(current_date.format("%Y-%m-%d").to_string()),
1613        );
1614    }
1615
1616    if date.month {
1617        let start = current_date.with_day(1).unwrap_or(current_date);
1618        return (
1619            Some(start.format("%Y-%m-%d").to_string()),
1620            Some(current_date.format("%Y-%m-%d").to_string()),
1621        );
1622    }
1623
1624    (date.since.clone(), date.until.clone())
1625}
1626
1627fn normalize_year_filter(date: &DateRangeFlags) -> Option<String> {
1628    if date.today || date.yesterday || date.week || date.month {
1629        None
1630    } else {
1631        date.year.clone()
1632    }
1633}
1634
1635fn get_date_range_label(date: &DateRangeFlags) -> Option<String> {
1636    get_date_range_label_for_date(date, tokmesh_core::bucket_timezone().today())
1637}
1638
1639fn get_date_range_label_for_date(
1640    date: &DateRangeFlags,
1641    current_date: chrono::NaiveDate,
1642) -> Option<String> {
1643    if date.today {
1644        return Some("Today".to_string());
1645    }
1646    if date.yesterday {
1647        return Some("Yesterday".to_string());
1648    }
1649    if date.week {
1650        return Some("Last 7 days".to_string());
1651    }
1652    if date.month {
1653        return Some(current_date.format("%B %Y").to_string());
1654    }
1655    if let Some(y) = &date.year {
1656        return Some(y.clone());
1657    }
1658    let mut parts = Vec::new();
1659    if let Some(s) = &date.since {
1660        parts.push(format!("from {}", s));
1661    }
1662    if let Some(u) = &date.until {
1663        parts.push(format!("to {}", u));
1664    }
1665    if parts.is_empty() {
1666        None
1667    } else {
1668        Some(parts.join(" "))
1669    }
1670}
1671
1672struct LightSpinner {
1673    running: Arc<AtomicBool>,
1674    handle: Option<JoinHandle<()>>,
1675}
1676
1677const TABLE_PRESET: &str = "││──├─┼┤│─┼├┤┬┴┌┐└┘";
1678
1679impl LightSpinner {
1680    const WIDTH: usize = 8;
1681    const HOLD_START: usize = 30;
1682    const HOLD_END: usize = 9;
1683    const TRAIL_LENGTH: usize = 4;
1684    const TRAIL_COLORS: [u8; 6] = [51, 44, 37, 30, 23, 17];
1685    const INACTIVE_COLOR: u8 = 240;
1686    const FRAME_MS: u64 = 40;
1687
1688    fn start(message: &'static str) -> Self {
1689        let running = Arc::new(AtomicBool::new(true));
1690        let running_thread = Arc::clone(&running);
1691        let message = message.to_string();
1692
1693        let handle = thread::spawn(move || {
1694            let mut frame = 0usize;
1695            let mut stderr = io::stderr().lock();
1696
1697            let _ = write!(stderr, "\x1b[?25l");
1698            let _ = stderr.flush();
1699
1700            while running_thread.load(Ordering::Relaxed) {
1701                let spinner = Self::frame(frame);
1702                let _ = write!(stderr, "\r\x1b[K  {} {}", spinner, message);
1703                let _ = stderr.flush();
1704                frame = frame.wrapping_add(1);
1705                thread::sleep(Duration::from_millis(Self::FRAME_MS));
1706            }
1707
1708            let _ = write!(stderr, "\r\x1b[K\x1b[?25h");
1709            let _ = stderr.flush();
1710        });
1711
1712        Self {
1713            running,
1714            handle: Some(handle),
1715        }
1716    }
1717
1718    fn stop(mut self) {
1719        self.stop_inner();
1720    }
1721
1722    fn stop_inner(&mut self) {
1723        self.running.store(false, Ordering::Relaxed);
1724        if let Some(handle) = self.handle.take() {
1725            let _ = handle.join();
1726        }
1727    }
1728
1729    fn frame(frame: usize) -> String {
1730        let (position, forward) = Self::scanner_state(frame);
1731        let mut out = String::new();
1732
1733        for i in 0..Self::WIDTH {
1734            let distance = if forward {
1735                if position >= i {
1736                    position - i
1737                } else {
1738                    usize::MAX
1739                }
1740            } else if i >= position {
1741                i - position
1742            } else {
1743                usize::MAX
1744            };
1745
1746            if distance < Self::TRAIL_LENGTH {
1747                let color = Self::TRAIL_COLORS[distance.min(Self::TRAIL_COLORS.len() - 1)];
1748                out.push_str(&format!("\x1b[38;5;{}m■\x1b[0m", color));
1749            } else {
1750                out.push_str(&format!("\x1b[38;5;{}m⬝\x1b[0m", Self::INACTIVE_COLOR));
1751            }
1752        }
1753
1754        out
1755    }
1756
1757    fn scanner_state(frame: usize) -> (usize, bool) {
1758        let forward_frames = Self::WIDTH;
1759        let backward_frames = Self::WIDTH - 1;
1760        let total_cycle = forward_frames + Self::HOLD_END + backward_frames + Self::HOLD_START;
1761        let normalized = frame % total_cycle;
1762
1763        if normalized < forward_frames {
1764            (normalized, true)
1765        } else if normalized < forward_frames + Self::HOLD_END {
1766            (Self::WIDTH - 1, true)
1767        } else if normalized < forward_frames + Self::HOLD_END + backward_frames {
1768            (
1769                Self::WIDTH - 2 - (normalized - forward_frames - Self::HOLD_END),
1770                false,
1771            )
1772        } else {
1773            (0, false)
1774        }
1775    }
1776}
1777
1778impl Drop for LightSpinner {
1779    fn drop(&mut self) {
1780        self.stop_inner();
1781    }
1782}
1783
1784#[allow(clippy::too_many_arguments)]
1785fn run_models_report(
1786    json: bool,
1787    home_dir: Option<String>,
1788    clients: Option<Vec<String>>,
1789    date: &DateRangeFlags,
1790    benchmark: bool,
1791    no_spinner: bool,
1792    group_by: tokmesh_core::GroupBy,
1793    cli_write_cache: bool,
1794    cli_no_write_cache: bool,
1795    hide_zero: bool,
1796) -> Result<()> {
1797    use std::time::Instant;
1798    use tokio::runtime::Runtime;
1799    use tokmesh_core::{get_model_report, GroupBy, ReportOptions};
1800
1801    let (since, until) = build_date_filter(date);
1802    let year = normalize_year_filter(date);
1803    let date_range = get_date_range_label(date);
1804    let effective_home_dir = resolve_effective_home_dir(&home_dir);
1805
1806    let had_cursor_cache = has_cursor_usage_cache_for_report(&home_dir);
1807    let explicit_cursor_filter = client_filter_explicitly_requests_cursor(&clients);
1808    let spinner = if no_spinner {
1809        None
1810    } else {
1811        Some(LightSpinner::start("Scanning session data..."))
1812    };
1813    let cursor_sync_result = auto_sync_cursor_for_local_report(&home_dir, &clients);
1814    let cursor_setup_warnings = setup_warnings_for_report(&home_dir, &clients);
1815    let use_env_roots = use_env_roots(&home_dir);
1816    let start = Instant::now();
1817    let rt = Runtime::new()?;
1818    let report = rt
1819        .block_on(async {
1820            get_model_report(ReportOptions {
1821                home_dir: home_dir.clone(),
1822                use_env_roots,
1823                clients: clients.clone(),
1824                since: since.clone(),
1825                until: until.clone(),
1826                year: year.clone(),
1827                group_by: group_by.clone(),
1828                scanner_settings: tui::settings::load_scanner_settings_for_home(&home_dir),
1829            })
1830            .await
1831        })
1832        .map_err(|e| anyhow::anyhow!(e))?;
1833    let mut report = report;
1834    if hide_zero {
1835        // Display-only filter: totals were computed in core over the full
1836        // entry set and intentionally still include the hidden rows.
1837        report.entries.retain(|e| {
1838            e.input != 0
1839                || e.output != 0
1840                || e.cache_read != 0
1841                || e.cache_write != 0
1842                || e.reasoning != 0
1843                || e.cost != 0.0
1844                || e.performance.total_duration_ms != 0
1845        });
1846    }
1847    let report = report;
1848
1849    if let Some(spinner) = spinner {
1850        spinner.stop();
1851    }
1852    emit_cursor_sync_warning(
1853        cursor_sync_result.as_ref(),
1854        had_cursor_cache,
1855        explicit_cursor_filter,
1856    );
1857    let processing_time_ms = start.elapsed().as_millis();
1858    let claude_message_count = report
1859        .entries
1860        .iter()
1861        .filter(|entry| model_usage_includes_client(entry, "claude"))
1862        .map(|entry| entry.message_count)
1863        .sum();
1864    let diagnostics = effective_home_dir
1865        .as_deref()
1866        .map(|home| {
1867            claude_diagnostics::diagnostics_for_empty_explicit_report(
1868                home,
1869                &clients,
1870                claude_message_count,
1871            )
1872        })
1873        .unwrap_or_default();
1874
1875    if json {
1876        #[derive(serde::Serialize)]
1877        #[serde(rename_all = "camelCase")]
1878        struct ModelUsageJson {
1879            client: String,
1880            merged_clients: Option<String>,
1881            #[serde(skip_serializing_if = "Option::is_none")]
1882            workspace_key: Option<serde_json::Value>,
1883            #[serde(skip_serializing_if = "Option::is_none")]
1884            workspace_label: Option<String>,
1885            #[serde(skip_serializing_if = "Option::is_none")]
1886            session_id: Option<String>,
1887            model: String,
1888            provider: String,
1889            input: i64,
1890            output: i64,
1891            cache_read: i64,
1892            cache_write: i64,
1893            reasoning: i64,
1894            message_count: i32,
1895            cost: f64,
1896            performance: tokmesh_core::ModelPerformance,
1897        }
1898
1899        #[derive(serde::Serialize)]
1900        #[serde(rename_all = "camelCase")]
1901        struct ModelReportJson {
1902            group_by: String,
1903            entries: Vec<ModelUsageJson>,
1904            total_input: i64,
1905            total_output: i64,
1906            total_cache_read: i64,
1907            total_cache_write: i64,
1908            total_messages: i32,
1909            total_cost: f64,
1910            processing_time_ms: u32,
1911            #[serde(skip_serializing_if = "Vec::is_empty")]
1912            warnings: Vec<String>,
1913            #[serde(skip_serializing_if = "Vec::is_empty")]
1914            diagnostics: Vec<claude_diagnostics::ClientDiagnostic>,
1915        }
1916
1917        let output = ModelReportJson {
1918            group_by: group_by.to_string(),
1919            entries: report
1920                .entries
1921                .into_iter()
1922                .map(|e| ModelUsageJson {
1923                    workspace_key: if group_by == GroupBy::WorkspaceModel {
1924                        Some(
1925                            e.workspace_key
1926                                .map(serde_json::Value::String)
1927                                .unwrap_or(serde_json::Value::Null),
1928                        )
1929                    } else {
1930                        None
1931                    },
1932                    workspace_label: if group_by == GroupBy::WorkspaceModel {
1933                        e.workspace_label
1934                    } else {
1935                        None
1936                    },
1937                    session_id: if matches!(group_by, GroupBy::Session | GroupBy::ClientSession) {
1938                        e.session_id
1939                    } else {
1940                        None
1941                    },
1942                    client: e.client,
1943                    merged_clients: e.merged_clients,
1944                    model: e.model,
1945                    provider: e.provider,
1946                    input: e.input,
1947                    output: e.output,
1948                    cache_read: e.cache_read,
1949                    cache_write: e.cache_write,
1950                    reasoning: e.reasoning,
1951                    message_count: e.message_count,
1952                    cost: e.cost,
1953                    performance: e.performance,
1954                })
1955                .collect(),
1956            total_input: report.total_input,
1957            total_output: report.total_output,
1958            total_cache_read: report.total_cache_read,
1959            total_cache_write: report.total_cache_write,
1960            total_messages: report.total_messages,
1961            total_cost: report.total_cost,
1962            processing_time_ms: report.processing_time_ms,
1963            warnings: cursor_setup_warnings,
1964            diagnostics,
1965        };
1966        println!("{}", serde_json::to_string_pretty(&output)?);
1967    } else {
1968        use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
1969        emit_client_diagnostics(&diagnostics);
1970
1971        emit_cursor_setup_warnings(&cursor_setup_warnings);
1972        let total_performance = aggregate_model_report_performance(&report.entries);
1973        let term_width = crossterm::terminal::size()
1974            .map(|(w, _)| w as usize)
1975            .unwrap_or(120);
1976        let compact = term_width < 100;
1977
1978        let mut table = Table::new();
1979        table.load_preset(TABLE_PRESET);
1980        let arrangement = if std::io::stdout().is_terminal() {
1981            ContentArrangement::DynamicFullWidth
1982        } else {
1983            ContentArrangement::Dynamic
1984        };
1985        table.set_content_arrangement(arrangement);
1986        table.enforce_styling();
1987
1988        let workspace_name = |label: Option<&str>| label.unwrap_or("Unknown workspace").to_string();
1989
1990        if compact {
1991            match group_by {
1992                GroupBy::Model => {
1993                    table.set_header(vec![
1994                        Cell::new("Clients").fg(Color::Cyan),
1995                        Cell::new("Providers").fg(Color::Cyan),
1996                        Cell::new("Model").fg(Color::Cyan),
1997                        Cell::new("Input").fg(Color::Cyan),
1998                        Cell::new("Output").fg(Color::Cyan),
1999                        Cell::new("ms/1K").fg(Color::Cyan),
2000                        Cell::new("Cost").fg(Color::Cyan),
2001                        Cell::new("Cost/1M").fg(Color::Cyan),
2002                    ]);
2003
2004                    for entry in &report.entries {
2005                        let clients_str = entry.merged_clients.as_deref().unwrap_or(&entry.client);
2006                        let capitalized_clients = clients_str
2007                            .split(", ")
2008                            .map(capitalize_client)
2009                            .collect::<Vec<_>>()
2010                            .join(", ");
2011                        let total_tokens = saturating_token_total(
2012                            entry.input,
2013                            entry.output,
2014                            entry.cache_read,
2015                            entry.cache_write,
2016                        );
2017                        table.add_row(vec![
2018                            Cell::new(capitalized_clients),
2019                            Cell::new(crate::tui::ui::widgets::get_provider_display_name(
2020                                &entry.provider,
2021                            ))
2022                            .add_attribute(Attribute::Dim),
2023                            Cell::new(&entry.model),
2024                            Cell::new(format_tokens_with_commas(entry.input))
2025                                .set_alignment(CellAlignment::Right),
2026                            Cell::new(format_tokens_with_commas(entry.output))
2027                                .set_alignment(CellAlignment::Right),
2028                            Cell::new(format_ms_per_1k(entry.performance.ms_per_1k_tokens))
2029                                .set_alignment(CellAlignment::Right),
2030                            Cell::new(format_currency(entry.cost))
2031                                .set_alignment(CellAlignment::Right),
2032                            Cell::new(format_cost_per_million(entry.cost, total_tokens))
2033                                .set_alignment(CellAlignment::Right),
2034                        ]);
2035                    }
2036
2037                    let total_tokens = saturating_token_total(
2038                        report.total_input,
2039                        report.total_output,
2040                        report.total_cache_read,
2041                        report.total_cache_write,
2042                    );
2043                    table.add_row(vec![
2044                        Cell::new("Total")
2045                            .fg(Color::Yellow)
2046                            .add_attribute(Attribute::Bold),
2047                        Cell::new(""),
2048                        Cell::new(""),
2049                        Cell::new(format_tokens_with_commas(report.total_input))
2050                            .fg(Color::Yellow)
2051                            .set_alignment(CellAlignment::Right),
2052                        Cell::new(format_tokens_with_commas(report.total_output))
2053                            .fg(Color::Yellow)
2054                            .set_alignment(CellAlignment::Right),
2055                        Cell::new(format_ms_per_1k(total_performance.ms_per_1k_tokens))
2056                            .fg(Color::Yellow)
2057                            .set_alignment(CellAlignment::Right),
2058                        Cell::new(format_currency(report.total_cost))
2059                            .fg(Color::Yellow)
2060                            .set_alignment(CellAlignment::Right),
2061                        Cell::new(format_cost_per_million(report.total_cost, total_tokens))
2062                            .fg(Color::Yellow)
2063                            .set_alignment(CellAlignment::Right),
2064                    ]);
2065                }
2066                GroupBy::ClientModel | GroupBy::ClientProviderModel => {
2067                    table.set_header(vec![
2068                        Cell::new("Client").fg(Color::Cyan),
2069                        Cell::new("Provider").fg(Color::Cyan),
2070                        Cell::new("Model").fg(Color::Cyan),
2071                        Cell::new("Input").fg(Color::Cyan),
2072                        Cell::new("Output").fg(Color::Cyan),
2073                        Cell::new("ms/1K").fg(Color::Cyan),
2074                        Cell::new("Cost").fg(Color::Cyan),
2075                        Cell::new("Cost/1M").fg(Color::Cyan),
2076                    ]);
2077
2078                    for entry in &report.entries {
2079                        let total_tokens = saturating_token_total(
2080                            entry.input,
2081                            entry.output,
2082                            entry.cache_read,
2083                            entry.cache_write,
2084                        );
2085                        table.add_row(vec![
2086                            Cell::new(capitalize_client(&entry.client)),
2087                            Cell::new(crate::tui::ui::widgets::get_provider_display_name(
2088                                &entry.provider,
2089                            ))
2090                            .add_attribute(Attribute::Dim),
2091                            Cell::new(&entry.model),
2092                            Cell::new(format_tokens_with_commas(entry.input))
2093                                .set_alignment(CellAlignment::Right),
2094                            Cell::new(format_tokens_with_commas(entry.output))
2095                                .set_alignment(CellAlignment::Right),
2096                            Cell::new(format_ms_per_1k(entry.performance.ms_per_1k_tokens))
2097                                .set_alignment(CellAlignment::Right),
2098                            Cell::new(format_currency(entry.cost))
2099                                .set_alignment(CellAlignment::Right),
2100                            Cell::new(format_cost_per_million(entry.cost, total_tokens))
2101                                .set_alignment(CellAlignment::Right),
2102                        ]);
2103                    }
2104
2105                    let total_tokens = saturating_token_total(
2106                        report.total_input,
2107                        report.total_output,
2108                        report.total_cache_read,
2109                        report.total_cache_write,
2110                    );
2111                    table.add_row(vec![
2112                        Cell::new("Total")
2113                            .fg(Color::Yellow)
2114                            .add_attribute(Attribute::Bold),
2115                        Cell::new(""),
2116                        Cell::new(""),
2117                        Cell::new(format_tokens_with_commas(report.total_input))
2118                            .fg(Color::Yellow)
2119                            .set_alignment(CellAlignment::Right),
2120                        Cell::new(format_tokens_with_commas(report.total_output))
2121                            .fg(Color::Yellow)
2122                            .set_alignment(CellAlignment::Right),
2123                        Cell::new(format_ms_per_1k(total_performance.ms_per_1k_tokens))
2124                            .fg(Color::Yellow)
2125                            .set_alignment(CellAlignment::Right),
2126                        Cell::new(format_currency(report.total_cost))
2127                            .fg(Color::Yellow)
2128                            .set_alignment(CellAlignment::Right),
2129                        Cell::new(format_cost_per_million(report.total_cost, total_tokens))
2130                            .fg(Color::Yellow)
2131                            .set_alignment(CellAlignment::Right),
2132                    ]);
2133                }
2134                GroupBy::Session | GroupBy::ClientSession => {
2135                    let show_client = group_by == GroupBy::ClientSession;
2136                    let mut header = Vec::with_capacity(6);
2137                    if show_client {
2138                        header.push(Cell::new("Client").fg(Color::Cyan));
2139                    }
2140                    header.extend([
2141                        Cell::new("Session").fg(Color::Cyan),
2142                        Cell::new("Model").fg(Color::Cyan),
2143                        Cell::new("Total").fg(Color::Cyan),
2144                        Cell::new("Cost").fg(Color::Cyan),
2145                    ]);
2146                    table.set_header(header);
2147
2148                    for entry in &report.entries {
2149                        let total_tokens = saturating_token_total(
2150                            entry.input,
2151                            entry.output,
2152                            entry.cache_read,
2153                            entry.cache_write,
2154                        );
2155                        let session_label = entry
2156                            .session_id
2157                            .clone()
2158                            .unwrap_or_else(|| "(unknown)".to_string());
2159                        let mut row = Vec::with_capacity(6);
2160                        if show_client {
2161                            row.push(Cell::new(capitalize_client(&entry.client)));
2162                        }
2163                        row.extend([
2164                            Cell::new(session_label),
2165                            Cell::new(&entry.model),
2166                            Cell::new(format_tokens_with_commas(total_tokens))
2167                                .set_alignment(CellAlignment::Right),
2168                            Cell::new(format_currency(entry.cost))
2169                                .set_alignment(CellAlignment::Right),
2170                        ]);
2171                        table.add_row(row);
2172                    }
2173
2174                    let total_all = saturating_token_total(
2175                        report.total_input,
2176                        report.total_output,
2177                        report.total_cache_read,
2178                        report.total_cache_write,
2179                    );
2180                    let mut total_row = Vec::with_capacity(6);
2181                    if show_client {
2182                        total_row.push(
2183                            Cell::new("Total")
2184                                .fg(Color::Yellow)
2185                                .add_attribute(Attribute::Bold),
2186                        );
2187                        total_row.push(Cell::new(""));
2188                    } else {
2189                        total_row.push(
2190                            Cell::new("Total")
2191                                .fg(Color::Yellow)
2192                                .add_attribute(Attribute::Bold),
2193                        );
2194                    }
2195                    total_row.push(Cell::new(""));
2196                    total_row.push(
2197                        Cell::new(format_tokens_with_commas(total_all))
2198                            .fg(Color::Yellow)
2199                            .set_alignment(CellAlignment::Right),
2200                    );
2201                    total_row.push(
2202                        Cell::new(format_currency(report.total_cost))
2203                            .fg(Color::Yellow)
2204                            .set_alignment(CellAlignment::Right),
2205                    );
2206                    table.add_row(total_row);
2207                }
2208                GroupBy::WorkspaceModel => {
2209                    table.set_header(vec![
2210                        Cell::new("Workspace").fg(Color::Cyan),
2211                        Cell::new("Model").fg(Color::Cyan),
2212                        Cell::new("ms/1K").fg(Color::Cyan),
2213                        Cell::new("Cost").fg(Color::Cyan),
2214                    ]);
2215
2216                    for entry in &report.entries {
2217                        table.add_row(vec![
2218                            Cell::new(workspace_name(entry.workspace_label.as_deref())),
2219                            Cell::new(&entry.model),
2220                            Cell::new(format_ms_per_1k(entry.performance.ms_per_1k_tokens))
2221                                .set_alignment(CellAlignment::Right),
2222                            Cell::new(format_currency(entry.cost))
2223                                .set_alignment(CellAlignment::Right),
2224                        ]);
2225                    }
2226
2227                    table.add_row(vec![
2228                        Cell::new("Total")
2229                            .fg(Color::Yellow)
2230                            .add_attribute(Attribute::Bold),
2231                        Cell::new(""),
2232                        Cell::new(format_ms_per_1k(total_performance.ms_per_1k_tokens))
2233                            .fg(Color::Yellow)
2234                            .set_alignment(CellAlignment::Right),
2235                        Cell::new(format_currency(report.total_cost))
2236                            .fg(Color::Yellow)
2237                            .set_alignment(CellAlignment::Right),
2238                    ]);
2239                }
2240            }
2241        } else {
2242            match group_by {
2243                GroupBy::Model => {
2244                    table.set_header(vec![
2245                        Cell::new("Clients").fg(Color::Cyan),
2246                        Cell::new("Providers").fg(Color::Cyan),
2247                        Cell::new("Model").fg(Color::Cyan),
2248                        Cell::new("Input").fg(Color::Cyan),
2249                        Cell::new("Output").fg(Color::Cyan),
2250                        Cell::new("Cache Write").fg(Color::Cyan),
2251                        Cell::new("Cache Read").fg(Color::Cyan),
2252                        Cell::new("Total").fg(Color::Cyan),
2253                        Cell::new("ms/1K").fg(Color::Cyan),
2254                        Cell::new("Cost").fg(Color::Cyan),
2255                        Cell::new("Cost/1M").fg(Color::Cyan),
2256                    ]);
2257
2258                    for entry in &report.entries {
2259                        let total = saturating_token_total(
2260                            entry.input,
2261                            entry.output,
2262                            entry.cache_read,
2263                            entry.cache_write,
2264                        );
2265
2266                        let clients_str = entry.merged_clients.as_deref().unwrap_or(&entry.client);
2267                        let capitalized_clients = clients_str
2268                            .split(", ")
2269                            .map(capitalize_client)
2270                            .collect::<Vec<_>>()
2271                            .join(", ");
2272                        table.add_row(vec![
2273                            Cell::new(capitalized_clients),
2274                            Cell::new(crate::tui::ui::widgets::get_provider_display_name(
2275                                &entry.provider,
2276                            ))
2277                            .add_attribute(Attribute::Dim),
2278                            Cell::new(&entry.model),
2279                            Cell::new(format_tokens_with_commas(entry.input))
2280                                .set_alignment(CellAlignment::Right),
2281                            Cell::new(format_tokens_with_commas(entry.output))
2282                                .set_alignment(CellAlignment::Right),
2283                            Cell::new(format_tokens_with_commas(entry.cache_write))
2284                                .set_alignment(CellAlignment::Right),
2285                            Cell::new(format_tokens_with_commas(entry.cache_read))
2286                                .set_alignment(CellAlignment::Right),
2287                            Cell::new(format_tokens_with_commas(total))
2288                                .set_alignment(CellAlignment::Right),
2289                            Cell::new(format_ms_per_1k(entry.performance.ms_per_1k_tokens))
2290                                .set_alignment(CellAlignment::Right),
2291                            Cell::new(format_currency(entry.cost))
2292                                .set_alignment(CellAlignment::Right),
2293                            Cell::new(format_cost_per_million(entry.cost, total))
2294                                .set_alignment(CellAlignment::Right),
2295                        ]);
2296                    }
2297
2298                    let total_all = saturating_token_total(
2299                        report.total_input,
2300                        report.total_output,
2301                        report.total_cache_read,
2302                        report.total_cache_write,
2303                    );
2304                    table.add_row(vec![
2305                        Cell::new("Total")
2306                            .fg(Color::Yellow)
2307                            .add_attribute(Attribute::Bold),
2308                        Cell::new(""),
2309                        Cell::new(""),
2310                        Cell::new(format_tokens_with_commas(report.total_input))
2311                            .fg(Color::Yellow)
2312                            .set_alignment(CellAlignment::Right),
2313                        Cell::new(format_tokens_with_commas(report.total_output))
2314                            .fg(Color::Yellow)
2315                            .set_alignment(CellAlignment::Right),
2316                        Cell::new(format_tokens_with_commas(report.total_cache_write))
2317                            .fg(Color::Yellow)
2318                            .set_alignment(CellAlignment::Right),
2319                        Cell::new(format_tokens_with_commas(report.total_cache_read))
2320                            .fg(Color::Yellow)
2321                            .set_alignment(CellAlignment::Right),
2322                        Cell::new(format_tokens_with_commas(total_all))
2323                            .fg(Color::Yellow)
2324                            .set_alignment(CellAlignment::Right),
2325                        Cell::new(format_ms_per_1k(total_performance.ms_per_1k_tokens))
2326                            .fg(Color::Yellow)
2327                            .set_alignment(CellAlignment::Right),
2328                        Cell::new(format_currency(report.total_cost))
2329                            .fg(Color::Yellow)
2330                            .set_alignment(CellAlignment::Right),
2331                        Cell::new(format_cost_per_million(report.total_cost, total_all))
2332                            .fg(Color::Yellow)
2333                            .set_alignment(CellAlignment::Right),
2334                    ]);
2335                }
2336                GroupBy::Session | GroupBy::ClientSession => {
2337                    let show_client = group_by == GroupBy::ClientSession;
2338                    let mut header = Vec::with_capacity(9);
2339                    if show_client {
2340                        header.push(Cell::new("Client").fg(Color::Cyan));
2341                    }
2342                    header.extend([
2343                        Cell::new("Session").fg(Color::Cyan),
2344                        Cell::new("Provider").fg(Color::Cyan),
2345                        Cell::new("Model").fg(Color::Cyan),
2346                        Cell::new("Input").fg(Color::Cyan),
2347                        Cell::new("Output").fg(Color::Cyan),
2348                        Cell::new("Total").fg(Color::Cyan),
2349                        Cell::new("Cost").fg(Color::Cyan),
2350                        Cell::new("Cost/1M").fg(Color::Cyan),
2351                    ]);
2352                    table.set_header(header);
2353
2354                    for entry in &report.entries {
2355                        let total = saturating_token_total(
2356                            entry.input,
2357                            entry.output,
2358                            entry.cache_read,
2359                            entry.cache_write,
2360                        );
2361                        let session_label = entry
2362                            .session_id
2363                            .clone()
2364                            .unwrap_or_else(|| "(unknown)".to_string());
2365                        let mut row = Vec::with_capacity(9);
2366                        if show_client {
2367                            row.push(Cell::new(capitalize_client(&entry.client)));
2368                        }
2369                        row.extend([
2370                            Cell::new(session_label),
2371                            Cell::new(crate::tui::ui::widgets::get_provider_display_name(
2372                                &entry.provider,
2373                            ))
2374                            .add_attribute(Attribute::Dim),
2375                            Cell::new(&entry.model),
2376                            Cell::new(format_tokens_with_commas(entry.input))
2377                                .set_alignment(CellAlignment::Right),
2378                            Cell::new(format_tokens_with_commas(entry.output))
2379                                .set_alignment(CellAlignment::Right),
2380                            Cell::new(format_tokens_with_commas(total))
2381                                .set_alignment(CellAlignment::Right),
2382                            Cell::new(format_currency(entry.cost))
2383                                .set_alignment(CellAlignment::Right),
2384                            Cell::new(format_cost_per_million(entry.cost, total))
2385                                .set_alignment(CellAlignment::Right),
2386                        ]);
2387                        table.add_row(row);
2388                    }
2389
2390                    let total_all = saturating_token_total(
2391                        report.total_input,
2392                        report.total_output,
2393                        report.total_cache_read,
2394                        report.total_cache_write,
2395                    );
2396                    let mut total_row: Vec<Cell> = Vec::with_capacity(9);
2397                    total_row.push(
2398                        Cell::new("Total")
2399                            .fg(Color::Yellow)
2400                            .add_attribute(Attribute::Bold),
2401                    );
2402                    let blanks = if show_client { 3 } else { 2 };
2403                    for _ in 0..blanks {
2404                        total_row.push(Cell::new(""));
2405                    }
2406                    total_row.push(
2407                        Cell::new(format_tokens_with_commas(report.total_input))
2408                            .fg(Color::Yellow)
2409                            .set_alignment(CellAlignment::Right),
2410                    );
2411                    total_row.push(
2412                        Cell::new(format_tokens_with_commas(report.total_output))
2413                            .fg(Color::Yellow)
2414                            .set_alignment(CellAlignment::Right),
2415                    );
2416                    total_row.push(
2417                        Cell::new(format_tokens_with_commas(total_all))
2418                            .fg(Color::Yellow)
2419                            .set_alignment(CellAlignment::Right),
2420                    );
2421                    total_row.push(
2422                        Cell::new(format_currency(report.total_cost))
2423                            .fg(Color::Yellow)
2424                            .set_alignment(CellAlignment::Right),
2425                    );
2426                    total_row.push(
2427                        Cell::new(format_cost_per_million(report.total_cost, total_all))
2428                            .fg(Color::Yellow)
2429                            .set_alignment(CellAlignment::Right),
2430                    );
2431                    table.add_row(total_row);
2432                }
2433                GroupBy::ClientModel | GroupBy::ClientProviderModel => {
2434                    table.set_header(vec![
2435                        Cell::new("Client").fg(Color::Cyan),
2436                        Cell::new("Provider").fg(Color::Cyan),
2437                        Cell::new("Model").fg(Color::Cyan),
2438                        Cell::new("Resolved").fg(Color::Cyan),
2439                        Cell::new("Input").fg(Color::Cyan),
2440                        Cell::new("Output").fg(Color::Cyan),
2441                        Cell::new("Cache Write").fg(Color::Cyan),
2442                        Cell::new("Cache Read").fg(Color::Cyan),
2443                        Cell::new("Total").fg(Color::Cyan),
2444                        Cell::new("ms/1K").fg(Color::Cyan),
2445                        Cell::new("Cost").fg(Color::Cyan),
2446                        Cell::new("Cost/1M").fg(Color::Cyan),
2447                    ]);
2448
2449                    for entry in &report.entries {
2450                        let total = saturating_token_total(
2451                            entry.input,
2452                            entry.output,
2453                            entry.cache_read,
2454                            entry.cache_write,
2455                        );
2456
2457                        table.add_row(vec![
2458                            Cell::new(capitalize_client(&entry.client)),
2459                            Cell::new(crate::tui::ui::widgets::get_provider_display_name(
2460                                &entry.provider,
2461                            ))
2462                            .add_attribute(Attribute::Dim),
2463                            Cell::new(&entry.model),
2464                            Cell::new(format_model_name(&entry.model)),
2465                            Cell::new(format_tokens_with_commas(entry.input))
2466                                .set_alignment(CellAlignment::Right),
2467                            Cell::new(format_tokens_with_commas(entry.output))
2468                                .set_alignment(CellAlignment::Right),
2469                            Cell::new(format_tokens_with_commas(entry.cache_write))
2470                                .set_alignment(CellAlignment::Right),
2471                            Cell::new(format_tokens_with_commas(entry.cache_read))
2472                                .set_alignment(CellAlignment::Right),
2473                            Cell::new(format_tokens_with_commas(total))
2474                                .set_alignment(CellAlignment::Right),
2475                            Cell::new(format_ms_per_1k(entry.performance.ms_per_1k_tokens))
2476                                .set_alignment(CellAlignment::Right),
2477                            Cell::new(format_currency(entry.cost))
2478                                .set_alignment(CellAlignment::Right),
2479                            Cell::new(format_cost_per_million(entry.cost, total))
2480                                .set_alignment(CellAlignment::Right),
2481                        ]);
2482                    }
2483
2484                    let total_all = saturating_token_total(
2485                        report.total_input,
2486                        report.total_output,
2487                        report.total_cache_read,
2488                        report.total_cache_write,
2489                    );
2490                    table.add_row(vec![
2491                        Cell::new("Total")
2492                            .fg(Color::Yellow)
2493                            .add_attribute(Attribute::Bold),
2494                        Cell::new(""),
2495                        Cell::new(""),
2496                        Cell::new(""),
2497                        Cell::new(format_tokens_with_commas(report.total_input))
2498                            .fg(Color::Yellow)
2499                            .set_alignment(CellAlignment::Right),
2500                        Cell::new(format_tokens_with_commas(report.total_output))
2501                            .fg(Color::Yellow)
2502                            .set_alignment(CellAlignment::Right),
2503                        Cell::new(format_tokens_with_commas(report.total_cache_write))
2504                            .fg(Color::Yellow)
2505                            .set_alignment(CellAlignment::Right),
2506                        Cell::new(format_tokens_with_commas(report.total_cache_read))
2507                            .fg(Color::Yellow)
2508                            .set_alignment(CellAlignment::Right),
2509                        Cell::new(format_tokens_with_commas(total_all))
2510                            .fg(Color::Yellow)
2511                            .set_alignment(CellAlignment::Right),
2512                        Cell::new(format_ms_per_1k(total_performance.ms_per_1k_tokens))
2513                            .fg(Color::Yellow)
2514                            .set_alignment(CellAlignment::Right),
2515                        Cell::new(format_currency(report.total_cost))
2516                            .fg(Color::Yellow)
2517                            .set_alignment(CellAlignment::Right),
2518                        Cell::new(format_cost_per_million(report.total_cost, total_all))
2519                            .fg(Color::Yellow)
2520                            .set_alignment(CellAlignment::Right),
2521                    ]);
2522                }
2523                GroupBy::WorkspaceModel => {
2524                    table.set_header(vec![
2525                        Cell::new("Workspace").fg(Color::Cyan),
2526                        Cell::new("Providers").fg(Color::Cyan),
2527                        Cell::new("Sources").fg(Color::Cyan),
2528                        Cell::new("Model").fg(Color::Cyan),
2529                        Cell::new("Input").fg(Color::Cyan),
2530                        Cell::new("Output").fg(Color::Cyan),
2531                        Cell::new("Cache Write").fg(Color::Cyan),
2532                        Cell::new("Cache Read").fg(Color::Cyan),
2533                        Cell::new("Total").fg(Color::Cyan),
2534                        Cell::new("ms/1K").fg(Color::Cyan),
2535                        Cell::new("Cost").fg(Color::Cyan),
2536                    ]);
2537
2538                    for entry in &report.entries {
2539                        let total = saturating_token_total(
2540                            entry.input,
2541                            entry.output,
2542                            entry.cache_read,
2543                            entry.cache_write,
2544                        );
2545                        let clients_str = entry.merged_clients.as_deref().unwrap_or(&entry.client);
2546                        let capitalized_clients = clients_str
2547                            .split(", ")
2548                            .map(capitalize_client)
2549                            .collect::<Vec<_>>()
2550                            .join(", ");
2551
2552                        table.add_row(vec![
2553                            Cell::new(workspace_name(entry.workspace_label.as_deref())),
2554                            Cell::new(crate::tui::ui::widgets::get_provider_display_name(
2555                                &entry.provider,
2556                            ))
2557                            .add_attribute(Attribute::Dim),
2558                            Cell::new(capitalized_clients),
2559                            Cell::new(&entry.model),
2560                            Cell::new(format_tokens_with_commas(entry.input))
2561                                .set_alignment(CellAlignment::Right),
2562                            Cell::new(format_tokens_with_commas(entry.output))
2563                                .set_alignment(CellAlignment::Right),
2564                            Cell::new(format_tokens_with_commas(entry.cache_write))
2565                                .set_alignment(CellAlignment::Right),
2566                            Cell::new(format_tokens_with_commas(entry.cache_read))
2567                                .set_alignment(CellAlignment::Right),
2568                            Cell::new(format_tokens_with_commas(total))
2569                                .set_alignment(CellAlignment::Right),
2570                            Cell::new(format_ms_per_1k(entry.performance.ms_per_1k_tokens))
2571                                .set_alignment(CellAlignment::Right),
2572                            Cell::new(format_currency(entry.cost))
2573                                .set_alignment(CellAlignment::Right),
2574                        ]);
2575                    }
2576
2577                    let total_all = saturating_token_total(
2578                        report.total_input,
2579                        report.total_output,
2580                        report.total_cache_read,
2581                        report.total_cache_write,
2582                    );
2583                    table.add_row(vec![
2584                        Cell::new("Total")
2585                            .fg(Color::Yellow)
2586                            .add_attribute(Attribute::Bold),
2587                        Cell::new(""),
2588                        Cell::new(""),
2589                        Cell::new(""),
2590                        Cell::new(format_tokens_with_commas(report.total_input))
2591                            .fg(Color::Yellow)
2592                            .set_alignment(CellAlignment::Right),
2593                        Cell::new(format_tokens_with_commas(report.total_output))
2594                            .fg(Color::Yellow)
2595                            .set_alignment(CellAlignment::Right),
2596                        Cell::new(format_tokens_with_commas(report.total_cache_write))
2597                            .fg(Color::Yellow)
2598                            .set_alignment(CellAlignment::Right),
2599                        Cell::new(format_tokens_with_commas(report.total_cache_read))
2600                            .fg(Color::Yellow)
2601                            .set_alignment(CellAlignment::Right),
2602                        Cell::new(format_tokens_with_commas(total_all))
2603                            .fg(Color::Yellow)
2604                            .set_alignment(CellAlignment::Right),
2605                        Cell::new(format_ms_per_1k(total_performance.ms_per_1k_tokens))
2606                            .fg(Color::Yellow)
2607                            .set_alignment(CellAlignment::Right),
2608                        Cell::new(format_currency(report.total_cost))
2609                            .fg(Color::Yellow)
2610                            .set_alignment(CellAlignment::Right),
2611                    ]);
2612                }
2613            }
2614        }
2615
2616        let title = match &date_range {
2617            Some(range) => format!("Token Usage Report by Model ({})", range),
2618            None => "Token Usage Report by Model".to_string(),
2619        };
2620        println!("\n  \x1b[36m{}\x1b[0m\n", title);
2621        println!("{}", dim_borders(&table.to_string()));
2622
2623        let total_tokens = saturating_token_total(
2624            report.total_input,
2625            report.total_output,
2626            report.total_cache_read,
2627            report.total_cache_write,
2628        );
2629        println!(
2630            "\x1b[90m\n  Total: {} messages, {} tokens, \x1b[32m{}\x1b[90m\x1b[0m",
2631            format_tokens_with_commas(report.total_messages as i64),
2632            format_tokens_with_commas(total_tokens),
2633            format_currency(report.total_cost)
2634        );
2635
2636        if benchmark {
2637            use colored::Colorize;
2638            println!(
2639                "{}",
2640                format!("  Processing time: {}ms (Rust native)", processing_time_ms).bright_black()
2641            );
2642        }
2643
2644        io::stdout().flush()?;
2645
2646        let settings = tui::settings::Settings::load();
2647        if resolve_should_write_cache(cli_write_cache, cli_no_write_cache, &settings) {
2648            write_light_cache(&home_dir, &clients, &since, &until, &year, &group_by);
2649        }
2650    }
2651
2652    Ok(())
2653}
2654
2655fn run_monthly_report(
2656    json: bool,
2657    home_dir: Option<String>,
2658    clients: Option<Vec<String>>,
2659    date: &DateRangeFlags,
2660    benchmark: bool,
2661    no_spinner: bool,
2662    hide_zero: bool,
2663) -> Result<()> {
2664    use std::time::Instant;
2665    use tokio::runtime::Runtime;
2666    use tokmesh_core::{get_monthly_report, GroupBy, ReportOptions};
2667
2668    let (since, until) = build_date_filter(date);
2669    let year = normalize_year_filter(date);
2670    let date_range = get_date_range_label(date);
2671
2672    let had_cursor_cache = has_cursor_usage_cache_for_report(&home_dir);
2673    let explicit_cursor_filter = client_filter_explicitly_requests_cursor(&clients);
2674    let spinner = if no_spinner {
2675        None
2676    } else {
2677        Some(LightSpinner::start("Scanning session data..."))
2678    };
2679    let cursor_sync_result = auto_sync_cursor_for_local_report(&home_dir, &clients);
2680    let cursor_setup_warnings = setup_warnings_for_report(&home_dir, &clients);
2681    let use_env_roots = use_env_roots(&home_dir);
2682    let start = Instant::now();
2683    let rt = Runtime::new()?;
2684    let report = rt
2685        .block_on(async {
2686            get_monthly_report(ReportOptions {
2687                home_dir: home_dir.clone(),
2688                use_env_roots,
2689                clients,
2690                since,
2691                until,
2692                year,
2693                group_by: GroupBy::default(),
2694                scanner_settings: tui::settings::load_scanner_settings_for_home(&home_dir),
2695            })
2696            .await
2697        })
2698        .map_err(|e| anyhow::anyhow!(e))?;
2699    let mut report = report;
2700    if hide_zero {
2701        // Display-only filter: totals still include the hidden rows.
2702        report.entries.retain(|e| {
2703            e.input != 0
2704                || e.output != 0
2705                || e.cache_read != 0
2706                || e.cache_write != 0
2707                || e.cost != 0.0
2708        });
2709    }
2710    let report = report;
2711
2712    if let Some(spinner) = spinner {
2713        spinner.stop();
2714    }
2715    emit_cursor_sync_warning(
2716        cursor_sync_result.as_ref(),
2717        had_cursor_cache,
2718        explicit_cursor_filter,
2719    );
2720
2721    let processing_time_ms = start.elapsed().as_millis();
2722
2723    if json {
2724        #[derive(serde::Serialize)]
2725        #[serde(rename_all = "camelCase")]
2726        struct MonthlyUsageJson {
2727            month: String,
2728            models: Vec<String>,
2729            input: i64,
2730            output: i64,
2731            cache_read: i64,
2732            cache_write: i64,
2733            message_count: i32,
2734            cost: f64,
2735        }
2736
2737        #[derive(serde::Serialize)]
2738        #[serde(rename_all = "camelCase")]
2739        struct MonthlyReportJson {
2740            entries: Vec<MonthlyUsageJson>,
2741            total_cost: f64,
2742            processing_time_ms: u32,
2743            #[serde(skip_serializing_if = "Vec::is_empty")]
2744            warnings: Vec<String>,
2745        }
2746
2747        let output = MonthlyReportJson {
2748            entries: report
2749                .entries
2750                .into_iter()
2751                .map(|e| MonthlyUsageJson {
2752                    month: e.month,
2753                    models: e.models,
2754                    input: e.input,
2755                    output: e.output,
2756                    cache_read: e.cache_read,
2757                    cache_write: e.cache_write,
2758                    message_count: e.message_count,
2759                    cost: e.cost,
2760                })
2761                .collect(),
2762            total_cost: report.total_cost,
2763            processing_time_ms: report.processing_time_ms,
2764            warnings: cursor_setup_warnings,
2765        };
2766
2767        println!("{}", serde_json::to_string_pretty(&output)?);
2768    } else {
2769        use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
2770
2771        emit_cursor_setup_warnings(&cursor_setup_warnings);
2772        let term_width = crossterm::terminal::size()
2773            .map(|(w, _)| w as usize)
2774            .unwrap_or(120);
2775        let compact = term_width < 100;
2776
2777        let mut table = Table::new();
2778        table.load_preset(TABLE_PRESET);
2779        let arrangement = if std::io::stdout().is_terminal() {
2780            ContentArrangement::DynamicFullWidth
2781        } else {
2782            ContentArrangement::Dynamic
2783        };
2784        table.set_content_arrangement(arrangement);
2785        table.enforce_styling();
2786        if compact {
2787            table.set_header(vec![
2788                Cell::new("Month").fg(Color::Cyan),
2789                Cell::new("Models").fg(Color::Cyan),
2790                Cell::new("Input").fg(Color::Cyan),
2791                Cell::new("Output").fg(Color::Cyan),
2792                Cell::new("Cost").fg(Color::Cyan),
2793                Cell::new("Cost/1M").fg(Color::Cyan),
2794            ]);
2795
2796            for entry in &report.entries {
2797                let models_col = if entry.models.is_empty() {
2798                    "-".to_string()
2799                } else {
2800                    let mut unique_models: Vec<String> = entry
2801                        .models
2802                        .iter()
2803                        .map(|model| format_model_name(model))
2804                        .collect::<std::collections::BTreeSet<_>>()
2805                        .into_iter()
2806                        .collect();
2807                    unique_models.sort();
2808                    unique_models
2809                        .iter()
2810                        .map(|m| format!("- {}", m))
2811                        .collect::<Vec<_>>()
2812                        .join("\n")
2813                };
2814                let total_tokens = saturating_token_total(
2815                    entry.input,
2816                    entry.output,
2817                    entry.cache_read,
2818                    entry.cache_write,
2819                );
2820
2821                table.add_row(vec![
2822                    Cell::new(entry.month.clone()),
2823                    Cell::new(models_col),
2824                    Cell::new(format_tokens_with_commas(entry.input))
2825                        .set_alignment(CellAlignment::Right),
2826                    Cell::new(format_tokens_with_commas(entry.output))
2827                        .set_alignment(CellAlignment::Right),
2828                    Cell::new(format_currency(entry.cost)).set_alignment(CellAlignment::Right),
2829                    Cell::new(format_cost_per_million(entry.cost, total_tokens))
2830                        .set_alignment(CellAlignment::Right),
2831                ]);
2832            }
2833
2834            let (total_input, total_output, total_cache_read, total_cache_write) =
2835                monthly_token_field_totals(&report.entries);
2836            let total_tokens = saturating_token_total(
2837                total_input,
2838                total_output,
2839                total_cache_read,
2840                total_cache_write,
2841            );
2842            table.add_row(vec![
2843                Cell::new("Total")
2844                    .fg(Color::Yellow)
2845                    .add_attribute(Attribute::Bold),
2846                Cell::new(""),
2847                Cell::new(format_tokens_with_commas(total_input))
2848                    .fg(Color::Yellow)
2849                    .set_alignment(CellAlignment::Right),
2850                Cell::new(format_tokens_with_commas(total_output))
2851                    .fg(Color::Yellow)
2852                    .set_alignment(CellAlignment::Right),
2853                Cell::new(format_currency(report.total_cost))
2854                    .fg(Color::Yellow)
2855                    .set_alignment(CellAlignment::Right),
2856                Cell::new(format_cost_per_million(report.total_cost, total_tokens))
2857                    .fg(Color::Yellow)
2858                    .set_alignment(CellAlignment::Right),
2859            ]);
2860        } else {
2861            table.set_header(vec![
2862                Cell::new("Month").fg(Color::Cyan),
2863                Cell::new("Models").fg(Color::Cyan),
2864                Cell::new("Input").fg(Color::Cyan),
2865                Cell::new("Output").fg(Color::Cyan),
2866                Cell::new("Cache Write").fg(Color::Cyan),
2867                Cell::new("Cache Read").fg(Color::Cyan),
2868                Cell::new("Total").fg(Color::Cyan),
2869                Cell::new("Cost").fg(Color::Cyan),
2870                Cell::new("Cost/1M").fg(Color::Cyan),
2871            ]);
2872
2873            for entry in &report.entries {
2874                let models_col = if entry.models.is_empty() {
2875                    "-".to_string()
2876                } else {
2877                    let mut unique_models: Vec<String> = entry
2878                        .models
2879                        .iter()
2880                        .map(|model| format_model_name(model))
2881                        .collect::<std::collections::BTreeSet<_>>()
2882                        .into_iter()
2883                        .collect();
2884                    unique_models.sort();
2885                    unique_models
2886                        .iter()
2887                        .map(|m| format!("- {}", m))
2888                        .collect::<Vec<_>>()
2889                        .join("\n")
2890                };
2891                let total = saturating_token_total(
2892                    entry.input,
2893                    entry.output,
2894                    entry.cache_read,
2895                    entry.cache_write,
2896                );
2897
2898                table.add_row(vec![
2899                    Cell::new(entry.month.clone()),
2900                    Cell::new(models_col),
2901                    Cell::new(format_tokens_with_commas(entry.input))
2902                        .set_alignment(CellAlignment::Right),
2903                    Cell::new(format_tokens_with_commas(entry.output))
2904                        .set_alignment(CellAlignment::Right),
2905                    Cell::new(format_tokens_with_commas(entry.cache_write))
2906                        .set_alignment(CellAlignment::Right),
2907                    Cell::new(format_tokens_with_commas(entry.cache_read))
2908                        .set_alignment(CellAlignment::Right),
2909                    Cell::new(format_tokens_with_commas(total)).set_alignment(CellAlignment::Right),
2910                    Cell::new(format_currency(entry.cost)).set_alignment(CellAlignment::Right),
2911                    Cell::new(format_cost_per_million(entry.cost, total))
2912                        .set_alignment(CellAlignment::Right),
2913                ]);
2914            }
2915
2916            let (total_input, total_output, total_cache_read, total_cache_write) =
2917                monthly_token_field_totals(&report.entries);
2918            let total_all = saturating_token_total(
2919                total_input,
2920                total_output,
2921                total_cache_read,
2922                total_cache_write,
2923            );
2924
2925            table.add_row(vec![
2926                Cell::new("Total")
2927                    .fg(Color::Yellow)
2928                    .add_attribute(Attribute::Bold),
2929                Cell::new(""),
2930                Cell::new(format_tokens_with_commas(total_input))
2931                    .fg(Color::Yellow)
2932                    .set_alignment(CellAlignment::Right),
2933                Cell::new(format_tokens_with_commas(total_output))
2934                    .fg(Color::Yellow)
2935                    .set_alignment(CellAlignment::Right),
2936                Cell::new(format_tokens_with_commas(total_cache_write))
2937                    .fg(Color::Yellow)
2938                    .set_alignment(CellAlignment::Right),
2939                Cell::new(format_tokens_with_commas(total_cache_read))
2940                    .fg(Color::Yellow)
2941                    .set_alignment(CellAlignment::Right),
2942                Cell::new(format_tokens_with_commas(total_all))
2943                    .fg(Color::Yellow)
2944                    .set_alignment(CellAlignment::Right),
2945                Cell::new(format_currency(report.total_cost))
2946                    .fg(Color::Yellow)
2947                    .set_alignment(CellAlignment::Right),
2948                Cell::new(format_cost_per_million(report.total_cost, total_all))
2949                    .fg(Color::Yellow)
2950                    .set_alignment(CellAlignment::Right),
2951            ]);
2952        }
2953
2954        let title = match &date_range {
2955            Some(range) => format!("Monthly Token Usage Report ({})", range),
2956            None => "Monthly Token Usage Report".to_string(),
2957        };
2958        println!("\n  \x1b[36m{}\x1b[0m\n", title);
2959        println!("{}", dim_borders(&table.to_string()));
2960
2961        println!(
2962            "\x1b[90m\n  Total Cost: \x1b[32m{}\x1b[90m\x1b[0m",
2963            format_currency(report.total_cost)
2964        );
2965
2966        if benchmark {
2967            use colored::Colorize;
2968            println!(
2969                "{}",
2970                format!("  Processing time: {}ms (Rust native)", processing_time_ms).bright_black()
2971            );
2972        }
2973    }
2974
2975    Ok(())
2976}
2977
2978fn run_hourly_report(
2979    json: bool,
2980    home_dir: Option<String>,
2981    clients: Option<Vec<String>>,
2982    date: &DateRangeFlags,
2983    benchmark: bool,
2984    no_spinner: bool,
2985    hide_zero: bool,
2986) -> Result<()> {
2987    use std::time::Instant;
2988    use tokio::runtime::Runtime;
2989    use tokmesh_core::{get_hourly_report, GroupBy, ReportOptions};
2990
2991    let (since, until) = build_date_filter(date);
2992    let year = normalize_year_filter(date);
2993    let date_range = get_date_range_label(date);
2994
2995    let had_cursor_cache = has_cursor_usage_cache_for_report(&home_dir);
2996    let explicit_cursor_filter = client_filter_explicitly_requests_cursor(&clients);
2997    let spinner = if no_spinner {
2998        None
2999    } else {
3000        Some(LightSpinner::start("Scanning session data..."))
3001    };
3002    let cursor_sync_result = auto_sync_cursor_for_local_report(&home_dir, &clients);
3003    let cursor_setup_warnings = setup_warnings_for_report(&home_dir, &clients);
3004    let use_env_roots = use_env_roots(&home_dir);
3005    let start = Instant::now();
3006    let rt = Runtime::new()?;
3007    let report = rt
3008        .block_on(async {
3009            get_hourly_report(ReportOptions {
3010                home_dir: home_dir.clone(),
3011                use_env_roots,
3012                clients,
3013                since,
3014                until,
3015                year,
3016                group_by: GroupBy::default(),
3017                scanner_settings: tui::settings::load_scanner_settings_for_home(&home_dir),
3018            })
3019            .await
3020        })
3021        .map_err(|e| anyhow::anyhow!(e))?;
3022    let mut report = report;
3023    if hide_zero {
3024        // Display-only filter: totals still include the hidden rows.
3025        report.entries.retain(|e| {
3026            e.input != 0
3027                || e.output != 0
3028                || e.cache_read != 0
3029                || e.cache_write != 0
3030                || e.reasoning != 0
3031                || e.cost != 0.0
3032        });
3033    }
3034    let report = report;
3035
3036    if let Some(spinner) = spinner {
3037        spinner.stop();
3038    }
3039    emit_cursor_sync_warning(
3040        cursor_sync_result.as_ref(),
3041        had_cursor_cache,
3042        explicit_cursor_filter,
3043    );
3044
3045    let processing_time_ms = start.elapsed().as_millis();
3046
3047    if json {
3048        #[derive(serde::Serialize)]
3049        #[serde(rename_all = "camelCase")]
3050        struct HourlyUsageJson {
3051            hour: String,
3052            clients: Vec<String>,
3053            models: Vec<String>,
3054            input: i64,
3055            output: i64,
3056            cache_read: i64,
3057            cache_write: i64,
3058            message_count: i32,
3059            turn_count: i32,
3060            cost: f64,
3061        }
3062
3063        #[derive(serde::Serialize)]
3064        #[serde(rename_all = "camelCase")]
3065        struct HourlyReportJson {
3066            entries: Vec<HourlyUsageJson>,
3067            total_cost: f64,
3068            processing_time_ms: u32,
3069            #[serde(skip_serializing_if = "Vec::is_empty")]
3070            warnings: Vec<String>,
3071        }
3072
3073        let output = HourlyReportJson {
3074            entries: report
3075                .entries
3076                .into_iter()
3077                .map(|e| HourlyUsageJson {
3078                    hour: e.hour,
3079                    clients: e.clients,
3080                    models: e.models,
3081                    input: e.input,
3082                    output: e.output,
3083                    cache_read: e.cache_read,
3084                    cache_write: e.cache_write,
3085                    message_count: e.message_count,
3086                    turn_count: e.turn_count,
3087                    cost: e.cost,
3088                })
3089                .collect(),
3090            total_cost: report.total_cost,
3091            processing_time_ms: report.processing_time_ms,
3092            warnings: cursor_setup_warnings,
3093        };
3094
3095        println!("{}", serde_json::to_string_pretty(&output)?);
3096    } else {
3097        use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};
3098
3099        emit_cursor_setup_warnings(&cursor_setup_warnings);
3100        let term_width = crossterm::terminal::size()
3101            .map(|(w, _)| w as usize)
3102            .unwrap_or(120);
3103        let compact = term_width < 100;
3104
3105        let mut table = Table::new();
3106        table.load_preset(TABLE_PRESET);
3107        let arrangement = if std::io::stdout().is_terminal() {
3108            ContentArrangement::DynamicFullWidth
3109        } else {
3110            ContentArrangement::Dynamic
3111        };
3112        table.set_content_arrangement(arrangement);
3113        table.enforce_styling();
3114
3115        if compact {
3116            table.set_header(vec![
3117                Cell::new("Hour").fg(Color::Cyan),
3118                Cell::new("Source").fg(Color::Cyan),
3119                Cell::new("Turn").fg(Color::Cyan),
3120                Cell::new("Msgs").fg(Color::Cyan),
3121                Cell::new("Input").fg(Color::Cyan),
3122                Cell::new("Output").fg(Color::Cyan),
3123                Cell::new("Cost").fg(Color::Cyan),
3124                Cell::new("Cost/1M").fg(Color::Cyan),
3125            ]);
3126
3127            for entry in &report.entries {
3128                let clients_col = {
3129                    let mut c: Vec<String> =
3130                        entry.clients.iter().map(|s| capitalize_client(s)).collect();
3131                    c.sort();
3132                    c.join(", ")
3133                };
3134                let turn_display = if entry.turn_count > 0 {
3135                    entry.turn_count.to_string()
3136                } else {
3137                    "—".to_string()
3138                };
3139                let total_tokens = saturating_token_total(
3140                    entry.input,
3141                    entry.output,
3142                    entry.cache_read,
3143                    entry.cache_write,
3144                );
3145                table.add_row(vec![
3146                    Cell::new(&entry.hour).fg(Color::White),
3147                    Cell::new(&clients_col),
3148                    Cell::new(&turn_display).set_alignment(CellAlignment::Right),
3149                    Cell::new(entry.message_count).set_alignment(CellAlignment::Right),
3150                    Cell::new(format_tokens_with_commas(entry.input))
3151                        .set_alignment(CellAlignment::Right),
3152                    Cell::new(format_tokens_with_commas(entry.output))
3153                        .set_alignment(CellAlignment::Right),
3154                    Cell::new(format_currency(entry.cost))
3155                        .fg(Color::Green)
3156                        .set_alignment(CellAlignment::Right),
3157                    Cell::new(format_cost_per_million(entry.cost, total_tokens))
3158                        .set_alignment(CellAlignment::Right),
3159                ]);
3160            }
3161        } else {
3162            table.set_header(vec![
3163                Cell::new("Hour").fg(Color::Cyan),
3164                Cell::new("Source").fg(Color::Cyan),
3165                Cell::new("Models").fg(Color::Cyan),
3166                Cell::new("Turn").fg(Color::Cyan),
3167                Cell::new("Msgs").fg(Color::Cyan),
3168                Cell::new("Input").fg(Color::Cyan),
3169                Cell::new("Output").fg(Color::Cyan),
3170                Cell::new("Cache R").fg(Color::Cyan),
3171                Cell::new("Cache W").fg(Color::Cyan),
3172                Cell::new("Cache×").fg(Color::Cyan),
3173                Cell::new("Cost").fg(Color::Cyan),
3174                Cell::new("Cost/1M").fg(Color::Cyan),
3175            ]);
3176
3177            for entry in &report.entries {
3178                let clients_col = {
3179                    let mut c: Vec<String> =
3180                        entry.clients.iter().map(|s| capitalize_client(s)).collect();
3181                    c.sort();
3182                    c.join(", ")
3183                };
3184                let models_col = if entry.models.is_empty() {
3185                    "-".to_string()
3186                } else {
3187                    let mut unique: Vec<String> = entry
3188                        .models
3189                        .iter()
3190                        .map(|m| format_model_name(m))
3191                        .collect::<std::collections::BTreeSet<_>>()
3192                        .into_iter()
3193                        .collect();
3194                    unique.sort();
3195                    unique.join(", ")
3196                };
3197
3198                let cache_hit = {
3199                    let paid = (entry.input as u64).saturating_add(entry.cache_write as u64);
3200                    if paid == 0 {
3201                        if entry.cache_read > 0 {
3202                            "∞".to_string()
3203                        } else {
3204                            "—".to_string()
3205                        }
3206                    } else {
3207                        format!("{:.1}x", entry.cache_read as f64 / paid as f64)
3208                    }
3209                };
3210
3211                let turn_display = if entry.turn_count > 0 {
3212                    entry.turn_count.to_string()
3213                } else {
3214                    "—".to_string()
3215                };
3216
3217                let total_tokens = saturating_token_total(
3218                    entry.input,
3219                    entry.output,
3220                    entry.cache_read,
3221                    entry.cache_write,
3222                );
3223
3224                table.add_row(vec![
3225                    Cell::new(&entry.hour).fg(Color::White),
3226                    Cell::new(&clients_col),
3227                    Cell::new(&models_col),
3228                    Cell::new(&turn_display).set_alignment(CellAlignment::Right),
3229                    Cell::new(entry.message_count).set_alignment(CellAlignment::Right),
3230                    Cell::new(format_tokens_with_commas(entry.input))
3231                        .set_alignment(CellAlignment::Right),
3232                    Cell::new(format_tokens_with_commas(entry.output))
3233                        .set_alignment(CellAlignment::Right),
3234                    Cell::new(format_tokens_with_commas(entry.cache_read))
3235                        .set_alignment(CellAlignment::Right),
3236                    Cell::new(format_tokens_with_commas(entry.cache_write))
3237                        .set_alignment(CellAlignment::Right),
3238                    Cell::new(&cache_hit)
3239                        .fg(Color::Cyan)
3240                        .set_alignment(CellAlignment::Right),
3241                    Cell::new(format_currency(entry.cost))
3242                        .fg(Color::Green)
3243                        .set_alignment(CellAlignment::Right),
3244                    Cell::new(format_cost_per_million(entry.cost, total_tokens))
3245                        .set_alignment(CellAlignment::Right),
3246                ]);
3247            }
3248        }
3249
3250        // Title
3251        use colored::Colorize;
3252        let title = if let Some(ref range) = date_range {
3253            format!("Hourly Usage ({})", range)
3254        } else {
3255            "Hourly Usage".to_string()
3256        };
3257        println!("\n  {}\n", title.bold());
3258
3259        // Table
3260        let table_str = table.to_string();
3261        println!("{}", dim_borders(&table_str));
3262
3263        // Footer with total
3264        println!(
3265            "\n  {}  {}",
3266            "Total:".bold(),
3267            format_currency(report.total_cost).green().bold()
3268        );
3269
3270        if benchmark {
3271            println!(
3272                "{}",
3273                format!("  Processing time: {}ms (Rust native)", processing_time_ms).bright_black()
3274            );
3275        }
3276    }
3277
3278    Ok(())
3279}
3280
3281fn run_wrapped_command(
3282    output: Option<String>,
3283    year: Option<String>,
3284    client_filter: Option<Vec<String>>,
3285    short: bool,
3286    agents: bool,
3287    show_clients: bool,
3288    disable_pinned: bool,
3289) -> Result<()> {
3290    use colored::Colorize;
3291
3292    println!("{}", "\n  Tokmesh - Generate Wrapped Image\n".cyan());
3293
3294    println!("{}", "  Generating wrapped image...".bright_black());
3295    println!();
3296
3297    let include_agents = !show_clients || agents;
3298    let wrapped_options = commands::wrapped::WrappedOptions {
3299        output,
3300        year,
3301        clients: client_filter,
3302        short,
3303        include_agents,
3304        pin_sisyphus: !disable_pinned,
3305    };
3306
3307    match commands::wrapped::run(wrapped_options) {
3308        Ok(output_path) => {
3309            println!(
3310                "{}",
3311                format!("\n  ✓ Generated wrapped image: {}\n", output_path).green()
3312            );
3313        }
3314        Err(err) => {
3315            eprintln!("{}", "\nError generating wrapped image:".red());
3316            eprintln!("  {}\n", err);
3317            std::process::exit(1);
3318        }
3319    }
3320
3321    Ok(())
3322}
3323
3324fn run_pricing_lookup(
3325    model_id: &str,
3326    json: bool,
3327    provider: Option<&str>,
3328    no_spinner: bool,
3329) -> Result<()> {
3330    use colored::Colorize;
3331    use indicatif::ProgressBar;
3332    use indicatif::ProgressStyle;
3333    use tokio::runtime::Runtime;
3334    use tokmesh_core::pricing::PricingService;
3335
3336    if model_id.eq_ignore_ascii_case("list-overrides") {
3337        return run_pricing_list_overrides(json);
3338    }
3339
3340    let provider_normalized = provider.map(|p| p.to_lowercase());
3341    if let Some(ref p) = provider_normalized {
3342        if p != "custom" && p != "litellm" && p != "openrouter" && p != "models.dev" {
3343            println!(
3344                "\n  {}",
3345                format!("Invalid provider: {}", provider.unwrap_or("")).red()
3346            );
3347            println!(
3348                "{}\n",
3349                "  Valid providers: custom, litellm, openrouter, models.dev".bright_black()
3350            );
3351            std::process::exit(1);
3352        }
3353    }
3354
3355    let spinner = if no_spinner {
3356        None
3357    } else {
3358        let provider_label = provider.map(|p| format!(" from {}", p)).unwrap_or_default();
3359        let pb = ProgressBar::new_spinner();
3360        pb.set_style(ProgressStyle::default_spinner());
3361        pb.set_message(format!("Fetching pricing data{}...", provider_label));
3362        pb.enable_steady_tick(std::time::Duration::from_millis(100));
3363        Some(pb)
3364    };
3365
3366    let rt = Runtime::new()?;
3367    let result = match rt.block_on(async {
3368        let svc = PricingService::get_or_init().await?;
3369        Ok::<_, String>(svc.lookup_with_source(model_id, provider_normalized.as_deref()))
3370    }) {
3371        Ok(result) => result,
3372        Err(err) => {
3373            if let Some(pb) = spinner {
3374                pb.finish_and_clear();
3375            }
3376            if json {
3377                #[derive(serde::Serialize)]
3378                #[serde(rename_all = "camelCase")]
3379                struct ErrorOutput {
3380                    error: String,
3381                    model_id: String,
3382                }
3383                println!(
3384                    "{}",
3385                    serde_json::to_string_pretty(&ErrorOutput {
3386                        error: err,
3387                        model_id: model_id.to_string(),
3388                    })?
3389                );
3390                std::process::exit(1);
3391            }
3392            return Err(anyhow::anyhow!(err));
3393        }
3394    };
3395
3396    if let Some(pb) = spinner {
3397        pb.finish_and_clear();
3398    }
3399
3400    if json {
3401        match result {
3402            Some(pricing) => {
3403                #[derive(serde::Serialize)]
3404                #[serde(rename_all = "camelCase")]
3405                struct PricingValues {
3406                    input_cost_per_token: f64,
3407                    output_cost_per_token: f64,
3408                    #[serde(skip_serializing_if = "Option::is_none")]
3409                    cache_read_input_token_cost: Option<f64>,
3410                    #[serde(skip_serializing_if = "Option::is_none")]
3411                    cache_creation_input_token_cost: Option<f64>,
3412                }
3413
3414                #[derive(serde::Serialize)]
3415                #[serde(rename_all = "camelCase")]
3416                struct PricingOutput {
3417                    model_id: String,
3418                    matched_key: String,
3419                    source: String,
3420                    pricing: PricingValues,
3421                }
3422
3423                let output = PricingOutput {
3424                    model_id: model_id.to_string(),
3425                    matched_key: pricing.matched_key,
3426                    source: pricing.source,
3427                    pricing: PricingValues {
3428                        input_cost_per_token: pricing.pricing.input_cost_per_token.unwrap_or(0.0),
3429                        output_cost_per_token: pricing.pricing.output_cost_per_token.unwrap_or(0.0),
3430                        cache_read_input_token_cost: pricing.pricing.cache_read_input_token_cost,
3431                        cache_creation_input_token_cost: pricing
3432                            .pricing
3433                            .cache_creation_input_token_cost,
3434                    },
3435                };
3436
3437                println!("{}", serde_json::to_string_pretty(&output)?);
3438            }
3439            None => {
3440                #[derive(serde::Serialize)]
3441                #[serde(rename_all = "camelCase")]
3442                struct ErrorOutput {
3443                    error: String,
3444                    model_id: String,
3445                }
3446
3447                let output = ErrorOutput {
3448                    error: "Model not found".to_string(),
3449                    model_id: model_id.to_string(),
3450                };
3451
3452                println!("{}", serde_json::to_string_pretty(&output)?);
3453                std::process::exit(1);
3454            }
3455        }
3456    } else {
3457        match result {
3458            Some(pricing) => {
3459                println!("\n  Pricing for: {}", model_id.bold());
3460                println!("  Matched key: {}", pricing.matched_key);
3461                let source_label = match pricing.source.to_lowercase().as_str() {
3462                    "custom" => "Custom",
3463                    "litellm" => "LiteLLM",
3464                    "openrouter" => "OpenRouter",
3465                    "models.dev" => "Models.dev",
3466                    _ => pricing.source.as_str(),
3467                };
3468                println!("  Source: {}", source_label);
3469                println!();
3470                let input = pricing.pricing.input_cost_per_token.unwrap_or(0.0);
3471                let output = pricing.pricing.output_cost_per_token.unwrap_or(0.0);
3472                println!("  Input:  ${:.2} / 1M tokens", input * 1_000_000.0);
3473                println!("  Output: ${:.2} / 1M tokens", output * 1_000_000.0);
3474                if let Some(cache_read) = pricing.pricing.cache_read_input_token_cost {
3475                    println!(
3476                        "  Cache Read:  ${:.2} / 1M tokens",
3477                        cache_read * 1_000_000.0
3478                    );
3479                }
3480                if let Some(cache_write) = pricing.pricing.cache_creation_input_token_cost {
3481                    println!(
3482                        "  Cache Write: ${:.2} / 1M tokens",
3483                        cache_write * 1_000_000.0
3484                    );
3485                }
3486                println!();
3487            }
3488            None => {
3489                println!("\n  {}\n", format!("Model not found: {}", model_id).red());
3490                std::process::exit(1);
3491            }
3492        }
3493    }
3494
3495    Ok(())
3496}
3497
3498fn run_pricing_list_overrides(json: bool) -> Result<()> {
3499    use colored::Colorize;
3500    use tokmesh_core::pricing::custom::CustomPricing;
3501    use tokmesh_core::pricing::ModelPricing;
3502
3503    fn per_million(value: Option<f64>) -> Option<f64> {
3504        value.map(|v| v * 1_000_000.0)
3505    }
3506
3507    #[derive(serde::Serialize)]
3508    #[serde(rename_all = "camelCase")]
3509    struct OverrideEntry {
3510        model_id: String,
3511        #[serde(skip_serializing_if = "Option::is_none")]
3512        input_cost_per_million_tokens: Option<f64>,
3513        #[serde(skip_serializing_if = "Option::is_none")]
3514        output_cost_per_million_tokens: Option<f64>,
3515        #[serde(skip_serializing_if = "Option::is_none")]
3516        cache_read_input_token_cost_per_million_tokens: Option<f64>,
3517        #[serde(skip_serializing_if = "Option::is_none")]
3518        cache_creation_input_token_cost_per_million_tokens: Option<f64>,
3519    }
3520
3521    fn entry(model_id: &str, pricing: &ModelPricing) -> OverrideEntry {
3522        OverrideEntry {
3523            model_id: model_id.to_string(),
3524            input_cost_per_million_tokens: per_million(pricing.input_cost_per_token),
3525            output_cost_per_million_tokens: per_million(pricing.output_cost_per_token),
3526            cache_read_input_token_cost_per_million_tokens: per_million(
3527                pricing.cache_read_input_token_cost,
3528            ),
3529            cache_creation_input_token_cost_per_million_tokens: per_million(
3530                pricing.cache_creation_input_token_cost,
3531            ),
3532        }
3533    }
3534
3535    let path = CustomPricing::default_path();
3536    let overrides = CustomPricing::load_from_path(&path);
3537    let mut entries: Vec<OverrideEntry> = overrides
3538        .entries()
3539        .map(|(model_id, pricing)| entry(model_id, pricing))
3540        .collect();
3541    entries.sort_by(|a, b| a.model_id.cmp(&b.model_id));
3542
3543    if json {
3544        #[derive(serde::Serialize)]
3545        #[serde(rename_all = "camelCase")]
3546        struct Output {
3547            path: String,
3548            count: usize,
3549            models: Vec<OverrideEntry>,
3550        }
3551
3552        println!(
3553            "{}",
3554            serde_json::to_string_pretty(&Output {
3555                path: path.display().to_string(),
3556                count: entries.len(),
3557                models: entries,
3558            })?
3559        );
3560        return Ok(());
3561    }
3562
3563    if entries.is_empty() {
3564        println!(
3565            "\n  {}\n  Tried: {}\n",
3566            "No custom pricing overrides loaded".yellow(),
3567            path.display()
3568        );
3569        return Ok(());
3570    }
3571
3572    println!("\n  {}", "Custom pricing overrides".bold());
3573    println!("  Path: {}", path.display());
3574    println!("  Loaded once at startup; restart tokmesh after editing this file.");
3575    println!();
3576
3577    for entry in entries {
3578        println!("  {}", entry.model_id.bold());
3579        if let Some(input) = entry.input_cost_per_million_tokens {
3580            println!("    Input:  ${:.2} / 1M tokens", input);
3581        }
3582        if let Some(output) = entry.output_cost_per_million_tokens {
3583            println!("    Output: ${:.2} / 1M tokens", output);
3584        }
3585        if let Some(cache_read) = entry.cache_read_input_token_cost_per_million_tokens {
3586            println!("    Cache Read:  ${:.2} / 1M tokens", cache_read);
3587        }
3588        if let Some(cache_write) = entry.cache_creation_input_token_cost_per_million_tokens {
3589            println!("    Cache Write: ${:.2} / 1M tokens", cache_write);
3590        }
3591    }
3592    println!();
3593
3594    Ok(())
3595}
3596
3597fn format_currency(n: f64) -> String {
3598    format!("${:.2}", n)
3599}
3600
3601fn format_cost_per_million(cost: f64, total_tokens: i64) -> String {
3602    if total_tokens <= 0 || !cost.is_finite() {
3603        return "—".to_string();
3604    }
3605    let cost_per_m = cost * 1_000_000.0 / total_tokens as f64;
3606    if !cost_per_m.is_finite() {
3607        "—".to_string()
3608    } else {
3609        format!("${:.2}/M", cost_per_m)
3610    }
3611}
3612
3613fn format_ms_per_1k(ms_per_1k_tokens: Option<f64>) -> String {
3614    let Some(value) = ms_per_1k_tokens else {
3615        return "—".to_string();
3616    };
3617    if !value.is_finite() || value <= 0.0 {
3618        "—".to_string()
3619    } else if value >= 1000.0 {
3620        format!("{:.1}s", value / 1000.0)
3621    } else {
3622        format!("{:.0}ms", value)
3623    }
3624}
3625
3626/// Saturating sum of the four billable token buckets (input/output/cache
3627/// read/cache write) used throughout the display layer for per-row and
3628/// grand-total token counts. tokmesh-core saturates these fields at the
3629/// per-message and per-entry level (see `TokenBreakdown::total` and
3630/// `model_report_token_totals`), so a corrupt/misbehaving source can
3631/// legitimately clamp a bucket to `i64::MAX`; combining up to four such
3632/// buckets with plain `+` can then overflow (debug panic / release wrap).
3633/// `saturating_add` keeps this fold a no-op for real token counts and only
3634/// changes behavior in that already-degraded case.
3635fn saturating_token_total(input: i64, output: i64, cache_read: i64, cache_write: i64) -> i64 {
3636    input
3637        .saturating_add(output)
3638        .saturating_add(cache_read)
3639        .saturating_add(cache_write)
3640}
3641
3642/// Sum the (input, output, cache_read, cache_write) token fields across
3643/// monthly usage entries with saturating_add. `MonthlyReport` (unlike
3644/// `ModelReport`) doesn't carry precomputed grand totals, so the display
3645/// layer aggregates `report.entries` itself; a saturating fold keeps that
3646/// aggregation safe against clamped (i64::MAX) entry buckets.
3647fn monthly_token_field_totals(entries: &[tokmesh_core::MonthlyUsage]) -> (i64, i64, i64, i64) {
3648    entries.iter().fold(
3649        (0, 0, 0, 0),
3650        |(input, output, cache_read, cache_write), entry| {
3651            (
3652                input.saturating_add(entry.input),
3653                output.saturating_add(entry.output),
3654                cache_read.saturating_add(entry.cache_read),
3655                cache_write.saturating_add(entry.cache_write),
3656            )
3657        },
3658    )
3659}
3660
3661fn model_entry_total_tokens(entry: &tokmesh_core::ModelUsage) -> i64 {
3662    // saturating_add (mirrors tokmesh_core::TokenBreakdown::total) so a
3663    // clamped (i64::MAX) bucket from a corrupt source can't overflow the
3664    // per-entry sum.
3665    entry
3666        .input
3667        .max(0)
3668        .saturating_add(entry.output.max(0))
3669        .saturating_add(entry.cache_read.max(0))
3670        .saturating_add(entry.cache_write.max(0))
3671        .saturating_add(entry.reasoning.max(0))
3672}
3673
3674fn aggregate_model_report_performance(
3675    entries: &[tokmesh_core::ModelUsage],
3676) -> tokmesh_core::ModelPerformance {
3677    let mut performance = tokmesh_core::ModelPerformance::default();
3678    for entry in entries {
3679        performance.total_duration_ms = performance
3680            .total_duration_ms
3681            .saturating_add(entry.performance.total_duration_ms);
3682        performance.timed_tokens = performance
3683            .timed_tokens
3684            .saturating_add(entry.performance.timed_tokens);
3685        performance.sample_count = performance
3686            .sample_count
3687            .saturating_add(entry.performance.sample_count);
3688    }
3689    // saturating fold: model_entry_total_tokens already saturates per entry,
3690    // but two saturated (i64::MAX) entries folded with plain `.sum()` can
3691    // still overflow the cross-entry total.
3692    let total_tokens = entries
3693        .iter()
3694        .map(model_entry_total_tokens)
3695        .fold(0i64, i64::saturating_add);
3696    performance.finalize(total_tokens);
3697    performance
3698}
3699
3700/// Format text as an OSC 8 clickable hyperlink with custom display text.
3701/// Falls back to plain display text when stdout is not a terminal.
3702fn osc8_link_with_text(url: &str, text: &str) -> String {
3703    if std::io::stdout().is_terminal() {
3704        format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, text)
3705    } else {
3706        text.to_string()
3707    }
3708}
3709
3710fn dim_borders(table_str: &str) -> String {
3711    let border_chars: &[char] = &['┌', '─', '┬', '┐', '│', '├', '┼', '┤', '└', '┴', '┘'];
3712    let mut result = String::with_capacity(table_str.len() * 2);
3713
3714    for ch in table_str.chars() {
3715        if border_chars.contains(&ch) {
3716            result.push_str("\x1b[90m");
3717            result.push(ch);
3718            result.push_str("\x1b[0m");
3719        } else {
3720            result.push(ch);
3721        }
3722    }
3723
3724    result
3725}
3726
3727fn format_model_name(model: &str) -> String {
3728    let name = model.strip_prefix("claude-").unwrap_or(model);
3729    if name.len() > 9 {
3730        let potential_date = &name[name.len() - 8..];
3731        if potential_date.chars().all(|c| c.is_ascii_digit())
3732            && name.as_bytes()[name.len() - 9] == b'-'
3733        {
3734            return name[..name.len() - 9].to_string();
3735        }
3736    }
3737    name.to_string()
3738}
3739
3740fn capitalize_client(client: &str) -> String {
3741    match client {
3742        "opencode" => "OpenCode".to_string(),
3743        "claude" => "Claude".to_string(),
3744        "codex" => "Codex".to_string(),
3745        "cursor" => "Cursor".to_string(),
3746        "gemini" => "Gemini".to_string(),
3747        "amp" => "Amp".to_string(),
3748        "codebuff" => "Codebuff".to_string(),
3749        "droid" => "Droid".to_string(),
3750        "crush" => "Crush".to_string(),
3751        "openclaw" => "openclaw".to_string(),
3752        "hermes" => "Hermes Agent".to_string(),
3753        "goose" => "Goose".to_string(),
3754        "warp" => "Warp".to_string(),
3755        "grok" => "Grok Build".to_string(),
3756        "9router" => "9Router".to_string(),
3757        "pi" => "Pi".to_string(),
3758        "gjc" => "Gajae-Code".to_string(),
3759        "jcode" => "Jcode".to_string(),
3760        "commandcode" => "Command Code".to_string(),
3761        "junie" => "Junie".to_string(),
3762        "zcode" => "ZCode".to_string(),
3763        "codebuddy" => "CodeBuddy".to_string(),
3764        "workbuddy" => "WorkBuddy".to_string(),
3765        "devin-cli" => "Devin CLI".to_string(),
3766        "devin-desktop" => "Devin Desktop".to_string(),
3767        other => other.to_string(),
3768    }
3769}
3770
3771fn run_clients_command(json: bool, home_dir: Option<String>) -> Result<()> {
3772    use tokmesh_core::{
3773        built_in_extra_scan_paths_for, extra_scan_paths_for, parse_local_clients, ClientId,
3774        LocalParseOptions,
3775    };
3776
3777    let explicit_home_dir = home_dir;
3778    let use_env_roots = use_env_roots(&explicit_home_dir);
3779    let scanner_settings = tui::settings::load_scanner_settings_for_home(&explicit_home_dir);
3780    let home_dir = explicit_home_dir
3781        .map(PathBuf::from)
3782        .or_else(dirs::home_dir)
3783        .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
3784    let home_dir_str = home_dir.to_string_lossy().to_string();
3785
3786    let parsed = parse_local_clients(LocalParseOptions {
3787        home_dir: Some(home_dir_str.clone()),
3788        use_env_roots,
3789        clients: Some(
3790            ClientId::iter()
3791                .filter(|client| client.parse_local())
3792                .map(|client| client.as_str().to_string())
3793                .collect(),
3794        ),
3795        since: None,
3796        until: None,
3797        year: None,
3798        scanner_settings: scanner_settings.clone(),
3799    })
3800    .map_err(|e| anyhow::anyhow!(e))?;
3801
3802    let headless_roots =
3803        tokmesh_core::scanner::headless_roots_with_env_strategy(&home_dir_str, use_env_roots);
3804    let headless_codex_count = parsed
3805        .messages
3806        .iter()
3807        .filter(|m| m.agent.as_deref() == Some("headless") && m.client == "codex")
3808        .count() as i32;
3809
3810    #[derive(serde::Serialize)]
3811    #[serde(rename_all = "camelCase")]
3812    struct ClientRow {
3813        client: String,
3814        label: String,
3815        sessions_path: String,
3816        sessions_path_exists: bool,
3817        #[serde(skip_serializing_if = "Vec::is_empty")]
3818        additional_paths: Vec<AdditionalPath>,
3819        #[serde(skip_serializing_if = "Vec::is_empty")]
3820        legacy_paths: Vec<LegacyPath>,
3821        message_count: i32,
3822        headless_supported: bool,
3823        #[serde(skip_serializing_if = "Vec::is_empty")]
3824        headless_paths: Vec<HeadlessPath>,
3825        headless_message_count: i32,
3826        #[serde(skip_serializing_if = "Option::is_none")]
3827        exporter_status: Option<String>,
3828        #[serde(skip_serializing_if = "Vec::is_empty")]
3829        extra_paths: Vec<ExtraPath>,
3830        #[serde(skip_serializing_if = "Vec::is_empty")]
3831        diagnostics: Vec<claude_diagnostics::ClientDiagnostic>,
3832    }
3833
3834    #[derive(serde::Serialize)]
3835    #[serde(rename_all = "camelCase")]
3836    struct AdditionalPath {
3837        path: String,
3838        exists: bool,
3839    }
3840
3841    #[derive(serde::Serialize)]
3842    #[serde(rename_all = "camelCase")]
3843    struct LegacyPath {
3844        path: String,
3845        exists: bool,
3846    }
3847
3848    #[derive(serde::Serialize)]
3849    #[serde(rename_all = "camelCase")]
3850    struct HeadlessPath {
3851        path: String,
3852        exists: bool,
3853    }
3854
3855    #[derive(serde::Serialize)]
3856    #[serde(rename_all = "camelCase")]
3857    struct ExtraPath {
3858        path: String,
3859        exists: bool,
3860        source: String,
3861    }
3862
3863    let all_clients: std::collections::HashSet<ClientId> = ClientId::iter().collect();
3864    let extra_dirs: Vec<(ClientId, String)> = if use_env_roots {
3865        let extra_dirs_val = std::env::var("TOKMESH_EXTRA_DIRS").unwrap_or_default();
3866        tokmesh_core::parse_extra_dirs(&extra_dirs_val, &all_clients)
3867    } else {
3868        Vec::new()
3869    };
3870    let built_in_extra_paths = built_in_extra_scan_paths_for(&home_dir_str, &all_clients);
3871    let settings_extra_dirs = extra_scan_paths_for(&scanner_settings, &all_clients);
3872    let copilot_exporter_path =
3873        tokmesh_core::copilot_exporter_path_with_env_strategy(use_env_roots);
3874
3875    let clients: Vec<ClientRow> =
3876        ClientId::iter()
3877            .map(|client| {
3878                let sessions_path = client
3879                    .data()
3880                    .resolve_path_with_env_strategy(&home_dir_str, use_env_roots);
3881                let sessions_path_exists = Path::new(&sessions_path).exists();
3882                let mut additional_paths: Vec<AdditionalPath> = built_in_extra_paths
3883                    .iter()
3884                    .filter(|(c, _)| *c == client)
3885                    .map(|(_, path)| AdditionalPath {
3886                        path: path.to_string_lossy().to_string(),
3887                        exists: path.exists(),
3888                    })
3889                    .collect();
3890                if client == ClientId::Zcode {
3891                    let path = home_dir.join(".zcode/cli/db/db.sqlite");
3892                    additional_paths.push(AdditionalPath {
3893                        path: path.to_string_lossy().to_string(),
3894                        exists: path.exists(),
3895                    });
3896                }
3897                if client == ClientId::DevinDesktop {
3898                    for root in tokmesh_core::scanner::devin_desktop_additional_roots(
3899                        &home_dir_str,
3900                        use_env_roots,
3901                    ) {
3902                        let path_str = root.to_string_lossy().to_string();
3903                        if !additional_paths.iter().any(|p| p.path == path_str) {
3904                            additional_paths.push(AdditionalPath {
3905                                path: path_str,
3906                                exists: root.exists(),
3907                            });
3908                        }
3909                    }
3910                }
3911                let legacy_paths = if client == ClientId::OpenClaw {
3912                    vec![
3913                        LegacyPath {
3914                            path: home_dir
3915                                .join(".clawdbot/agents")
3916                                .to_string_lossy()
3917                                .to_string(),
3918                            exists: home_dir.join(".clawdbot/agents").exists(),
3919                        },
3920                        LegacyPath {
3921                            path: home_dir
3922                                .join(".moltbot/agents")
3923                                .to_string_lossy()
3924                                .to_string(),
3925                            exists: home_dir.join(".moltbot/agents").exists(),
3926                        },
3927                        LegacyPath {
3928                            path: home_dir
3929                                .join(".moldbot/agents")
3930                                .to_string_lossy()
3931                                .to_string(),
3932                            exists: home_dir.join(".moldbot/agents").exists(),
3933                        },
3934                    ]
3935                } else {
3936                    vec![]
3937                };
3938                let (headless_supported, headless_paths, headless_message_count) =
3939                    if client == ClientId::Codex {
3940                        (
3941                            true,
3942                            headless_roots
3943                                .iter()
3944                                .map(|root| {
3945                                    let path = root.join(client.as_str());
3946                                    HeadlessPath {
3947                                        path: path.to_string_lossy().to_string(),
3948                                        exists: path.exists(),
3949                                    }
3950                                })
3951                                .collect(),
3952                            headless_codex_count,
3953                        )
3954                    } else {
3955                        (false, vec![], 0)
3956                    };
3957
3958                let label = match client {
3959                    ClientId::Claude => "Claude Code",
3960                    ClientId::Codex => "Codex CLI",
3961                    ClientId::Copilot => "Copilot CLI",
3962                    ClientId::Gemini => "Gemini CLI",
3963                    ClientId::Cursor => "Cursor IDE",
3964                    ClientId::Kimi => "Kimi CLI",
3965                    ClientId::AntigravityCli => "Antigravity CLI",
3966                    _ => client_ui::display_name(client),
3967                }
3968                .to_string();
3969
3970                let mut extra_paths: Vec<ExtraPath> = settings_extra_dirs
3971                    .iter()
3972                    .filter(|(c, _)| *c == client)
3973                    .map(|(_, path)| ExtraPath {
3974                        path: path.to_string_lossy().to_string(),
3975                        exists: path.exists(),
3976                        source: "settings".to_string(),
3977                    })
3978                    .collect();
3979                extra_paths.extend(extra_dirs.iter().filter(|(c, _)| *c == client).map(
3980                    |(_, path)| ExtraPath {
3981                        path: path.clone(),
3982                        exists: Path::new(path).exists(),
3983                        source: "env".to_string(),
3984                    },
3985                ));
3986
3987                let diagnostics = if client == ClientId::Claude {
3988                    claude_diagnostics::diagnostics_for_clients_row(&home_dir)
3989                } else {
3990                    Vec::new()
3991                };
3992
3993                ClientRow {
3994                    client: client.as_str().to_string(),
3995                    label,
3996                    sessions_path,
3997                    sessions_path_exists,
3998                    additional_paths,
3999                    legacy_paths,
4000                    message_count: parsed.counts.get(client),
4001                    headless_supported,
4002                    headless_paths,
4003                    headless_message_count,
4004                    exporter_status: (client == ClientId::Copilot
4005                        && copilot_exporter_path.is_some())
4006                    .then(|| "configured".to_string()),
4007                    extra_paths,
4008                    diagnostics,
4009                }
4010            })
4011            .collect();
4012
4013    if json {
4014        #[derive(serde::Serialize)]
4015        #[serde(rename_all = "camelCase")]
4016        struct Output {
4017            headless_roots: Vec<String>,
4018            clients: Vec<ClientRow>,
4019            note: String,
4020        }
4021
4022        let output = Output {
4023            headless_roots: headless_roots
4024                .iter()
4025                .map(|p| p.to_string_lossy().to_string())
4026                .collect(),
4027            clients,
4028            note: "Headless capture is supported for Codex CLI only.".to_string(),
4029        };
4030
4031        println!("{}", serde_json::to_string_pretty(&output)?);
4032    } else {
4033        use colored::Colorize;
4034
4035        println!("\n  {}", "Local clients & session counts".cyan());
4036        println!(
4037            "  {}",
4038            format!(
4039                "Headless roots: {}",
4040                headless_roots
4041                    .iter()
4042                    .map(|p| p.to_string_lossy())
4043                    .collect::<Vec<_>>()
4044                    .join(", ")
4045            )
4046            .bright_black()
4047        );
4048        println!();
4049
4050        for row in clients {
4051            println!("  {}", row.label.white());
4052            println!(
4053                "  {}",
4054                format!(
4055                    "sessions: {}",
4056                    describe_path_for_home(&row.sessions_path, row.sessions_path_exists, &home_dir)
4057                )
4058                .bright_black()
4059            );
4060
4061            if !row.additional_paths.is_empty() {
4062                let additional_desc: Vec<String> = row
4063                    .additional_paths
4064                    .iter()
4065                    .map(|ap| describe_path_for_home(&ap.path, ap.exists, &home_dir))
4066                    .collect();
4067                println!(
4068                    "  {}",
4069                    format!("additional: {}", additional_desc.join(", ")).bright_black()
4070                );
4071            }
4072
4073            if !row.legacy_paths.is_empty() {
4074                let legacy_desc: Vec<String> = row
4075                    .legacy_paths
4076                    .iter()
4077                    .map(|lp| describe_path_for_home(&lp.path, lp.exists, &home_dir))
4078                    .collect();
4079                println!(
4080                    "  {}",
4081                    format!("legacy: {}", legacy_desc.join(", ")).bright_black()
4082                );
4083            }
4084
4085            if !row.extra_paths.is_empty() {
4086                let settings_desc: Vec<String> = row
4087                    .extra_paths
4088                    .iter()
4089                    .filter(|ep| ep.source == "settings")
4090                    .map(|ep| describe_path_for_home(&ep.path, ep.exists, &home_dir))
4091                    .collect();
4092                if !settings_desc.is_empty() {
4093                    println!(
4094                        "  {}",
4095                        format!("extra (settings): {}", settings_desc.join(", ")).bright_black()
4096                    );
4097                }
4098
4099                let env_desc: Vec<String> = row
4100                    .extra_paths
4101                    .iter()
4102                    .filter(|ep| ep.source == "env")
4103                    .map(|ep| describe_path_for_home(&ep.path, ep.exists, &home_dir))
4104                    .collect();
4105                if !env_desc.is_empty() {
4106                    println!(
4107                        "  {}",
4108                        format!("extra (env): {}", env_desc.join(", ")).bright_black()
4109                    );
4110                }
4111            }
4112
4113            if let Some(exporter_status) = row.exporter_status.as_ref() {
4114                println!(
4115                    "  {}",
4116                    format!("exporter: {}", exporter_status).bright_black()
4117                );
4118            }
4119
4120            if row.headless_supported {
4121                let headless_desc: Vec<String> = row
4122                    .headless_paths
4123                    .iter()
4124                    .map(|hp| describe_path_for_home(&hp.path, hp.exists, &home_dir))
4125                    .collect();
4126                println!(
4127                    "  {}",
4128                    format!("headless: {}", headless_desc.join(", ")).bright_black()
4129                );
4130                println!(
4131                    "  {}",
4132                    format!(
4133                        "messages: {} (headless: {})",
4134                        format_number(row.message_count),
4135                        format_number(row.headless_message_count)
4136                    )
4137                    .bright_black()
4138                );
4139            } else {
4140                println!(
4141                    "  {}",
4142                    format!("messages: {}", format_number(row.message_count)).bright_black()
4143                );
4144            }
4145
4146            for diagnostic in &row.diagnostics {
4147                println!(
4148                    "  {}",
4149                    format!("{}: {}", diagnostic.severity, diagnostic.message).yellow()
4150                );
4151                println!("  {}", diagnostic.help.bright_black());
4152            }
4153
4154            println!();
4155        }
4156
4157        println!(
4158            "  {}",
4159            "Note: Headless capture is supported for Codex CLI only.".bright_black()
4160        );
4161        println!();
4162    }
4163
4164    Ok(())
4165}
4166
4167fn get_headless_roots(home_dir: &Path) -> Vec<PathBuf> {
4168    let mut roots = Vec::new();
4169
4170    if let Ok(env_dir) = std::env::var("TOKMESH_HEADLESS_DIR") {
4171        roots.push(PathBuf::from(env_dir));
4172    } else {
4173        roots.push(home_dir.join(".config/tokmesh/headless"));
4174    }
4175
4176    roots
4177}
4178
4179fn describe_path_for_home(path: &str, exists: bool, home: &Path) -> String {
4180    let path_display = path.replace(&home.to_string_lossy().to_string(), "~");
4181    if exists {
4182        format!("{} ✓", path_display)
4183    } else {
4184        format!("{} ✗", path_display)
4185    }
4186}
4187
4188fn format_number(n: i32) -> String {
4189    if n >= 1_000_000 {
4190        format!("{:.1}M", n as f64 / 1_000_000.0)
4191    } else if n >= 1_000 {
4192        format!("{:.1}K", n as f64 / 1_000.0)
4193    } else {
4194        n.to_string()
4195    }
4196}
4197
4198#[derive(serde::Serialize)]
4199#[serde(rename_all = "camelCase")]
4200struct TsTokenBreakdown {
4201    input: i64,
4202    output: i64,
4203    cache_read: i64,
4204    cache_write: i64,
4205    reasoning: i64,
4206}
4207
4208#[derive(serde::Serialize)]
4209#[serde(rename_all = "camelCase")]
4210struct TsSourceContribution {
4211    client: String,
4212    model_id: String,
4213    #[serde(skip_serializing_if = "Option::is_none")]
4214    provider_id: Option<String>,
4215    tokens: TsTokenBreakdown,
4216    cost: f64,
4217    messages: i32,
4218    #[serde(skip_serializing_if = "Option::is_none")]
4219    provenance: Option<TsClientContributionProvenance>,
4220}
4221
4222#[derive(serde::Serialize)]
4223#[serde(rename_all = "camelCase")]
4224struct TsClientContributionProvenance {
4225    schema_version: u32,
4226    message_count: i32,
4227    model_count: u32,
4228}
4229
4230#[derive(serde::Serialize)]
4231#[serde(rename_all = "camelCase")]
4232struct TsDailyTotals {
4233    tokens: i64,
4234    cost: f64,
4235    messages: i32,
4236}
4237
4238#[derive(serde::Serialize)]
4239#[serde(rename_all = "camelCase")]
4240struct TsDailyContribution {
4241    date: String,
4242    totals: TsDailyTotals,
4243    intensity: u8,
4244    token_breakdown: TsTokenBreakdown,
4245    clients: Vec<TsSourceContribution>,
4246    #[serde(skip_serializing_if = "Option::is_none")]
4247    active_time_ms: Option<i64>,
4248}
4249
4250#[derive(serde::Serialize)]
4251struct DateRange {
4252    start: String,
4253    end: String,
4254}
4255
4256#[derive(serde::Serialize)]
4257#[serde(rename_all = "camelCase")]
4258struct TsYearSummary {
4259    year: String,
4260    total_tokens: i64,
4261    total_cost: f64,
4262    range: DateRange,
4263}
4264
4265#[derive(serde::Serialize)]
4266#[serde(rename_all = "camelCase")]
4267struct TsDataSummary {
4268    total_tokens: i64,
4269    total_cost: f64,
4270    total_days: i32,
4271    active_days: i32,
4272    average_per_day: f64,
4273    max_cost_in_single_day: f64,
4274    clients: Vec<String>,
4275    models: Vec<String>,
4276}
4277
4278#[derive(serde::Serialize)]
4279#[serde(rename_all = "camelCase")]
4280struct TsExportMeta {
4281    generated_at: String,
4282    version: String,
4283    date_range: DateRange,
4284}
4285
4286#[derive(serde::Serialize)]
4287#[serde(rename_all = "camelCase")]
4288struct TsSubmitDevice {
4289    id: String,
4290    #[serde(skip_serializing_if = "Option::is_none")]
4291    name: Option<String>,
4292}
4293
4294#[derive(serde::Serialize)]
4295#[serde(rename_all = "camelCase")]
4296struct TsTimeMetrics {
4297    total_active_time_ms: i64,
4298    longest_continuous_ms: i64,
4299    max_concurrent_sessions: u32,
4300    session_count: u32,
4301}
4302
4303#[derive(serde::Serialize)]
4304#[serde(rename_all = "camelCase")]
4305struct TsClientManifestCoverage {
4306    mode: &'static str,
4307    start: String,
4308    end: String,
4309    missing_data: &'static str,
4310}
4311
4312#[derive(serde::Serialize)]
4313#[serde(rename_all = "camelCase")]
4314struct TsClientManifestEntry {
4315    client: String,
4316    parser_revision: u32,
4317    #[serde(skip_serializing_if = "Option::is_none")]
4318    coverage: Option<TsClientManifestCoverage>,
4319}
4320
4321#[derive(serde::Serialize)]
4322#[serde(rename_all = "camelCase")]
4323struct TsClientManifest {
4324    schema_version: u32,
4325    clients: Vec<TsClientManifestEntry>,
4326}
4327
4328#[derive(Clone, Debug, PartialEq, Eq)]
4329struct SubmitReplacementCoverage {
4330    clients: Vec<String>,
4331    start: String,
4332    end: String,
4333}
4334
4335fn resolve_submit_replacement(
4336    replace: bool,
4337    clients: Option<&[String]>,
4338    date: &DateRangeFlags,
4339) -> Result<Option<SubmitReplacementCoverage>> {
4340    if !replace {
4341        return Ok(None);
4342    }
4343    if date.today || date.yesterday || date.week || date.month || date.year.is_some() {
4344        return Err(anyhow::anyhow!(
4345            "--replace only accepts explicit --since and --until bounds; remove --today/--yesterday/--week/--month/--year"
4346        ));
4347    }
4348
4349    let replacement_clients = clients
4350        .filter(|clients| !clients.is_empty())
4351        .map(<[String]>::to_vec)
4352        .ok_or_else(|| anyhow::anyhow!("--replace requires at least one explicit --client"))?;
4353    let start = date
4354        .since
4355        .as_deref()
4356        .ok_or_else(|| anyhow::anyhow!("--replace requires a bounded --since date"))?;
4357    let end = date
4358        .until
4359        .as_deref()
4360        .ok_or_else(|| anyhow::anyhow!("--replace requires a bounded --until date"))?;
4361    let start_date = chrono::NaiveDate::parse_from_str(start, "%Y-%m-%d")
4362        .map_err(|_| anyhow::anyhow!("--replace --since must use YYYY-MM-DD"))?;
4363    let end_date = chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d")
4364        .map_err(|_| anyhow::anyhow!("--replace --until must use YYYY-MM-DD"))?;
4365    if start_date > end_date {
4366        return Err(anyhow::anyhow!(
4367            "--replace --until must be on or after --since"
4368        ));
4369    }
4370
4371    Ok(Some(SubmitReplacementCoverage {
4372        clients: replacement_clients,
4373        start: start.to_string(),
4374        end: end.to_string(),
4375    }))
4376}
4377
4378fn validate_replacement_contributions(
4379    graph: &tokmesh_core::GraphResult,
4380    replacement: Option<&SubmitReplacementCoverage>,
4381) -> Result<()> {
4382    let Some(replacement) = replacement else {
4383        return Ok(());
4384    };
4385
4386    let missing: Vec<&str> = replacement
4387        .clients
4388        .iter()
4389        .map(String::as_str)
4390        .filter(|client| {
4391            !graph.contributions.iter().any(|day| {
4392                day.clients
4393                    .iter()
4394                    .any(|contribution| contribution.client == *client)
4395            })
4396        })
4397        .collect();
4398    if !missing.is_empty() {
4399        anyhow::bail!(
4400            "--replace requires at least one local contribution for each selected client after filtering; no contribution found for: {}",
4401            missing.join(", ")
4402        );
4403    }
4404    Ok(())
4405}
4406
4407#[derive(serde::Serialize)]
4408#[serde(rename_all = "camelCase")]
4409struct TsTokenContributionData {
4410    meta: TsExportMeta,
4411    #[serde(skip_serializing_if = "Option::is_none")]
4412    device: Option<TsSubmitDevice>,
4413    summary: TsDataSummary,
4414    years: Vec<TsYearSummary>,
4415    contributions: Vec<TsDailyContribution>,
4416    #[serde(skip_serializing_if = "Option::is_none")]
4417    time_metrics: Option<TsTimeMetrics>,
4418    #[serde(skip_serializing_if = "Option::is_none")]
4419    client_manifest: Option<TsClientManifest>,
4420}
4421
4422fn submit_parser_revision(client: &str) -> u32 {
4423    if client == "codex" {
4424        2
4425    } else {
4426        1
4427    }
4428}
4429
4430fn build_submit_client_manifest(
4431    graph: &tokmesh_core::GraphResult,
4432    replacement: Option<&SubmitReplacementCoverage>,
4433) -> TsClientManifest {
4434    use std::collections::{BTreeMap, BTreeSet};
4435
4436    let mut clients = BTreeSet::new();
4437    for day in &graph.contributions {
4438        for contribution in &day.clients {
4439            clients.insert(contribution.client.clone());
4440        }
4441    }
4442    if let Some(replacement) = replacement {
4443        clients.extend(replacement.clients.iter().cloned());
4444    }
4445
4446    let replacement_clients: BTreeMap<&str, &SubmitReplacementCoverage> = replacement
4447        .into_iter()
4448        .flat_map(|coverage| {
4449            coverage
4450                .clients
4451                .iter()
4452                .map(move |client| (client.as_str(), coverage))
4453        })
4454        .collect();
4455
4456    TsClientManifest {
4457        schema_version: 1,
4458        clients: clients
4459            .into_iter()
4460            .map(|client| {
4461                let coverage = replacement_clients.get(client.as_str()).map(|replacement| {
4462                    TsClientManifestCoverage {
4463                        mode: "full",
4464                        start: replacement.start.clone(),
4465                        end: replacement.end.clone(),
4466                        missing_data: "tombstone",
4467                    }
4468                });
4469                TsClientManifestEntry {
4470                    parser_revision: submit_parser_revision(&client),
4471                    client,
4472                    coverage,
4473                }
4474            })
4475            .collect(),
4476    }
4477}
4478
4479/// Build the leaderboard submit/export body.
4480///
4481/// - `Leaderboard::Tokscale`: tokscale.ai shape (no clientManifest / provenance).
4482/// - `Leaderboard::TokensCi`: tokens.ci shape (clientManifest + per-row provenance on submit).
4483fn to_ts_token_contribution_data(
4484    graph: &tokmesh_core::GraphResult,
4485    device: Option<&device::SubmitDevice>,
4486    board: leaderboard::Leaderboard,
4487    replacement: Option<&SubmitReplacementCoverage>,
4488) -> TsTokenContributionData {
4489    let tokensci = board == leaderboard::Leaderboard::TokensCi;
4490    let include_submit_provenance = tokensci && device.is_some();
4491
4492    TsTokenContributionData {
4493        meta: TsExportMeta {
4494            generated_at: graph.meta.generated_at.clone(),
4495            version: graph.meta.version.clone(),
4496            date_range: DateRange {
4497                start: graph.meta.date_range_start.clone(),
4498                end: graph.meta.date_range_end.clone(),
4499            },
4500        },
4501        device: device.map(|d| TsSubmitDevice {
4502            id: d.id.clone(),
4503            name: d.name.clone(),
4504        }),
4505        client_manifest: if tokensci && device.is_some() {
4506            Some(build_submit_client_manifest(graph, replacement))
4507        } else {
4508            None
4509        },
4510        summary: TsDataSummary {
4511            total_tokens: graph.summary.total_tokens,
4512            total_cost: graph.summary.total_cost,
4513            total_days: graph.summary.total_days,
4514            active_days: graph.summary.active_days,
4515            average_per_day: graph.summary.average_per_day,
4516            max_cost_in_single_day: graph.summary.max_cost_in_single_day,
4517            clients: graph.summary.clients.clone(),
4518            models: graph.summary.models.clone(),
4519        },
4520        years: graph
4521            .years
4522            .iter()
4523            .map(|y| TsYearSummary {
4524                year: y.year.clone(),
4525                total_tokens: y.total_tokens,
4526                total_cost: y.total_cost,
4527                range: DateRange {
4528                    start: y.range_start.clone(),
4529                    end: y.range_end.clone(),
4530                },
4531            })
4532            .collect(),
4533        contributions: graph
4534            .contributions
4535            .iter()
4536            .map(|d| {
4537                let mut clients: Vec<TsSourceContribution> = d
4538                    .clients
4539                    .iter()
4540                    .map(|s| TsSourceContribution {
4541                        client: s.client.clone(),
4542                        model_id: s.model_id.clone(),
4543                        provider_id: if s.provider_id.is_empty() {
4544                            None
4545                        } else {
4546                            Some(s.provider_id.clone())
4547                        },
4548                        tokens: TsTokenBreakdown {
4549                            input: s.tokens.input,
4550                            output: s.tokens.output,
4551                            cache_read: s.tokens.cache_read,
4552                            cache_write: s.tokens.cache_write,
4553                            reasoning: s.tokens.reasoning,
4554                        },
4555                        cost: s.cost,
4556                        messages: s.messages,
4557                        provenance: include_submit_provenance.then(|| {
4558                            TsClientContributionProvenance {
4559                                schema_version: submit_parser_revision(&s.client),
4560                                message_count: s.messages,
4561                                model_count: 1,
4562                            }
4563                        }),
4564                    })
4565                    .collect();
4566                // Stable order for deterministic dry-run / diffs.
4567                clients.sort_by(|a, b| (&a.client, &a.model_id).cmp(&(&b.client, &b.model_id)));
4568                TsDailyContribution {
4569                    date: d.date.clone(),
4570                    totals: TsDailyTotals {
4571                        tokens: d.totals.tokens,
4572                        cost: d.totals.cost,
4573                        messages: d.totals.messages,
4574                    },
4575                    intensity: d.intensity,
4576                    token_breakdown: TsTokenBreakdown {
4577                        input: d.token_breakdown.input,
4578                        output: d.token_breakdown.output,
4579                        cache_read: d.token_breakdown.cache_read,
4580                        cache_write: d.token_breakdown.cache_write,
4581                        reasoning: d.token_breakdown.reasoning,
4582                    },
4583                    clients,
4584                    active_time_ms: d.active_time_ms,
4585                }
4586            })
4587            .collect(),
4588        time_metrics: graph.time_metrics.as_ref().map(|tm| TsTimeMetrics {
4589            total_active_time_ms: tm.total_active_time_ms,
4590            longest_continuous_ms: tm.longest_continuous_ms,
4591            max_concurrent_sessions: tm.max_concurrent_sessions,
4592            session_count: tm.session_count,
4593        }),
4594    }
4595}
4596
4597fn run_leaderboard_command(
4598    board: leaderboard::Leaderboard,
4599    command: LeaderboardCommand,
4600    _home: &Option<String>,
4601) -> Result<()> {
4602    match command {
4603        LeaderboardCommand::Login { token } => {
4604            let rt = tokio::runtime::Runtime::new()?;
4605            match token {
4606                Some(token) => rt.block_on(auth::login_with_token(board, &token)),
4607                None => rt.block_on(auth::login(board)),
4608            }
4609        }
4610        LeaderboardCommand::Logout => auth::logout(board),
4611        LeaderboardCommand::Whoami => auth::whoami(board),
4612        LeaderboardCommand::Qr { yes } => auth::show_qr(board, yes),
4613        LeaderboardCommand::Submit {
4614            clients,
4615            date,
4616            dry_run,
4617            json,
4618            replace,
4619        } => {
4620            let (since, until) = build_date_filter(&date);
4621            let year = normalize_year_filter(&date);
4622            let client_filter = build_client_filter_with_defaults(clients, &[]);
4623            if replace && board != leaderboard::Leaderboard::TokensCi {
4624                anyhow::bail!("--replace is only supported for `tokmesh tokensci submit`");
4625            }
4626            if json && !dry_run {
4627                anyhow::bail!("--json is only valid together with --dry-run");
4628            }
4629            let replacement = resolve_submit_replacement(replace, client_filter.as_deref(), &date)?;
4630            run_submit_command(
4631                board,
4632                SubmitCommandOptions {
4633                    clients: client_filter,
4634                    since,
4635                    until,
4636                    year,
4637                    dry_run,
4638                    dry_run_json: json,
4639                    replacement,
4640                    mode: SubmitMode::Interactive,
4641                },
4642            )
4643        }
4644        LeaderboardCommand::Autosubmit { subcommand } => run_autosubmit_command(board, subcommand),
4645        LeaderboardCommand::DeleteSubmittedData => run_delete_data_command(board),
4646    }
4647}
4648
4649fn build_leaderboard_http_client(timeout: std::time::Duration) -> Result<reqwest::Client> {
4650    reqwest::Client::builder()
4651        .connect_timeout(std::time::Duration::from_secs(10))
4652        .timeout(timeout)
4653        .build()
4654        .map_err(|error| anyhow::anyhow!("Failed to build leaderboard HTTP client: {error}"))
4655}
4656
4657async fn read_leaderboard_response_body(
4658    response: reqwest::Response,
4659    operation: &str,
4660) -> Result<String> {
4661    response
4662        .text()
4663        .await
4664        .map_err(|error| anyhow::anyhow!("Failed to read {operation} response body: {error}"))
4665}
4666
4667fn run_delete_data_command(board: leaderboard::Leaderboard) -> Result<()> {
4668    use colored::Colorize;
4669    use std::io::{self, Write};
4670    use tokio::runtime::Runtime;
4671
4672    let auth_token = auth::resolve_api_token(board).ok_or_else(|| {
4673        anyhow::anyhow!(
4674            "Not logged in to {}. Run `tokmesh {} login` or set {}.",
4675            board,
4676            board.as_str(),
4677            board.api_token_env()
4678        )
4679    })?;
4680
4681    println!("\n{}", "  ⚠ Delete all submitted usage data".red().bold());
4682    println!("{}", "  This will permanently remove:".bright_black());
4683    println!("{}", "    • Leaderboard entries".bright_black());
4684    println!("{}", "    • Public profile stats".bright_black());
4685    println!("{}", "    • Daily usage history".bright_black());
4686    println!(
4687        "{}",
4688        "  Your account and API tokens will stay active.\n".bright_black()
4689    );
4690
4691    print!(
4692        "{}",
4693        "  Are you sure you want to delete all submitted data? (y/N): ".white()
4694    );
4695    io::stdout().flush()?;
4696    let mut input = String::new();
4697    io::stdin().read_line(&mut input)?;
4698    if input.trim().to_lowercase() != "y" {
4699        println!("{}", "  Cancelled.".bright_black());
4700        return Ok(());
4701    }
4702
4703    print!(
4704        "{}",
4705        "  This cannot be undone. You will lose all historical token/cost data. Continue? (y/N): "
4706            .white()
4707    );
4708    io::stdout().flush()?;
4709    input.clear();
4710    io::stdin().read_line(&mut input)?;
4711    if input.trim().to_lowercase() != "y" {
4712        println!("{}", "  Cancelled.".bright_black());
4713        return Ok(());
4714    }
4715
4716    print!("{}", "  Type \"delete my data\" to confirm: ".white());
4717    io::stdout().flush()?;
4718    input.clear();
4719    io::stdin().read_line(&mut input)?;
4720    if input.trim().to_lowercase() != "delete my data" {
4721        println!("{}", "  Confirmation failed. Cancelled.".bright_black());
4722        return Ok(());
4723    }
4724
4725    println!("\n{}", "  Deleting submitted data...".bright_black());
4726
4727    let api_url = auth::get_api_base_url(board);
4728    let rt = Runtime::new()?;
4729
4730    let client = build_leaderboard_http_client(std::time::Duration::from_secs(30))?;
4731    let response = rt.block_on(async {
4732        client
4733            .delete(format!("{}/api/settings/submitted-data", api_url))
4734            .header("Authorization", format!("Bearer {}", auth_token.token))
4735            .send()
4736            .await
4737    });
4738
4739    match response {
4740        Ok(resp) => {
4741            let status = resp.status();
4742            let body = rt.block_on(read_leaderboard_response_body(resp, "delete"))?;
4743
4744            match interpret_delete_submitted_data_response(status, &body)? {
4745                DeleteSubmittedDataOutcome::Deleted(count) => {
4746                    let message = count.map_or_else(
4747                        || {
4748                            "  Submitted data deleted. Leaderboard and profile will refresh shortly."
4749                                .to_string()
4750                        },
4751                        |count| {
4752                            format!(
4753                                "  Deleted {} submission(s). Leaderboard and profile will refresh shortly.",
4754                                count
4755                            )
4756                        },
4757                    );
4758                    println!("{}", message.green());
4759                }
4760                DeleteSubmittedDataOutcome::NotFound => {
4761                    println!("{}", "  No submitted data found for this account.".yellow());
4762                }
4763            }
4764        }
4765        Err(e) => {
4766            return Err(anyhow::anyhow!("Request failed: {}", e));
4767        }
4768    }
4769
4770    Ok(())
4771}
4772
4773#[derive(Debug, PartialEq, Eq)]
4774enum DeleteSubmittedDataOutcome {
4775    Deleted(Option<i64>),
4776    NotFound,
4777}
4778
4779fn interpret_delete_submitted_data_response(
4780    status: reqwest::StatusCode,
4781    body: &str,
4782) -> Result<DeleteSubmittedDataOutcome> {
4783    let parsed = serde_json::from_str::<serde_json::Value>(body).ok();
4784    if status.is_success() {
4785        let deleted = parsed
4786            .as_ref()
4787            .and_then(|body| body.get("deleted"))
4788            .and_then(|v| v.as_bool())
4789            .unwrap_or(true);
4790        let count = parsed
4791            .as_ref()
4792            .and_then(|body| body.get("deletedSubmissions"))
4793            .and_then(|v| v.as_i64())
4794            .or(deleted.then_some(0).filter(|_| parsed.is_some()));
4795
4796        if deleted {
4797            Ok(DeleteSubmittedDataOutcome::Deleted(count))
4798        } else {
4799            Ok(DeleteSubmittedDataOutcome::NotFound)
4800        }
4801    } else {
4802        let err = parsed
4803            .as_ref()
4804            .and_then(|body| body.get("error"))
4805            .and_then(|v| v.as_str())
4806            .map(str::to_string)
4807            .unwrap_or_else(|| bounded_response_text(body));
4808        Err(anyhow::anyhow!("Failed ({}): {}", status, err))
4809    }
4810}
4811
4812#[allow(clippy::too_many_arguments)]
4813fn run_time_metrics_report(
4814    json: bool,
4815    home_dir: Option<String>,
4816    clients: Option<Vec<String>>,
4817    since: Option<String>,
4818    until: Option<String>,
4819    year: Option<String>,
4820    no_spinner: bool,
4821) -> Result<()> {
4822    use tokio::runtime::Runtime;
4823    use tokmesh_core::{get_time_metrics_report, GroupBy, ReportOptions};
4824
4825    let had_cursor_cache = has_cursor_usage_cache_for_report(&home_dir);
4826    let explicit_cursor_filter = client_filter_explicitly_requests_cursor(&clients);
4827    let spinner = if no_spinner {
4828        None
4829    } else {
4830        Some(LightSpinner::start("Computing time metrics..."))
4831    };
4832    let cursor_sync_result = auto_sync_cursor_for_local_report(&home_dir, &clients);
4833    let cursor_setup_warnings = setup_warnings_for_report(&home_dir, &clients);
4834    let use_env_roots = use_env_roots(&home_dir);
4835    let rt = Runtime::new()?;
4836    let report = rt
4837        .block_on(async {
4838            get_time_metrics_report(ReportOptions {
4839                home_dir: home_dir.clone(),
4840                use_env_roots,
4841                clients,
4842                since,
4843                until,
4844                year,
4845                group_by: GroupBy::default(),
4846                scanner_settings: tui::settings::load_scanner_settings_for_home(&home_dir),
4847            })
4848            .await
4849        })
4850        .map_err(|e| anyhow::anyhow!(e))?;
4851
4852    if let Some(spinner) = spinner {
4853        spinner.stop();
4854    }
4855    emit_cursor_sync_warning(
4856        cursor_sync_result.as_ref(),
4857        had_cursor_cache,
4858        explicit_cursor_filter,
4859    );
4860
4861    let m = &report.metrics;
4862
4863    if json {
4864        #[derive(serde::Serialize)]
4865        #[serde(rename_all = "camelCase")]
4866        struct TimeMetricsReportJson<'a> {
4867            metrics: &'a tokmesh_core::TimeMetrics,
4868            processing_time_ms: u32,
4869            #[serde(skip_serializing_if = "Vec::is_empty")]
4870            warnings: Vec<String>,
4871        }
4872
4873        let output = TimeMetricsReportJson {
4874            metrics: &report.metrics,
4875            processing_time_ms: report.processing_time_ms,
4876            warnings: cursor_setup_warnings,
4877        };
4878        println!("{}", serde_json::to_string_pretty(&output)?);
4879    } else {
4880        emit_cursor_setup_warnings(&cursor_setup_warnings);
4881        println!("Session Time Metrics");
4882        println!("====================");
4883        println!(
4884            "Total active time:       {}",
4885            format_duration_ms(m.total_active_time_ms)
4886        );
4887        println!(
4888            "Total wall-clock time:   {}",
4889            format_duration_ms(m.total_wall_time_ms)
4890        );
4891        println!(
4892            "Longest continuous use:  {}",
4893            format_duration_ms(m.longest_continuous_ms)
4894        );
4895        println!("Max concurrent sessions: {}", m.max_concurrent_sessions);
4896        println!("Total sessions:          {}", m.session_count);
4897        println!("Processing time:         {}ms", report.processing_time_ms);
4898    }
4899
4900    Ok(())
4901}
4902
4903fn format_duration_ms(ms: i64) -> String {
4904    if ms <= 0 {
4905        return "0s".to_string();
4906    }
4907    let total_secs = ms / 1000;
4908    let hours = total_secs / 3600;
4909    let minutes = (total_secs % 3600) / 60;
4910    let secs = total_secs % 60;
4911
4912    if hours > 0 {
4913        format!("{}h {}m {}s", hours, minutes, secs)
4914    } else if minutes > 0 {
4915        format!("{}m {}s", minutes, secs)
4916    } else {
4917        format!("{}s", secs)
4918    }
4919}
4920
4921#[allow(clippy::too_many_arguments)]
4922fn run_graph_command(
4923    output: Option<String>,
4924    home_dir: Option<String>,
4925    clients: Option<Vec<String>>,
4926    since: Option<String>,
4927    until: Option<String>,
4928    year: Option<String>,
4929    benchmark: bool,
4930    no_spinner: bool,
4931) -> Result<()> {
4932    use colored::Colorize;
4933    use std::time::Instant;
4934    use tokmesh_core::{generate_local_graph_report, GroupBy, ReportOptions};
4935
4936    let show_progress = output.is_some() && !no_spinner;
4937    let had_cursor_cache = has_cursor_usage_cache_for_report(&home_dir);
4938    let explicit_cursor_filter = client_filter_explicitly_requests_cursor(&clients);
4939    let cursor_sync_result = auto_sync_cursor_for_local_report(&home_dir, &clients);
4940    let cursor_setup_warnings = setup_warnings_for_report(&home_dir, &clients);
4941
4942    if show_progress {
4943        eprintln!("  Scanning session data...");
4944    }
4945    let start = Instant::now();
4946
4947    if show_progress {
4948        eprintln!("  Generating graph data...");
4949    }
4950    let use_env_roots = use_env_roots(&home_dir);
4951    let rt = tokio::runtime::Runtime::new()?;
4952    let graph_result = rt
4953        .block_on(async {
4954            generate_local_graph_report(ReportOptions {
4955                home_dir: home_dir.clone(),
4956                use_env_roots,
4957                clients,
4958                since,
4959                until,
4960                year,
4961                group_by: GroupBy::default(),
4962                scanner_settings: tui::settings::load_scanner_settings_for_home(&home_dir),
4963            })
4964            .await
4965        })
4966        .map_err(|e| anyhow::anyhow!(e))?;
4967    emit_cursor_sync_warning(
4968        cursor_sync_result.as_ref(),
4969        had_cursor_cache,
4970        explicit_cursor_filter,
4971    );
4972    emit_cursor_setup_warnings(&cursor_setup_warnings);
4973
4974    let processing_time_ms = start.elapsed().as_millis() as u32;
4975    let output_data = to_ts_token_contribution_data(
4976        &graph_result,
4977        None,
4978        leaderboard::Leaderboard::Tokscale,
4979        None,
4980    );
4981    let json_output = serde_json::to_string_pretty(&output_data)?;
4982
4983    if let Some(output_path) = output {
4984        std::fs::write(&output_path, json_output)?;
4985
4986        eprintln!(
4987            "{}",
4988            format!("✓ Graph data written to {}", output_path).green()
4989        );
4990        eprintln!(
4991            "{}",
4992            format!(
4993                "  {} days, {} clients, {} models",
4994                output_data.contributions.len(),
4995                output_data.summary.clients.len(),
4996                output_data.summary.models.len()
4997            )
4998            .bright_black()
4999        );
5000        eprintln!(
5001            "{}",
5002            format!(
5003                "  Total: {}",
5004                format_currency(output_data.summary.total_cost)
5005            )
5006            .bright_black()
5007        );
5008
5009        if benchmark {
5010            eprintln!(
5011                "{}",
5012                format!("  Processing time: {}ms (Rust native)", processing_time_ms).bright_black()
5013            );
5014            if let Some(sync) = cursor_sync_result {
5015                if sync.synced {
5016                    eprintln!(
5017                        "{}",
5018                        format!(
5019                            "  Cursor: {} usage events synced (full lifetime data)",
5020                            sync.rows
5021                        )
5022                        .bright_black()
5023                    );
5024                } else if let Some(err) = sync.error {
5025                    if had_cursor_cache {
5026                        eprintln!("{}", format!("  Cursor: sync failed - {}", err).yellow());
5027                    }
5028                }
5029            }
5030        }
5031    } else {
5032        println!("{}", json_output);
5033    }
5034
5035    Ok(())
5036}
5037
5038/// Import a third-party aggregate export (currently clawdboard) and emit it as
5039/// standard tokmesh JSON — the same shape `tokmesh graph` produces.
5040///
5041/// This deliberately does NOT upload: backfilled aggregates cannot be verified
5042/// the way locally-scanned sessions are, so submitting them requires
5043/// server-side support for tagging backfilled data distinctly from live CLI
5044/// usage.
5045fn run_import_command(
5046    file: String,
5047    format: String,
5048    output: Option<String>,
5049    dry_run: bool,
5050) -> Result<()> {
5051    use colored::Colorize;
5052
5053    let fmt = format.trim().to_lowercase();
5054    if !commands::import::SUPPORTED_FORMATS.contains(&fmt.as_str()) {
5055        return Err(anyhow::anyhow!(
5056            "Unsupported import format '{}'. Supported: {}",
5057            format,
5058            commands::import::SUPPORTED_FORMATS.join(", ")
5059        ));
5060    }
5061
5062    // All human-readable banners/summaries/warnings go to stderr so stdout
5063    // stays pure JSON when no --output path is given (matching `tokmesh
5064    // graph`'s behavior) — e.g. `tokmesh import export.json > out.json`
5065    // must produce a valid JSON file.
5066    eprintln!("\n  {}\n", "Tokmesh - Import Usage Data".cyan());
5067
5068    let contents = std::fs::read_to_string(&file)
5069        .map_err(|e| anyhow::anyhow!("Failed to read '{}': {}", file, e))?;
5070    let outcome = commands::import::parse_export(&fmt, &contents)?;
5071    let graph = &outcome.graph;
5072
5073    eprintln!("{}", "  Imported data:".white());
5074    eprintln!(
5075        "{}",
5076        format!(
5077            "    Date range: {} to {}",
5078            graph.meta.date_range_start, graph.meta.date_range_end
5079        )
5080        .bright_black()
5081    );
5082    eprintln!(
5083        "{}",
5084        format!("    Active days: {}", graph.summary.active_days).bright_black()
5085    );
5086    eprintln!(
5087        "{}",
5088        format!(
5089            "    Total tokens: {}",
5090            format_tokens_with_commas(graph.summary.total_tokens)
5091        )
5092        .bright_black()
5093    );
5094    eprintln!(
5095        "{}",
5096        format!(
5097            "    Total cost: {}",
5098            format_currency(graph.summary.total_cost)
5099        )
5100        .bright_black()
5101    );
5102    if !graph.summary.clients.is_empty() {
5103        eprintln!(
5104            "{}",
5105            format!("    Clients: {}", graph.summary.clients.join(", ")).bright_black()
5106        );
5107    }
5108    eprintln!(
5109        "{}",
5110        format!("    Models: {}", graph.summary.models.len()).bright_black()
5111    );
5112
5113    if !outcome.unknown_clients.is_empty() {
5114        eprintln!(
5115            "\n  {}",
5116            format!(
5117                "Warning: unrecognized client id(s): {}. The leaderboard only \
5118                 accepts known clients, so these would be rejected on submit.",
5119                outcome.unknown_clients.join(", ")
5120            )
5121            .yellow()
5122        );
5123    }
5124
5125    if outcome.negative_values_clamped > 0 {
5126        eprintln!(
5127            "{}",
5128            format!(
5129                "\n  Warning: {} negative token/cost value(s) in the export were clamped to \
5130                 zero.",
5131                outcome.negative_values_clamped
5132            )
5133            .yellow()
5134        );
5135    }
5136
5137    if outcome.suspect_cost_rows > 0 {
5138        eprintln!(
5139            "{}",
5140            format!(
5141                "\n  Warning: {} modelBreakdown row(s) have cost > 0 but all token fields are \
5142                 0. The server rejects submissions shaped like this (\"Cost submitted without \
5143                 tokens\"), so these rows would be rejected if ever uploaded.",
5144                outcome.suspect_cost_rows
5145            )
5146            .yellow()
5147        );
5148    }
5149
5150    if outcome.future_dated_rows > 0 {
5151        eprintln!(
5152            "{}",
5153            format!(
5154                "\n  Warning: {} row(s) are dated in the future. The submit endpoint rejects \
5155                 dates too far ahead, so these rows would be rejected if ever uploaded.",
5156                outcome.future_dated_rows
5157            )
5158            .yellow()
5159        );
5160    }
5161
5162    if outcome.unparseable_cost_rows > 0 {
5163        eprintln!(
5164            "{}",
5165            format!(
5166                "\n  Warning: {} totalCost value(s) in the export could not be parsed and were \
5167                 treated as 0.",
5168                outcome.unparseable_cost_rows
5169            )
5170            .yellow()
5171        );
5172    }
5173
5174    if outcome.non_finite_cost_rows > 0 {
5175        eprintln!(
5176            "{}",
5177            format!(
5178                "\n  Warning: {} cost value(s) in the export were non-finite (NaN/Infinity) \
5179                 and were sanitized to 0.",
5180                outcome.non_finite_cost_rows
5181            )
5182            .yellow()
5183        );
5184    }
5185
5186    if outcome.multi_model_fallback_rows > 0 {
5187        eprintln!(
5188            "{}",
5189            format!(
5190                "\n  Warning: {} row(s) had no per-model breakdown and multiple models used; \
5191                 all usage in those rows was attributed to the first model only.",
5192                outcome.multi_model_fallback_rows
5193            )
5194            .yellow()
5195        );
5196    }
5197
5198    for warning in &outcome.breakdown_reconciliation_warnings {
5199        eprintln!("{}", format!("\n  Warning: {}", warning).yellow());
5200    }
5201
5202    if dry_run {
5203        eprintln!(
5204            "{}",
5205            "\n  Dry run - not emitting normalized JSON.\n".yellow()
5206        );
5207        return Ok(());
5208    }
5209
5210    let payload =
5211        to_ts_token_contribution_data(graph, None, leaderboard::Leaderboard::Tokscale, None);
5212    let json_output = serde_json::to_string_pretty(&payload)?;
5213
5214    if let Some(output_path) = output {
5215        std::fs::write(&output_path, json_output)?;
5216        eprintln!(
5217            "{}",
5218            format!("\n  ✓ Normalized tokmesh data written to {}", output_path).green()
5219        );
5220    } else {
5221        println!("{}", json_output);
5222    }
5223
5224    // Be explicit about the upload boundary so nobody assumes `import` puts
5225    // data on the leaderboard.
5226    eprintln!(
5227        "{}",
5228        "\n  Note: import only converts data to tokmesh's format; it does not \
5229         upload to the leaderboard.\n  Uploading backfilled history needs \
5230         server-side support for tagging it distinctly from live CLI usage.\n"
5231            .bright_black()
5232    );
5233
5234    Ok(())
5235}
5236
5237#[derive(Debug, Default, serde::Deserialize)]
5238struct SubmitResponse {
5239    #[serde(rename = "submissionId")]
5240    submission_id: Option<String>,
5241    #[allow(dead_code)]
5242    username: Option<String>,
5243    metrics: Option<SubmitMetrics>,
5244    warnings: Option<Vec<String>>,
5245    error: Option<String>,
5246    details: Option<Vec<String>>,
5247}
5248
5249#[derive(Debug, serde::Deserialize)]
5250struct SubmitMetrics {
5251    #[serde(rename = "totalTokens")]
5252    total_tokens: Option<i64>,
5253    #[serde(rename = "totalCost")]
5254    total_cost: Option<f64>,
5255    #[serde(rename = "activeDays")]
5256    active_days: Option<i32>,
5257    #[allow(dead_code)]
5258    sources: Option<Vec<String>>,
5259}
5260
5261#[derive(Debug)]
5262enum SubmitResponseOutcome {
5263    Success(SubmitResponse),
5264    Failure { error: String, details: Vec<String> },
5265}
5266
5267fn bounded_response_text(body: &str) -> String {
5268    const MAX_CHARS: usize = 300;
5269    let cleaned: String = body
5270        .chars()
5271        .filter(|character| !character.is_control())
5272        .take(MAX_CHARS)
5273        .collect();
5274    if cleaned.is_empty() {
5275        "Unknown error".to_string()
5276    } else if body
5277        .chars()
5278        .filter(|character| !character.is_control())
5279        .count()
5280        > MAX_CHARS
5281    {
5282        format!("{cleaned}...")
5283    } else {
5284        cleaned
5285    }
5286}
5287
5288fn interpret_submit_response(status: reqwest::StatusCode, body: &str) -> SubmitResponseOutcome {
5289    let parsed = serde_json::from_str::<SubmitResponse>(body).ok();
5290    if status.is_success() {
5291        return SubmitResponseOutcome::Success(parsed.unwrap_or_default());
5292    }
5293
5294    let error = parsed
5295        .as_ref()
5296        .and_then(|response| response.error.clone())
5297        .unwrap_or_else(|| bounded_response_text(body));
5298    let details = parsed
5299        .and_then(|response| response.details)
5300        .unwrap_or_default();
5301    SubmitResponseOutcome::Failure { error, details }
5302}
5303
5304/// A client row dropped from a submission because it carried cost without any
5305/// token attribution. See [`exclude_tokenless_cost_contributions`].
5306#[derive(Debug, Clone, PartialEq)]
5307struct ExcludedTokenlessRow {
5308    date: String,
5309    client: String,
5310    model_id: String,
5311    provider_id: String,
5312    cost: f64,
5313}
5314
5315fn client_token_total(tokens: &tokmesh_core::TokenBreakdown) -> i64 {
5316    // TokenBreakdown::total() already saturating_adds its fields so a clamped
5317    // (i64::MAX) bucket from a corrupt source can't overflow this display fold.
5318    tokens.total()
5319}
5320
5321/// Cursor's pre-2025-05 exports include `premium-tool-call` rows billed per
5322/// tool invocation with no token attribution. The server grandfathers these
5323/// (cost > 0, tokens = 0) rather than rejecting them, so the client must not
5324/// drop them either — otherwise that legitimate cost silently disappears from
5325/// the submission. Keep in sync with `CURSOR_LEGACY_TOKENLESS_MODELS` in
5326/// packages/frontend/src/lib/validation/submission.ts.
5327fn is_legacy_tokenless_cursor_row(client: &tokmesh_core::ClientContribution) -> bool {
5328    client.client == "cursor"
5329        && client.model_id == "premium-tool-call"
5330        && client_token_total(&client.tokens) == 0
5331}
5332
5333fn is_aggregate_only_warp_row(client: &tokmesh_core::ClientContribution) -> bool {
5334    client.client == "warp"
5335        && client.model_id == "aggregate-requests"
5336        && client_token_total(&client.tokens) == 0
5337}
5338
5339/// A row the server's "Cost submitted without tokens" sanity check would
5340/// reject: real cost with every token bucket at zero, excluding the Cursor
5341/// `premium-tool-call` carve-out above.
5342fn is_tokenless_costed_row(client: &tokmesh_core::ClientContribution) -> bool {
5343    (is_aggregate_only_warp_row(client) || client.cost > 0.0)
5344        && client_token_total(&client.tokens) == 0
5345        && !is_legacy_tokenless_cursor_row(client)
5346}
5347
5348/// Drop client rows that report cost without any tokens so the submission
5349/// passes the server's cost-without-tokens validation instead of being
5350/// rejected wholesale.
5351///
5352/// Cursor's usage export lists historical request/On-Demand charges (e.g.
5353/// `auto`, `claude-3.5-sonnet`, `o3`) with empty token columns, and Warp/Oz
5354/// only exposes aggregate request/spend counters. The server rejects cost with
5355/// no tokens, and request counts must not be submitted as fabricated tokens, so
5356/// we exclude the offending rows here and report them to the user.
5357///
5358/// Excluded rows always carry zero tokens, so only cost/messages change; token
5359/// totals, breakdowns, and intensities are untouched. Summary and year rollups
5360/// are recomputed from the trimmed contributions.
5361fn exclude_tokenless_cost_contributions(
5362    graph_result: &mut tokmesh_core::GraphResult,
5363) -> Vec<ExcludedTokenlessRow> {
5364    let mut excluded: Vec<ExcludedTokenlessRow> = Vec::new();
5365
5366    for day in graph_result.contributions.iter_mut() {
5367        let date = day.date.clone();
5368        let mut removed_cost = 0.0;
5369        let mut removed_messages: i32 = 0;
5370
5371        day.clients.retain(|client| {
5372            if is_tokenless_costed_row(client) {
5373                excluded.push(ExcludedTokenlessRow {
5374                    date: date.clone(),
5375                    client: client.client.clone(),
5376                    model_id: client.model_id.clone(),
5377                    provider_id: client.provider_id.clone(),
5378                    cost: client.cost,
5379                });
5380                removed_cost += client.cost;
5381                removed_messages = removed_messages.saturating_add(client.messages);
5382                false
5383            } else {
5384                true
5385            }
5386        });
5387
5388        if removed_cost > 0.0 || removed_messages > 0 {
5389            day.totals.cost = (day.totals.cost - removed_cost).max(0.0);
5390            day.totals.messages = day.totals.messages.saturating_sub(removed_messages).max(0);
5391        }
5392    }
5393
5394    if !excluded.is_empty() {
5395        graph_result.summary = tokmesh_core::calculate_summary(&graph_result.contributions);
5396        graph_result.years = tokmesh_core::calculate_years(&graph_result.contributions);
5397    }
5398
5399    excluded
5400}
5401
5402/// Print the rows dropped by [`exclude_tokenless_cost_contributions`] so the
5403/// user can see exactly what was left out, capping the per-row detail so a long
5404/// history of legacy Cursor charges doesn't flood the terminal.
5405fn report_excluded_tokenless_rows(excluded: &[ExcludedTokenlessRow]) {
5406    use colored::Colorize;
5407
5408    if excluded.is_empty() {
5409        return;
5410    }
5411
5412    const MAX_DETAIL_ROWS: usize = 20;
5413    let total_cost: f64 = excluded.iter().map(|row| row.cost).sum();
5414
5415    println!(
5416        "{}",
5417        format!(
5418            "  Excluded {} aggregate/cost-only row(s) with no token data:",
5419            excluded.len()
5420        )
5421        .yellow()
5422    );
5423
5424    for row in excluded.iter().take(MAX_DETAIL_ROWS) {
5425        let provider = if row.provider_id.is_empty() {
5426            String::new()
5427        } else {
5428            format!(" (provider={})", row.provider_id)
5429        };
5430        println!(
5431            "{}",
5432            format!(
5433                "    - {}/{}{} on {}: ${:.4}",
5434                row.client, row.model_id, provider, row.date, row.cost
5435            )
5436            .bright_black()
5437        );
5438    }
5439
5440    if excluded.len() > MAX_DETAIL_ROWS {
5441        println!(
5442            "{}",
5443            format!("    ... and {} more", excluded.len() - MAX_DETAIL_ROWS).bright_black()
5444        );
5445    }
5446
5447    println!(
5448        "{}",
5449        format!(
5450            "    Excluded {} total; the rest is submitted.",
5451            format_currency(total_cost)
5452        )
5453        .bright_black()
5454    );
5455    println!();
5456}
5457
5458#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5459enum SubmitMode {
5460    Interactive,
5461    Autosubmit,
5462}
5463
5464struct SubmitCommandOptions {
5465    clients: Option<Vec<String>>,
5466    since: Option<String>,
5467    until: Option<String>,
5468    year: Option<String>,
5469    dry_run: bool,
5470    dry_run_json: bool,
5471    replacement: Option<SubmitReplacementCoverage>,
5472    mode: SubmitMode,
5473}
5474
5475fn run_autosubmit_command(
5476    board: leaderboard::Leaderboard,
5477    subcommand: commands::autosubmit::AutosubmitSubcommand,
5478) -> Result<()> {
5479    use commands::autosubmit::{AutosubmitRunDecision, AutosubmitSubcommand};
5480
5481    match subcommand {
5482        AutosubmitSubcommand::Enable(args) => commands::autosubmit::enable(board, args),
5483        AutosubmitSubcommand::Status { json } => commands::autosubmit::status(board, json),
5484        AutosubmitSubcommand::Disable => commands::autosubmit::disable(board),
5485        AutosubmitSubcommand::Run { force } => {
5486            let Some(_lock) = commands::autosubmit::try_acquire_run_lock(board)? else {
5487                println!("Autosubmit is already running.");
5488                return Ok(());
5489            };
5490            let now_ms = chrono::Utc::now().timestamp_millis();
5491            let (settings, decision) = commands::autosubmit::load_run_config(board, force, now_ms)?;
5492            match decision {
5493                AutosubmitRunDecision::Disabled => {
5494                    println!("Autosubmit is disabled.");
5495                    return Ok(());
5496                }
5497                AutosubmitRunDecision::NotDue { next_run_at_ms } => {
5498                    println!(
5499                        "Autosubmit is not due yet. Next run: {}.",
5500                        commands::autosubmit::format_timestamp_ms(next_run_at_ms)
5501                    );
5502                    return Ok(());
5503                }
5504                AutosubmitRunDecision::Due => {}
5505            }
5506
5507            let (clients, since, until, year) = commands::autosubmit::submit_filters(&settings);
5508            match run_submit_command(
5509                board,
5510                SubmitCommandOptions {
5511                    clients,
5512                    since,
5513                    until,
5514                    year,
5515                    dry_run: false,
5516                    dry_run_json: false,
5517                    replacement: None,
5518                    mode: SubmitMode::Autosubmit,
5519                },
5520            ) {
5521                Ok(()) => {
5522                    commands::autosubmit::record_run_success(
5523                        board,
5524                        chrono::Utc::now().timestamp_millis(),
5525                    )?;
5526                    Ok(())
5527                }
5528                Err(err) => {
5529                    let message = err.to_string();
5530                    let _ = commands::autosubmit::record_run_error(board, &message);
5531                    Err(err)
5532                }
5533            }
5534        }
5535    }
5536}
5537
5538fn run_submit_command(
5539    board: leaderboard::Leaderboard,
5540    options: SubmitCommandOptions,
5541) -> Result<()> {
5542    use colored::Colorize;
5543    use tokio::runtime::Runtime;
5544    use tokmesh_core::{generate_graph, GroupBy, ReportOptions};
5545
5546    let SubmitCommandOptions {
5547        clients,
5548        since,
5549        until,
5550        year,
5551        dry_run,
5552        dry_run_json,
5553        replacement,
5554        mode,
5555    } = options;
5556
5557    let auth_token = if dry_run {
5558        None
5559    } else {
5560        match auth::resolve_api_token(board) {
5561            Some(token) => Some(token),
5562            None => {
5563                if mode == SubmitMode::Autosubmit {
5564                    return Err(anyhow::anyhow!(
5565                        "Autosubmit requires login to {}. Run `tokmesh {} login` or set {}.",
5566                        board,
5567                        board.as_str(),
5568                        board.api_token_env()
5569                    ));
5570                }
5571                eprintln!("\n  {}", format!("Not logged in to {}.", board).yellow());
5572                eprintln!(
5573                    "{}",
5574                    format!(
5575                        "  Run 'tokmesh {} login' or set {}.\n",
5576                        board.as_str(),
5577                        board.api_token_env()
5578                    )
5579                    .bright_black()
5580                );
5581                std::process::exit(1);
5582            }
5583        }
5584    };
5585
5586    if !dry_run_json {
5587        println!("\n  {}\n", format!("Tokmesh → {} — Submit", board).cyan());
5588    }
5589
5590    let explicit_cursor_filter = client_filter_explicitly_requests_cursor(&clients);
5591    let explicit_warp_filter = client_filter_explicitly_requests_warp(&clients);
5592    let clients = clients.or_else(|| Some(default_submit_clients()));
5593
5594    let include_cursor = clients
5595        .as_ref()
5596        .is_none_or(|s| s.iter().any(|src| src == "cursor"));
5597    let report_home: Option<String> = None;
5598    let has_cursor_cache = has_cursor_usage_cache_for_report(&report_home);
5599    if include_cursor && cursor::is_cursor_logged_in() {
5600        if !dry_run_json {
5601            println!("{}", "  Syncing Cursor usage data...".bright_black());
5602        }
5603        let rt_sync = Runtime::new()?;
5604        let sync_result = rt_sync.block_on(async { cursor::sync_cursor_cache().await });
5605        if sync_result.synced && !dry_run_json {
5606            println!(
5607                "{}",
5608                format!("  Cursor: {} usage events synced", sync_result.rows).bright_black()
5609            );
5610        } else if let Some(err) = sync_result.error {
5611            if has_cursor_cache && !dry_run_json {
5612                println!(
5613                    "{}",
5614                    format!("  Cursor sync failed; using cached data: {}", err).yellow()
5615                );
5616            }
5617        }
5618    }
5619    if explicit_cursor_filter || explicit_warp_filter {
5620        let cursor_setup_warnings = setup_warnings_for_report(&report_home, &clients);
5621        emit_cursor_setup_warnings(&cursor_setup_warnings);
5622    }
5623
5624    if !dry_run_json {
5625        println!("{}", "  Scanning local session data...".bright_black());
5626    }
5627
5628    let rt = Runtime::new()?;
5629    let mut graph_result = rt
5630        .block_on(async {
5631            generate_graph(ReportOptions {
5632                home_dir: None,
5633                use_env_roots: true,
5634                clients,
5635                since,
5636                until,
5637                year,
5638                group_by: GroupBy::default(),
5639                scanner_settings: tui::settings::load_scanner_settings(),
5640            })
5641            .await
5642        })
5643        .map_err(|e| anyhow::anyhow!(e))?;
5644
5645    // Preserve local-calendar contributions here. The API validator owns the
5646    // UTC+ timezone buffer; client-side UTC capping silently drops current-day
5647    // usage for users east of UTC.
5648    // Drop cost-only rows the server would reject (Cursor historical exports
5649    // record per-request cost with empty token columns) and report what was
5650    // left out, so a single legacy charge can't block the whole submission.
5651    let excluded_rows = exclude_tokenless_cost_contributions(&mut graph_result);
5652    if !dry_run_json {
5653        report_excluded_tokenless_rows(&excluded_rows);
5654    }
5655    validate_replacement_contributions(&graph_result, replacement.as_ref())?;
5656
5657    if !dry_run_json {
5658        println!("{}", "  Data to submit:".white());
5659        println!(
5660            "{}",
5661            format!(
5662                "    Date range: {} to {}",
5663                graph_result.meta.date_range_start, graph_result.meta.date_range_end,
5664            )
5665            .bright_black()
5666        );
5667        println!(
5668            "{}",
5669            format!("    Active days: {}", graph_result.summary.active_days).bright_black()
5670        );
5671        println!(
5672            "{}",
5673            format!(
5674                "    Total tokens: {}",
5675                format_tokens_with_commas(graph_result.summary.total_tokens)
5676            )
5677            .bright_black()
5678        );
5679        println!(
5680            "{}",
5681            format!(
5682                "    Total cost: {}",
5683                format_currency(graph_result.summary.total_cost)
5684            )
5685            .bright_black()
5686        );
5687        println!(
5688            "{}",
5689            format!("    Clients: {}", graph_result.summary.clients.join(", ")).bright_black()
5690        );
5691        println!(
5692            "{}",
5693            format!("    Models: {} models", graph_result.summary.models.len()).bright_black()
5694        );
5695        println!();
5696    }
5697
5698    if graph_result.summary.total_tokens == 0 {
5699        if dry_run_json {
5700            // Still emit an empty-ish payload path? Prefer explicit error for scripts.
5701            anyhow::bail!("No usage data found to submit.");
5702        }
5703        println!("{}", "  No usage data found to submit.\n".yellow());
5704        return Ok(());
5705    }
5706
5707    let submit_device = device::resolve_submit_device()?;
5708    let submit_payload = to_ts_token_contribution_data(
5709        &graph_result,
5710        Some(&submit_device),
5711        board,
5712        replacement.as_ref(),
5713    );
5714
5715    if dry_run {
5716        if dry_run_json {
5717            println!("{}", serde_json::to_string_pretty(&submit_payload)?);
5718        } else {
5719            println!("{}", "  Dry run - not submitting data.\n".yellow());
5720        }
5721        return Ok(());
5722    }
5723
5724    println!("{}", "  Submitting to server...".bright_black());
5725
5726    let api_url = auth::get_api_base_url(board);
5727    let auth_token = auth_token.expect("non-dry-run submit must resolve authentication");
5728    let client = build_leaderboard_http_client(std::time::Duration::from_secs(120))?;
5729
5730    let response = rt.block_on(async {
5731        client
5732            .post(format!("{}/api/submit", api_url))
5733            .header("Content-Type", "application/json")
5734            .header("Authorization", format!("Bearer {}", auth_token.token))
5735            .json(&submit_payload)
5736            .send()
5737            .await
5738    });
5739
5740    match response {
5741        Ok(resp) => {
5742            let status = resp.status();
5743            let response_body = rt.block_on(read_leaderboard_response_body(resp, "submit"))?;
5744            let body = match interpret_submit_response(status, &response_body) {
5745                SubmitResponseOutcome::Success(body) => body,
5746                SubmitResponseOutcome::Failure { error, details } => {
5747                    eprintln!("\n  {}", format!("Error: {}", error).red());
5748                    for detail in details {
5749                        eprintln!("{}", format!("    - {}", detail).bright_black());
5750                    }
5751                    println!();
5752                    if mode == SubmitMode::Autosubmit {
5753                        return Err(anyhow::anyhow!(error));
5754                    }
5755                    std::process::exit(1);
5756                }
5757            };
5758
5759            println!("\n  {}", "Successfully submitted!".green());
5760            println!();
5761            println!("{}", "  Summary:".white());
5762            if let Some(id) = body.submission_id {
5763                println!("{}", format!("    Submission ID: {}", id).bright_black());
5764            }
5765            if let Some(metrics) = &body.metrics {
5766                if let Some(tokens) = metrics.total_tokens {
5767                    println!(
5768                        "{}",
5769                        format!("    Total tokens: {}", format_tokens_with_commas(tokens))
5770                            .bright_black()
5771                    );
5772                }
5773                if let Some(cost) = metrics.total_cost {
5774                    println!(
5775                        "{}",
5776                        format!("    Total cost: {}", format_currency(cost)).bright_black()
5777                    );
5778                }
5779                if let Some(days) = metrics.active_days {
5780                    println!("{}", format!("    Active days: {}", days).bright_black());
5781                }
5782            }
5783            if let Some(username) = body
5784                .username
5785                .clone()
5786                .or_else(|| auth_token.username.clone())
5787            {
5788                println!();
5789                println!(
5790                    "{}",
5791                    osc8_link_with_text(
5792                        &format!("{}/u/{}", api_url, username),
5793                        &format!("  View your profile: {}/u/{}", api_url, username),
5794                    )
5795                    .cyan()
5796                );
5797                println!();
5798            }
5799
5800            if let Some(warnings) = body.warnings {
5801                if !warnings.is_empty() {
5802                    println!("{}", "  Warnings:".yellow());
5803                    for warning in warnings {
5804                        println!("{}", format!("    - {}", warning).bright_black());
5805                    }
5806                    println!();
5807                }
5808            }
5809        }
5810        Err(err) => {
5811            eprintln!("\n  {}", "Error: Failed to connect to server.".red());
5812            eprintln!("{}\n", format!("  {}", err).bright_black());
5813            if mode == SubmitMode::Autosubmit {
5814                return Err(anyhow::anyhow!("Failed to connect to server: {err}"));
5815            }
5816            std::process::exit(1);
5817        }
5818    }
5819
5820    // Warm the TUI cache so the next `tokmesh` launch is instant.
5821    // Detached subprocess so submit returns to the shell immediately on large
5822    // datasets — a full re-scan would otherwise block for tens of seconds.
5823    if mode == SubmitMode::Interactive {
5824        spawn_warm_tui_cache_detached();
5825    }
5826
5827    Ok(())
5828}
5829
5830fn spawn_warm_tui_cache_detached() {
5831    use std::process::{Command, Stdio};
5832
5833    let exe = match std::env::current_exe() {
5834        Ok(p) => p,
5835        Err(_) => return,
5836    };
5837
5838    let mut cmd = Command::new(exe);
5839    cmd.arg("warm-tui-cache")
5840        .stdin(Stdio::null())
5841        .stdout(Stdio::null())
5842        .stderr(Stdio::null());
5843
5844    #[cfg(unix)]
5845    {
5846        use std::os::unix::process::CommandExt;
5847        // New process group so the child is not killed by Ctrl-C in the
5848        // parent's shell and survives after submit exits.
5849        cmd.process_group(0);
5850    }
5851
5852    let _ = cmd.spawn();
5853}
5854
5855/// Resolve the filter set used by a no-`--client`-flag TUI launch.
5856///
5857/// Mirrors the resolution that `build_client_filter` + `tui::run` perform
5858/// when the user passes no CLI client flag:
5859///
5860/// 1. If `defaultClients` from `~/.config/tokmesh/settings.json` is
5861///    set, use that (after dropping unknown ids).
5862/// 2. Otherwise fall back to `ClientFilter::default_set()` (every real
5863///    client, Synthetic excluded).
5864///
5865/// This **must** stay in lockstep with the resolution that
5866/// `tui::run(.., clients = None, ..)` would compute. If it drifts, the
5867/// `submit` warm cache uses one filter set while the next no-flag TUI
5868/// launch wants another, the cache key mismatches, and the warming
5869/// becomes a wasted background scan.
5870fn resolve_default_tui_filter_set() -> std::collections::HashSet<ClientFilter> {
5871    resolve_default_tui_filter_set_with(&tui::settings::load_default_clients())
5872}
5873
5874/// Pure variant of `resolve_default_tui_filter_set` for unit-testable
5875/// resolution. `configured` is the (raw, pre-validation) list of ids
5876/// from settings.json.
5877fn resolve_default_tui_filter_set_with(
5878    configured: &[String],
5879) -> std::collections::HashSet<ClientFilter> {
5880    let parsed: Vec<ClientFilter> = configured
5881        .iter()
5882        .filter_map(|s| ClientFilter::from_filter_str(s))
5883        .collect();
5884    if parsed.is_empty() {
5885        ClientFilter::default_set()
5886    } else {
5887        parsed.into_iter().collect()
5888    }
5889}
5890
5891fn resolve_should_write_cache(
5892    cli_write: bool,
5893    cli_no_write: bool,
5894    settings: &tui::settings::Settings,
5895) -> bool {
5896    if cli_no_write {
5897        return false;
5898    }
5899    if cli_write {
5900        return true;
5901    }
5902    settings.light.write_cache
5903}
5904
5905fn resolve_light_cache_filter_set(
5906    clients: &Option<Vec<String>>,
5907) -> std::collections::HashSet<ClientFilter> {
5908    if let Some(clients) = clients {
5909        clients
5910            .iter()
5911            .filter_map(|client| ClientFilter::from_filter_str(client))
5912            .collect()
5913    } else {
5914        resolve_default_tui_filter_set()
5915    }
5916}
5917
5918fn write_light_cache(
5919    home_dir: &Option<String>,
5920    clients: &Option<Vec<String>>,
5921    since: &Option<String>,
5922    until: &Option<String>,
5923    year: &Option<String>,
5924    group_by: &tokmesh_core::GroupBy,
5925) {
5926    use crate::tui::{save_cached_data, CacheReportScope, DataLoader};
5927
5928    // The TUI cache key includes date filters, but not `--home`. Writing
5929    // home-scoped data would still poison the default cache, so keep that
5930    // guard until home is part of the cache key.
5931    if home_dir.is_some() {
5932        eprintln!(
5933            "tokmesh: --write-cache skipped because --home is set; \
5934             the TUI cache key does not include that filter and writing would poison future TUI launches."
5935        );
5936        return;
5937    }
5938
5939    let enabled_set = resolve_light_cache_filter_set(clients);
5940    let scan_clients: Vec<tokmesh_core::ClientId> = enabled_set
5941        .iter()
5942        .filter_map(|filter| filter.to_client_id())
5943        .collect();
5944    let include_synthetic = enabled_set.contains(&ClientFilter::Synthetic);
5945
5946    // Cache writes are best-effort: the report has already been flushed
5947    // to stdout by the time we reach here, so a scan failure from the
5948    // background loader must NOT propagate up and turn a successful
5949    // user-visible report into a non-zero exit code. Mirrors the
5950    // pattern in `run_warm_tui_cache` below.
5951    let loader = DataLoader::with_filters(None, since.clone(), until.clone(), year.clone());
5952    let report_scope = CacheReportScope::new(since.clone(), until.clone(), year.clone());
5953    if let Ok(data) = loader.load(&scan_clients, group_by, include_synthetic) {
5954        save_cached_data(&data, &enabled_set, group_by, &report_scope);
5955    }
5956}
5957
5958fn run_warm_tui_cache() -> Result<()> {
5959    use crate::tui::{save_cached_data, CacheReportScope, DataLoader, TUI_DEFAULT_GROUP_BY};
5960    use tokmesh_core::ClientId;
5961
5962    // Warm the cache using the same default filter set the TUI uses on
5963    // a no-flag launch. Going through `resolve_default_tui_filter_set()`
5964    // keeps these two paths in lockstep — including the user's
5965    // `defaultClients` setting, which the TUI honors via
5966    // `build_client_filter`. If they drift, every TUI launch after
5967    // `submit` becomes a cache miss instead of a fresh hit, defeating
5968    // the warming.
5969    //
5970    // The `group_by` MUST be `TUI_DEFAULT_GROUP_BY`, NOT
5971    // `GroupBy::default()`. Using `GroupBy::default()` here is the bug
5972    // that motivated this constant — the TUI's cache reader keys on
5973    // `TUI_DEFAULT_GROUP_BY` (= `GroupBy::Model`) while
5974    // `GroupBy::default()` is `GroupBy::ClientModel`, so the warm cache
5975    // was written under a key the TUI never queried. Every submit
5976    // silently invalidated the next TUI launch.
5977    let enabled_set = resolve_default_tui_filter_set();
5978    let scan_clients: Vec<ClientId> = enabled_set
5979        .iter()
5980        .filter_map(|f| f.to_client_id())
5981        .collect();
5982    let include_synthetic = enabled_set.contains(&ClientFilter::Synthetic);
5983    let loader = DataLoader::with_filters(None, None, None, None);
5984    if let Ok(data) = loader.load(&scan_clients, &TUI_DEFAULT_GROUP_BY, include_synthetic) {
5985        save_cached_data(
5986            &data,
5987            &enabled_set,
5988            &TUI_DEFAULT_GROUP_BY,
5989            &CacheReportScope::default(),
5990        );
5991    }
5992    Ok(())
5993}
5994
5995fn run_cursor_command(subcommand: CursorSubcommand) -> Result<()> {
5996    match subcommand {
5997        CursorSubcommand::Login { name } => cursor::run_cursor_login(name),
5998        CursorSubcommand::Logout {
5999            name,
6000            all,
6001            purge_cache,
6002        } => cursor::run_cursor_logout(name, all, purge_cache),
6003        CursorSubcommand::Status { name } => cursor::run_cursor_status(name),
6004        CursorSubcommand::Accounts { json } => cursor::run_cursor_accounts(json),
6005        CursorSubcommand::Sync { json } => cursor::run_cursor_sync(json),
6006        CursorSubcommand::Switch { name } => cursor::run_cursor_switch(&name),
6007    }
6008}
6009
6010fn run_codex_command(subcommand: CodexSubcommand) -> Result<()> {
6011    match subcommand {
6012        CodexSubcommand::Import { name } => commands::usage::codex::run_codex_import(name),
6013        CodexSubcommand::Accounts { json } => commands::usage::codex::run_codex_accounts(json),
6014        CodexSubcommand::Switch { name } => commands::usage::codex::run_codex_switch(&name),
6015        CodexSubcommand::Remove { name } => commands::usage::codex::run_codex_remove(&name),
6016        CodexSubcommand::Status { name, json } => {
6017            commands::usage::codex::run_codex_status(name, json)
6018        }
6019        CodexSubcommand::Activity { json } => commands::codex_activity::run(json),
6020    }
6021}
6022
6023fn run_antigravity_command(subcommand: AntigravitySubcommand) -> Result<()> {
6024    match subcommand {
6025        AntigravitySubcommand::Sync => antigravity::run_antigravity_sync(),
6026        AntigravitySubcommand::Status { json } => antigravity::run_antigravity_status(json),
6027        AntigravitySubcommand::PurgeCache => antigravity::run_antigravity_purge_cache(),
6028    }
6029}
6030
6031/// Parse `--variant` into a typed value.
6032///
6033/// Returns:
6034/// - `Ok(Some(v))` when a recognized value was provided
6035/// - `Ok(None)` when the flag was omitted entirely
6036/// - `Err` when an unrecognized value was provided
6037///
6038/// The earlier version returned `Option<_>` and merged the "unrecognized" and
6039/// "omitted" cases, which let callers silently fall through to "all variants"
6040/// when the user typed something like `--variant slo` — they got every variant
6041/// touched instead of an error.
6042fn parse_variant_arg(arg: Option<&str>) -> Result<Option<trae::auth::TraeVariant>> {
6043    match arg {
6044        Some("solo") => Ok(Some(trae::auth::TraeVariant::Solo)),
6045        Some("ide") => Ok(Some(trae::auth::TraeVariant::Ide)),
6046        Some(other) => anyhow::bail!("unknown variant: {other}, valid values: solo, ide"),
6047        None => Ok(None),
6048    }
6049}
6050
6051fn run_trae_command(subcommand: TraeSubcommand) -> Result<()> {
6052    use colored::Colorize;
6053    let rt = tokio::runtime::Runtime::new()?;
6054
6055    match subcommand {
6056        TraeSubcommand::Login { manual, variant } => {
6057            if manual {
6058                use std::io::{self, Write};
6059                // Default to international Solo when `--variant` is omitted.
6060                let selected =
6061                    parse_variant_arg(variant.as_deref())?.unwrap_or(trae::auth::TraeVariant::Solo);
6062                println!();
6063                println!("  {}", "Trae Manual Token Login".cyan());
6064                println!(
6065                    "  {}",
6066                    "Paste your JWT access token from the browser DevTools:".bright_black()
6067                );
6068                println!(
6069                    "  {}",
6070                    "1. Open https://www.trae.ai/account-setting#usage".bright_black()
6071                );
6072                println!(
6073                    "  {}",
6074                    "2. F12 → Network → filter 'query_user_usage' → copy Authorization value"
6075                        .bright_black()
6076                );
6077                print!("  Token: ");
6078                io::stdout().flush()?;
6079                let mut token = String::new();
6080                io::stdin().read_line(&mut token)?;
6081                let token = token.trim().to_string();
6082                if token.is_empty() {
6083                    anyhow::bail!("token must not be empty");
6084                }
6085                trae::auth::save_manual_token(selected, token, None)?;
6086                println!(
6087                    "\n  {}",
6088                    format!("Token saved for {}", selected.client_str()).green()
6089                );
6090            } else {
6091                let variants: Vec<trae::auth::TraeVariant> =
6092                    match parse_variant_arg(variant.as_deref())? {
6093                        Some(v) => vec![v],
6094                        None => trae::auth::all_variants().to_vec(),
6095                    };
6096
6097                let mut any_success = false;
6098                for v in variants {
6099                    match rt.block_on(trae::auth::resolve_token(v)) {
6100                        Ok(_) => {
6101                            println!("  {} logged in (auto-detected)", v.client_str().green());
6102                            any_success = true;
6103                        }
6104                        Err(e) => {
6105                            println!("  {} auto-login failed: {}", v.client_str().yellow(), e);
6106                        }
6107                    }
6108                }
6109                if !any_success {
6110                    println!(
6111                        "  {}",
6112                        "No Trae credentials found. Use --manual to paste a token by hand."
6113                            .yellow()
6114                    );
6115                }
6116            }
6117            Ok(())
6118        }
6119        TraeSubcommand::Logout { variant } => {
6120            let variants: Vec<trae::auth::TraeVariant> =
6121                match parse_variant_arg(variant.as_deref())? {
6122                    Some(v) => vec![v],
6123                    None => trae::auth::all_variants().to_vec(),
6124                };
6125            for v in variants {
6126                trae::auth::logout(v)?;
6127                println!("  {} logged out", v.client_str().green());
6128            }
6129            Ok(())
6130        }
6131        TraeSubcommand::Status { json } => {
6132            let mut status = serde_json::Map::new();
6133            for v in trae::auth::all_variants() {
6134                let has = trae::auth::has_credentials(v);
6135                if json {
6136                    status.insert(v.client_str().to_string(), serde_json::Value::Bool(has));
6137                } else {
6138                    println!(
6139                        "  {}: {}",
6140                        v.client_str(),
6141                        if has {
6142                            "authenticated".green()
6143                        } else {
6144                            "not authenticated".yellow()
6145                        }
6146                    );
6147                }
6148            }
6149            if json {
6150                println!("{}", serde_json::to_string_pretty(&status)?);
6151            }
6152            Ok(())
6153        }
6154        TraeSubcommand::Sync { since, include_aux } => {
6155            let days = since.unwrap_or(30);
6156            // Negative `days` would compute `now - (negative * 86400)` → a
6157            // future `start_time`, and zero collapses the query window to an
6158            // empty range. Reject both at the CLI boundary instead of
6159            // forwarding garbage to the sync layer.
6160            if days <= 0 {
6161                anyhow::bail!("--since must be a positive number of days (got {days})");
6162            }
6163            // Trae IDE and Trae Solo share account-level usage data, so we
6164            // always sync once using whichever credential source is available.
6165            let variants: Vec<trae::auth::TraeVariant> = trae::auth::all_variants()
6166                .into_iter()
6167                .filter(|v| trae::auth::has_credentials(*v))
6168                .collect();
6169            rt.block_on(trae::sync::run_trae_sync(&variants, days, include_aux))
6170        }
6171    }
6172}
6173
6174fn run_warp_command(subcommand: WarpSubcommand) -> Result<()> {
6175    match subcommand {
6176        WarpSubcommand::Login { token, cookie } => warp::run_warp_login(token, cookie),
6177        WarpSubcommand::Logout { purge_cache } => warp::run_warp_logout(purge_cache),
6178        WarpSubcommand::Status { json } => warp::run_warp_status(json),
6179        WarpSubcommand::Sync { json } => warp::run_warp_sync(json),
6180    }
6181}
6182
6183fn format_tokens_with_commas(n: i64) -> String {
6184    let s = n.to_string();
6185    let bytes = s.as_bytes();
6186    let len = bytes.len();
6187    let mut result = String::with_capacity(len + len / 3);
6188    for (i, &b) in bytes.iter().enumerate() {
6189        if i > 0 && (len - i).is_multiple_of(3) {
6190            result.push(',');
6191        }
6192        result.push(b as char);
6193    }
6194    result
6195}
6196
6197struct CaptureCommandOutcome {
6198    exit_code: i32,
6199    timed_out: bool,
6200}
6201
6202fn run_capture_command(
6203    command: &str,
6204    args: &[String],
6205    output_path: &Path,
6206    timeout: Duration,
6207) -> Result<CaptureCommandOutcome> {
6208    use std::io::{Read, Write};
6209    use std::process::Command;
6210    use std::thread;
6211    use std::time::Instant;
6212
6213    let mut child = Command::new(command)
6214        .args(args)
6215        .stdout(std::process::Stdio::piped())
6216        .stderr(std::process::Stdio::inherit())
6217        .stdin(std::process::Stdio::inherit())
6218        .spawn()
6219        .map_err(|e| anyhow::anyhow!("Failed to spawn '{}': {}", command, e))?;
6220
6221    let stdout = child
6222        .stdout
6223        .take()
6224        .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout from command"))?;
6225
6226    let mut output_file = std::fs::File::create(output_path).map_err(|e| {
6227        anyhow::anyhow!(
6228            "Failed to create output file '{}': {}",
6229            output_path.display(),
6230            e
6231        )
6232    })?;
6233
6234    let output_handle = thread::spawn(move || -> Result<()> {
6235        let mut reader = std::io::BufReader::new(stdout);
6236        let mut buffer = [0; 8192];
6237        loop {
6238            match reader.read(&mut buffer) {
6239                Ok(0) => return Ok(()),
6240                Ok(n) => output_file
6241                    .write_all(&buffer[..n])
6242                    .map_err(|e| anyhow::anyhow!("Failed to write to output file: {}", e))?,
6243                Err(e) => {
6244                    return Err(anyhow::anyhow!(
6245                        "Failed to read from subprocess stdout: {}",
6246                        e
6247                    ));
6248                }
6249            }
6250        }
6251    });
6252
6253    let deadline = Instant::now() + timeout;
6254    let mut timed_out = false;
6255    let status = loop {
6256        if let Some(status) = child
6257            .try_wait()
6258            .map_err(|e| anyhow::anyhow!("Failed to wait for subprocess: {}", e))?
6259        {
6260            break status;
6261        }
6262
6263        if Instant::now() >= deadline {
6264            timed_out = true;
6265            let _ = child.kill();
6266            break child
6267                .wait()
6268                .map_err(|e| anyhow::anyhow!("Failed to wait for timed-out subprocess: {}", e))?;
6269        }
6270
6271        thread::sleep(Duration::from_millis(25));
6272    };
6273
6274    let output_result = output_handle
6275        .join()
6276        .map_err(|_| anyhow::anyhow!("Subprocess stdout reader thread panicked"))?;
6277    if !timed_out {
6278        output_result?;
6279    }
6280
6281    Ok(CaptureCommandOutcome {
6282        exit_code: status.code().unwrap_or(1),
6283        timed_out,
6284    })
6285}
6286
6287fn run_headless_command(
6288    source: &str,
6289    args: Vec<String>,
6290    format: Option<String>,
6291    output: Option<String>,
6292    no_auto_flags: bool,
6293) -> Result<()> {
6294    use chrono::Utc;
6295    use uuid::Uuid;
6296
6297    let source_lower = source.to_lowercase();
6298    if source_lower != "codex" {
6299        eprintln!("\n  Error: Unknown headless source '{}'.", source);
6300        eprintln!("  Currently only 'codex' is supported.\n");
6301        std::process::exit(1);
6302    }
6303
6304    let resolved_format = match format {
6305        Some(f) if f == "json" || f == "jsonl" => f,
6306        Some(f) => {
6307            eprintln!("\n  Error: Invalid format '{}'. Use json or jsonl.\n", f);
6308            std::process::exit(1);
6309        }
6310        None => "jsonl".to_string(),
6311    };
6312
6313    let mut final_args = args.clone();
6314    if !no_auto_flags && source_lower == "codex" && !final_args.contains(&"--json".to_string()) {
6315        final_args.push("--json".to_string());
6316    }
6317
6318    let home_dir =
6319        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
6320    let headless_roots = get_headless_roots(&home_dir);
6321
6322    let output_path = if let Some(custom_output) = output {
6323        let parent = Path::new(&custom_output)
6324            .parent()
6325            .unwrap_or_else(|| Path::new("."));
6326        std::fs::create_dir_all(parent)?;
6327        custom_output
6328    } else {
6329        let root = headless_roots
6330            .first()
6331            .cloned()
6332            .unwrap_or_else(|| home_dir.join(".config/tokmesh/headless"));
6333        let dir = root.join(&source_lower);
6334        std::fs::create_dir_all(&dir)?;
6335
6336        let now = Utc::now();
6337        let timestamp = now.format("%Y-%m-%dT%H-%M-%S-%3fZ").to_string();
6338        let uuid_short = Uuid::new_v4()
6339            .to_string()
6340            .replace("-", "")
6341            .chars()
6342            .take(8)
6343            .collect::<String>();
6344        let filename = format!(
6345            "{}-{}-{}.{}",
6346            source_lower, timestamp, uuid_short, resolved_format
6347        );
6348
6349        dir.join(filename).to_string_lossy().to_string()
6350    };
6351
6352    let settings = tui::settings::Settings::load();
6353    let timeout = settings.get_native_timeout();
6354
6355    use colored::Colorize;
6356    println!("\n  {}", "Headless capture".cyan());
6357    println!("  {}", format!("source: {}", source_lower).bright_black());
6358    println!("  {}", format!("output: {}", output_path).bright_black());
6359    println!(
6360        "  {}",
6361        format!("timeout: {}s", timeout.as_secs()).bright_black()
6362    );
6363    println!();
6364
6365    let outcome =
6366        run_capture_command(&source_lower, &final_args, Path::new(&output_path), timeout)?;
6367
6368    if outcome.timed_out {
6369        eprintln!(
6370            "{}",
6371            format!("\n  Subprocess timed out after {}s", timeout.as_secs()).red()
6372        );
6373        eprintln!("{}", "  Partial output saved. Increase timeout with TOKMESH_NATIVE_TIMEOUT_MS or settings.json".bright_black());
6374        println!();
6375        std::process::exit(124);
6376    }
6377
6378    println!(
6379        "{}",
6380        format!("✓ Saved headless output to {}", output_path).green()
6381    );
6382    println!();
6383
6384    if outcome.exit_code != 0 {
6385        std::process::exit(outcome.exit_code);
6386    }
6387
6388    Ok(())
6389}
6390
6391#[cfg(test)]
6392mod tests {
6393    use super::*;
6394    use clap::Parser;
6395    use reqwest::StatusCode;
6396    use tokmesh_core::{
6397        calculate_summary, calculate_years, ClientContribution, DailyContribution, DailyTotals,
6398        GraphMeta, GraphResult, TokenBreakdown,
6399    };
6400
6401    #[test]
6402    fn test_parse_variant_arg_accepts_known_values() {
6403        assert_eq!(
6404            parse_variant_arg(Some("solo")).unwrap(),
6405            Some(trae::auth::TraeVariant::Solo)
6406        );
6407        assert_eq!(
6408            parse_variant_arg(Some("ide")).unwrap(),
6409            Some(trae::auth::TraeVariant::Ide)
6410        );
6411    }
6412
6413    #[test]
6414    fn test_parse_variant_arg_none_when_omitted() {
6415        assert_eq!(parse_variant_arg(None).unwrap(), None);
6416    }
6417
6418    #[test]
6419    fn test_parse_variant_arg_rejects_unknown_value() {
6420        // The earlier `Option`-returning version converted this to `None`
6421        // and the caller fell through to "all variants" — a typo like
6422        // `--variant slo` would log out every variant. Now we error out.
6423        let err = parse_variant_arg(Some("slo")).unwrap_err();
6424        let msg = err.to_string();
6425        assert!(msg.contains("unknown variant"), "got: {msg}");
6426        assert!(msg.contains("slo"), "got: {msg}");
6427    }
6428
6429    #[test]
6430    fn test_parse_variant_arg_rejects_empty_string() {
6431        assert!(parse_variant_arg(Some("")).is_err());
6432    }
6433
6434    #[test]
6435    fn saturating_token_total_saturates_instead_of_overflowing() {
6436        // tokmesh-core clamps corrupt per-field token buckets to
6437        // i64::MAX. The CLI display layer combines up to four such buckets
6438        // (input/output/cache_read/cache_write) into row and grand totals; a
6439        // plain `+` fold would panic in debug builds / wrap in release once
6440        // two clamped buckets are combined.
6441        assert_eq!(saturating_token_total(i64::MAX, i64::MAX, 0, 0), i64::MAX);
6442        assert_eq!(saturating_token_total(i64::MAX, 1, i64::MAX, 1), i64::MAX);
6443        // Real, non-overflowing counts still combine normally.
6444        assert_eq!(saturating_token_total(10, 20, 30, 40), 100);
6445    }
6446
6447    #[test]
6448    fn monthly_token_field_totals_saturate_across_entries() {
6449        // MonthlyReport has no precomputed grand totals, so the display layer
6450        // aggregates report.entries itself. Two entries each carrying a
6451        // clamped (i64::MAX) input bucket must not overflow that aggregation.
6452        let make = |input: i64| tokmesh_core::MonthlyUsage {
6453            month: "2026-07".to_string(),
6454            models: vec![],
6455            input,
6456            output: 0,
6457            cache_read: 0,
6458            cache_write: 0,
6459            message_count: 1,
6460            cost: 0.0,
6461        };
6462        let entries = vec![make(i64::MAX), make(i64::MAX)];
6463        let (total_input, total_output, total_cache_read, total_cache_write) =
6464            monthly_token_field_totals(&entries);
6465        assert_eq!(total_input, i64::MAX);
6466        assert_eq!(total_output, 0);
6467        assert_eq!(total_cache_read, 0);
6468        assert_eq!(total_cache_write, 0);
6469    }
6470
6471    #[test]
6472    fn model_entry_total_tokens_saturates_a_single_entrys_buckets() {
6473        let entry = tokmesh_core::ModelUsage {
6474            client: "antigravity-cli".to_string(),
6475            merged_clients: None,
6476            workspace_key: None,
6477            workspace_label: None,
6478            session_id: None,
6479            model: "gemini-3-pro".to_string(),
6480            provider: "antigravity".to_string(),
6481            input: i64::MAX,
6482            output: 0,
6483            cache_read: i64::MAX,
6484            cache_write: 0,
6485            reasoning: 0,
6486            message_count: 1,
6487            cost: 0.0,
6488            performance: tokmesh_core::ModelPerformance::default(),
6489        };
6490        assert_eq!(model_entry_total_tokens(&entry), i64::MAX);
6491    }
6492
6493    #[test]
6494    fn aggregate_model_report_performance_saturates_cross_entry_total() {
6495        // model_entry_total_tokens already saturates each entry to i64::MAX;
6496        // folding two such entries with plain `.sum()` would still overflow.
6497        let make = || tokmesh_core::ModelUsage {
6498            client: "antigravity-cli".to_string(),
6499            merged_clients: None,
6500            workspace_key: None,
6501            workspace_label: None,
6502            session_id: None,
6503            model: "gemini-3-pro".to_string(),
6504            provider: "antigravity".to_string(),
6505            input: i64::MAX,
6506            output: 0,
6507            cache_read: i64::MAX,
6508            cache_write: 0,
6509            reasoning: 0,
6510            message_count: 1,
6511            cost: 0.0,
6512            performance: tokmesh_core::ModelPerformance::default(),
6513        };
6514        let entries = vec![make(), make()];
6515        // Must not panic (debug overflow) — the saturating fold caps at i64::MAX.
6516        let performance = aggregate_model_report_performance(&entries);
6517        assert_eq!(performance.timed_tokens, 0);
6518    }
6519
6520    #[test]
6521    fn client_token_total_saturates_instead_of_overflowing() {
6522        let tokens = TokenBreakdown {
6523            input: i64::MAX,
6524            output: 0,
6525            cache_read: i64::MAX,
6526            cache_write: 0,
6527            reasoning: 0,
6528        };
6529        assert_eq!(client_token_total(&tokens), i64::MAX);
6530    }
6531
6532    fn token_breakdown(total_tokens: i64) -> TokenBreakdown {
6533        TokenBreakdown {
6534            input: total_tokens,
6535            output: 0,
6536            cache_read: 0,
6537            cache_write: 0,
6538            reasoning: 0,
6539        }
6540    }
6541
6542    fn daily_contribution(
6543        date: &str,
6544        total_tokens: i64,
6545        total_cost: f64,
6546        client: &str,
6547        model_id: &str,
6548    ) -> DailyContribution {
6549        DailyContribution {
6550            date: date.to_string(),
6551            totals: DailyTotals {
6552                tokens: total_tokens,
6553                cost: total_cost,
6554                messages: 1,
6555            },
6556            intensity: 0,
6557            token_breakdown: token_breakdown(total_tokens),
6558            clients: vec![ClientContribution {
6559                client: client.to_string(),
6560                model_id: model_id.to_string(),
6561                provider_id: "openai".to_string(),
6562                tokens: token_breakdown(total_tokens),
6563                cost: total_cost,
6564                messages: 1,
6565            }],
6566            active_time_ms: None,
6567        }
6568    }
6569
6570    fn graph_result_with_contributions(contributions: Vec<DailyContribution>) -> GraphResult {
6571        GraphResult {
6572            meta: GraphMeta {
6573                generated_at: "2026-03-24T00:00:00Z".to_string(),
6574                version: "test".to_string(),
6575                date_range_start: contributions
6576                    .first()
6577                    .map(|c| c.date.clone())
6578                    .unwrap_or_default(),
6579                date_range_end: contributions
6580                    .last()
6581                    .map(|c| c.date.clone())
6582                    .unwrap_or_default(),
6583                processing_time_ms: 0,
6584            },
6585            summary: calculate_summary(&contributions),
6586            years: calculate_years(&contributions),
6587            contributions,
6588            time_metrics: None,
6589        }
6590    }
6591
6592    // Tests below call `build_client_filter_with_defaults` directly with
6593    // an explicit `defaults` slice instead of `build_client_filter`, which
6594    // reads from `~/.config/tokmesh/settings.json`. Reading host config
6595    // makes tests non-hermetic — a developer with their own
6596    // `defaultClients` set would break the assertions. The wrapper is
6597    // covered separately by tests that pass an explicit `&[]`.
6598
6599    #[test]
6600    fn test_build_client_filter_all_false() {
6601        let flags = ClientFlags::default();
6602        assert_eq!(build_client_filter_with_defaults(flags, &[]), None);
6603    }
6604
6605    /// The 32 per-client boolean flags removed in 4.0.0. After removal every
6606    /// one of these must produce a clap parse error — backward-compat parsing
6607    /// is intentionally gone (breaking change). Keep this list in sync with the
6608    /// flags deleted from `ClientFlags`.
6609    const REMOVED_LEGACY_CLIENT_FLAGS: [&str; 32] = [
6610        "opencode",
6611        "claude",
6612        "codex",
6613        "copilot",
6614        "gemini",
6615        "cursor",
6616        "amp",
6617        "codebuff",
6618        "droid",
6619        "openclaw",
6620        "hermes",
6621        "pi",
6622        "kimi",
6623        "qwen",
6624        "roocode",
6625        "kilocode",
6626        "kilo",
6627        "mux",
6628        "crush",
6629        "goose",
6630        "antigravity",
6631        "zed",
6632        "kiro",
6633        "trae",
6634        "warp",
6635        "cline",
6636        "gjc",
6637        "grok",
6638        "jcode",
6639        "commandcode",
6640        "micode",
6641        "synthetic",
6642    ];
6643
6644    #[test]
6645    fn test_removed_legacy_client_flags_now_error() {
6646        for flag in REMOVED_LEGACY_CLIENT_FLAGS {
6647            let arg = format!("--{flag}");
6648            let result = Cli::try_parse_from(["tokmesh", arg.as_str()]);
6649            assert!(
6650                result.is_err(),
6651                "expected `{arg}` to be rejected after removal, but it parsed"
6652            );
6653        }
6654    }
6655
6656    #[test]
6657    fn test_canonical_client_still_parses_for_removed_flag_names() {
6658        // Every removed boolean flag name remains a valid `--client` value.
6659        for flag in REMOVED_LEGACY_CLIENT_FLAGS {
6660            let cli = Cli::try_parse_from(["tokmesh", "--client", flag])
6661                .unwrap_or_else(|_| panic!("`--client {flag}` should parse"));
6662            assert_eq!(
6663                build_client_filter_with_defaults(cli.clients, &[]),
6664                Some(vec![flag.to_string()]),
6665                "`--client {flag}` should resolve to a single source"
6666            );
6667        }
6668    }
6669
6670    #[test]
6671    fn test_canonical_client_parses_single_and_multi() {
6672        let cli = Cli::try_parse_from(["tokmesh", "--client", "opencode"]).expect("parse ok");
6673        assert_eq!(
6674            build_client_filter_with_defaults(cli.clients, &[]),
6675            Some(vec!["opencode".to_string()])
6676        );
6677
6678        let cli =
6679            Cli::try_parse_from(["tokmesh", "--client", "opencode,claude"]).expect("parse ok");
6680        assert_eq!(
6681            build_client_filter_with_defaults(cli.clients, &[]),
6682            Some(vec!["opencode".to_string(), "claude".to_string()])
6683        );
6684
6685        let cli = Cli::try_parse_from(["tokmesh", "--client", "synthetic"]).expect("parse ok");
6686        assert_eq!(
6687            build_client_filter_with_defaults(cli.clients, &[]),
6688            Some(vec!["synthetic".to_string()])
6689        );
6690    }
6691
6692    #[test]
6693    fn test_build_client_filter_canonical_clients_preserve_user_order() {
6694        // `--client claude,opencode,pi` should keep user-typed order so
6695        // downstream display (e.g. table grouping previews) is stable.
6696        let flags = ClientFlags {
6697            clients: vec![
6698                ClientFilter::Claude,
6699                ClientFilter::Opencode,
6700                ClientFilter::Pi,
6701            ],
6702        };
6703        assert_eq!(
6704            build_client_filter_with_defaults(flags, &[]),
6705            Some(vec![
6706                "claude".to_string(),
6707                "opencode".to_string(),
6708                "pi".to_string(),
6709            ])
6710        );
6711    }
6712
6713    #[test]
6714    fn test_build_client_filter_canonical_dedups_repeats() {
6715        let flags = ClientFlags {
6716            clients: vec![
6717                ClientFilter::Claude,
6718                ClientFilter::Claude,
6719                ClientFilter::Opencode,
6720            ],
6721        };
6722        assert_eq!(
6723            build_client_filter_with_defaults(flags, &[]),
6724            Some(vec!["claude".to_string(), "opencode".to_string()])
6725        );
6726    }
6727
6728    #[test]
6729    fn test_client_filter_as_filter_str_matches_client_id_for_overlap() {
6730        // Every ClientFilter variant except Synthetic and NineRouter must
6731        // agree with ClientId::as_str() so the core filter list stays
6732        // consistent.  NineRouter is a filter-only alias that maps to
6733        // ClientId::Gjc and intentionally has no matching ClientId of its own.
6734        for filter in ClientFilter::value_variants() {
6735            if matches!(filter, ClientFilter::Synthetic | ClientFilter::NineRouter) {
6736                continue;
6737            }
6738            let id = filter.as_filter_str();
6739            assert!(
6740                tokmesh_core::ClientId::from_str(id).is_some(),
6741                "ClientFilter::{:?} -> {:?} has no matching ClientId",
6742                filter,
6743                id,
6744            );
6745        }
6746    }
6747
6748    #[test]
6749    fn test_client_filter_to_client_id_round_trip() {
6750        // For every non-Synthetic filter:
6751        //   from_client_id(to_client_id(filter).unwrap()) == filter
6752        // and the canonical id strings agree.
6753        for filter in ClientFilter::value_variants() {
6754            match filter.to_client_id() {
6755                Some(id) => {
6756                    // NineRouter is a filter-only alias that maps to Gjc's
6757                    // scan root; it intentionally does not round-trip.
6758                    if !matches!(filter, ClientFilter::NineRouter) {
6759                        assert_eq!(
6760                            ClientFilter::from_client_id(id),
6761                            *filter,
6762                            "round-trip mismatch for {:?}",
6763                            filter
6764                        );
6765                        assert_eq!(
6766                            id.as_str(),
6767                            filter.as_filter_str(),
6768                            "id string drift between ClientId and ClientFilter for {:?}",
6769                            filter
6770                        );
6771                    }
6772                }
6773                None => {
6774                    // Synthetic is the only meta-client without a ClientId.
6775                    assert!(matches!(filter, ClientFilter::Synthetic));
6776                }
6777            }
6778        }
6779    }
6780
6781    #[test]
6782    fn test_client_filter_nine_router_round_trip() {
6783        use tokmesh_core::ClientId;
6784        // 9Router maps to Gjc scan roots and round-trips through
6785        // both the ClientId<->ClientFilter conversions and the id string.
6786        assert_eq!(ClientFilter::NineRouter.as_filter_str(), "9router");
6787        assert_eq!(ClientFilter::NineRouter.to_client_id(), Some(ClientId::Gjc));
6788        assert_eq!(
6789            ClientFilter::from_client_id(ClientId::Gjc),
6790            ClientFilter::Gjc
6791        );
6792        // --client gjc also round-trips correctly.
6793        assert_eq!(ClientFilter::Gjc.as_filter_str(), "gjc");
6794        assert_eq!(ClientFilter::Gjc.to_client_id(), Some(ClientId::Gjc));
6795        assert_eq!(
6796            ClientFilter::Gjc.to_client_id(),
6797            Some(ClientId::Gjc),
6798            "--client gjc should map to ClientId::Gjc"
6799        );
6800    }
6801
6802    #[test]
6803    fn test_client_filter_order_matches_client_id_all() {
6804        // Picker rendering, --help possible-values listing, and any
6805        // future iteration over `ClientFilter::value_variants()` all
6806        // assume the variant order mirrors `ClientId::ALL` (with
6807        // Synthetic appended). Guard that invariant explicitly.
6808        let filters: Vec<ClientFilter> = ClientFilter::value_variants()
6809            .iter()
6810            .copied()
6811            .filter(|f| !matches!(f, ClientFilter::Synthetic | ClientFilter::NineRouter))
6812            .collect();
6813        let ids: Vec<tokmesh_core::ClientId> = tokmesh_core::ClientId::ALL.to_vec();
6814        assert_eq!(filters.len(), ids.len());
6815        for (filter, id) in filters.iter().zip(ids.iter()) {
6816            assert_eq!(
6817                filter.to_client_id(),
6818                Some(*id),
6819                "ClientFilter declaration order diverged from ClientId::ALL at {:?}",
6820                filter
6821            );
6822        }
6823        // Synthetic is the very last variant.
6824        assert_eq!(
6825            ClientFilter::value_variants().last().copied(),
6826            Some(ClientFilter::Synthetic)
6827        );
6828    }
6829
6830    #[test]
6831    fn test_client_filter_from_filter_str_accepts_canonical_ids() {
6832        for filter in ClientFilter::value_variants() {
6833            let id = filter.as_filter_str();
6834            assert_eq!(ClientFilter::from_filter_str(id), Some(*filter));
6835        }
6836        assert_eq!(ClientFilter::from_filter_str("not-a-client"), None);
6837    }
6838
6839    #[test]
6840    fn test_client_filter_default_set_excludes_non_distinct_clients() {
6841        // Synthetic detection is opt-in: it post-processes other clients'
6842        // sessions to re-attribute messages to a different bucket. The
6843        // pre-refactor default was "every ClientId, include_synthetic =
6844        // false"; default_set() must preserve that contract.
6845        //
6846        // NineRouter is likewise excluded: it's a CLI-level alias filter
6847        // for Gjc (`--client 9router` round-trips to `ClientId::Gjc`, see
6848        // test_client_filter_nine_router_round_trip), not a distinct
6849        // scannable client. Including it in the default set alongside Gjc
6850        // would not add coverage — it would just be a second name for the
6851        // same scan root.
6852        let default = ClientFilter::default_set();
6853        assert!(
6854            !default.contains(&ClientFilter::Synthetic),
6855            "default_set() must NOT include Synthetic — it is opt-in only"
6856        );
6857        assert!(
6858            !default.contains(&ClientFilter::NineRouter),
6859            "default_set() must NOT include NineRouter — it is a Gjc alias, not a distinct client"
6860        );
6861        // Every real, non-alias client must be present so first-launch
6862        // reports cover every integration the binary knows about.
6863        for filter in ClientFilter::value_variants() {
6864            if matches!(filter, ClientFilter::Synthetic | ClientFilter::NineRouter) {
6865                continue;
6866            }
6867            assert!(default.contains(filter), "default_set() missing {filter:?}");
6868        }
6869        // Size sanity: every variant minus Synthetic and the NineRouter alias.
6870        assert_eq!(
6871            default.len(),
6872            ClientFilter::value_variants().len() - 2,
6873            "default_set() size drifted from value_variants() - 2"
6874        );
6875    }
6876
6877    #[test]
6878    fn test_resolve_default_tui_filter_set_uses_configured_defaults() {
6879        // When `defaultClients` is set, the warm-cache resolver must use
6880        // it verbatim — otherwise the warm cache would store every real
6881        // client while the next no-flag TUI launch wants only the configured
6882        // ones, producing a guaranteed cache miss.
6883        let configured = vec!["opencode".to_string(), "claude".to_string()];
6884        let set = resolve_default_tui_filter_set_with(&configured);
6885        let mut expected = std::collections::HashSet::new();
6886        expected.insert(ClientFilter::Opencode);
6887        expected.insert(ClientFilter::Claude);
6888        assert_eq!(set, expected);
6889    }
6890
6891    #[test]
6892    fn test_resolve_default_tui_filter_set_falls_back_when_empty() {
6893        // No defaultClients configured → use the canonical default set.
6894        let set = resolve_default_tui_filter_set_with(&[]);
6895        assert_eq!(set, ClientFilter::default_set());
6896    }
6897
6898    #[test]
6899    fn test_resolve_default_tui_filter_set_drops_unknown_ids() {
6900        // A stale settings.json entry (renamed/removed client) must not
6901        // crash; unknown ids are dropped and the resolver still produces
6902        // a usable filter set.
6903        let configured = vec!["opencode".to_string(), "not-a-real-client".to_string()];
6904        let set = resolve_default_tui_filter_set_with(&configured);
6905        let mut expected = std::collections::HashSet::new();
6906        expected.insert(ClientFilter::Opencode);
6907        assert_eq!(set, expected);
6908    }
6909
6910    #[test]
6911    fn test_resolve_default_tui_filter_set_all_unknown_falls_back() {
6912        // If every configured id is invalid, treat as if nothing is
6913        // configured rather than producing an empty filter set (which
6914        // would mean "scan nothing", definitely not the intent).
6915        let configured = vec!["not-real".to_string(), "also-fake".to_string()];
6916        let set = resolve_default_tui_filter_set_with(&configured);
6917        assert_eq!(set, ClientFilter::default_set());
6918    }
6919
6920    #[test]
6921    fn test_resolve_default_tui_filter_set_supports_synthetic() {
6922        // Power users who explicitly want synthetic detection on every
6923        // launch can put it in defaultClients.
6924        let configured = vec!["claude".to_string(), "synthetic".to_string()];
6925        let set = resolve_default_tui_filter_set_with(&configured);
6926        let mut expected = std::collections::HashSet::new();
6927        expected.insert(ClientFilter::Claude);
6928        expected.insert(ClientFilter::Synthetic);
6929        assert_eq!(set, expected);
6930    }
6931
6932    #[test]
6933    fn test_build_client_filter_with_defaults_when_no_flags() {
6934        // No CLI flags + a defaultClients list → defaults apply.
6935        let flags = ClientFlags::default();
6936        let defaults = vec!["opencode".to_string(), "claude".to_string()];
6937        assert_eq!(
6938            build_client_filter_with_defaults(flags, &defaults),
6939            Some(vec!["opencode".to_string(), "claude".to_string()])
6940        );
6941    }
6942
6943    #[test]
6944    fn test_build_client_filter_cli_overrides_defaults_completely() {
6945        // User passes --client → defaults must be ignored entirely
6946        // (no merge). This is the predictable semantics: "I asked for X,
6947        // give me X" not "I asked for X but you also added Y from settings".
6948        let flags = ClientFlags {
6949            clients: vec![ClientFilter::Codex],
6950        };
6951        let defaults = vec!["opencode".to_string(), "claude".to_string()];
6952        assert_eq!(
6953            build_client_filter_with_defaults(flags, &defaults),
6954            Some(vec!["codex".to_string()])
6955        );
6956    }
6957
6958    #[test]
6959    fn test_build_client_filter_canonical_flag_overrides_defaults() {
6960        // A canonical `--client` value counts as "user passed something" →
6961        // defaults ignored. CLI flags always win over settings.json.
6962        let flags = ClientFlags {
6963            clients: vec![ClientFilter::Opencode],
6964        };
6965        let defaults = vec!["claude".to_string()];
6966        assert_eq!(
6967            build_client_filter_with_defaults(flags, &defaults),
6968            Some(vec!["opencode".to_string()])
6969        );
6970    }
6971
6972    #[test]
6973    fn test_build_client_filter_defaults_dropped_for_unknown_ids() {
6974        // Stale settings entry (e.g. removed/renamed client) → silently
6975        // dropped, never errors. Ensures a typo never breaks tokmesh.
6976        let flags = ClientFlags::default();
6977        let defaults = vec!["opencode".to_string(), "not-a-client".to_string()];
6978        assert_eq!(
6979            build_client_filter_with_defaults(flags, &defaults),
6980            Some(vec!["opencode".to_string()])
6981        );
6982    }
6983
6984    #[test]
6985    fn test_build_client_filter_defaults_dedup_preserves_order() {
6986        let flags = ClientFlags::default();
6987        let defaults = vec![
6988            "claude".to_string(),
6989            "opencode".to_string(),
6990            "claude".to_string(),
6991        ];
6992        assert_eq!(
6993            build_client_filter_with_defaults(flags, &defaults),
6994            Some(vec!["claude".to_string(), "opencode".to_string()])
6995        );
6996    }
6997
6998    #[test]
6999    fn test_build_client_filter_no_flags_no_defaults_returns_none() {
7000        let flags = ClientFlags::default();
7001        let defaults: Vec<String> = vec![];
7002        assert_eq!(build_client_filter_with_defaults(flags, &defaults), None);
7003    }
7004
7005    #[test]
7006    fn test_client_filter_parses_lowercase_canonical_names() {
7007        // clap ValueEnum should accept the lowercase ids verbatim so
7008        // `--client opencode,claude` mirrors the legacy flag spelling.
7009        for filter in ClientFilter::value_variants() {
7010            let id = filter.as_filter_str();
7011            let parsed =
7012                <ClientFilter as ValueEnum>::from_str(id, true).expect("variant should parse");
7013            assert_eq!(parsed.as_filter_str(), id, "round-trip mismatch for {id}");
7014        }
7015    }
7016
7017    #[test]
7018    fn test_client_flags_parses_canonical_form() {
7019        // End-to-end smoke test: ensure clap derives accept the new
7020        // `--client a,b` and `-c a -c b` shapes through the CLI parser.
7021        let cli =
7022            Cli::try_parse_from(["tokmesh", "--client", "opencode,claude"]).expect("parse ok");
7023        assert_eq!(
7024            cli.clients.clients,
7025            vec![ClientFilter::Opencode, ClientFilter::Claude]
7026        );
7027
7028        let cli =
7029            Cli::try_parse_from(["tokmesh", "-c", "opencode", "-c", "claude"]).expect("parse ok");
7030        assert_eq!(
7031            cli.clients.clients,
7032            vec![ClientFilter::Opencode, ClientFilter::Claude]
7033        );
7034    }
7035
7036    #[test]
7037    fn test_wrapped_parses_clients_view_flag() {
7038        let cli = Cli::try_parse_from(["tokmesh", "wrapped"]).expect("parse ok");
7039        let Some(Commands::Wrapped {
7040            show_clients,
7041            agents,
7042            ..
7043        }) = cli.command
7044        else {
7045            panic!("expected wrapped command");
7046        };
7047        assert!(!show_clients);
7048        assert!(!agents);
7049
7050        let cli = Cli::try_parse_from(["tokmesh", "wrapped", "--clients"]).expect("parse ok");
7051        let Some(Commands::Wrapped { show_clients, .. }) = cli.command else {
7052            panic!("expected wrapped command");
7053        };
7054        assert!(show_clients);
7055    }
7056
7057    #[test]
7058    fn test_wrapped_client_filter_coexists_with_clients_view_flag() {
7059        let cli =
7060            Cli::try_parse_from(["tokmesh", "wrapped", "--client", "opencode"]).expect("parse ok");
7061        let Some(Commands::Wrapped {
7062            client_flags,
7063            show_clients,
7064            ..
7065        }) = cli.command
7066        else {
7067            panic!("expected wrapped command");
7068        };
7069        assert_eq!(client_flags.clients, vec![ClientFilter::Opencode]);
7070        assert!(!show_clients);
7071
7072        let cli = Cli::try_parse_from(["tokmesh", "wrapped", "--clients", "--client", "opencode"])
7073            .expect("parse ok");
7074        let Some(Commands::Wrapped {
7075            client_flags,
7076            show_clients,
7077            ..
7078        }) = cli.command
7079        else {
7080            panic!("expected wrapped command");
7081        };
7082        assert_eq!(client_flags.clients, vec![ClientFilter::Opencode]);
7083        assert!(show_clients);
7084    }
7085
7086    #[test]
7087    fn test_client_flag_accepts_uppercase() {
7088        let cli =
7089            Cli::try_parse_from(["tokmesh", "--client", "OPENCODE"]).expect("uppercase parses");
7090        assert_eq!(cli.clients.clients, vec![ClientFilter::Opencode]);
7091
7092        let cli = Cli::try_parse_from(["tokmesh", "-c", "Codebuff,Antigravity"])
7093            .expect("mixed-case parses");
7094        assert_eq!(
7095            cli.clients.clients,
7096            vec![ClientFilter::Codebuff, ClientFilter::Antigravity]
7097        );
7098    }
7099
7100    #[test]
7101    fn test_client_flag_rejects_unknown_and_empty_values() {
7102        assert!(Cli::try_parse_from(["tokmesh", "--client", "unknown"]).is_err());
7103        assert!(Cli::try_parse_from(["tokmesh", "--client", ""]).is_err());
7104    }
7105
7106    #[test]
7107    fn test_default_submit_clients_excludes_crush() {
7108        let clients = default_submit_clients();
7109        assert!(clients.contains(&"synthetic".to_string()));
7110        assert!(clients.contains(&"zed".to_string()));
7111        assert!(!clients.contains(&"crush".to_string()));
7112    }
7113
7114    #[test]
7115    fn test_build_client_filter_with_defaults_uses_defaults_when_no_flags() {
7116        let flags = ClientFlags::default();
7117        let defaults = vec!["opencode".to_string(), "claude".to_string()];
7118        assert_eq!(
7119            build_client_filter_with_defaults(flags, &defaults),
7120            Some(vec!["opencode".to_string(), "claude".to_string()])
7121        );
7122    }
7123
7124    #[test]
7125    fn test_build_client_filter_with_defaults_empty_defaults_returns_none() {
7126        let flags = ClientFlags::default();
7127        assert_eq!(build_client_filter_with_defaults(flags, &[]), None);
7128    }
7129
7130    #[test]
7131    fn test_client_filter_goose_round_trip() {
7132        assert_eq!(
7133            ClientFilter::from_filter_str("goose"),
7134            Some(ClientFilter::Goose)
7135        );
7136        assert_eq!(ClientFilter::Goose.as_filter_str(), "goose");
7137        assert_eq!(
7138            ClientFilter::Goose.to_client_id(),
7139            Some(tokmesh_core::ClientId::Goose)
7140        );
7141        assert_eq!(
7142            ClientFilter::from_client_id(tokmesh_core::ClientId::Goose),
7143            ClientFilter::Goose
7144        );
7145    }
7146
7147    #[test]
7148    fn test_client_filter_zed_round_trip() {
7149        assert_eq!(
7150            ClientFilter::from_filter_str("zed"),
7151            Some(ClientFilter::Zed)
7152        );
7153        assert_eq!(ClientFilter::Zed.as_filter_str(), "zed");
7154        assert_eq!(
7155            ClientFilter::Zed.to_client_id(),
7156            Some(tokmesh_core::ClientId::Zed)
7157        );
7158        assert_eq!(
7159            ClientFilter::from_client_id(tokmesh_core::ClientId::Zed),
7160            ClientFilter::Zed
7161        );
7162    }
7163
7164    #[test]
7165    fn test_client_filter_default_set_includes_goose() {
7166        let default = ClientFilter::default_set();
7167        assert!(
7168            default.contains(&ClientFilter::Goose),
7169            "default_set() must include Goose so the no-filter path scans it"
7170        );
7171    }
7172
7173    #[test]
7174    fn test_delete_submitted_data_command_parses() {
7175        let cli = Cli::try_parse_from(["tokmesh", "tokscale", "delete-submitted-data"]).unwrap();
7176        assert!(matches!(
7177            cli.command,
7178            Some(Commands::Tokscale {
7179                command: LeaderboardCommand::DeleteSubmittedData
7180            })
7181        ));
7182        let cli = Cli::try_parse_from(["tokmesh", "tokensci", "delete-submitted-data"]).unwrap();
7183        assert!(matches!(
7184            cli.command,
7185            Some(Commands::Tokensci {
7186                command: LeaderboardCommand::DeleteSubmittedData
7187            })
7188        ));
7189    }
7190
7191    #[test]
7192    fn test_codex_activity_command_parses() {
7193        let cli = Cli::try_parse_from(["tokmesh", "codex", "activity", "--json"]).unwrap();
7194        assert!(matches!(
7195            cli.command,
7196            Some(Commands::Codex {
7197                subcommand: CodexSubcommand::Activity { json: true }
7198            })
7199        ));
7200    }
7201
7202    #[test]
7203    fn test_autosubmit_commands_parse() {
7204        let cli = Cli::try_parse_from([
7205            "tokmesh",
7206            "tokensci",
7207            "autosubmit",
7208            "enable",
7209            "--interval",
7210            "2h",
7211            "--client",
7212            "opencode,claude",
7213            "--week",
7214        ])
7215        .unwrap();
7216        assert!(matches!(
7217            cli.command,
7218            Some(Commands::Tokensci {
7219                command: LeaderboardCommand::Autosubmit {
7220                    subcommand: commands::autosubmit::AutosubmitSubcommand::Enable(_),
7221                }
7222            })
7223        ));
7224
7225        let cli =
7226            Cli::try_parse_from(["tokmesh", "tokscale", "autosubmit", "status", "--json"]).unwrap();
7227        assert!(matches!(
7228            cli.command,
7229            Some(Commands::Tokscale {
7230                command: LeaderboardCommand::Autosubmit {
7231                    subcommand: commands::autosubmit::AutosubmitSubcommand::Status { json: true },
7232                }
7233            })
7234        ));
7235
7236        let cli =
7237            Cli::try_parse_from(["tokmesh", "tokscale", "autosubmit", "run", "--force"]).unwrap();
7238        assert!(matches!(
7239            cli.command,
7240            Some(Commands::Tokscale {
7241                command: LeaderboardCommand::Autosubmit {
7242                    subcommand: commands::autosubmit::AutosubmitSubcommand::Run { force: true },
7243                }
7244            })
7245        ));
7246
7247        let cli = Cli::try_parse_from(["tokmesh", "tokensci", "autosubmit", "disable"]).unwrap();
7248        assert!(matches!(
7249            cli.command,
7250            Some(Commands::Tokensci {
7251                command: LeaderboardCommand::Autosubmit {
7252                    subcommand: commands::autosubmit::AutosubmitSubcommand::Disable,
7253                }
7254            })
7255        ));
7256    }
7257
7258    #[test]
7259    fn test_login_token_option_parses() {
7260        let cli = Cli::try_parse_from(["tokmesh", "tokscale", "login", "--token", "tt_ci_token"])
7261            .unwrap();
7262        assert!(matches!(
7263            cli.command,
7264            Some(Commands::Tokscale {
7265                command: LeaderboardCommand::Login {
7266                    token: Some(token),
7267                }
7268            }) if token == "tt_ci_token"
7269        ));
7270
7271        let cli = Cli::try_parse_from(["tokmesh", "tokensci", "login", "--token", "tt_ci_token"])
7272            .unwrap();
7273        assert!(matches!(
7274            cli.command,
7275            Some(Commands::Tokensci {
7276                command: LeaderboardCommand::Login {
7277                    token: Some(token),
7278                }
7279            }) if token == "tt_ci_token"
7280        ));
7281    }
7282
7283    #[test]
7284    fn test_interpret_delete_submitted_data_response_success() {
7285        let body = serde_json::json!({
7286            "deleted": true,
7287            "deletedSubmissions": 2
7288        })
7289        .to_string();
7290
7291        let outcome = interpret_delete_submitted_data_response(StatusCode::OK, &body).unwrap();
7292        match outcome {
7293            DeleteSubmittedDataOutcome::Deleted(count) => assert_eq!(count, Some(2)),
7294            DeleteSubmittedDataOutcome::NotFound => panic!("expected deleted outcome"),
7295        }
7296    }
7297
7298    #[test]
7299    fn test_interpret_delete_submitted_data_response_accepts_non_json_2xx() {
7300        assert_eq!(
7301            interpret_delete_submitted_data_response(StatusCode::NO_CONTENT, "").unwrap(),
7302            DeleteSubmittedDataOutcome::Deleted(None)
7303        );
7304        assert_eq!(
7305            interpret_delete_submitted_data_response(StatusCode::OK, "deleted").unwrap(),
7306            DeleteSubmittedDataOutcome::Deleted(None)
7307        );
7308    }
7309
7310    #[test]
7311    fn test_interpret_delete_submitted_data_response_failure() {
7312        let body = serde_json::json!({
7313            "error": "Not authenticated"
7314        })
7315        .to_string();
7316
7317        let err = interpret_delete_submitted_data_response(StatusCode::UNAUTHORIZED, &body)
7318            .unwrap_err()
7319            .to_string();
7320        assert!(err.contains("Failed (401 Unauthorized): Not authenticated"));
7321    }
7322
7323    #[test]
7324    fn test_interpret_submit_response_accepts_non_json_2xx() {
7325        assert!(matches!(
7326            interpret_submit_response(StatusCode::NO_CONTENT, ""),
7327            SubmitResponseOutcome::Success(SubmitResponse {
7328                submission_id: None,
7329                ..
7330            })
7331        ));
7332        assert!(matches!(
7333            interpret_submit_response(StatusCode::OK, "accepted"),
7334            SubmitResponseOutcome::Success(_)
7335        ));
7336    }
7337
7338    #[test]
7339    fn test_interpret_submit_response_preserves_json_failure_details() {
7340        let outcome = interpret_submit_response(
7341            StatusCode::UNPROCESSABLE_ENTITY,
7342            r#"{"error":"Invalid contribution","details":["bad date"]}"#,
7343        );
7344        match outcome {
7345            SubmitResponseOutcome::Failure { error, details } => {
7346                assert_eq!(error, "Invalid contribution");
7347                assert_eq!(details, vec!["bad date"]);
7348            }
7349            SubmitResponseOutcome::Success(_) => panic!("expected failure"),
7350        }
7351    }
7352
7353    #[test]
7354    fn test_leaderboard_response_body_read_error_is_not_treated_as_empty() {
7355        use std::io::{Read, Write};
7356        use std::net::TcpListener;
7357
7358        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
7359        let address = listener.local_addr().unwrap();
7360        let server = std::thread::spawn(move || {
7361            let (mut stream, _) = listener.accept().unwrap();
7362            let mut request = [0_u8; 1024];
7363            let bytes_read = stream.read(&mut request).unwrap();
7364            assert!(bytes_read > 0);
7365            stream
7366                .write_all(
7367                    b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nshort",
7368                )
7369                .unwrap();
7370        });
7371
7372        let runtime = tokio::runtime::Runtime::new().unwrap();
7373        let response = runtime
7374            .block_on(reqwest::get(format!("http://{address}")))
7375            .unwrap();
7376        let error = runtime
7377            .block_on(read_leaderboard_response_body(response, "submit"))
7378            .unwrap_err()
7379            .to_string();
7380        server.join().unwrap();
7381
7382        assert!(error.contains("Failed to read submit response body"));
7383    }
7384
7385    #[test]
7386    fn test_build_date_filter_custom_range() {
7387        let (since, until) = build_date_filter(&DateRangeFlags {
7388            since: Some("2024-01-01".to_string()),
7389            until: Some("2024-12-31".to_string()),
7390            ..DateRangeFlags::default()
7391        });
7392        assert_eq!(since, Some("2024-01-01".to_string()));
7393        assert_eq!(until, Some("2024-12-31".to_string()));
7394    }
7395
7396    #[test]
7397    fn test_build_date_filter_no_filters() {
7398        let (since, until) = build_date_filter(&DateRangeFlags::default());
7399        assert_eq!(since, None);
7400        assert_eq!(until, None);
7401    }
7402
7403    #[test]
7404    fn test_build_date_filter_today_uses_provided_local_date() {
7405        let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 8).unwrap();
7406        let (since, until) = build_date_filter_for_date(
7407            &DateRangeFlags {
7408                today: true,
7409                ..DateRangeFlags::default()
7410            },
7411            today,
7412        );
7413        assert_eq!(since, Some("2026-03-08".to_string()));
7414        assert_eq!(until, Some("2026-03-08".to_string()));
7415    }
7416
7417    #[test]
7418    fn test_build_date_filter_yesterday_uses_provided_local_date() {
7419        let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 8).unwrap();
7420        let (since, until) = build_date_filter_for_date(
7421            &DateRangeFlags {
7422                yesterday: true,
7423                ..DateRangeFlags::default()
7424            },
7425            today,
7426        );
7427        assert_eq!(since, Some("2026-03-07".to_string()));
7428        assert_eq!(until, Some("2026-03-07".to_string()));
7429    }
7430
7431    #[test]
7432    fn test_build_date_filter_week_uses_provided_local_date() {
7433        let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 8).unwrap();
7434        let (since, until) = build_date_filter_for_date(
7435            &DateRangeFlags {
7436                week: true,
7437                ..DateRangeFlags::default()
7438            },
7439            today,
7440        );
7441        assert_eq!(since, Some("2026-03-02".to_string()));
7442        assert_eq!(until, Some("2026-03-08".to_string()));
7443    }
7444
7445    #[test]
7446    fn test_build_date_filter_month_uses_provided_local_date() {
7447        let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 8).unwrap();
7448        let (since, until) = build_date_filter_for_date(
7449            &DateRangeFlags {
7450                month: true,
7451                ..DateRangeFlags::default()
7452            },
7453            today,
7454        );
7455        assert_eq!(since, Some("2026-03-01".to_string()));
7456        assert_eq!(until, Some("2026-03-08".to_string()));
7457    }
7458
7459    #[test]
7460    fn test_normalize_year_filter_with_year() {
7461        let year = normalize_year_filter(&DateRangeFlags {
7462            year: Some("2024".to_string()),
7463            ..DateRangeFlags::default()
7464        });
7465        assert_eq!(year, Some("2024".to_string()));
7466    }
7467
7468    #[test]
7469    fn test_normalize_year_filter_with_today() {
7470        let year = normalize_year_filter(&DateRangeFlags {
7471            today: true,
7472            year: Some("2024".to_string()),
7473            ..DateRangeFlags::default()
7474        });
7475        assert_eq!(year, None);
7476    }
7477
7478    #[test]
7479    fn test_normalize_year_filter_with_yesterday() {
7480        let year = normalize_year_filter(&DateRangeFlags {
7481            yesterday: true,
7482            year: Some("2024".to_string()),
7483            ..DateRangeFlags::default()
7484        });
7485        assert_eq!(year, None);
7486    }
7487
7488    #[test]
7489    fn test_normalize_year_filter_with_week() {
7490        let year = normalize_year_filter(&DateRangeFlags {
7491            week: true,
7492            year: Some("2024".to_string()),
7493            ..DateRangeFlags::default()
7494        });
7495        assert_eq!(year, None);
7496    }
7497
7498    #[test]
7499    fn test_normalize_year_filter_with_month() {
7500        let year = normalize_year_filter(&DateRangeFlags {
7501            month: true,
7502            year: Some("2024".to_string()),
7503            ..DateRangeFlags::default()
7504        });
7505        assert_eq!(year, None);
7506    }
7507
7508    #[test]
7509    fn test_normalize_year_filter_no_year() {
7510        let year = normalize_year_filter(&DateRangeFlags::default());
7511        assert_eq!(year, None);
7512    }
7513
7514    /// Parses `args` expecting failure; panics if parsing unexpectedly
7515    /// succeeds. Avoids `unwrap_err()` since `Cli` does not derive `Debug`.
7516    fn expect_parse_error(args: &[&str]) -> clap::Error {
7517        match Cli::try_parse_from(args) {
7518            Ok(_) => panic!("expected `{}` to fail to parse", args.join(" ")),
7519            Err(err) => err,
7520        }
7521    }
7522
7523    #[test]
7524    fn test_date_shortcut_flags_conflict() {
7525        let err = expect_parse_error(&["tokmesh", "--today", "--yesterday"]);
7526        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7527
7528        let err = expect_parse_error(&["tokmesh", "--week", "--month"]);
7529        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7530    }
7531
7532    #[test]
7533    fn test_date_shortcut_conflicts_with_since_until_year() {
7534        let err = expect_parse_error(&["tokmesh", "--today", "--since", "2024-01-01"]);
7535        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7536
7537        let err = expect_parse_error(&["tokmesh", "--week", "--until", "2024-12-31"]);
7538        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7539
7540        let err = expect_parse_error(&["tokmesh", "--month", "--year", "2024"]);
7541        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7542    }
7543
7544    #[test]
7545    fn test_date_shortcut_conflict_applies_to_subcommands() {
7546        let err = expect_parse_error(&["tokmesh", "models", "--today", "--yesterday"]);
7547        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
7548    }
7549
7550    #[test]
7551    fn test_since_until_year_still_combine() {
7552        let cli = Cli::try_parse_from([
7553            "tokmesh",
7554            "--since",
7555            "2024-01-01",
7556            "--until",
7557            "2024-12-31",
7558            "--year",
7559            "2024",
7560        ])
7561        .unwrap();
7562        assert_eq!(cli.date.since.as_deref(), Some("2024-01-01"));
7563        assert_eq!(cli.date.until.as_deref(), Some("2024-12-31"));
7564        assert_eq!(cli.date.year.as_deref(), Some("2024"));
7565    }
7566
7567    #[test]
7568    fn test_format_tokens_with_commas_small() {
7569        assert_eq!(format_tokens_with_commas(123), "123");
7570    }
7571
7572    #[test]
7573    fn test_format_tokens_with_commas_thousands() {
7574        assert_eq!(format_tokens_with_commas(1234), "1,234");
7575    }
7576
7577    #[test]
7578    fn test_format_tokens_with_commas_millions() {
7579        assert_eq!(format_tokens_with_commas(1234567), "1,234,567");
7580    }
7581
7582    #[test]
7583    fn test_format_tokens_with_commas_billions() {
7584        assert_eq!(format_tokens_with_commas(1234567890), "1,234,567,890");
7585    }
7586
7587    #[test]
7588    fn test_format_tokens_with_commas_zero() {
7589        assert_eq!(format_tokens_with_commas(0), "0");
7590    }
7591
7592    #[test]
7593    fn test_format_tokens_with_commas_negative() {
7594        assert_eq!(format_tokens_with_commas(-1234567), "-1,234,567");
7595    }
7596
7597    #[test]
7598    fn test_format_currency_zero() {
7599        assert_eq!(format_currency(0.0), "$0.00");
7600    }
7601
7602    #[test]
7603    fn test_format_currency_small() {
7604        assert_eq!(format_currency(12.34), "$12.34");
7605    }
7606
7607    #[test]
7608    fn test_format_currency_large() {
7609        assert_eq!(format_currency(1234.56), "$1234.56");
7610    }
7611
7612    #[test]
7613    fn test_format_currency_rounds() {
7614        assert_eq!(format_currency(12.345), "$12.35");
7615        assert_eq!(format_currency(12.344), "$12.34");
7616    }
7617
7618    #[test]
7619    fn test_capitalize_client_opencode() {
7620        assert_eq!(capitalize_client("opencode"), "OpenCode");
7621    }
7622
7623    #[test]
7624    fn test_capitalize_client_claude() {
7625        assert_eq!(capitalize_client("claude"), "Claude");
7626    }
7627
7628    #[test]
7629    fn test_capitalize_client_codex() {
7630        assert_eq!(capitalize_client("codex"), "Codex");
7631    }
7632
7633    #[test]
7634    fn test_capitalize_client_cursor() {
7635        assert_eq!(capitalize_client("cursor"), "Cursor");
7636    }
7637
7638    #[test]
7639    fn test_capitalize_client_gemini() {
7640        assert_eq!(capitalize_client("gemini"), "Gemini");
7641    }
7642
7643    #[test]
7644    fn test_capitalize_client_amp() {
7645        assert_eq!(capitalize_client("amp"), "Amp");
7646    }
7647
7648    #[test]
7649    fn test_capitalize_client_droid() {
7650        assert_eq!(capitalize_client("droid"), "Droid");
7651    }
7652
7653    #[test]
7654    fn test_capitalize_client_crush() {
7655        assert_eq!(capitalize_client("crush"), "Crush");
7656    }
7657
7658    #[test]
7659    fn test_capitalize_client_openclaw() {
7660        assert_eq!(capitalize_client("openclaw"), "openclaw");
7661    }
7662
7663    #[test]
7664    fn test_capitalize_client_hermes() {
7665        assert_eq!(capitalize_client("hermes"), "Hermes Agent");
7666    }
7667
7668    #[test]
7669    fn test_capitalize_client_codebuff() {
7670        assert_eq!(capitalize_client("codebuff"), "Codebuff");
7671    }
7672
7673    #[test]
7674    fn test_capitalize_client_pi() {
7675        assert_eq!(capitalize_client("pi"), "Pi");
7676    }
7677
7678    #[test]
7679    fn test_capitalize_client_jcode() {
7680        assert_eq!(capitalize_client("jcode"), "Jcode");
7681    }
7682
7683    #[test]
7684    fn test_capitalize_client_unknown() {
7685        assert_eq!(capitalize_client("unknown"), "unknown");
7686    }
7687
7688    #[test]
7689    fn test_get_date_range_label_today() {
7690        let label = get_date_range_label(&DateRangeFlags {
7691            today: true,
7692            ..DateRangeFlags::default()
7693        });
7694        assert_eq!(label, Some("Today".to_string()));
7695    }
7696
7697    #[test]
7698    fn test_get_date_range_label_yesterday() {
7699        let label = get_date_range_label(&DateRangeFlags {
7700            yesterday: true,
7701            ..DateRangeFlags::default()
7702        });
7703        assert_eq!(label, Some("Yesterday".to_string()));
7704    }
7705
7706    #[test]
7707    fn test_get_date_range_label_week() {
7708        let label = get_date_range_label(&DateRangeFlags {
7709            week: true,
7710            ..DateRangeFlags::default()
7711        });
7712        assert_eq!(label, Some("Last 7 days".to_string()));
7713    }
7714
7715    #[test]
7716    fn test_get_date_range_label_month_uses_provided_local_date() {
7717        let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
7718        let label = get_date_range_label_for_date(
7719            &DateRangeFlags {
7720                month: true,
7721                ..DateRangeFlags::default()
7722            },
7723            today,
7724        );
7725        assert_eq!(label, Some("March 2026".to_string()));
7726    }
7727
7728    #[test]
7729    fn test_get_date_range_label_year() {
7730        let label = get_date_range_label(&DateRangeFlags {
7731            year: Some("2024".to_string()),
7732            ..DateRangeFlags::default()
7733        });
7734        assert_eq!(label, Some("2024".to_string()));
7735    }
7736
7737    #[test]
7738    fn test_get_date_range_label_custom_since() {
7739        let label = get_date_range_label(&DateRangeFlags {
7740            since: Some("2024-01-01".to_string()),
7741            ..DateRangeFlags::default()
7742        });
7743        assert_eq!(label, Some("from 2024-01-01".to_string()));
7744    }
7745
7746    #[test]
7747    fn test_get_date_range_label_custom_until() {
7748        let label = get_date_range_label(&DateRangeFlags {
7749            until: Some("2024-12-31".to_string()),
7750            ..DateRangeFlags::default()
7751        });
7752        assert_eq!(label, Some("to 2024-12-31".to_string()));
7753    }
7754
7755    #[test]
7756    fn test_get_date_range_label_custom_range() {
7757        let label = get_date_range_label(&DateRangeFlags {
7758            since: Some("2024-01-01".to_string()),
7759            until: Some("2024-12-31".to_string()),
7760            ..DateRangeFlags::default()
7761        });
7762        assert_eq!(label, Some("from 2024-01-01 to 2024-12-31".to_string()));
7763    }
7764
7765    #[test]
7766    fn test_get_date_range_label_none() {
7767        let label = get_date_range_label(&DateRangeFlags::default());
7768        assert_eq!(label, None);
7769    }
7770
7771    #[test]
7772    fn test_light_spinner_frame_0() {
7773        let frame = LightSpinner::frame(0);
7774        assert!(frame.contains("■"));
7775        assert!(frame.contains("⬝"));
7776    }
7777
7778    #[test]
7779    fn test_light_spinner_frame_1() {
7780        let frame = LightSpinner::frame(1);
7781        assert!(frame.contains("■"));
7782        assert!(frame.contains("⬝"));
7783    }
7784
7785    #[test]
7786    fn test_light_spinner_frame_2() {
7787        let frame = LightSpinner::frame(2);
7788        assert!(frame.contains("■"));
7789        assert!(frame.contains("⬝"));
7790    }
7791
7792    #[test]
7793    fn test_light_spinner_scanner_state_forward_start() {
7794        let (position, forward) = LightSpinner::scanner_state(0);
7795        assert_eq!(position, 0);
7796        assert!(forward);
7797    }
7798
7799    #[test]
7800    fn test_light_spinner_scanner_state_forward_mid() {
7801        let (position, forward) = LightSpinner::scanner_state(4);
7802        assert_eq!(position, 4);
7803        assert!(forward);
7804    }
7805
7806    #[test]
7807    fn test_light_spinner_scanner_state_forward_end() {
7808        let (position, forward) = LightSpinner::scanner_state(7);
7809        assert_eq!(position, 7);
7810        assert!(forward);
7811    }
7812
7813    #[test]
7814    fn test_light_spinner_scanner_state_hold_end() {
7815        let (position, forward) = LightSpinner::scanner_state(8);
7816        assert_eq!(position, 7);
7817        assert!(forward);
7818    }
7819
7820    #[test]
7821    fn test_light_spinner_scanner_state_backward_start() {
7822        let (position, forward) = LightSpinner::scanner_state(17);
7823        assert_eq!(position, 6);
7824        assert!(!forward);
7825    }
7826
7827    #[test]
7828    fn test_light_spinner_scanner_state_backward_end() {
7829        let (position, forward) = LightSpinner::scanner_state(23);
7830        assert_eq!(position, 0);
7831        assert!(!forward);
7832    }
7833
7834    #[test]
7835    fn test_light_spinner_scanner_state_hold_start() {
7836        let (position, forward) = LightSpinner::scanner_state(24);
7837        assert_eq!(position, 0);
7838        assert!(!forward);
7839    }
7840
7841    #[test]
7842    fn test_light_spinner_scanner_state_cycle_wrap() {
7843        // Total cycle = 8 + 9 + 7 + 30 = 54
7844        let (position1, forward1) = LightSpinner::scanner_state(0);
7845        let (position2, forward2) = LightSpinner::scanner_state(54);
7846        assert_eq!(position1, position2);
7847        assert_eq!(forward1, forward2);
7848    }
7849
7850    fn client_contribution(
7851        client: &str,
7852        model_id: &str,
7853        provider_id: &str,
7854        total_tokens: i64,
7855        cost: f64,
7856        messages: i32,
7857    ) -> ClientContribution {
7858        ClientContribution {
7859            client: client.to_string(),
7860            model_id: model_id.to_string(),
7861            provider_id: provider_id.to_string(),
7862            tokens: token_breakdown(total_tokens),
7863            cost,
7864            messages,
7865        }
7866    }
7867
7868    fn day_with_clients(
7869        date: &str,
7870        token_breakdown_total: i64,
7871        clients: Vec<ClientContribution>,
7872    ) -> DailyContribution {
7873        let tokens: i64 = clients.iter().map(|c| client_token_total(&c.tokens)).sum();
7874        let cost: f64 = clients.iter().map(|c| c.cost).sum();
7875        let messages: i32 = clients.iter().map(|c| c.messages).sum();
7876        DailyContribution {
7877            date: date.to_string(),
7878            totals: DailyTotals {
7879                tokens,
7880                cost,
7881                messages,
7882            },
7883            intensity: 0,
7884            token_breakdown: token_breakdown(token_breakdown_total),
7885            clients,
7886            active_time_ms: None,
7887        }
7888    }
7889
7890    #[test]
7891    fn test_exclude_tokenless_cost_drops_offenders_and_keeps_the_rest() {
7892        // A token-bearing row shares the day with a tokenless cursor charge
7893        // (cost, no tokens) and a grandfathered premium-tool-call row.
7894        let mut graph = graph_result_with_contributions(vec![day_with_clients(
7895            "2025-05-28",
7896            100,
7897            vec![
7898                client_contribution("cursor", "claude-3.7-sonnet", "anthropic", 100, 0.03, 1),
7899                client_contribution("cursor", "auto", "cursor", 0, 0.04, 1),
7900                client_contribution("cursor", "premium-tool-call", "cursor", 0, 2.05, 44),
7901            ],
7902        )]);
7903
7904        let excluded = exclude_tokenless_cost_contributions(&mut graph);
7905
7906        // Only the tokenless `auto` row is dropped.
7907        assert_eq!(excluded.len(), 1);
7908        assert_eq!(excluded[0].model_id, "auto");
7909        assert!((excluded[0].cost - 0.04).abs() < 1e-9);
7910
7911        let day = &graph.contributions[0];
7912        assert_eq!(day.clients.len(), 2);
7913        assert!(day.clients.iter().all(|c| c.model_id != "auto"));
7914        // premium-tool-call is preserved (server carve-out).
7915        assert!(day
7916            .clients
7917            .iter()
7918            .any(|c| c.model_id == "premium-tool-call"));
7919        // Tokens untouched; cost/messages reduced by the dropped row only.
7920        assert_eq!(day.totals.tokens, 100);
7921        assert!((day.totals.cost - 2.08).abs() < 1e-9);
7922        assert_eq!(day.totals.messages, 45);
7923        assert!((graph.summary.total_cost - 2.08).abs() < 1e-9);
7924        assert_eq!(graph.summary.total_tokens, 100);
7925    }
7926
7927    #[test]
7928    fn test_exclude_tokenless_cost_zeroes_a_fully_tokenless_day() {
7929        let mut graph = graph_result_with_contributions(vec![day_with_clients(
7930            "2025-05-30",
7931            0,
7932            vec![
7933                client_contribution("cursor", "auto", "cursor", 0, 0.04, 1),
7934                client_contribution("cursor", "auto", "cursor", 0, 0.04, 1),
7935            ],
7936        )]);
7937
7938        let excluded = exclude_tokenless_cost_contributions(&mut graph);
7939
7940        assert_eq!(excluded.len(), 2);
7941        let day = &graph.contributions[0];
7942        assert!(day.clients.is_empty());
7943        assert_eq!(day.totals.cost, 0.0);
7944        assert_eq!(day.totals.tokens, 0);
7945        assert_eq!(graph.summary.total_cost, 0.0);
7946    }
7947
7948    #[test]
7949    fn test_exclude_tokenless_cost_is_noop_without_offenders() {
7950        let mut graph = graph_result_with_contributions(vec![day_with_clients(
7951            "2025-05-28",
7952            100,
7953            vec![
7954                client_contribution("codex", "gpt-5", "openai", 100, 0.03, 1),
7955                // Grandfathered cursor legacy row must not be dropped.
7956                client_contribution("cursor", "premium-tool-call", "cursor", 0, 2.05, 44),
7957            ],
7958        )]);
7959        let original_cost = graph.summary.total_cost;
7960
7961        let excluded = exclude_tokenless_cost_contributions(&mut graph);
7962
7963        assert!(excluded.is_empty());
7964        assert_eq!(graph.contributions[0].clients.len(), 2);
7965        assert_eq!(graph.summary.total_cost, original_cost);
7966    }
7967
7968    #[test]
7969    fn test_exclude_tokenless_cost_drops_warp_aggregate_requests() {
7970        let mut graph = graph_result_with_contributions(vec![day_with_clients(
7971            "2026-01-02",
7972            0,
7973            vec![client_contribution(
7974                "warp",
7975                "aggregate-requests",
7976                "warp",
7977                0,
7978                12.34,
7979                42,
7980            )],
7981        )]);
7982
7983        let excluded = exclude_tokenless_cost_contributions(&mut graph);
7984
7985        assert_eq!(excluded.len(), 1);
7986        assert_eq!(excluded[0].client, "warp");
7987        assert_eq!(excluded[0].model_id, "aggregate-requests");
7988        assert!(graph.contributions[0].clients.is_empty());
7989        assert_eq!(graph.summary.total_tokens, 0);
7990        assert_eq!(graph.summary.total_cost, 0.0);
7991    }
7992
7993    #[test]
7994    fn test_submit_payload_includes_device_when_provided() {
7995        let graph = graph_result_with_contributions(vec![daily_contribution(
7996            "2026-12-31",
7997            20,
7998            2.50,
7999            "codex",
8000            "model-b",
8001        )]);
8002        let device = device::SubmitDevice {
8003            id: "dev_test".to_string(),
8004            name: Some("Test device".to_string()),
8005        };
8006
8007        let payload = to_ts_token_contribution_data(
8008            &graph,
8009            Some(&device),
8010            leaderboard::Leaderboard::Tokscale,
8011            None,
8012        );
8013
8014        assert_eq!(payload.device.as_ref().unwrap().id, "dev_test");
8015        assert_eq!(
8016            payload.device.as_ref().unwrap().name.as_deref(),
8017            Some("Test device")
8018        );
8019    }
8020
8021    #[test]
8022    fn replacement_validation_requires_filtered_contributions_for_each_client() {
8023        let graph = graph_result_with_contributions(vec![daily_contribution(
8024            "2026-12-31",
8025            20,
8026            2.50,
8027            "opencode",
8028            "model-b",
8029        )]);
8030        let replacement = SubmitReplacementCoverage {
8031            clients: vec!["opencode".to_string(), "codex".to_string()],
8032            start: "2026-12-01".to_string(),
8033            end: "2026-12-31".to_string(),
8034        };
8035
8036        let error = validate_replacement_contributions(&graph, Some(&replacement))
8037            .unwrap_err()
8038            .to_string();
8039        assert!(error.contains("no contribution found for: codex"));
8040
8041        let replacement = SubmitReplacementCoverage {
8042            clients: vec!["opencode".to_string()],
8043            ..replacement
8044        };
8045        validate_replacement_contributions(&graph, Some(&replacement)).unwrap();
8046        validate_replacement_contributions(&graph, None).unwrap();
8047    }
8048
8049    #[test]
8050    fn resolve_cli_write_overrides_settings_false() {
8051        let settings = tui::settings::Settings {
8052            light: tui::settings::LightSettings { write_cache: false },
8053            ..tui::settings::Settings::default()
8054        };
8055        assert!(resolve_should_write_cache(true, false, &settings));
8056    }
8057
8058    #[test]
8059    fn resolve_cli_no_write_overrides_settings_true() {
8060        let settings = tui::settings::Settings {
8061            light: tui::settings::LightSettings { write_cache: true },
8062            ..tui::settings::Settings::default()
8063        };
8064        assert!(!resolve_should_write_cache(false, true, &settings));
8065    }
8066
8067    #[test]
8068    fn resolve_settings_true_with_no_cli_flag() {
8069        let settings = tui::settings::Settings {
8070            light: tui::settings::LightSettings { write_cache: true },
8071            ..tui::settings::Settings::default()
8072        };
8073        assert!(resolve_should_write_cache(false, false, &settings));
8074    }
8075
8076    #[test]
8077    fn resolve_settings_false_with_no_cli_flag() {
8078        let settings = tui::settings::Settings {
8079            light: tui::settings::LightSettings { write_cache: false },
8080            ..tui::settings::Settings::default()
8081        };
8082        assert!(!resolve_should_write_cache(false, false, &settings));
8083    }
8084
8085    #[test]
8086    fn resolve_settings_default_returns_false() {
8087        assert!(!resolve_should_write_cache(
8088            false,
8089            false,
8090            &tui::settings::Settings::default()
8091        ));
8092    }
8093
8094    #[test]
8095    fn clap_rejects_write_cache_without_light() {
8096        assert!(Cli::try_parse_from(["tokmesh", "--write-cache"]).is_err());
8097    }
8098
8099    #[test]
8100    fn clap_rejects_no_write_cache_without_light() {
8101        assert!(Cli::try_parse_from(["tokmesh", "--no-write-cache"]).is_err());
8102    }
8103
8104    #[test]
8105    fn clap_rejects_both_write_flags_together() {
8106        assert!(
8107            Cli::try_parse_from(["tokmesh", "--light", "--write-cache", "--no-write-cache",])
8108                .is_err()
8109        );
8110    }
8111
8112    #[test]
8113    fn clap_accepts_models_light_write_cache_after_subcommand() {
8114        assert!(Cli::try_parse_from(["tokmesh", "models", "--light", "--write-cache"]).is_ok());
8115    }
8116
8117    #[test]
8118    fn clap_accepts_cursor_sync_command() {
8119        assert!(Cli::try_parse_from(["tokmesh", "cursor", "sync"]).is_ok());
8120        assert!(Cli::try_parse_from(["tokmesh", "cursor", "sync", "--json"]).is_ok());
8121    }
8122
8123    #[test]
8124    fn clap_accepts_codex_account_commands() {
8125        assert!(Cli::try_parse_from(["tokmesh", "codex", "import", "--name", "work"]).is_ok());
8126        assert!(Cli::try_parse_from(["tokmesh", "codex", "accounts"]).is_ok());
8127        assert!(Cli::try_parse_from(["tokmesh", "codex", "accounts", "--json"]).is_ok());
8128        assert!(Cli::try_parse_from(["tokmesh", "codex", "switch", "work"]).is_ok());
8129        assert!(Cli::try_parse_from(["tokmesh", "codex", "remove", "work"]).is_ok());
8130        assert!(Cli::try_parse_from(["tokmesh", "codex", "status"]).is_ok());
8131        assert!(Cli::try_parse_from(["tokmesh", "codex", "status", "--name", "work"]).is_ok());
8132        assert!(
8133            Cli::try_parse_from(["tokmesh", "codex", "status", "--name", "work", "--json"]).is_ok()
8134        );
8135    }
8136
8137    #[test]
8138    fn clap_accepts_warp_status_and_sync_commands() {
8139        assert!(Cli::try_parse_from(["tokmesh", "warp", "status"]).is_ok());
8140        assert!(Cli::try_parse_from(["tokmesh", "warp", "status", "--json"]).is_ok());
8141        assert!(Cli::try_parse_from(["tokmesh", "warp", "sync"]).is_ok());
8142        assert!(Cli::try_parse_from(["tokmesh", "warp", "sync", "--json"]).is_ok());
8143    }
8144
8145    #[test]
8146    fn client_filter_round_trips_warp() {
8147        assert_eq!(
8148            ClientFilter::from_filter_str("warp"),
8149            Some(ClientFilter::Warp)
8150        );
8151        assert_eq!(ClientFilter::Warp.as_filter_str(), "warp");
8152        assert_eq!(
8153            ClientFilter::Warp.to_client_id(),
8154            Some(tokmesh_core::ClientId::Warp)
8155        );
8156        assert_eq!(
8157            ClientFilter::from_client_id(tokmesh_core::ClientId::Warp),
8158            ClientFilter::Warp
8159        );
8160    }
8161
8162    #[test]
8163    fn client_filter_round_trips_grok() {
8164        assert_eq!(
8165            ClientFilter::from_filter_str("grok"),
8166            Some(ClientFilter::Grok)
8167        );
8168        assert_eq!(ClientFilter::Grok.as_filter_str(), "grok");
8169        assert_eq!(
8170            ClientFilter::Grok.to_client_id(),
8171            Some(tokmesh_core::ClientId::Grok)
8172        );
8173        assert_eq!(
8174            ClientFilter::from_client_id(tokmesh_core::ClientId::Grok),
8175            ClientFilter::Grok
8176        );
8177    }
8178
8179    #[test]
8180    fn default_submit_clients_excludes_warp_aggregate_source() {
8181        let clients = default_submit_clients();
8182        assert!(!clients.contains(&"warp".to_string()));
8183    }
8184
8185    #[test]
8186    fn warp_setup_warning_explains_missing_aggregate_cache() {
8187        let temp = tempfile::TempDir::new().unwrap();
8188        let warnings = warp_setup_warnings_for_report(
8189            &Some(temp.path().to_string_lossy().to_string()),
8190            &Some(vec!["warp".to_string()]),
8191        );
8192
8193        assert_eq!(warnings.len(), 1);
8194        assert!(warnings[0].contains("tokmesh warp"));
8195        assert!(warnings[0].contains("does not infer tokens from request counts"));
8196    }
8197
8198    #[test]
8199    fn cursor_auto_sync_enabled_for_default_report() {
8200        assert!(should_auto_sync_cursor_for_local_report(&None, &None));
8201    }
8202
8203    #[test]
8204    fn cursor_auto_sync_enabled_when_cursor_filter_is_explicit() {
8205        assert!(should_auto_sync_cursor_for_local_report(
8206            &None,
8207            &Some(vec!["cursor".to_string()])
8208        ));
8209    }
8210
8211    #[test]
8212    fn cursor_auto_sync_disabled_when_filter_excludes_cursor() {
8213        assert!(!should_auto_sync_cursor_for_local_report(
8214            &None,
8215            &Some(vec!["codex".to_string()])
8216        ));
8217    }
8218
8219    #[test]
8220    fn cursor_auto_sync_disabled_for_home_override() {
8221        assert!(!should_auto_sync_cursor_for_local_report(
8222            &Some("/tmp/other-home".to_string()),
8223            &None
8224        ));
8225        assert!(!should_auto_sync_cursor_for_local_report(
8226            &Some("/tmp/other-home".to_string()),
8227            &Some(vec!["cursor".to_string()])
8228        ));
8229    }
8230
8231    #[test]
8232    fn cursor_auto_sync_runtime_init_failure_is_best_effort() {
8233        let result = run_best_effort_cursor_sync_with_runtime_factory(|| {
8234            Err(std::io::Error::other("runtime unavailable"))
8235        });
8236
8237        assert!(!result.synced);
8238        assert_eq!(result.rows, 0);
8239        assert!(result
8240            .error
8241            .as_deref()
8242            .is_some_and(|error| error.contains("runtime unavailable")));
8243    }
8244
8245    #[test]
8246    fn write_light_cache_refuses_when_home_dir_set() {
8247        // --home rebinds the scan root; DataLoader::load currently ignores
8248        // this field and resolves home from dirs::home_dir() with
8249        // use_env_roots=true, so the printed --light report is built from
8250        // <home> while a naive cache write would store data scanned from
8251        // the default home. Refuse the write to avoid that drift.
8252        let group_by = tokmesh_core::GroupBy::default();
8253        write_light_cache(
8254            &Some("/tmp/fake-home".to_string()),
8255            &None,
8256            &None,
8257            &None,
8258            &None,
8259            &group_by,
8260        );
8261    }
8262}