1use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use crate::engine_contract::{Error, Result};
17
18const NANOS_PER_SEC: u32 = 1_000_000_000;
20
21const MAX_FRACTION_DIGITS: usize = 9;
23
24pub 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 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
54pub 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
79pub fn parse_control_budget(input: &str) -> Result<Option<usize>> {
84 parse_control_limit(input, "control budget")
85}
86
87pub 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
113pub 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 if subsec == 0 { (-secs, 0) } else { (-secs - 1, NANOS_PER_SEC - subsec) }
128 }
129 };
130
131 format_rfc3339_parts(seconds, nanos)
132}
133
134pub(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
157fn 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
176pub 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
187fn 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
214fn 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
235fn 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
288fn 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
301fn 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 "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
330fn 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
363fn 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
376fn 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
437fn 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 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
449fn 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
457fn 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 if second > 60 {
482 return Err(when_error(input, "second must be between 00 and 60"));
483 }
484 Ok(())
485}
486
487fn 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
498fn is_leap_year(year: i64) -> bool {
500 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
501}
502
503fn 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
524fn 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
539fn 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
547fn when_error(input: &str, hint: &str) -> Error {
549 Error::InvalidValue { kind: "time", value: input.to_string(), hint: hint.to_string() }
550}
551
552fn 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 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 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 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 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 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 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 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 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 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 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 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 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}