Skip to main content

fdu_core/query/
query_values.rs

1//! Value grammars for time and size arguments.
2//!
3//! One parser serves the CLI, the Rust API, and the Python bindings, so every surface
4//! accepts exactly the same strings rather than three dialects that drift. `now` is a
5//! parameter rather than a call to [`SystemTime::now`] so callers and tests control the
6//! reference instant, and so every age in one invocation subtracts from the same moment.
7//!
8//! The grammars are deliberately closed. Natural-language forms (`yesterday`,
9//! `2 weeks ago`) are rejected because they are locale-dependent and unbounded, and
10//! calendar units (months, years) are rejected because they require arithmetic that only
11//! approximates — a grammar for file ages must not approximate. Every rejection names
12//! the spelling that would have worked.
13
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use crate::engine_contract::{Error, Result};
17
18/// Nanoseconds in one second.
19const NANOS_PER_SEC: u32 = 1_000_000_000;
20
21/// The most fractional digits an `@epoch` value can carry before the rest is ignored.
22const MAX_FRACTION_DIGITS: usize = 9;
23
24/// Parse a `WHEN` value into an absolute instant.
25///
26/// Accepts `now`, a compound age (`200ms`, `45s`, `2h`, `1h30m`), an RFC 3339 timestamp
27/// carrying an offset (`2026-08-10T18:22:31.482919114Z`), or `@` seconds since the Unix
28/// epoch (`@1786413716`, `@1786413716.482919114`).
29///
30/// Ages subtract from `now`, so a caller that captures one instant per invocation gets a
31/// consistent window across several arguments.
32pub fn parse_when(input: &str, now: SystemTime) -> Result<SystemTime> {
33    let value = input.trim();
34    if value.is_empty() {
35        return Err(when_error(input, "expected `now`, an age like `2h`, or a timestamp"));
36    }
37    if value.eq_ignore_ascii_case("now") {
38        return Ok(now);
39    }
40    if let Some(epoch) = value.strip_prefix('@') {
41        return parse_epoch(input, epoch);
42    }
43    // Ages are digits and letters only, so a `-` or `:` can only be a timestamp. Deciding
44    // by shape rather than by trying each parser keeps the error message specific to what
45    // the user was evidently reaching for.
46    if value.contains('-') || value.contains(':') {
47        return parse_timestamp(input, value);
48    }
49    let age = parse_age(input, value)?;
50    now.checked_sub(age)
51        .ok_or_else(|| when_error(input, "age reaches before the representable time range"))
52}
53
54/// Parse a `SIZE` value into a byte count.
55///
56/// Accepts a bare byte count (`512`), decimal suffixes (`10k`, `10KB`, `1.5M`, `2G`), and
57/// binary suffixes (`10Ki`, `1.5GiB`). Suffixes are case-insensitive, and a fraction is
58/// allowed because `1.5GiB` is how people write sizes.
59pub fn parse_size(input: &str) -> Result<u64> {
60    let value = input.trim();
61    if value.is_empty() {
62        return Err(size_error(input, "expected a byte count like `512`, `10M`, or `1.5GiB`"));
63    }
64
65    let digits_end = value.find(|c: char| !c.is_ascii_digit() && c != '.').unwrap_or(value.len());
66    let (number, suffix) = value.split_at(digits_end);
67    if number.is_empty() {
68        return Err(size_error(input, "expected a number before the unit, as in `10M`"));
69    }
70
71    let factor = size_factor(suffix)
72        .ok_or_else(|| size_error(input, &format!("unknown size unit {suffix:?}; use B, K/KB, M/MB, G/GB, T/TB, P/PB, or the binary forms KiB, MiB, GiB, TiB, PiB")))?;
73
74    scale_decimal(number, factor).ok_or_else(|| {
75        size_error(input, "size is not a number this machine can represent in bytes")
76    })
77}
78
79/// Parse a control budget: a [`parse_size`] value, or `all` for no bound.
80///
81/// The grammar of [`crate::control::ControlLimits::budget`], shared so the command line's
82/// `--gitignore-budget` and the Python API's `control_budget` accept the same words.
83pub fn parse_control_budget(input: &str) -> Result<Option<usize>> {
84    parse_control_limit(input, "control budget")
85}
86
87/// Parse a control line limit: a [`parse_size`] value, or `all` for no bound.
88///
89/// The grammar of [`crate::control::ControlLimits::line_limit`], shared so the command
90/// line's `--gitignore-line-limit` and the Python API's `control_line_limit` accept the same
91/// words.
92pub fn parse_control_line_limit(input: &str) -> Result<Option<usize>> {
93    parse_control_limit(input, "control line limit")
94}
95
96fn parse_control_limit(input: &str, kind: &'static str) -> Result<Option<usize>> {
97    if input.trim().eq_ignore_ascii_case("all") {
98        return Ok(None);
99    }
100    let bytes = parse_size(input).map_err(|error| match error {
101        Error::InvalidValue { value, hint, .. } => {
102            Error::InvalidValue { kind, value, hint: format!("{hint}, or `all` for no bound") }
103        }
104        other => other,
105    })?;
106    usize::try_from(bytes).map(Some).map_err(|_| Error::InvalidValue {
107        kind,
108        value: input.to_string(),
109        hint: "larger than this machine can address; use `all` for no bound".to_string(),
110    })
111}
112
113/// Render an instant as an RFC 3339 timestamp in UTC, with nanosecond precision.
114///
115/// The exact inverse of the RFC 3339 branch of [`parse_when`], so a report's
116/// `scan_started_at` can be fed straight back as a watermark and select precisely the
117/// files touched after that scan began.
118pub fn format_rfc3339(time: SystemTime) -> String {
119    let (seconds, nanos) = match time.duration_since(UNIX_EPOCH) {
120        Ok(after) => (i64::try_from(after.as_secs()).unwrap_or(i64::MAX), after.subsec_nanos()),
121        Err(before) => {
122            let magnitude = before.duration();
123            let subsec = magnitude.subsec_nanos();
124            let secs = i64::try_from(magnitude.as_secs()).unwrap_or(i64::MAX);
125            // Nanoseconds always run forward from the second, so an instant before the
126            // epoch is the next second down plus a forward fraction.
127            if subsec == 0 { (-secs, 0) } else { (-secs - 1, NANOS_PER_SEC - subsec) }
128        }
129    };
130
131    format_rfc3339_parts(seconds, nanos)
132}
133
134/// Render integer nanoseconds since the Unix epoch without passing through [`SystemTime`].
135///
136/// `SystemTime` has platform-defined precision. Windows stores 100-nanosecond FILETIME
137/// ticks, so converting an exact timestamp through it discards the final two digits. The
138/// index and Python API already carry integer nanoseconds; formatting those values directly
139/// keeps their representation byte-for-byte portable.
140pub(crate) fn format_rfc3339_nanos(timestamp: i64) -> String {
141    let nanos_per_second = i64::from(NANOS_PER_SEC);
142    let seconds = timestamp.div_euclid(nanos_per_second);
143    let nanos = u32::try_from(timestamp.rem_euclid(nanos_per_second)).unwrap_or(0);
144    format_rfc3339_parts(seconds, nanos)
145}
146
147fn format_rfc3339_parts(seconds: i64, nanos: u32) -> String {
148    let days = seconds.div_euclid(86_400);
149    let time_of_day = seconds.rem_euclid(86_400);
150    let (year, month, day) = civil_from_days(days);
151    let (hour, minute, second) =
152        (time_of_day / 3_600, (time_of_day % 3_600) / 60, time_of_day % 60);
153
154    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{nanos:09}Z")
155}
156
157/// The proleptic Gregorian date some number of days from the Unix epoch.
158///
159/// Howard Hinnant's `civil_from_days`, the inverse of [`days_from_civil`].
160fn civil_from_days(days: i64) -> (i64, u32, u32) {
161    let shifted = days + 719_468;
162    let era = if shifted >= 0 { shifted } else { shifted - 146_096 } / 146_097;
163    let day_of_era = shifted - era * 146_097;
164    let year_of_era =
165        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
166    let year = year_of_era + era * 400;
167    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
168    let shifted_month = (5 * day_of_year + 2) / 153;
169    let day = u32::try_from(day_of_year - (153 * shifted_month + 2) / 5 + 1).unwrap_or(1);
170    let month =
171        u32::try_from(if shifted_month < 10 { shifted_month + 3 } else { shifted_month - 9 })
172            .unwrap_or(1);
173    (if month <= 2 { year + 1 } else { year }, month, day)
174}
175
176/// Convert an instant to nanoseconds since the Unix epoch, negative before it.
177///
178/// The index stores timestamps as `i64` nanoseconds, so selection windows have to reach
179/// the same representation before they can be compared against entries.
180pub fn system_time_to_nanos(time: SystemTime) -> Option<i64> {
181    match time.duration_since(UNIX_EPOCH) {
182        Ok(after) => i64::try_from(after.as_nanos()).ok(),
183        Err(before) => i64::try_from(before.duration().as_nanos()).ok().map(i64::wrapping_neg),
184    }
185}
186
187/// Multiply a possibly fractional decimal string by `factor`, in integer arithmetic.
188///
189/// Floating point would be the obvious implementation and the wrong one: `0.1 * 10^9` is
190/// not an integer in binary, and a size filter that is one byte off is a filter that
191/// silently drops a file.
192fn scale_decimal(number: &str, factor: u64) -> Option<u64> {
193    let (whole, fraction) = match number.split_once('.') {
194        Some((whole, fraction)) => (whole, fraction),
195        None => (number, ""),
196    };
197    if number.matches('.').count() > 1 || (whole.is_empty() && fraction.is_empty()) {
198        return None;
199    }
200
201    let factor = u128::from(factor);
202    let whole: u128 = if whole.is_empty() { 0 } else { whole.parse().ok()? };
203    let mut total = whole.checked_mul(factor)?;
204
205    if !fraction.is_empty() {
206        let digits: u128 = fraction.parse().ok()?;
207        let scale = 10u128.checked_pow(u32::try_from(fraction.len()).ok()?)?;
208        total = total.checked_add(digits.checked_mul(factor)? / scale)?;
209    }
210
211    u64::try_from(total).ok()
212}
213
214/// Bytes per size suffix, or `None` when the suffix is not part of the grammar.
215fn size_factor(suffix: &str) -> Option<u64> {
216    const K: u64 = 1_000;
217    const KI: u64 = 1_024;
218    let unit = suffix.trim().to_ascii_lowercase();
219    Some(match unit.as_str() {
220        "" | "b" => 1,
221        "k" | "kb" => K,
222        "m" | "mb" => K.pow(2),
223        "g" | "gb" => K.pow(3),
224        "t" | "tb" => K.pow(4),
225        "p" | "pb" => K.pow(5),
226        "ki" | "kib" => KI,
227        "mi" | "mib" => KI.pow(2),
228        "gi" | "gib" => KI.pow(3),
229        "ti" | "tib" => KI.pow(4),
230        "pi" | "pib" => KI.pow(5),
231        _ => return None,
232    })
233}
234
235/// Parse a compound age such as `200ms`, `45s`, `2h`, or `1h30m` into a duration.
236///
237/// Whole units only: `200ms` is a millisecond count, while `0.2s` remains a fractional
238/// age and is rejected. The accumulation is a [`Duration`] so a sub-second unit is not
239/// truncated to zero seconds (fdu-8o7g).
240fn parse_age(input: &str, value: &str) -> Result<Duration> {
241    if value.contains(char::is_whitespace) {
242        return Err(when_error(
243            input,
244            "spaces are not part of the age grammar; write compound ages like `1h30m`",
245        ));
246    }
247    if !value.starts_with(|c: char| c.is_ascii_digit()) {
248        return Err(when_error(input, "expected `now`, an age like `2h`, or a timestamp"));
249    }
250
251    let mut total = Duration::ZERO;
252    let mut rest = value;
253    while !rest.is_empty() {
254        let digits_end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
255        let (digits, tail) = rest.split_at(digits_end);
256        if digits.is_empty() {
257            return Err(when_error(input, "expected a number before each unit, as in `1h30m`"));
258        }
259        if tail.starts_with('.') {
260            return Err(when_error(
261                input,
262                "fractional ages are not supported; write them as compounds, as in `1h30m` rather than `1.5h`",
263            ));
264        }
265
266        let unit_end = tail.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(tail.len());
267        let (unit, remainder) = tail.split_at(unit_end);
268        if unit.is_empty() {
269            return Err(when_error(
270                input,
271                "expected a unit after the number: ms, s, m, h, d, or w, as in `45s` or `2h`",
272            ));
273        }
274
275        let count: u64 = digits
276            .parse()
277            .map_err(|_| when_error(input, "age is larger than this machine can represent"))?;
278        let piece = age_unit_duration(input, unit, count)?;
279        total = total
280            .checked_add(piece)
281            .ok_or_else(|| when_error(input, "age is larger than this machine can represent"))?;
282        rest = remainder;
283    }
284
285    Ok(total)
286}
287
288/// Duration for `count` of one age unit, rejecting calendar units with the substitution
289/// to use.
290fn age_unit_duration(input: &str, unit: &str, count: u64) -> Result<Duration> {
291    let unit = unit.to_ascii_lowercase();
292    if matches!(unit.as_str(), "ms" | "msec" | "msecs" | "millisecond" | "milliseconds") {
293        return Ok(Duration::from_millis(count));
294    }
295    let seconds = age_unit_seconds(input, &unit)?
296        .checked_mul(count)
297        .ok_or_else(|| when_error(input, "age is larger than this machine can represent"))?;
298    Ok(Duration::from_secs(seconds))
299}
300
301/// Seconds in one whole-second age unit.
302fn age_unit_seconds(input: &str, unit: &str) -> Result<u64> {
303    const MINUTE: u64 = 60;
304    const HOUR: u64 = 60 * MINUTE;
305    const DAY: u64 = 24 * HOUR;
306    Ok(match unit {
307        "s" | "sec" | "secs" | "second" | "seconds" => 1,
308        "m" | "min" | "mins" | "minute" | "minutes" => MINUTE,
309        "h" | "hr" | "hrs" | "hour" | "hours" => HOUR,
310        "d" | "day" | "days" => DAY,
311        "w" | "week" | "weeks" => 7 * DAY,
312        // Calendar units are rejected rather than approximated: a month is not a fixed
313        // number of days, and a file age that quietly means 30.44 days is a bug waiting
314        // for a bug report nobody can reproduce.
315        "mo" | "mon" | "month" | "months" | "y" | "yr" | "yrs" | "year" | "years" => {
316            return Err(when_error(
317                input,
318                "calendar units are not supported because they are not a fixed length; use days, as in `30d` or `365d`",
319            ));
320        }
321        _ => {
322            return Err(when_error(
323                input,
324                &format!("unknown age unit {unit:?}; use ms, s, m, h, d, or w"),
325            ));
326        }
327    })
328}
329
330/// Parse `@`-prefixed seconds since the Unix epoch, with an optional fraction.
331fn parse_epoch(input: &str, value: &str) -> Result<SystemTime> {
332    let (seconds, fraction) = match value.split_once('.') {
333        Some((seconds, fraction)) => (seconds, fraction),
334        None => (value, ""),
335    };
336    if seconds.is_empty() && fraction.is_empty() {
337        return Err(when_error(input, "expected seconds after `@`, as in `@1786413716`"));
338    }
339
340    let negative = seconds.starts_with('-');
341    let digits = seconds.strip_prefix(['-', '+']).unwrap_or(seconds);
342    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
343        return Err(when_error(input, "expected a whole number of seconds after `@`"));
344    }
345    if !fraction.is_empty() && !fraction.bytes().all(|b| b.is_ascii_digit()) {
346        return Err(when_error(input, "expected only digits in the fractional seconds after `@`"));
347    }
348
349    let seconds: u64 = digits
350        .parse()
351        .map_err(|_| when_error(input, "epoch seconds are outside the representable range"))?;
352    let nanos = fraction_to_nanos(fraction);
353
354    let magnitude = Duration::new(seconds, nanos);
355    let instant = if negative {
356        UNIX_EPOCH.checked_sub(magnitude)
357    } else {
358        UNIX_EPOCH.checked_add(magnitude)
359    };
360    instant.ok_or_else(|| when_error(input, "epoch seconds are outside the representable range"))
361}
362
363/// Interpret fractional digits as nanoseconds, padding or truncating to nine.
364fn fraction_to_nanos(fraction: &str) -> u32 {
365    if fraction.is_empty() {
366        return 0;
367    }
368    let mut nanos: u32 = 0;
369    for index in 0..MAX_FRACTION_DIGITS {
370        let digit = fraction.as_bytes().get(index).map_or(0, |b| u32::from(b - b'0'));
371        nanos = nanos * 10 + digit;
372    }
373    nanos
374}
375
376/// Parse an RFC 3339 timestamp, which must carry its own offset.
377///
378/// A bare local date or date-time is rejected on purpose: resolving one needs a time-zone
379/// database this crate deliberately does not depend on, and guessing UTC would answer a
380/// New York prompt with an instant several hours off without saying so.
381fn parse_timestamp(input: &str, value: &str) -> Result<SystemTime> {
382    let bytes = value.as_bytes();
383    if bytes.len() < 10 || bytes[4] != b'-' || bytes[7] != b'-' {
384        return Err(when_error(
385            input,
386            "expected a timestamp like `2026-08-10T18:22:31Z`, an age like `2h`, or `now`",
387        ));
388    }
389
390    let year: i64 = parse_field(input, &value[0..4])?;
391    let month: u32 = parse_field(input, &value[5..7])?;
392    let day: u32 = parse_field(input, &value[8..10])?;
393    let rest = &value[10..];
394
395    if rest.is_empty() || !rest.starts_with(['T', 't', ' ']) {
396        return Err(local_time_error(input));
397    }
398    let time = &rest[1..];
399
400    let offset_index = time
401        .find(['Z', 'z', '+'])
402        .or_else(|| time.rfind('-'))
403        .ok_or_else(|| local_time_error(input))?;
404    let (clock, offset) = time.split_at(offset_index);
405
406    if clock.len() < 8 || clock.as_bytes()[2] != b':' || clock.as_bytes()[5] != b':' {
407        return Err(when_error(input, "expected a time like `18:22:31` before the offset"));
408    }
409    let hour: u32 = parse_field(input, &clock[0..2])?;
410    let minute: u32 = parse_field(input, &clock[3..5])?;
411    let second: u32 = parse_field(input, &clock[6..8])?;
412    let nanos = match clock[8..].strip_prefix('.') {
413        Some(fraction) if !fraction.is_empty() && fraction.bytes().all(|b| b.is_ascii_digit()) => {
414            fraction_to_nanos(fraction)
415        }
416        Some(_) => return Err(when_error(input, "expected digits after the decimal point")),
417        None if clock.len() == 8 => 0,
418        None => return Err(when_error(input, "expected `.` before fractional seconds")),
419    };
420
421    validate_civil(input, month, day, hour, minute, second, year)?;
422    let offset_seconds = parse_offset(input, offset)?;
423
424    let days = days_from_civil(year, month, day);
425    let seconds = days
426        .checked_mul(86_400)
427        .and_then(|day_seconds| day_seconds.checked_add(i64::from(hour) * 3_600))
428        .and_then(|partial| partial.checked_add(i64::from(minute) * 60))
429        .and_then(|partial| partial.checked_add(i64::from(second)))
430        .and_then(|utc| utc.checked_sub(offset_seconds))
431        .ok_or_else(|| when_error(input, "timestamp is outside the representable range"))?;
432
433    epoch_offset(seconds, nanos)
434        .ok_or_else(|| when_error(input, "timestamp is outside the representable range"))
435}
436
437/// Build an instant from signed epoch seconds plus a nanosecond remainder.
438fn epoch_offset(seconds: i64, nanos: u32) -> Option<SystemTime> {
439    if seconds >= 0 {
440        return UNIX_EPOCH.checked_add(Duration::new(u64::try_from(seconds).ok()?, nanos));
441    }
442    // Nanoseconds always run forward from the second, so a negative second with a
443    // fraction is one second further back plus the fraction forward.
444    let magnitude = u64::try_from(seconds.checked_neg()?).ok()?;
445    let before = UNIX_EPOCH.checked_sub(Duration::from_secs(magnitude))?;
446    before.checked_add(Duration::from_nanos(u64::from(nanos)))
447}
448
449/// Parse a fixed-width numeric field, rejecting signs and stray characters.
450fn parse_field<T: std::str::FromStr>(input: &str, field: &str) -> Result<T> {
451    if field.is_empty() || !field.bytes().all(|b| b.is_ascii_digit()) {
452        return Err(when_error(input, "expected digits in every timestamp field"));
453    }
454    field.parse().map_err(|_| when_error(input, "timestamp field is out of range"))
455}
456
457/// Reject civil fields that no calendar produces.
458fn validate_civil(
459    input: &str,
460    month: u32,
461    day: u32,
462    hour: u32,
463    minute: u32,
464    second: u32,
465    year: i64,
466) -> Result<()> {
467    if !(1..=12).contains(&month) {
468        return Err(when_error(input, "month must be between 01 and 12"));
469    }
470    if day < 1 || day > days_in_month(year, month) {
471        return Err(when_error(input, "day is not a day of that month"));
472    }
473    if hour > 23 {
474        return Err(when_error(input, "hour must be between 00 and 23"));
475    }
476    if minute > 59 {
477        return Err(when_error(input, "minute must be between 00 and 59"));
478    }
479    // RFC 3339 allows `60` for a leap second; treating it as the following instant keeps
480    // a legal timestamp parseable without pretending we track leap seconds.
481    if second > 60 {
482        return Err(when_error(input, "second must be between 00 and 60"));
483    }
484    Ok(())
485}
486
487/// Days in a month, accounting for leap years.
488fn days_in_month(year: i64, month: u32) -> u32 {
489    match month {
490        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
491        4 | 6 | 9 | 11 => 30,
492        2 if is_leap_year(year) => 29,
493        2 => 28,
494        _ => 0,
495    }
496}
497
498/// Whether a proleptic Gregorian year is a leap year.
499fn is_leap_year(year: i64) -> bool {
500    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
501}
502
503/// Parse `Z` or `±HH:MM` into seconds east of UTC.
504fn parse_offset(input: &str, offset: &str) -> Result<i64> {
505    if offset.eq_ignore_ascii_case("z") {
506        return Ok(0);
507    }
508    let (sign, rest) = match offset.split_at(1) {
509        ("+", rest) => (1, rest),
510        ("-", rest) => (-1, rest),
511        _ => return Err(local_time_error(input)),
512    };
513    if rest.len() != 5 || rest.as_bytes()[2] != b':' {
514        return Err(when_error(input, "expected an offset like `Z`, `+05:30`, or `-08:00`"));
515    }
516    let hours: i64 = parse_field(input, &rest[0..2])?;
517    let minutes: i64 = parse_field(input, &rest[3..5])?;
518    if hours > 23 || minutes > 59 {
519        return Err(when_error(input, "offset must be within ±23:59"));
520    }
521    Ok(sign * (hours * 3_600 + minutes * 60))
522}
523
524/// Days from the Unix epoch to a proleptic Gregorian date.
525///
526/// Howard Hinnant's `days_from_civil`, which is exact for every year this crate can
527/// represent and needs no lookup table.
528fn days_from_civil(year: i64, month: u32, day: u32) -> i64 {
529    let year = if month <= 2 { year - 1 } else { year };
530    let era = if year >= 0 { year } else { year - 399 } / 400;
531    let year_of_era = year - era * 400;
532    let month = i64::from(month);
533    let day_of_year =
534        (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + i64::from(day) - 1;
535    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
536    era * 146_097 + day_of_era - 719_468
537}
538
539/// The rejection for a timestamp with no offset, which is the local-time form.
540fn local_time_error(input: &str) -> Error {
541    when_error(
542        input,
543        "local date and time are not supported yet because resolving one needs a time-zone database; write an RFC 3339 timestamp with an offset, as in `2026-08-10T12:30:00Z` or `2026-08-10T12:30:00-08:00`, or use `@` epoch seconds",
544    )
545}
546
547/// Build a time-grammar rejection.
548fn when_error(input: &str, hint: &str) -> Error {
549    Error::InvalidValue { kind: "time", value: input.to_string(), hint: hint.to_string() }
550}
551
552/// Build a size-grammar rejection.
553fn size_error(input: &str, hint: &str) -> Error {
554    Error::InvalidValue { kind: "size", value: input.to_string(), hint: hint.to_string() }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    /// A fixed reference instant so every age assertion is exact: 2026-08-10T18:22:31Z.
562    const NOW_SECS: u64 = 1_786_386_151;
563
564    fn now() -> SystemTime {
565        UNIX_EPOCH + Duration::from_secs(NOW_SECS)
566    }
567
568    fn seconds_before_now(value: &str) -> u64 {
569        duration_before_now(value).as_secs()
570    }
571
572    fn duration_before_now(value: &str) -> Duration {
573        let parsed = parse_when(value, now()).expect("value parses");
574        now().duration_since(parsed).expect("not in the future")
575    }
576
577    fn epoch_nanos(value: &str) -> i64 {
578        let parsed = parse_when(value, now()).expect("value parses");
579        system_time_to_nanos(parsed).expect("representable")
580    }
581
582    fn time_rejection(value: &str) -> String {
583        match parse_when(value, now()) {
584            Err(Error::InvalidValue { kind: "time", hint, .. }) => hint,
585            other => panic!("expected {value:?} to be rejected as a time, got {other:?}"),
586        }
587    }
588
589    fn size_rejection(value: &str) -> String {
590        match parse_size(value) {
591            Err(Error::InvalidValue { kind: "size", hint, .. }) => hint,
592            other => panic!("expected {value:?} to be rejected as a size, got {other:?}"),
593        }
594    }
595
596    #[test]
597    fn now_keyword_returns_the_reference_instant() {
598        assert_eq!(parse_when("now", now()).expect("parses"), now());
599        assert_eq!(parse_when("  NOW  ", now()).expect("parses"), now());
600    }
601
602    #[test]
603    fn ages_subtract_from_the_reference_instant() {
604        for (value, expected) in [
605            ("45s", 45),
606            ("45sec", 45),
607            ("45seconds", 45),
608            ("2m", 120),
609            ("2min", 120),
610            ("2minutes", 120),
611            ("2h", 7_200),
612            ("2hr", 7_200),
613            ("2hours", 7_200),
614            ("7d", 604_800),
615            ("7days", 604_800),
616            ("1w", 604_800),
617            ("2weeks", 1_209_600),
618        ] {
619            assert_eq!(seconds_before_now(value), expected, "{value}");
620        }
621    }
622
623    #[test]
624    fn compound_ages_sum_their_parts() {
625        assert_eq!(seconds_before_now("1h30m"), 5_400);
626        assert_eq!(seconds_before_now("1d12h30m15s"), 131_415);
627        // Order is not enforced, because the sum is what the value means.
628        assert_eq!(seconds_before_now("30m1h"), 5_400);
629    }
630
631    #[test]
632    fn millisecond_ages_are_whole_units_not_fractions() {
633        assert_eq!(duration_before_now("200ms"), Duration::from_millis(200));
634        assert_eq!(duration_before_now("200msec"), Duration::from_millis(200));
635        assert_eq!(duration_before_now("1s200ms"), Duration::from_millis(1_200));
636        assert_eq!(duration_before_now("1000ms"), Duration::from_secs(1));
637        let hint = time_rejection("0.2s");
638        assert!(hint.contains("fractional"), "{hint}");
639    }
640
641    #[test]
642    fn rfc3339_timestamps_are_exact_and_honor_their_offset() {
643        // The same instant written three ways must parse to the same nanosecond.
644        let utc = epoch_nanos("2026-08-10T18:22:31Z");
645        assert_eq!(utc, 1_786_386_151_000_000_000);
646        assert_eq!(epoch_nanos("2026-08-10T10:22:31-08:00"), utc);
647        assert_eq!(epoch_nanos("2026-08-10T23:52:31+05:30"), utc);
648        // Lowercase separators are legal RFC 3339.
649        assert_eq!(epoch_nanos("2026-08-10t18:22:31z"), utc);
650    }
651
652    #[test]
653    fn rfc3339_fractions_round_trip_to_the_nanosecond() {
654        let precise = epoch_nanos("2026-08-10T18:22:31.482919114Z");
655        assert_eq!(
656            precise / 100 * 100,
657            1_786_386_151_482_919_100,
658            "a report's scan_started_at must survive being fed back as a watermark"
659        );
660        // Shorter fractions pad rather than truncate.
661        assert_eq!(epoch_nanos("2026-08-10T18:22:31.5Z"), 1_786_386_151_500_000_000);
662    }
663
664    #[test]
665    fn epoch_values_accept_an_optional_fraction() {
666        assert_eq!(epoch_nanos("@1786386151"), 1_786_386_151_000_000_000);
667        // Truncated to the platform's tick, then compared in the same units, so this
668        // asserts the fraction is carried rather than asserting a clock precision.
669        let fractional = epoch_nanos("@1786386151.482919114");
670        assert_eq!(fractional / 100 * 100, 1_786_386_151_482_919_100);
671        assert_eq!(epoch_nanos("@0"), 0);
672        assert_eq!(epoch_nanos("@-1"), -1_000_000_000);
673    }
674
675    #[test]
676    fn pre_epoch_timestamps_are_negative_nanoseconds() {
677        assert_eq!(epoch_nanos("1969-12-31T23:59:59Z"), -1_000_000_000);
678        assert_eq!(epoch_nanos("1970-01-01T00:00:00Z"), 0);
679    }
680
681    #[test]
682    fn leap_days_parse_only_in_leap_years() {
683        assert!(parse_when("2024-02-29T00:00:00Z", now()).is_ok());
684        assert_eq!(time_rejection("2026-02-29T00:00:00Z"), "day is not a day of that month");
685    }
686
687    #[test]
688    fn calendar_units_are_rejected_with_a_days_suggestion() {
689        for value in ["3mo", "3months", "1y", "2years"] {
690            assert!(
691                time_rejection(value).contains("use days"),
692                "{value} should suggest days, got {:?}",
693                time_rejection(value)
694            );
695        }
696    }
697
698    #[test]
699    fn fractional_ages_are_rejected_with_a_compound_suggestion() {
700        let hint = time_rejection("1.5h");
701        assert!(hint.contains("1h30m"), "expected a compound suggestion, got {hint:?}");
702    }
703
704    #[test]
705    fn natural_language_is_rejected() {
706        assert!(time_rejection("yesterday").contains("expected `now`"));
707        assert!(time_rejection("2 weeks ago").contains("spaces are not part"));
708    }
709
710    #[test]
711    fn local_timestamps_are_rejected_rather_than_assumed_to_be_utc() {
712        // Guessing UTC here would answer a New York prompt several hours off in silence,
713        // which is exactly the failure the freshness rules exist to prevent.
714        for value in ["2026-08-10", "2026-08-10 12:30", "2026-08-10T12:30:00"] {
715            let hint = time_rejection(value);
716            assert!(hint.contains("offset"), "{value} should ask for an offset, got {hint:?}");
717        }
718    }
719
720    #[test]
721    fn malformed_times_name_the_grammar() {
722        assert!(time_rejection("").contains("expected `now`"));
723        assert!(time_rejection("2h30").contains("expected a unit"));
724        assert!(time_rejection("5x").contains("unknown age unit"));
725        assert!(time_rejection("2026-13-01T00:00:00Z").contains("month must be"));
726        assert!(time_rejection("2026-08-10T25:00:00Z").contains("hour must be"));
727    }
728
729    #[test]
730    fn sizes_accept_decimal_and_binary_units() {
731        for (value, expected) in [
732            ("512", 512),
733            ("512B", 512),
734            ("10k", 10_000),
735            ("10KB", 10_000),
736            ("10M", 10_000_000),
737            ("2G", 2_000_000_000),
738            ("1T", 1_000_000_000_000),
739            ("10Ki", 10_240),
740            ("10KiB", 10_240),
741            ("1Mi", 1_048_576),
742            ("1GiB", 1_073_741_824),
743        ] {
744            assert_eq!(parse_size(value).expect("parses"), expected, "{value}");
745        }
746    }
747
748    #[test]
749    fn fractional_sizes_scale_exactly() {
750        // Integer arithmetic, not floating point: 1.5 GiB is exactly 1610612736 bytes.
751        assert_eq!(parse_size("1.5GiB").expect("parses"), 1_610_612_736);
752        assert_eq!(parse_size("1.5M").expect("parses"), 1_500_000);
753        assert_eq!(parse_size("0.5k").expect("parses"), 500);
754        assert_eq!(parse_size("2.25M").expect("parses"), 2_250_000);
755    }
756
757    #[test]
758    fn size_units_are_case_insensitive() {
759        assert_eq!(parse_size("10mb").expect("parses"), parse_size("10MB").expect("parses"));
760        assert_eq!(parse_size("1gib").expect("parses"), parse_size("1GiB").expect("parses"));
761    }
762
763    #[test]
764    fn malformed_sizes_name_the_units() {
765        assert!(size_rejection("").contains("expected a byte count"));
766        assert!(size_rejection("M").contains("expected a number"));
767        assert!(size_rejection("10X").contains("unknown size unit"));
768        assert!(size_rejection("1.2.3M").contains("not a number"));
769        // A size that cannot fit in a byte count is rejected, never wrapped.
770        assert!(size_rejection("99999999P").contains("not a number"));
771    }
772
773    #[test]
774    fn each_control_limit_is_a_size_or_all_and_names_itself_when_rejected() {
775        assert_eq!(parse_control_budget("16M").expect("size"), Some(16_000_000));
776        assert_eq!(parse_control_budget("4MiB").expect("size"), Some(4 * 1024 * 1024));
777        assert_eq!(parse_control_budget(" ALL ").expect("all"), None);
778        assert_eq!(parse_control_line_limit("64KiB").expect("size"), Some(64 * 1024));
779        assert_eq!(parse_control_line_limit("all").expect("all"), None);
780        for (parse, kind) in [
781            (parse_control_budget as fn(&str) -> Result<Option<usize>>, "control budget"),
782            (parse_control_line_limit, "control line limit"),
783        ] {
784            assert_eq!(
785                parse("lots").expect_err("not a size").to_string(),
786                format!(
787                    "invalid {kind} \"lots\": expected a number before the unit, as in `10M`, \
788                     or `all` for no bound"
789                )
790            );
791        }
792    }
793
794    #[test]
795    fn formatting_is_the_exact_inverse_of_parsing() {
796        // The watermark contract is about instants, not strings: a rendered
797        // scan_started_at must parse back to the instant it came from, or an incremental
798        // follow-up query silently shifts its window.
799        //
800        // Asserted this way rather than as string equality because `SystemTime`
801        // granularity is platform-defined — Windows keeps 100-nanosecond FILETIME ticks,
802        // so a literal with finer digits cannot survive any round trip through it. What
803        // must hold everywhere is that rendering an instant and parsing it back returns
804        // that same instant.
805        for value in [
806            "2026-08-10T18:22:31.482919114Z",
807            "1970-01-01T00:00:00.000000000Z",
808            "1969-12-31T23:59:59.000000000Z",
809            "2024-02-29T12:00:00.000000000Z",
810            "1999-12-31T23:59:59.999999999Z",
811            "2100-03-01T00:00:00.000000000Z",
812        ] {
813            let parsed = parse_when(value, now()).expect("parses");
814            let rendered = format_rfc3339(parsed);
815            let reparsed = parse_when(&rendered, now()).expect("re-parses");
816            assert_eq!(parsed, reparsed, "instant round trip for {value} via {rendered}");
817        }
818    }
819
820    #[test]
821    fn whole_second_timestamps_render_byte_for_byte() {
822        // Within every platform's precision, the text form is exact too — which is what
823        // makes the goldens stable.
824        for value in [
825            "1970-01-01T00:00:00.000000000Z",
826            "2026-08-10T18:22:31.000000000Z",
827            "2024-02-29T12:00:00.000000000Z",
828        ] {
829            let parsed = parse_when(value, now()).expect("parses");
830            assert_eq!(format_rfc3339(parsed), value);
831        }
832    }
833
834    #[test]
835    fn integer_nanoseconds_render_byte_for_byte_on_every_platform() {
836        assert_eq!(
837            format_rfc3339_nanos(1_786_386_151_123_456_789),
838            "2026-08-10T18:22:31.123456789Z"
839        );
840        assert_eq!(format_rfc3339_nanos(-1), "1969-12-31T23:59:59.999999999Z");
841    }
842
843    #[test]
844    fn formatting_covers_dates_across_leap_and_century_boundaries() {
845        let at = |secs: i64| {
846            if secs >= 0 {
847                UNIX_EPOCH + Duration::from_secs(secs.unsigned_abs())
848            } else {
849                UNIX_EPOCH - Duration::from_secs(secs.unsigned_abs())
850            }
851        };
852        assert_eq!(format_rfc3339(at(0)), "1970-01-01T00:00:00.000000000Z");
853        assert_eq!(format_rfc3339(at(-86_400)), "1969-12-31T00:00:00.000000000Z");
854        assert_eq!(format_rfc3339(at(951_782_400)), "2000-02-29T00:00:00.000000000Z");
855    }
856
857    #[test]
858    fn nanosecond_conversion_survives_both_sides_of_the_epoch() {
859        // A 100-nanosecond step, because that is the coarsest granularity any supported
860        // platform's `SystemTime` keeps: finer offsets are rounded before this function
861        // ever sees them, which would make the test about the clock rather than the
862        // conversion.
863        const TICK: u64 = 100;
864        assert_eq!(system_time_to_nanos(UNIX_EPOCH), Some(0));
865        assert_eq!(
866            system_time_to_nanos(UNIX_EPOCH + Duration::from_nanos(TICK)),
867            Some(i64::try_from(TICK).expect("fits"))
868        );
869        assert_eq!(
870            system_time_to_nanos(UNIX_EPOCH - Duration::from_nanos(TICK)),
871            Some(-i64::try_from(TICK).expect("fits"))
872        );
873    }
874}