Skip to content

Commit bf54821

Browse files
committed
chore: sync date-bound validation with main
Bring the date-bound validation branch up to the current main so the existing parser regression tests and the new validation coverage coexist without merge conflicts.
2 parents db345e4 + 2bcd6f5 commit bf54821

8 files changed

Lines changed: 293 additions & 31 deletions

File tree

‎docs/guide/cli-options.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ 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).
25+
2426
### Recent Periods
2527

2628
Instead of working out dates, ask for the most recent periods of whatever the report groups by:

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ use crate::arg_parser::ArgParser;
44
use crate::help::{print_help_and_exit, print_version_and_exit};
55
use ccusage_cli::{
66
AgentCommandArgs, AgentReportKind, BlocksArgs, CliConfig, CodexSpeed, Command, CostMode,
7-
CostSource, DailyArgs, OPENCODE_AGENT_REPORTS, STANDARD_AGENT_REPORTS, SessionArgs, SharedArgs,
8-
SortOrder, StatuslineArgs, VisualBurnRate, WeekDay, WeeklyArgs, normalize_date_bound,
7+
CostSource, DATE_BOUND_FORMATS, DailyArgs, OPENCODE_AGENT_REPORTS, STANDARD_AGENT_REPORTS,
8+
SessionArgs, SharedArgs, SortOrder, StatuslineArgs, VisualBurnRate, WeekDay, WeeklyArgs,
9+
normalize_date_bound,
910
};
1011

