Skip to content

Commit fb0409f

Browse files
committed
fix(cli): reject a --since date later than --until
A reversed window loaded every entry, kept nothing, and printed an empty report with exit 0, which reads as "no usage data" rather than a typo. Both bounds are already normalized to YYYYMMDD, so the check is a string comparison that runs after config bounds and flags are merged, for every report command.
1 parent d341949 commit fb0409f

3 files changed

Lines changed: 162 additions & 6 deletions

File tree

‎docs/guide/cli-options.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ ccusage monthly --since 20260101
2121
ccusage session --until 20260531
2222
```
2323

24-
Both bounds accept `YYYY-MM-DD` or `YYYYMMDD` and are inclusive. Any other spelling, or a value that is not a real calendar date such as `2026-02-30`, is rejected with a non-zero exit code instead of silently changing which rows the report keeps. The same check applies to `since` and `until` in a [configuration file](/guide/config-files).
24+
Both bounds accept `YYYY-MM-DD` or `YYYYMMDD` and are inclusive. Any other spelling, or a value that is not a real calendar date such as `2026-02-30`, is rejected with a non-zero exit code instead of silently changing which rows the report keeps. The same check applies to `since` and `until` in a [configuration file](/guide/config-files). A window whose `--since` date is later than its `--until` date is rejected the same way, whether the bounds come from flags, a configuration file, or a mix of both.
2525

2626
### Recent Periods
2727

‎rust/crates/ccusage-cli-parser/src/parser.rs‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ impl Cli {
125125
if let Some(message) = last_option_error(command.as_ref(), &shared) {
126126
return Err(message);
127127
}
128+
if let Some(message) = date_window_error(command.as_ref(), &shared) {
129+
return Err(message);
130+
}
128131
Ok(Self { command, shared })
129132
}
130133
}
@@ -1044,10 +1047,14 @@ fn parse_last_periods(value: &str) -> Result<u32, String> {
10441047
}
10451048
}
10461049

1047-
/// `--last` counts the report's own calendar periods, so it only makes sense on
1048-
/// the reports that group rows by day, week, or month.
1049-
fn last_option_error(command: Option<&Command>, root_shared: &SharedArgs) -> Option<String> {
1050-
let (shared, supported) = match command {
1050+
/// The shared options that apply to a command (statusline falls back to the root
1051+
/// options), plus whether that command groups rows by a calendar period (day,
1052+
/// week, or month).
1053+
fn report_shared<'a>(
1054+
command: Option<&'a Command>,
1055+
root_shared: &'a SharedArgs,
1056+
) -> (&'a SharedArgs, bool) {
1057+
match command {
10511058
None => (root_shared, true),
10521059
Some(Command::All(args)) => (&args.shared, args.kind != AgentReportKind::Session),
10531060
Some(Command::Daily(args)) => (&args.shared, true),
@@ -1075,7 +1082,13 @@ fn last_option_error(command: Option<&Command>, root_shared: &SharedArgs) -> Opt
10751082
| Command::Grok(args)
10761083
| Command::ZCode(args),
10771084
) => (&args.shared, args.kind != AgentReportKind::Session),
1078-
};
1085+
}
1086+
}
1087+
1088+
/// `--last` counts the report's own calendar periods, so it only makes sense on
1089+
/// the reports that group rows by day, week, or month.
1090+
fn last_option_error(command: Option<&Command>, root_shared: &SharedArgs) -> Option<String> {
1091+
let (shared, supported) = report_shared(command, root_shared);
10791092
shared.last?;
10801093
if !supported {
10811094
return Some(
@@ -1092,6 +1105,17 @@ fn last_option_error(command: Option<&Command>, root_shared: &SharedArgs) -> Opt
10921105
None
10931106
}
10941107

1108+
/// Both bounds are already normalized to `YYYYMMDD`, so a plain string comparison
1109+
/// orders them. A reversed window would otherwise load everything and print an
1110+
/// empty report that looks like missing data.
1111+
fn date_window_error(command: Option<&Command>, root_shared: &SharedArgs) -> Option<String> {
1112+
let (shared, _) = report_shared(command, root_shared);
1113+
let since = shared.since.as_deref()?;
1114+
let until = shared.until.as_deref()?;
1115+
(since > until)
1116+
.then(|| format!("The --since date '{since}' is later than the --until date '{until}'."))
1117+
}
1118+
10951119
fn parse_cost_mode(value: &str) -> Result<CostMode, String> {
10961120
match value {
10971121
"auto" => Ok(CostMode::Auto),

‎rust/crates/ccusage-cli-parser/src/tests.rs‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,138 @@ fn rejects_last_periods_on_reports_without_a_period() {
336336
}
337337
}
338338

339+
#[test]
340+
fn rejects_a_since_date_later_than_the_until_date() {
341+
let cases: [&[&str]; 6] = [
342+
&["ccusage", "--since", "2026-09-14", "--until", "2026-09-01"],
343+
&[
344+
"ccusage", "daily", "--since", "20260914", "--until", "20260901",
345+
],
346+
&[
347+
"ccusage",
348+
"session",
349+
"--since",
350+
"2026-09-14",
351+
"--until",
352+
"20260901",
353+
],
354+
&[
355+
"ccusage",
356+
"claude",
357+
"session",
358+
"--since",
359+
"2026-09-14",
360+
"--until",
361+
"20260901",
362+
],
363+
&[
364+
"ccusage",
365+
"blocks",
366+
"--since",
367+
"2026-09-14",
368+
"--until",
369+
"2026-09-01",
370+
],
371+
&[
372+
"ccusage",
373+
"codex",
374+
"monthly",
375+
"--since",
376+
"2026-09-14",
377+
"--until",
378+
"2026-09-01",
379+
],
380+
];
381+
for args in cases {
382+
assert_eq!(
383+
parse_error(args),
384+
"The --since date '20260914' is later than the --until date '20260901'."
385+
);
386+
}
387+
}
388+
389+
#[test]
390+
fn accepts_a_since_date_equal_to_the_until_date() {
391+
let cli = parse(&[
392+
"ccusage",
393+
"claude",
394+
"daily",
395+
"--since",
396+
"2026-09-14",
397+
"--until",
398+
"20260914",
399+
]);
400+
let Some(Command::Daily(args)) = cli.command else {
401+
panic!("expected claude daily command");
402+
};
403+
assert_eq!(args.shared.since.as_deref(), Some("20260914"));
404+
assert_eq!(args.shared.until.as_deref(), Some("20260914"));
405+
}
406+
407+
#[test]
408+
fn rejects_a_config_since_date_later_than_the_until_flag() {
409+
let config = TestConfig {
410+
shared_since: Some("20260914"),
411+
..TestConfig::default()
412+
};
413+
let result = Cli::parse_from_with_config(
414+
["ccusage", "daily", "--until", "2026-09-01"]
415+
.iter()
416+
.map(OsString::from),
417+
&config,
418+
5.0,
419+
env!("CARGO_PKG_VERSION"),
420+
);
421+
let Err(error) = result else {
422+
panic!("expected parse error");
423+
};
424+
assert_eq!(
425+
error,
426+
"The --since date '20260914' is later than the --until date '20260901'."
427+
);
428+
}
429+
430+
#[test]
431+
fn checks_the_date_window_after_config_and_flags_are_merged() {
432+
let fixture = fs_fixture!({
433+
"ccusage.json": r#"{ "defaults": { "since": "2026-09-14", "until": "2026-09-01" } }"#,
434+
});
435+
let config_path = fixture.path("ccusage.json").to_string_lossy().into_owned();
436+
let parse_with_file = |extra: &[&str]| {
437+
let mut args = vec![
438+
"daily".to_string(),
439+
"--config".to_string(),
440+
config_path.clone(),
441+
];
442+
args.extend(extra.iter().map(|arg| arg.to_string()));
443+
let config = ccusage_config::ConfigContext::from_args(&args);
444+
Cli::parse_from_with_config(
445+
std::iter::once(OsString::from("ccusage"))
446+
.chain(args.iter().map(|arg| OsString::from(arg.as_str()))),
447+
&config,
448+
ccusage_core::DEFAULT_SESSION_DURATION_HOURS,
449+
env!("CARGO_PKG_VERSION"),
450+
)
451+
};
452+
453+
let Err(error) = parse_with_file(&[]) else {
454+
panic!("expected a reversed config window to be rejected");
455+
};
456+
assert_eq!(
457+
error,
458+
"The --since date '20260914' is later than the --until date '20260901'."
459+
);
460+
461+
let Ok(cli) = parse_with_file(&["--until", "2026-09-30"]) else {
462+
panic!("expected a flag to repair the reversed config window");
463+
};
464+
let Some(Command::All(args)) = cli.command else {
465+
panic!("expected unified daily command");
466+
};
467+
assert_eq!(args.shared.since.as_deref(), Some("20260914"));
468+
assert_eq!(args.shared.until.as_deref(), Some("20260930"));
469+
}
470+
339471
#[test]
340472
fn rejects_last_periods_alongside_an_explicit_date_window() {
341473
assert_eq!(

0 commit comments

Comments
 (0)