use std::ffi::OsString;
use serde_json::{Value, json};
use crate::help::{help_text, help_text_for_args};
use crate::*;
use csusage_cli::*;
use csusage_test_support::fs_fixture;
fn parse(args: &[&str]) -> Cli {
Cli::parse_from(args.iter().map(OsString::from)).unwrap()
}
fn parse_with_config(args: &[&str], config: &dyn CliConfig) -> Cli {
Cli::parse_from_with_config(
args.iter().map(OsString::from),
config,
5.0,
env!("CARGO_PKG_VERSION"),
)
.unwrap()
}
fn parse_error(args: &[&str]) -> String {
match Cli::parse_from(args.iter().map(OsString::from)) {
Ok(_) => panic!("expected parse error"),
Err(error) => error,
}
}
#[derive(Default)]
struct TestConfig {
shared_json: Option<bool>,
shared_order: Option<SortOrder>,
shared_since: Option<&'static str>,
shared_timezone: Option<&'static str>,
shared_compact: Option<bool>,
weekly_start: Option<WeekDay>,
blocks_active: Option<bool>,
blocks_token_limit: Option<&'static str>,
blocks_session_length: Option<f64>,
statusline_visual_burn_rate: Option<VisualBurnRate>,
statusline_cost_source: Option<CostSource>,
statusline_refresh_interval: Option<u64>,
codex_speed: Option<CodexSpeed>,
pi_path: Option<&'static str>,
open_claw_path: Option<&'static str>,
}
impl CliConfig for TestConfig {
fn apply_shared(&self, shared: &mut SharedArgs) {
if let Some(json) = self.shared_json {
shared.json = json;
}
if let Some(order) = self.shared_order {
shared.order = order;
}
if let Some(since) = self.shared_since {
shared.since = Some(since.to_string());
}
if let Some(timezone) = self.shared_timezone {
shared.timezone = Some(timezone.to_string());
}
if let Some(compact) = self.shared_compact {
shared.compact = compact;
}
}
fn apply_weekly_args(&self, args: &mut WeeklyArgs) {
if let Some(start_of_week) = self.weekly_start {
args.start_of_week = start_of_week;
}
}
fn apply_blocks_args(&self, args: &mut BlocksArgs) {
if let Some(active) = self.blocks_active {
args.active = active;
}
if let Some(token_limit) = self.blocks_token_limit {
args.token_limit = Some(token_limit.to_string());
}
if let Some(session_length) = self.blocks_session_length {
args.session_length = session_length;
}
}
fn apply_statusline_args(&self, args: &mut StatuslineArgs) {
if let Some(visual_burn_rate) = self.statusline_visual_burn_rate {
args.visual_burn_rate = visual_burn_rate;
}
if let Some(cost_source) = self.statusline_cost_source {
args.cost_source = cost_source;
}
if let Some(refresh_interval) = self.statusline_refresh_interval {
args.refresh_interval = refresh_interval;
}
}
fn apply_agent_args(
&self,
codex_speed: &mut CodexSpeed,
pi_path: Option<&mut Option<String>>,
open_claw_path: Option<&mut Option<String>>,
) {
if let Some(speed) = self.codex_speed {
*codex_speed = speed;
}
if let (Some(path), Some(pi_path)) = (self.pi_path, pi_path) {
*pi_path = Some(path.to_string());
}
if let (Some(path), Some(open_claw_path)) = (self.open_claw_path, open_claw_path) {
*open_claw_path = Some(path.to_string());
}
}
}
fn shared_snapshot(shared: &SharedArgs) -> Value {
json!({
"since": shared.since.as_deref(),
"until": shared.until.as_deref(),
"json": shared.json,
"mode": format!("{:?}", shared.mode),
"debug": shared.debug,
"debugSamples": shared.debug_samples,
"order": format!("{:?}", shared.order),
"breakdown": shared.breakdown,
"offline": shared.offline,
"noOffline": shared.no_offline,
"color": shared.color,
"noColor": shared.no_color,
"timezone": shared.timezone.as_deref(),
"jq": shared.jq.as_deref(),
"config": shared.config.as_ref().map(|path| path.to_string_lossy().to_string()),
"compact": shared.compact,
"singleThread": shared.single_thread,
})
}
fn cli_snapshot(cli: Cli) -> Value {
json!({
"shared": shared_snapshot(&cli.shared),
"command": command_snapshot(cli.command),
})
}
fn command_snapshot(command: Option<Command>) -> Value {
match command {
None => Value::Null,
Some(Command::All(args)) => agent_command_snapshot("all", args),
Some(Command::Daily(args)) => json!({
"type": "daily",
"shared": shared_snapshot(&args.shared),
"instances": args.instances,
"project": args.project,
"projectAliases": args.project_aliases,
}),
Some(Command::Monthly(shared)) => json!({
"type": "monthly",
"shared": shared_snapshot(&shared),
}),
Some(Command::Weekly(args)) => json!({
"type": "weekly",
"shared": shared_snapshot(&args.shared),
"startOfWeek": format!("{:?}", args.start_of_week),
}),
Some(Command::Session(args)) => json!({
"type": "session",
"shared": shared_snapshot(&args.shared),
"id": args.id,
}),
Some(Command::Blocks(args)) => json!({
"type": "blocks",
"shared": shared_snapshot(&args.shared),
"active": args.active,
"recent": args.recent,
"tokenLimit": args.token_limit,
"sessionLength": args.session_length,
}),
Some(Command::Statusline(args)) => json!({
"type": "statusline",
"offline": args.offline,
"noOffline": args.no_offline,
"visualBurnRate": format!("{:?}", args.visual_burn_rate),
"costSource": format!("{:?}", args.cost_source),
"cache": args.cache,
"noCache": args.no_cache,
"refreshInterval": args.refresh_interval,
"contextLowThreshold": args.context_low_threshold,
"contextMediumThreshold": args.context_medium_threshold,
"config": args.config.as_ref().map(|path| path.to_string_lossy().to_string()),
"debug": args.debug,
}),
Some(Command::Codex(args)) => agent_command_snapshot("codex", args),
Some(Command::OpenCode(args)) => agent_command_snapshot("opencode", args),
Some(Command::Amp(args)) => agent_command_snapshot("amp", args),
Some(Command::Droid(args)) => agent_command_snapshot("droid", args),
Some(Command::Codebuff(args)) => agent_command_snapshot("codebuff", args),
Some(Command::Hermes(args)) => agent_command_snapshot("hermes", args),
Some(Command::Pi(args)) => agent_command_snapshot("pi", args),
Some(Command::Goose(args)) => agent_command_snapshot("goose", args),
Some(Command::Kilo(args)) => agent_command_snapshot("kilo", args),
Some(Command::Copilot(args)) => agent_command_snapshot("copilot", args),
Some(Command::Gemini(args)) => agent_command_snapshot("gemini", args),
Some(Command::Antigravity(args)) => agent_command_snapshot("antigravity", args),
Some(Command::Kimi(args)) => agent_command_snapshot("kimi", args),
Some(Command::Qwen(args)) => agent_command_snapshot("qwen", args),
Some(Command::OpenClaw(args)) => agent_command_snapshot("openclaw", args),
Some(Command::Grok(args)) => agent_command_snapshot("grok", args),
Some(Command::ZCode(args)) => agent_command_snapshot("zcode", args),
Some(Command::ClaudeScience(args)) => agent_command_snapshot("claude-science", args),
}
}
fn agent_command_snapshot(agent: &str, args: AgentCommandArgs) -> Value {
json!({
"type": agent,
"shared": shared_snapshot(&args.shared),
"kind": format!("{:?}", args.kind),
"sections": args.sections.map(|sections| sections
.into_iter()
.map(|section| format!("{section:?}"))
.collect::<Vec<_>>()),
"byAgent": args.by_agent,
"piPath": args.pi_path,
"openClawPath": args.open_claw_path,
"codexSpeed": format!("{:?}", args.codex_speed),
})
}
#[test]
fn parses_root_daily_as_all_agent_report() {
let cli = parse(&["ccusage", "daily", "--json", "--since", "20260102"]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(args.kind, AgentReportKind::Daily);
assert!(args.shared.json);
assert_eq!(args.shared.since.as_deref(), Some("20260102"));
}
#[test]
fn commandless_invocation_preserves_default_dispatch() {
let cli = parse(&["ccusage"]);
assert!(cli.command.is_none());
}
#[test]
fn rejects_date_bounds_that_are_not_real_calendar_dates() {
for value in [
"2026-02-30",
"2026-13-01",
"abc",
"2026/07/10",
"2026-07-10T00:00:00Z",
] {
assert_eq!(
parse_error(&["ccusage", "daily", "--since", value]),
format!("Invalid value for --since '{value}'. Expected YYYY-MM-DD or YYYYMMDD.")
);
assert_eq!(
parse_error(&["ccusage", "daily", "--until", value]),
format!("Invalid value for --until '{value}'. Expected YYYY-MM-DD or YYYYMMDD.")
);
}
}
#[test]
fn accepts_both_documented_date_bound_formats() {
for (value, normalized) in [("2026-07-10", "20260710"), ("20260710", "20260710")] {
let cli = parse(&["ccusage", "daily", "--since", value, "--until", value]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(args.shared.since.as_deref(), Some(normalized));
assert_eq!(args.shared.until.as_deref(), Some(normalized));
}
}
#[test]
fn parses_last_periods_on_period_reports() {
let cli = parse(&["ccusage", "daily", "--last", "1"]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(args.shared.last, Some(1));
let cli = parse(&["ccusage", "claude", "weekly", "--last", "2"]);
let Some(Command::Weekly(args)) = cli.command else {
panic!("expected weekly command");
};
assert_eq!(args.shared.last, Some(2));
let cli = parse(&["ccusage", "codex", "monthly", "--last=3"]);
let Some(Command::Codex(args)) = cli.command else {
panic!("expected codex command");
};
assert_eq!(args.shared.last, Some(3));
}
#[test]
fn parses_last_periods_before_the_command_name() {
let cli = parse(&["ccusage", "--last", "1", "weekly"]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(args.kind, AgentReportKind::Weekly);
assert_eq!(args.shared.last, Some(1));
let cli = parse(&["ccusage", "--last", "1"]);
assert!(cli.command.is_none());
assert_eq!(cli.shared.last, Some(1));
}
#[test]
fn leaves_the_short_alias_of_the_removed_locale_option_unused() {
assert_eq!(
parse_error(&["ccusage", "daily", "-l", "1"]),
"Unknown option '-l'"
);
}
#[test]
fn rejects_last_periods_on_reports_without_a_period() {
for args in [
["ccusage", "session", "--last", "1"],
["ccusage", "blocks", "--last", "1"],
["ccusage", "codex", "session", "--last=1"],
] {
assert_eq!(
parse_error(&args),
"The --last option is only available for the daily, weekly, and monthly reports."
);
}
}
#[test]
fn rejects_last_periods_alongside_an_explicit_date_window() {
assert_eq!(
parse_error(&["ccusage", "daily", "--last", "1", "--since", "20260101"]),
"The --last option cannot be combined with --since or --until."
);
assert_eq!(
parse_error(&["ccusage", "daily", "--last", "1", "--until", "20260101"]),
"The --last option cannot be combined with --since or --until."
);
}
#[test]
fn rejects_last_periods_with_multi_section_reports() {
assert_eq!(
parse_error(&["ccusage", "daily", "--last", "1", "--sections", "monthly"]),
"The --last option cannot be used with --sections."
);
}
#[test]
fn rejects_last_periods_that_are_not_positive_whole_numbers() {
assert_eq!(
parse_error(&["ccusage", "daily", "--last", "0"]),
"Invalid value for --last '0'. Expected a whole number of periods, 1 or greater."
);
assert_eq!(
parse_error(&["ccusage", "daily", "--last", "week"]),
"Invalid value for --last 'week'. Expected a whole number of periods, 1 or greater."
);
}
#[test]
fn parses_unified_sections_and_by_agent_flags() {
let cli = parse(&[
"ccusage",
"daily",
"--json",
"--sections",
"monthly,session",
"--by-agent",
]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(args.kind, AgentReportKind::Daily);
assert_eq!(
args.sections.as_deref(),
Some(&[AgentReportKind::Monthly, AgentReportKind::Session][..])
);
assert!(args.by_agent);
}
#[test]
fn parses_root_sections_and_by_agent_flags_without_daily_token() {
let cli = parse(&[
"ccusage",
"--json",
"--sections",
"monthly,session",
"--by-agent",
]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(args.kind, AgentReportKind::Daily);
assert_eq!(
args.sections.as_deref(),
Some(&[AgentReportKind::Monthly, AgentReportKind::Session][..])
);
assert!(args.by_agent);
}
#[test]
fn parses_inline_unified_sections_value() {
let cli = parse(&["ccusage", "daily", "--sections=daily,monthly"]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(
args.sections.as_deref(),
Some(&[AgentReportKind::Daily, AgentReportKind::Monthly][..])
);
}
#[test]
fn ignores_empty_unified_section_tokens() {
let cli = parse(&["ccusage", "daily", "--sections", "daily,,monthly,"]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(
args.sections.as_deref(),
Some(&[AgentReportKind::Daily, AgentReportKind::Monthly][..])
);
}
#[test]
fn dedupes_unified_section_tokens_preserving_first_occurrence() {
let cli = parse(&["ccusage", "daily", "--sections", "daily,daily,monthly"]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(
args.sections.as_deref(),
Some(&[AgentReportKind::Daily, AgentReportKind::Monthly][..])
);
}
#[test]
fn parses_top_level_session_sections_as_all_agent_report_without_id() {
let cli = parse(&[
"ccusage",
"session",
"--sections",
"daily,weekly",
"--by-agent",
]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert_eq!(
args.sections.as_deref(),
Some(&[AgentReportKind::Daily, AgentReportKind::Weekly][..])
);
assert!(args.by_agent);
}
#[test]
fn rejects_sections_and_by_agent_with_top_level_session_id() {
let sections_error = parse_error(&["ccusage", "session", "--id", "abc", "--sections", "daily"]);
assert_eq!(
sections_error,
"The --sections and --by-agent options cannot be used with session --id."
);
let by_agent_error = parse_error(&["ccusage", "session", "--id", "abc", "--by-agent"]);
assert_eq!(
by_agent_error,
"The --sections and --by-agent options cannot be used with session --id."
);
}
#[test]
fn rejects_unified_only_flags_on_per_agent_subcommands() {
let sections_error = parse_error(&["ccusage", "codex", "daily", "--sections", "daily"]);
assert_eq!(sections_error, "Unknown codex option '--sections'");
let by_agent_error = parse_error(&["ccusage", "codex", "daily", "--by-agent"]);
assert_eq!(by_agent_error, "Unknown codex option '--by-agent'");
}
#[test]
fn rejects_invalid_unified_section_token() {
let error = parse_error(&["ccusage", "daily", "--sections", "daily,yearly"]);
assert_eq!(
error,
"Invalid --sections value 'yearly'. Expected one or more of: daily, weekly, monthly, session."
);
}
#[test]
fn preserves_shared_config_defaults_with_unified_sections() {
let config = TestConfig {
shared_json: Some(true),
shared_since: Some("20260102"),
..TestConfig::default()
};
let cli = parse_with_config(&["ccusage", "monthly", "--sections", "daily"], &config);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert!(args.shared.json);
assert_eq!(args.shared.since.as_deref(), Some("20260102"));
assert_eq!(
args.sections.as_deref(),
Some(&[AgentReportKind::Daily][..])
);
}
#[test]
fn parses_root_session_as_all_agent_report_without_id() {
let cli = parse(&["ccusage", "session", "--json"]);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn applies_config_defaults_and_command_options_before_cli_options() {
let config = TestConfig {
shared_json: Some(true),
shared_order: Some(SortOrder::Desc),
shared_since: Some("20260102"),
..TestConfig::default()
};
let cli = parse_with_config(&["ccusage", "daily", "--order", "asc"], &config);
let Some(Command::All(args)) = cli.command else {
panic!("expected all-agent command");
};
assert!(args.shared.json);
assert_eq!(args.shared.since.as_deref(), Some("20260102"));
assert_eq!(args.shared.order, SortOrder::Asc);
}
#[test]
fn applies_agent_namespace_config_to_codex_speed() {
let config = TestConfig {
codex_speed: Some(CodexSpeed::Fast),
..TestConfig::default()
};
let cli = parse_with_config(&["ccusage", "codex", "daily"], &config);
let Some(Command::Codex(args)) = cli.command else {
panic!("expected codex command");
};
assert_eq!(args.codex_speed, CodexSpeed::Fast);
}
#[test]
fn applies_config_file_passed_after_agent_command() {
let config = TestConfig {
shared_json: Some(true),
shared_timezone: Some("Asia/Tokyo"),
shared_since: Some("20260101"),
codex_speed: Some(CodexSpeed::Standard),
..TestConfig::default()
};
let cli = parse_with_config(
&[
"ccusage",
"codex",
"monthly",
"--config",
"/tmp/ccusage.json",
],
&config,
);
let Some(Command::Codex(args)) = cli.command else {
panic!("expected codex command");
};
assert_eq!(args.kind, AgentReportKind::Monthly);
assert!(args.shared.json);
assert_eq!(args.shared.timezone.as_deref(), Some("Asia/Tokyo"));
assert_eq!(args.shared.since.as_deref(), Some("20260101"));
assert_eq!(args.codex_speed, CodexSpeed::Standard);
}
#[test]
fn applies_schema_documented_config_file_options() {
let config = TestConfig {
shared_json: Some(true),
shared_compact: Some(true),
weekly_start: Some(WeekDay::Monday),
blocks_active: Some(true),
blocks_token_limit: Some("500000"),
blocks_session_length: Some(6.0),
statusline_visual_burn_rate: Some(VisualBurnRate::EmojiText),
statusline_cost_source: Some(CostSource::Both),
statusline_refresh_interval: Some(3),
pi_path: Some("/tmp/pi-sessions"),
open_claw_path: Some("/tmp/openclaw"),
..TestConfig::default()
};
let cli = parse_with_config(&["ccusage", "claude", "weekly"], &config);
let Some(Command::Weekly(args)) = cli.command else {
panic!("expected weekly command");
};
assert!(args.shared.json);
assert!(args.shared.compact);
assert_eq!(args.start_of_week, WeekDay::Monday);
let cli = parse_with_config(&["ccusage", "claude", "blocks"], &config);
let Some(Command::Blocks(args)) = cli.command else {
panic!("expected blocks command");
};
assert!(args.active);
assert_eq!(args.token_limit.as_deref(), Some("500000"));
assert_eq!(args.session_length, 6.0);
let cli = parse_with_config(&["ccusage", "claude", "statusline"], &config);
let Some(Command::Statusline(args)) = cli.command else {
panic!("expected statusline command");
};
assert_eq!(args.visual_burn_rate, VisualBurnRate::EmojiText);
assert_eq!(args.cost_source, CostSource::Both);
assert_eq!(args.refresh_interval, 3);
let cli = parse_with_config(&["ccusage", "pi", "daily"], &config);
let Some(Command::Pi(args)) = cli.command else {
panic!("expected pi command");
};
assert_eq!(args.pi_path.as_deref(), Some("/tmp/pi-sessions"));
let cli = parse_with_config(&["ccusage", "openclaw", "daily"], &config);
let Some(Command::OpenClaw(args)) = cli.command else {
panic!("expected openclaw command");
};
assert_eq!(args.open_claw_path.as_deref(), Some("/tmp/openclaw"));
}
#[test]
fn root_help_lists_agent_namespaces_without_nested_commands() {
let help = help_text();
let agents = [
"claude",
"codex",
"opencode",
"amp",
"droid",
"codebuff",
"hermes",
"pi",
"goose",
"kilo",
"copilot",
"gemini",
"kimi",
"qwen",
"openclaw",
"grok",
"zcode",
"claude-science",
];
for agent in agents {
assert!(help.contains(&format!("\n {agent} ")));
assert!(!help.contains(&format!("\n {agent} daily")));
}
}
#[test]
fn root_help_lists_command_descriptions_and_follow_up_help_commands() {
let help = help_text();
assert!(help.contains("codex Show Codex token usage commands"));
assert!(help.contains("For more info, run any command with the `--help` flag:"));
assert!(help.contains("ccusage codex --help"));
assert!(!help.contains("ccusage codex daily --help"));
}
#[test]
fn contextual_codex_help_lists_speed_choices() {
let help = help_text_for_args(&[
"ccusage".to_string(),
"codex".to_string(),
"daily".to_string(),
"--help".to_string(),
]);
assert!(help.contains("Show Codex token usage grouped by day"));
assert!(help.contains("USAGE:\n ccusage codex daily <OPTIONS>"));
assert!(help.contains("choices: auto | standard | fast"));
}
#[test]
fn contextual_help_strips_path_like_program_name() {
let help = help_text_for_args(&[
"/usr/local/bin/ccusage".to_string(),
"codex".to_string(),
"daily".to_string(),
]);
assert!(help.contains("USAGE:\n ccusage codex daily <OPTIONS>"));
}
#[test]
fn contextual_help_strips_windows_program_name() {
let help = help_text_for_args(&[
"C:\\Tools\\ccusage.exe".to_string(),
"codex".to_string(),
"daily".to_string(),
]);
assert!(help.contains("USAGE:\n ccusage codex daily <OPTIONS>"));
}
#[test]
fn contextual_agent_help_lists_agent_subcommands() {
let help = help_text_for_args(&["ccusage".to_string(), "claude".to_string()]);
assert!(help.contains("USAGE:\n ccusage claude <COMMANDS>"));
assert!(help.contains("daily Show usage report grouped by date"));
assert!(help.contains("statusline Display compact status line for Claude Code hooks"));
assert!(help.contains("ccusage claude statusline --help"));
assert!(!help.contains("ccusage claude daily <OPTIONS>"));
}
#[test]
fn contextual_all_agent_help_lists_color_options() {
let help = help_text_for_args(&["ccusage".to_string(), "daily".to_string()]);
assert!(help.contains("--color"));
assert!(help.contains("--no-color"));
}
#[test]
fn contextual_root_session_help_lists_id_option() {
let help = help_text_for_args(&["ccusage".to_string(), "session".to_string()]);
assert!(help.contains("--id"));
}
#[test]
fn contextual_statusline_help_lists_choice_options() {
let help = help_text_for_args(&["ccusage".to_string(), "statusline".to_string()]);
assert!(help.contains("choices: off | emoji | text | emoji-text"));
assert!(help.contains("choices: auto | ccusage | cc | both"));
}
#[test]
fn grok_help_does_not_advertise_path_option() {
let help = help_text_for_args(&[
"ccusage".to_string(),
"grok".to_string(),
"daily".to_string(),
]);
assert!(!help.contains("--grok-path"));
}
#[test]
fn snapshots_root_and_contextual_help_text() {
insta::assert_snapshot!("root_help", help_text());
insta::assert_snapshot!(
"claude_agent_help",
help_text_for_args(&["ccusage".to_string(), "claude".to_string()])
);
insta::assert_snapshot!(
"codex_daily_help",
help_text_for_args(&[
"ccusage".to_string(),
"codex".to_string(),
"daily".to_string(),
])
);
insta::assert_snapshot!(
"statusline_help",
help_text_for_args(&["ccusage".to_string(), "statusline".to_string()])
);
}
#[test]
fn snapshots_representative_cli_parse_shapes() {
let cases = vec![
json!({
"case": "default all-agent daily",
"cli": cli_snapshot(parse(&["ccusage"])),
}),
json!({
"case": "root daily with shared flags",
"cli": cli_snapshot(parse(&[
"ccusage",
"--json",
"--since=20260102",
"--until",
"20260110",
"--mode",
"calculate",
"--debug",
"--debug-samples",
"9",
"--order",
"desc",
"--breakdown",
"--offline",
"--no-offline",
"--color",
"--no-color",
"--timezone",
"Asia/Tokyo",
"--jq",
".totals",
"--compact",
"--single-thread",
"daily",
])),
}),
json!({
"case": "claude weekly monday",
"cli": cli_snapshot(parse(&[
"ccusage",
"claude",
"weekly",
"--start-of-week",
"monday",
])),
}),
json!({
"case": "claude daily project instances",
"cli": cli_snapshot(parse(&[
"ccusage",
"claude",
"daily",
"--instances",
"--project",
"repo",
"--project-aliases",
"repo=Repository",
])),
}),
json!({
"case": "codex monthly fast",
"cli": cli_snapshot(parse(&[
"ccusage",
"codex",
"monthly",
"--speed=fast",
])),
}),
json!({
"case": "opencode weekly",
"cli": cli_snapshot(parse(&["ccusage", "opencode", "weekly", "--json"])),
}),
json!({
"case": "pi session path",
"cli": cli_snapshot(parse(&[
"ccusage",
"pi",
"session",
"--pi-path",
"/tmp/pi-sessions",
])),
}),
json!({
"case": "openclaw session path",
"cli": cli_snapshot(parse(&[
"ccusage",
"openclaw",
"session",
"--open-claw-path=/tmp/openclaw",
])),
}),
json!({
"case": "grok daily",
"cli": cli_snapshot(parse(&["ccusage", "grok", "daily", "--json"])),
}),
json!({
"case": "antigravity session",
"cli": cli_snapshot(parse(&["ccusage", "antigravity", "session", "--json"])),
}),
json!({
"case": "zcode daily",
"cli": cli_snapshot(parse(&["ccusage", "zcode", "daily", "--json"])),
}),
json!({
"case": "blocks active recent",
"cli": cli_snapshot(parse(&[
"ccusage",
"blocks",
"--active",
"--recent",
"--token-limit",
"max",
"--session-length=6.5",
])),
}),
json!({
"case": "statusline thresholds",
"cli": cli_snapshot(parse(&[
"ccusage",
"statusline",
"--no-offline",
"--visual-burn-rate",
"emoji-text",
"--cost-source",
"both",
"--no-cache",
"--refresh-interval",
"3",
"--context-low-threshold",
"45",
"--context-medium-threshold",
"75",
"--debug",
])),
}),
];
insta::assert_json_snapshot!(cases);
}
#[test]
fn snapshots_cli_parse_error_guidance() {
let cases = vec![
json!({
"args": ["ccusage", "--daily"],
"error": parse_error(&["ccusage", "--daily"]),
}),
json!({
"args": ["ccusage", "daily", "--agent", "codex"],
"error": parse_error(&["ccusage", "daily", "--agent", "codex"]),
}),
json!({
"args": ["ccusage", "codex", "blocks"],
"error": parse_error(&["ccusage", "codex", "blocks"]),
}),
json!({
"args": ["ccusage", "--mode", "bad"],
"error": parse_error(&["ccusage", "--mode", "bad"]),
}),
json!({
"args": ["ccusage", "blocks", "--session-length", "abc"],
"error": parse_error(&["ccusage", "blocks", "--session-length", "abc"]),
}),
json!({
"args": ["ccusage", "statusline", "--visual-burn-rate", "loud"],
"error": parse_error(&[
"ccusage",
"statusline",
"--visual-burn-rate",
"loud",
]),
}),
json!({
"args": ["ccusage", "pi", "weekly"],
"error": parse_error(&["ccusage", "pi", "weekly"]),
}),
];
insta::assert_json_snapshot!(cases);
}
#[test]
fn cargo_version_is_independent_from_release_version() {
assert_eq!(env!("CARGO_PKG_VERSION"), "0.0.0");
}
#[test]
fn parses_claude_daily_options() {
let cli = parse(&[
"ccusage",
"claude",
"daily",
"--json",
"--mode",
"display",
"--instances",
"--project",
"repo",
]);
let Some(Command::Daily(args)) = cli.command else {
panic!("expected daily command");
};
assert!(args.shared.json);
assert_eq!(args.shared.mode, CostMode::Display);
assert!(args.instances);
assert_eq!(args.project.as_deref(), Some("repo"));
}
#[test]
fn rejects_removed_locale_option() {
let result = Cli::parse_from(
["ccusage", "--locale", "en-CA"]
.into_iter()
.map(OsString::from),
);
assert!(result.is_err());
}
#[test]
fn parses_blocks_defaults_and_values() {
let cli = parse(&[
"ccusage",
"blocks",
"-a",
"--token-limit=max",
"--session-length",
"6",
]);
let Some(Command::Blocks(args)) = cli.command else {
panic!("expected blocks command");
};
assert!(args.active);
assert_eq!(args.token_limit.as_deref(), Some("max"));
assert_eq!(args.session_length, 6.0);
}
#[test]
fn parses_claude_blocks_short_active_option() {
let cli = parse(&["ccusage", "claude", "blocks", "-a"]);
let Some(Command::Blocks(args)) = cli.command else {
panic!("expected blocks command");
};
assert!(args.active);
}
#[test]
fn parses_statusline_options() {
let cli = parse(&[
"ccusage",
"statusline",
"--no-cache",
"--timezone",
"Asia/Tokyo",
"--visual-burn-rate",
"emoji-text",
"--cost-source",
"both",
]);
let Some(Command::Statusline(args)) = cli.command else {
panic!("expected statusline command");
};
assert!(args.offline);
assert!(args.no_cache);
assert_eq!(args.timezone.as_deref(), Some("Asia/Tokyo"));
assert_eq!(args.visual_burn_rate, VisualBurnRate::EmojiText);
assert_eq!(args.cost_source, CostSource::Both);
}
#[test]
fn parses_codex_default_daily_options() {
let cli = parse(&["ccusage", "codex", "--json", "--since", "20260102"]);
let Some(Command::Codex(args)) = cli.command else {
panic!("expected codex command");
};
assert_eq!(args.kind, AgentReportKind::Daily);
assert!(args.shared.json);
assert_eq!(args.shared.since.as_deref(), Some("20260102"));
}
#[test]
fn parses_codex_speed_option() {
let cli = parse(&["ccusage", "codex", "daily", "--speed", "fast"]);
let Some(Command::Codex(args)) = cli.command else {
panic!("expected codex command");
};
assert_eq!(args.codex_speed, CodexSpeed::Fast);
}
#[test]
fn rejects_removed_codex_by_source_option() {
assert_eq!(
parse_error(&["ccusage", "codex", "daily", "--by-source"]),
"Unknown codex option '--by-source'"
);
assert_eq!(
parse_error(&["ccusage", "daily", "--by-source"]),
"Unknown option '--by-source'"
);
}
#[test]
fn parses_legacy_colon_agent_commands() {
let cli = parse(&["ccusage", "codex:monthly", "--json"]);
let Some(Command::Codex(args)) = cli.command else {
panic!("expected codex command");
};
assert_eq!(args.kind, AgentReportKind::Monthly);
assert!(args.shared.json);
}
#[test]
fn rejects_report_flag_aliases_with_guidance() {
let error = parse_error(&["ccusage", "--daily"]);
assert_eq!(
error,
"Report flags like --daily are not supported. Use \"ccusage daily\" instead."
);
}
#[test]
fn rejects_agent_filter_options_with_guidance() {
let error = parse_error(&["ccusage", "daily", "--agent", "codex"]);
assert_eq!(
error,
"Agent filters like --agent are not supported. Use \"ccusage <agent> <report>\", for example \"ccusage codex daily\"."
);
}
#[test]
fn rejects_unsupported_agent_reports_with_guidance() {
let error = parse_error(&["ccusage", "codex", "blocks"]);
assert_eq!(
error,
"The \"blocks\" report is only available for Claude Code usage.\nUse \"ccusage codex daily\" for Codex usage reports."
);
}
#[test]
fn parses_claude_namespace_session_options() {
let cli = parse(&["ccusage", "claude", "session", "--json", "--id", "abc"]);
let Some(Command::Session(args)) = cli.command else {
panic!("expected claude session command");
};
assert!(args.shared.json);
assert_eq!(args.id.as_deref(), Some("abc"));
}
#[test]
fn parses_top_level_session_id_lookup() {
let cli = parse(&["ccusage", "session", "--json", "--id", "abc"]);
let Some(Command::Session(args)) = cli.command else {
panic!("expected session command");
};
assert!(args.shared.json);
assert_eq!(args.id.as_deref(), Some("abc"));
}
#[test]
fn parses_opencode_weekly_options() {
let cli = parse(&["ccusage", "opencode", "weekly", "--json"]);
let Some(Command::OpenCode(args)) = cli.command else {
panic!("expected opencode command");
};
assert_eq!(args.kind, AgentReportKind::Weekly);
assert!(args.shared.json);
}
#[test]
fn parses_amp_session_options() {
let cli = parse(&["ccusage", "amp", "session", "--json"]);
let Some(Command::Amp(args)) = cli.command else {
panic!("expected amp command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_droid_session_options() {
let cli = parse(&["ccusage", "droid", "session", "--json"]);
let Some(Command::Droid(args)) = cli.command else {
panic!("expected droid command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_codebuff_session_options() {
let cli = parse(&["ccusage", "codebuff", "session", "--json"]);
let Some(Command::Codebuff(args)) = cli.command else {
panic!("expected codebuff command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_qwen_session_options() {
let cli = parse(&["ccusage", "qwen", "session", "--json"]);
let Some(Command::Qwen(args)) = cli.command else {
panic!("expected qwen command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_pi_session_options() {
let cli = parse(&[
"ccusage",
"pi",
"session",
"--json",
"--pi-path",
"/tmp/pi-sessions",
]);
let Some(Command::Pi(args)) = cli.command else {
panic!("expected pi command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
assert_eq!(args.pi_path.as_deref(), Some("/tmp/pi-sessions"));
}
#[test]
fn parses_kilo_session_options() {
let cli = parse(&["ccusage", "kilo", "session", "--json"]);
let Some(Command::Kilo(args)) = cli.command else {
panic!("expected kilo command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_goose_session_options() {
let cli = parse(&["ccusage", "goose", "session", "--json"]);
let Some(Command::Goose(args)) = cli.command else {
panic!("expected goose command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_copilot_session_options() {
let cli = parse(&["ccusage", "copilot", "session", "--json"]);
let Some(Command::Copilot(args)) = cli.command else {
panic!("expected copilot command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_gemini_session_options() {
let cli = parse(&["ccusage", "gemini", "session", "--json"]);
let Some(Command::Gemini(args)) = cli.command else {
panic!("expected gemini command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_kimi_session_options() {
let cli = parse(&["ccusage", "kimi", "session", "--json"]);
let Some(Command::Kimi(args)) = cli.command else {
panic!("expected kimi command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
}
#[test]
fn parses_openclaw_session_options() {
let cli = parse(&[
"ccusage",
"openclaw",
"session",
"--json",
"--open-claw-path",
"/tmp/openclaw",
]);
let Some(Command::OpenClaw(args)) = cli.command else {
panic!("expected openclaw command");
};
assert_eq!(args.kind, AgentReportKind::Session);
assert!(args.shared.json);
assert_eq!(args.open_claw_path.as_deref(), Some("/tmp/openclaw"));
}
#[test]
fn parses_grok_daily_options() {
let cli = parse(&["ccusage", "grok", "daily", "--json"]);
let Some(Command::Grok(args)) = cli.command else {
panic!("expected grok command");
};
assert_eq!(args.kind, AgentReportKind::Daily);
assert!(args.shared.json);
}
#[test]
fn parses_zcode_daily_options() {
let cli = parse(&["ccusage", "zcode", "daily", "--json"]);
let Some(Command::ZCode(args)) = cli.command else {
panic!("expected zcode command");
};
assert_eq!(args.kind, AgentReportKind::Daily);
assert!(args.shared.json);
}
#[test]
fn rejects_grok_path_option() {
let error = parse_error(&["ccusage", "grok", "daily", "--grok-path", "/tmp/grok-home"]);
assert_eq!(error, "Unknown option '--grok-path'");
}
#[test]
fn named_pi_store_validation_does_not_break_statusline() {
let fixture = fs_fixture!({
"ccusage.json": r#"{ "pi": { "stores": [{ "name": "codex", "path": "/tmp/omp" }] } }"#,
});
let args = vec![
"statusline".to_string(),
"--config".to_string(),
fixture.path("ccusage.json").to_string_lossy().into_owned(),
];
let config = csusage_config::ConfigContext::from_args(&args);
let parsed = Cli::parse_from_with_config(
std::iter::once(std::ffi::OsString::from("ccusage")).chain(
args.iter()
.map(|arg| std::ffi::OsString::from(arg.as_str())),
),
&config,
csusage_core::DEFAULT_SESSION_DURATION_HOURS,
env!("CARGO_PKG_VERSION"),
);
assert!(parsed.is_ok());
}
#[test]
fn named_pi_store_validation_does_not_break_agent_commands() {
let fixture = fs_fixture!({
"ccusage.json": r#"{ "pi": { "stores": [{ "name": "codex", "path": "/tmp/omp" }] } }"#,
});
let args = vec![
"codex".to_string(),
"daily".to_string(),
"--config".to_string(),
fixture.path("ccusage.json").to_string_lossy().into_owned(),
];
let config = csusage_config::ConfigContext::from_args(&args);
let parsed = Cli::parse_from_with_config(
std::iter::once(std::ffi::OsString::from("ccusage")).chain(
args.iter()
.map(|arg| std::ffi::OsString::from(arg.as_str())),
),
&config,
csusage_core::DEFAULT_SESSION_DURATION_HOURS,
env!("CARGO_PKG_VERSION"),
);
assert!(parsed.is_ok());
}
#[test]
fn reports_named_pi_store_validation_through_cli_config_error_path() {
let fixture = fs_fixture!({
"ccusage.json": r#"{ "pi": { "stores": [{ "name": "codex", "path": "/tmp/omp" }] } }"#,
});
let args = vec![
"daily".to_string(),
"--config".to_string(),
fixture.path("ccusage.json").to_string_lossy().into_owned(),
];
let config = csusage_config::ConfigContext::from_args(&args);
let result = Cli::parse_from_with_config(
std::iter::once(std::ffi::OsString::from("ccusage")).chain(
args.iter()
.map(|arg| std::ffi::OsString::from(arg.as_str())),
),
&config,
csusage_core::DEFAULT_SESSION_DURATION_HOURS,
env!("CARGO_PKG_VERSION"),
);
let Err(error) = result else {
panic!("expected config error");
};
assert_eq!(
error,
"Invalid ccusage config: pi.stores name 'codex' collides with a built-in agent"
);
}