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 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#[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 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 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 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 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 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 #[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
1221fn 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
1236fn 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 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 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 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 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 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 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 let table_str = table.to_string();
3261 println!("{}", dim_borders(&table_str));
3262
3263 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
3626fn 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
3642fn 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 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 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
3700fn 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
4479fn 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 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
5038fn 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 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 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#[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 tokens.total()
5319}
5320
5321fn 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
5339fn 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
5348fn 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
5402fn 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 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 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 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 cmd.process_group(0);
5850 }
5851
5852 let _ = cmd.spawn();
5853}
5854
5855fn resolve_default_tui_filter_set() -> std::collections::HashSet<ClientFilter> {
5871 resolve_default_tui_filter_set_with(&tui::settings::load_default_clients())
5872}
5873
5874fn 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 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 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 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
6031fn 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 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 if days <= 0 {
6161 anyhow::bail!("--since must be a positive number of days (got {days})");
6162 }
6163 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 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 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 assert_eq!(saturating_token_total(10, 20, 30, 40), 100);
6445 }
6446
6447 #[test]
6448 fn monthly_token_field_totals_saturate_across_entries() {
6449 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 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 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 #[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 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 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 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 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 filter in ClientFilter::value_variants() {
6754 match filter.to_client_id() {
6755 Some(id) => {
6756 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(day
7916 .clients
7917 .iter()
7918 .any(|c| c.model_id == "premium-tool-call"));
7919 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 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 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}