1112
use crate::Cli;
@@ -713,10 +714,10 @@ fn parse_shared_arg_for_command(
713714
fn parse_shared_arg(parser: &mut ArgParser, shared: &mut SharedArgs) -> Result<(), String> {
714715
match parser.next_flag()?.as_str() {
715716
"-s" | "--since" => {
716-
shared.since = Some(normalize_date_bound(&parser.value_for("--since")?))
717+
shared.since = Some(parse_date_bound("--since", &parser.value_for("--since")?)?)
717718
}
718719
"-u" | "--until" => {
719-
shared.until = Some(normalize_date_bound(&parser.value_for("--until")?))
720+
shared.until = Some(parse_date_bound("--until", &parser.value_for("--until")?)?)
720721
}
721722
"--last" => shared.last = Some(parse_last_periods(&parser.value_for("--last")?)?),
722723
"-j" | "--json" => shared.json = true,
@@ -1005,6 +1006,12 @@ fn is_shared_flag(arg: &str) -> bool {
10051006
)
10061007
}
10071008

1009+
fn parse_date_bound(flag: &str, value: &str) -> Result<String, String> {
1010+
normalize_date_bound(value).ok_or_else(|| {
1011+
format!("Invalid value for {flag} '{value}'. Expected {DATE_BOUND_FORMATS}.")
1012+
})
1013+
}
1014+
10081015
fn parse_last_periods(value: &str) -> Result<u32, String> {
10091016
match value.parse() {
10101017
Ok(0) | Err(_) => Err(format!(

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,38 @@ fn commandless_invocation_preserves_default_dispatch() {
242242
assert!(cli.command.is_none());
243243
}
244244

245+
#[test]
246+
fn rejects_date_bounds_that_are_not_real_calendar_dates() {
247+
for value in [
248+
"2026-02-30",
249+
"2026-13-01",
250+
"abc",
251+
"2026/07/10",
252+
"2026-07-10T00:00:00Z",
253+
] {
254+
assert_eq!(
255+
parse_error(&["ccusage", "daily", "--since", value]),
256+
format!("Invalid value for --since '{value}'. Expected YYYY-MM-DD or YYYYMMDD.")
257+
);
258+
assert_eq!(
259+
parse_error(&["ccusage", "daily", "--until", value]),
260+
format!("Invalid value for --until '{value}'. Expected YYYY-MM-DD or YYYYMMDD.")
261+
);
262+
}
263+
}
264+
265+
#[test]
266+
fn accepts_both_documented_date_bound_formats() {
267+
for (value, normalized) in [("2026-07-10", "20260710"), ("20260710", "20260710")] {
268+
let cli = parse(&["ccusage", "daily", "--since", value, "--until", value]);
269+
let Some(Command::All(args)) = cli.command else {
270+
panic!("expected all-agent command");
271+
};
272+
assert_eq!(args.shared.since.as_deref(), Some(normalized));
273+
assert_eq!(args.shared.until.as_deref(), Some(normalized));
274+
}
275+
}
276+
245277
#[test]
246278
fn parses_last_periods_on_period_reports() {
247279
let cli = parse(&["ccusage", "daily", "--last", "1"]);

‎rust/crates/ccusage-cli/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ renderer, and the help JSON live in `ccusage-cli-parser` instead.
1818
- `types::Command`
1919
- `types::CostMode`
2020
- `types::CostSource`
21+
- `types::DATE_BOUND_FORMATS`
2122
- `types::DailyArgs`
2223
- `types::NamedPiStore`
2324
- `types::NoConfig`

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ mod types;
22

33
pub use types::{
44
AgentCommandArgs, AgentReportKind, BlocksArgs, CliConfig, CodexSpeed, Command, CostMode,
5-
CostSource, DailyArgs, NamedPiStore, NoConfig, OPENCODE_AGENT_REPORTS, PricingOverride,
6-
STANDARD_AGENT_REPORTS, SessionArgs, SharedArgs, SortOrder, StatuslineArgs, VisualBurnRate,
7-
WeekDay, WeeklyArgs, normalize_date_bound,
5+
CostSource, DATE_BOUND_FORMATS, DailyArgs, NamedPiStore, NoConfig, OPENCODE_AGENT_REPORTS,
6+
PricingOverride, STANDARD_AGENT_REPORTS, SessionArgs, SharedArgs, SortOrder, StatuslineArgs,
7+
VisualBurnRate, WeekDay, WeeklyArgs, normalize_date_bound,
88
};

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

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,57 @@ impl SharedArgs {
6666
}
6767
}
6868

69-
pub fn normalize_date_bound(value: &str) -> String {
70-
value.replace('-', "")
69+
/// The two documented spellings of a `--since` / `--until` bound.
70+
pub const DATE_BOUND_FORMATS: &str = "YYYY-MM-DD or YYYYMMDD";
71+
72+
/// Normalizes a date bound into the `YYYYMMDD` form the report keys use.
73+
///
74+
/// Reports compare bounds against row keys as plain strings, so a value that is
75+
/// not one of the two documented formats, or that is not a real calendar date,
76+
/// would silently turn the filter into a no-op or drop every row. Returns
77+
/// `None` for those values so callers can reject them.
78+
pub fn normalize_date_bound(value: &str) -> Option<String> {
79+
let bytes = value.as_bytes();
80+
let digits: [u8; 8] = match bytes.len() {
81+
8 => bytes.try_into().ok()?,
82+
10 if bytes[4] == b'-' && bytes[7] == b'-' => {
83+
let mut digits = [0u8; 8];
84+
digits[..4].copy_from_slice(&bytes[..4]);
85+
digits[4..6].copy_from_slice(&bytes[5..7]);
86+
digits[6..].copy_from_slice(&bytes[8..]);
87+
digits
88+
}
89+
_ => return None,
90+
};
91+
if !digits.iter().all(u8::is_ascii_digit) {
92+
return None;
93+
}
94+
let number = |slice: &[u8]| {
95+
slice
96+
.iter()
97+
.fold(0u32, |value, digit| value * 10 + u32::from(digit - b'0'))
98+
};
99+
let year = number(&digits[..4]) as i32;
100+
let month = number(&digits[4..6]);
101+
let day = number(&digits[6..]);
102+
if day == 0 || day > days_in_month(year, month) {
103+
return None;
104+
}
105+
std::str::from_utf8(&digits).ok().map(str::to_string)
106+
}
107+
108+
fn days_in_month(year: i32, month: u32) -> u32 {
109+
match month {
110+
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
111+
4 | 6 | 9 | 11 => 30,
112+
2 if is_leap_year(year) => 29,
113+
2 => 28,
114+
_ => 0,
115+
}
116+
}
117+
118+
fn is_leap_year(year: i32) -> bool {
119+
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
71120
}
72121

73122
#[derive(Clone)]
@@ -265,3 +314,60 @@ pub trait CliConfig {
265314
pub struct NoConfig;
266315

267316
impl CliConfig for NoConfig {}
317+
318+
#[cfg(test)]
319+
mod tests {
320+
use super::normalize_date_bound;
321+
322+
#[test]
323+
fn accepts_both_documented_formats() {
324+
assert_eq!(
325+
normalize_date_bound("2026-07-10").as_deref(),
326+
Some("20260710")
327+
);
328+
assert_eq!(
329+
normalize_date_bound("20260710").as_deref(),
330+
Some("20260710")
331+
);
332+
}
333+
334+
#[test]
335+
fn accepts_leap_day_only_in_leap_years() {
336+
assert_eq!(
337+
normalize_date_bound("2024-02-29").as_deref(),
338+
Some("20240229")
339+
);
340+
assert_eq!(
341+
normalize_date_bound("2000-02-29").as_deref(),
342+
Some("20000229")
343+
);
344+
assert_eq!(normalize_date_bound("2026-02-29"), None);
345+
assert_eq!(normalize_date_bound("1900-02-29"), None);
346+
}
347+
348+
#[test]
349+
fn rejects_impossible_calendar_dates() {
350+
assert_eq!(normalize_date_bound("2026-02-30"), None);
351+
assert_eq!(normalize_date_bound("2026-13-01"), None);
352+
assert_eq!(normalize_date_bound("2026-00-10"), None);
353+
assert_eq!(normalize_date_bound("2026-07-00"), None);
354+
assert_eq!(normalize_date_bound("2026-04-31"), None);
355+
}
356+
357+
#[test]
358+
fn rejects_undocumented_spellings() {
359+
assert_eq!(normalize_date_bound("abc"), None);
360+
assert_eq!(normalize_date_bound(""), None);
361+
assert_eq!(normalize_date_bound("2026/07/10"), None);
362+
assert_eq!(normalize_date_bound("2026-7-10"), None);
363+
assert_eq!(normalize_date_bound("2026-07-10T00:00:00Z"), None);
364+
assert_eq!(normalize_date_bound("2026-07-1"), None);
365+
assert_eq!(normalize_date_bound("2026_07_10"), None);
366+
}
367+
368+
#[test]
369+
fn rejects_non_ascii_values_without_panicking() {
370+
assert_eq!(normalize_date_bound("20260710"), None);
371+
assert_eq!(normalize_date_bound("2026-07-10"), None);
372+
}
373+
}

0 commit comments

Comments
 (0)