Skip to main content

lanekeep_core/
suppression.rs

1//! Suppression directives: a next-line form and a whole-file form.
2//!
3//! Each is a comment carrying its form's token, the rule ids it silences, and a mandatory
4//! `reason:` — optionally followed by `expires:` and a date. The two tokens are the
5//! `NEXT_LINE` and `WHOLE_FILE` constants below, and this file cannot spell either of them
6//! out, for the reason under "Scanning text, not the tree". So the shape, with the tokens
7//! stood in for:
8//!
9//! ```text
10//! // <next-line-token> local/no-numeric-sizes reason: legacy API requires exact 44
11//! minWidth: 44,
12//!
13//! // <whole-file-token> local/no-primitive-components reason: generated fixture
14//! ```
15//!
16//! # A suppression that does not work must say so
17//!
18//! The failure mode this module is arranged against is a directive that looks like it
19//! silences something and does not — a typo in the rule id, a missing `reason:`, a token
20//! with a hyphen dropped out of it. The author moves on believing the violation is handled,
21//! and nothing ever tells them otherwise.
22//!
23//! So a malformed directive is **reported**, not skipped. Every field that could be
24//! mistyped is either required or checked, and the diagnostic names what was wrong.
25//!
26//! # Why `reason:` is mandatory
27//!
28//! A suppression is a decision to accept a violation, and the next person to read it needs
29//! to know whether that decision still holds. Without a reason it is indistinguishable from
30//! someone silencing a diagnostic to make a build pass.
31//!
32//! # Scanning text, not the tree
33//!
34//! Directives are found by scanning the source for a standalone token rather than by
35//! walking comments in the parse tree. That is what §10 specifies, it costs one pass over
36//! bytes already in memory, and it works for a file that failed to parse.
37//!
38//! The cost is that a directive inside a string literal counts. That is a strange thing to
39//! write and the consequence is a suppression that does nothing visible, which the unused
40//! report surfaces.
41//!
42//! It also applies to this file, which lanekeep checks along with the rest of its own
43//! source. A token spelled out here — in prose, in a doc example, in a test fixture — is a
44//! directive, not a description of one: either a malformed one that gets reported, or a
45//! well-formed one quietly silencing the rule it names, for the whole file if it is the
46//! whole-file form. So the two constants below are assembled from pieces, and everything
47//! needing a token builds it from them. Nothing in this file writes one out.
48
49use crate::rule_id::RuleId;
50
51/// The token introducing a directive that covers the following line.
52///
53/// Assembled rather than written, per the module documentation. The value is byte-for-byte
54/// what the scanner looks for; only this file's own source differs.
55const NEXT_LINE: &str = concat!("lanekeep", "-ignore-next-line");
56
57/// The token introducing a directive that covers the whole file. Assembled for the same
58/// reason as `NEXT_LINE`.
59const WHOLE_FILE: &str = concat!("lanekeep", "-ignore-file");
60
61/// What a directive covers.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Scope {
64    /// The line after the directive.
65    NextLine,
66    /// Every line in the file.
67    File,
68}
69
70/// A calendar date, for `expires:`.
71///
72/// Deliberately not a general date type: it exists to be parsed from `YYYY-MM-DD`, compared,
73/// and printed. Comparison is on the tuple, which is why the fields are in that order.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
75pub struct Date {
76    /// Four-digit year.
77    pub year: u16,
78    /// One-based month.
79    pub month: u8,
80    /// One-based day.
81    pub day: u8,
82}
83
84impl Date {
85    /// Parse `YYYY-MM-DD`.
86    ///
87    /// Rejects anything else, including a real date written another way. A directive whose
88    /// expiry could not be read would otherwise never expire, which is the one outcome an
89    /// expiry exists to prevent.
90    #[must_use]
91    pub fn parse(text: &str) -> Option<Self> {
92        let bytes = text.as_bytes();
93        if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
94            return None;
95        }
96
97        let year: u16 = text.get(0..4)?.parse().ok()?;
98        let month: u8 = text.get(5..7)?.parse().ok()?;
99        let day: u8 = text.get(8..10)?.parse().ok()?;
100
101        // Range-checked but not calendar-checked: 2026-02-30 is accepted. Validating month
102        // lengths would need leap-year rules for no benefit — the date is only ever
103        // compared, and an impossible date compares perfectly sensibly.
104        if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
105            return None;
106        }
107
108        Some(Self { year, month, day })
109    }
110
111    /// The date `days` days after this one.
112    ///
113    /// For a horizon check: whether an expiry is *more than* `n` days out is
114    /// `expires > today.add_days(n)`. Values past the 4-digit-year range clamp
115    /// the way `from_unix_days` clamps.
116    #[must_use]
117    pub fn add_days(self, days: u32) -> Self {
118        from_unix_days(days_from_civil(self) + i64::from(days))
119    }
120}
121
122impl std::fmt::Display for Date {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
125    }
126}
127
128/// A directive that parsed.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Suppression {
131    /// What it covers.
132    pub scope: Scope,
133    /// The rules it silences. Never empty — a directive naming none is malformed.
134    pub rules: Vec<RuleId>,
135    /// Why, as the author wrote it.
136    pub reason: String,
137    /// When it stops applying, if it says.
138    pub expires: Option<Date>,
139    /// One-based line the directive is on.
140    pub line: u32,
141    /// One-based column the directive starts at.
142    pub column: u32,
143}
144
145impl Suppression {
146    /// Whether this directive covers a violation of `rule` at `line`.
147    #[must_use]
148    pub fn covers(&self, rule: &RuleId, line: u32) -> bool {
149        let in_scope = match self.scope {
150            Scope::File => true,
151            // The line after the directive. Not the directive's own line: a trailing
152            // comment on the offending line would be a different form, and supporting both
153            // silently would make it unclear which one a reader is looking at.
154            Scope::NextLine => line == self.line + 1,
155        };
156        in_scope && self.rules.contains(rule)
157    }
158}
159
160/// A directive that did not parse, and why.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct Malformed {
163    /// One-based line.
164    pub line: u32,
165    /// One-based column.
166    pub column: u32,
167    /// What is wrong, phrased for the person who wrote it.
168    pub problem: String,
169}
170
171/// Everything a file's directives amount to.
172#[derive(Debug, Clone, Default, PartialEq, Eq)]
173pub struct Suppressions {
174    /// Directives that parsed.
175    pub valid: Vec<Suppression>,
176    /// Directives that did not.
177    pub malformed: Vec<Malformed>,
178}
179
180impl Suppressions {
181    /// Whether anything here could silence a violation.
182    #[must_use]
183    pub fn is_empty(&self) -> bool {
184        self.valid.is_empty() && self.malformed.is_empty()
185    }
186
187    /// The index of the directive covering a violation, if one does.
188    ///
189    /// The index rather than a boolean, so a caller can record which directives were used
190    /// and report the rest as unused.
191    #[must_use]
192    pub fn covering(&self, rule: &RuleId, line: u32) -> Option<usize> {
193        self.valid
194            .iter()
195            .position(|suppression| suppression.covers(rule, line))
196    }
197}
198
199/// Find every directive in a file.
200///
201/// Never fails: a directive that cannot be understood becomes a [`Malformed`] entry rather
202/// than an error, because one bad comment must not stop a file from being checked.
203#[must_use]
204pub fn parse(source: &str) -> Suppressions {
205    let mut found = Suppressions::default();
206
207    for (index, text) in source.lines().enumerate() {
208        let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
209
210        let Some((scope, at)) = find_directive(text) else {
211            continue;
212        };
213        let column = u32::try_from(at + 1).unwrap_or(u32::MAX);
214
215        let token = match scope {
216            Scope::NextLine => NEXT_LINE,
217            Scope::File => WHOLE_FILE,
218        };
219        let rest = text.get(at + token.len()..).unwrap_or_default();
220
221        match parse_body(scope, rest, line, column) {
222            Ok(suppression) => found.valid.push(suppression),
223            Err(problem) => found.malformed.push(Malformed {
224                line,
225                column,
226                problem,
227            }),
228        }
229    }
230
231    found
232}
233
234/// Locate a directive token in a line, if it stands alone.
235///
236/// "Stands alone" means not adjacent to a word character on either side, which is what stops
237/// prose about a token with `-ish` appended from matching. Neither token is a prefix of the
238/// other, so which one is looked for first does not matter — only which one sits earlier in
239/// the line.
240fn find_directive(text: &str) -> Option<(Scope, usize)> {
241    let next_line = standalone(text, NEXT_LINE).map(|at| (Scope::NextLine, at));
242    let whole_file = standalone(text, WHOLE_FILE).map(|at| (Scope::File, at));
243
244    match (next_line, whole_file) {
245        (Some(a), Some(b)) => Some(if a.1 <= b.1 { a } else { b }),
246        (found, None) | (None, found) => found,
247    }
248}
249
250/// The offset of `token` in `text`, if it appears as a standalone word.
251fn standalone(text: &str, token: &str) -> Option<usize> {
252    let mut from = 0usize;
253    while let Some(offset) = text.get(from..)?.find(token) {
254        let at = from + offset;
255        let before = text[..at].chars().next_back();
256        let after = text[at + token.len()..].chars().next();
257
258        let bounded = !before.is_some_and(is_word)
259            // A trailing `-` would make this the token with `-later` appended, which is not
260            // the directive and must not be treated as one.
261            && !after.is_some_and(|c| is_word(c) || c == '-');
262
263        if bounded {
264            return Some(at);
265        }
266        from = at + token.len();
267    }
268    None
269}
270
271const fn is_word(c: char) -> bool {
272    c.is_ascii_alphanumeric() || c == '_'
273}
274
275/// Parse everything after the directive token.
276fn parse_body(scope: Scope, rest: &str, line: u32, column: u32) -> Result<Suppression, String> {
277    // `reason:` splits the directive: rule ids before, prose after. Splitting on the keyword
278    // rather than on whitespace is what lets a reason contain anything, including a colon.
279    let Some((ids, tail)) = rest.split_once("reason:") else {
280        return Err(format!(
281            "suppression has no `reason:` — a suppression is a decision to accept a \
282             violation, and the next person to read it cannot tell whether it still holds \
283             without one\n  write: {} <rule-id> reason: why this is acceptable",
284            token_for(scope)
285        ));
286    };
287
288    let rules = parse_rules(ids)?;
289
290    // `expires:` may follow the reason. Taken from the end so the reason keeps any text
291    // before it — a reason is prose and must not be truncated by a word appearing in it.
292    let (reason, expires) = match tail.rsplit_once("expires:") {
293        Some((before, date)) => {
294            let text = date.trim();
295            let Some(parsed) = Date::parse(text) else {
296                return Err(format!(
297                    "suppression has an unreadable `expires: {text}` — expected \
298                     YYYY-MM-DD\n  an expiry that cannot be read would never expire, which \
299                     is the one thing an expiry exists to prevent"
300                ));
301            };
302            (before.trim(), Some(parsed))
303        }
304        None => (tail.trim(), None),
305    };
306
307    if reason.is_empty() {
308        return Err(format!(
309            "suppression has an empty `reason:`\n  write: {} <rule-id> reason: why this is \
310             acceptable",
311            token_for(scope)
312        ));
313    }
314
315    Ok(Suppression {
316        scope,
317        rules,
318        reason: reason.to_owned(),
319        expires,
320        line,
321        column,
322    })
323}
324
325/// Parse the rule ids between the directive and `reason:`.
326fn parse_rules(text: &str) -> Result<Vec<RuleId>, String> {
327    let mut rules = Vec::new();
328
329    for token in text.split([',', ' ', '\t']).filter(|t| !t.is_empty()) {
330        match token.parse::<RuleId>() {
331            Ok(id) => rules.push(id),
332            Err(_) => {
333                return Err(format!(
334                    "`{token}` is not a rule id\n  ids are namespaced — `lanekeep/<name>` \
335                     for built-in rules, `local/<name>` for this project's"
336                ));
337            }
338        }
339    }
340
341    if rules.is_empty() {
342        return Err(String::from(
343            "suppression names no rules\n  a directive that silenced everything would hide \
344             violations nobody chose to accept — name the rules it is for",
345        ));
346    }
347
348    Ok(rules)
349}
350
351const fn token_for(scope: Scope) -> &'static str {
352    match scope {
353        Scope::NextLine => NEXT_LINE,
354        Scope::File => WHOLE_FILE,
355    }
356}
357
358/// Today, from the host clock.
359///
360/// The one place the checker consults the clock for a result it reports, and it is
361/// deliberately here rather than in the sandbox: a rule must not be able to observe the
362/// date, but a suppression's expiry has to be compared against something. Callers fix it
363/// once per run so two files checked a millisecond apart cannot disagree about what day it
364/// is — and any file whose result depends on it says so in its cache key.
365///
366/// `local/no-ambient-observation` names the full list of places this repository's own
367/// source legitimately reads the clock — this one, and the run budget's origin in
368/// `lanekeep-js`, which is a different case: that clock can stop a result from being
369/// produced at all, never change what one contains.
370///
371/// UTC, not local time. A deadline that moved with the reader's time zone would expire
372/// twice in some places and not at all in others.
373#[must_use]
374pub fn today() -> Date {
375    let seconds = std::time::SystemTime::now()
376        .duration_since(std::time::UNIX_EPOCH)
377        .map_or(0, |elapsed| elapsed.as_secs());
378    from_unix_days(i64::try_from(seconds / 86_400).unwrap_or(0))
379}
380
381/// Civil date as a count of days since 1970-01-01.
382///
383/// Howard Hinnant's `days_from_civil`, the exact inverse of [`from_unix_days`].
384fn days_from_civil(date: Date) -> i64 {
385    let y = i64::from(date.year) - i64::from(date.month <= 2);
386    let era = (if y >= 0 { y } else { y - 399 }) / 400;
387    let yoe = y - era * 400;
388    let m = i64::from(date.month) + if date.month > 2 { -3 } else { 9 };
389    let doy = (153 * m + 2) / 5 + i64::from(date.day) - 1;
390    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
391    era * 146_097 + doe - 719_468
392}
393
394/// Civil date from a count of days since 1970-01-01.
395///
396/// Howard Hinnant's `civil_from_days`, which is exact for the whole proleptic Gregorian
397/// range and needs no table. Written out rather than pulled in: one function against a
398/// dependency that would carry formatting, parsing and time zones for a date this only ever
399/// compares.
400fn from_unix_days(days: i64) -> Date {
401    let z = days + 719_468;
402    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
403    let day_of_era = z - era * 146_097;
404    let year_of_era =
405        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
406    let year = year_of_era + era * 400;
407    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
408    let shifted_month = (5 * day_of_year + 2) / 153;
409    let day = day_of_year - (153 * shifted_month + 2) / 5 + 1;
410    let month = if shifted_month < 10 {
411        shifted_month + 3
412    } else {
413        shifted_month - 9
414    };
415
416    Date {
417        year: u16::try_from(if month <= 2 { year + 1 } else { year }).unwrap_or(1970),
418        month: u8::try_from(month).unwrap_or(1),
419        day: u8::try_from(day).unwrap_or(1),
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    // Every fixture here builds its directive from `NEXT_LINE` or `WHOLE_FILE` rather than
428    // writing the token out. The bytes handed to `parse` are unchanged — which is the point,
429    // since these tests are about scanning bytes — while this file stays free of directives.
430    // See the module documentation.
431
432    fn rule(id: &str) -> RuleId {
433        id.parse().expect("valid id")
434    }
435
436    fn only(source: &str) -> Suppression {
437        let found = parse(source);
438        assert!(
439            found.malformed.is_empty(),
440            "unexpectedly malformed: {:?}",
441            found.malformed
442        );
443        assert_eq!(found.valid.len(), 1, "{:?}", found.valid);
444        found.valid.into_iter().next().expect("one")
445    }
446
447    fn problem(source: &str) -> String {
448        let found = parse(source);
449        assert!(
450            found.valid.is_empty(),
451            "unexpectedly valid: {:?}",
452            found.valid
453        );
454        assert_eq!(found.malformed.len(), 1, "{:?}", found.malformed);
455        found.malformed.into_iter().next().expect("one").problem
456    }
457
458    #[test]
459    fn a_next_line_directive_parses() {
460        let found = only(&format!(
461            "// {NEXT_LINE} local/a reason: legacy\nminWidth: 44,\n"
462        ));
463        assert_eq!(found.scope, Scope::NextLine);
464        assert_eq!(found.rules, vec![rule("local/a")]);
465        assert_eq!(found.reason, "legacy");
466        assert_eq!(found.line, 1);
467        assert_eq!(found.expires, None);
468    }
469
470    #[test]
471    fn a_file_directive_parses() {
472        let found = only(&format!(
473            "// {WHOLE_FILE} local/a reason: generated fixture\n"
474        ));
475        assert_eq!(found.scope, Scope::File);
476        assert_eq!(found.reason, "generated fixture");
477    }
478
479    #[test]
480    fn several_rules_may_be_named() {
481        let found = only(&format!(
482            "// {NEXT_LINE} local/a, local/b lanekeep/c reason: x\n"
483        ));
484        assert_eq!(
485            found.rules,
486            vec![rule("local/a"), rule("local/b"), rule("lanekeep/c")]
487        );
488    }
489
490    #[test]
491    fn an_expiry_parses_and_leaves_the_reason_intact() {
492        let found = only(&format!(
493            "// {WHOLE_FILE} local/a reason: waiting on the rewrite expires: 2026-12-31\n"
494        ));
495        assert_eq!(found.reason, "waiting on the rewrite");
496        assert_eq!(
497            found.expires,
498            Some(Date {
499                year: 2026,
500                month: 12,
501                day: 31
502            })
503        );
504    }
505
506    #[test]
507    fn a_reason_may_contain_a_colon() {
508        // Splitting on the `reason:` keyword rather than on whitespace is what allows this.
509        let found = only(&format!(
510            "// {WHOLE_FILE} local/a reason: see ticket ABC-1: the API\n"
511        ));
512        assert_eq!(found.reason, "see ticket ABC-1: the API");
513    }
514
515    #[test]
516    fn a_missing_reason_is_malformed() {
517        // The failure this module exists for: a directive that looks like it works.
518        let text = problem(&format!("// {NEXT_LINE} local/a\n"));
519        assert!(text.contains("no `reason:`"), "{text}");
520    }
521
522    #[test]
523    fn an_empty_reason_is_malformed() {
524        let text = problem(&format!("// {NEXT_LINE} local/a reason:   \n"));
525        assert!(text.contains("empty"), "{text}");
526    }
527
528    #[test]
529    fn naming_no_rules_is_malformed() {
530        // A blanket suppression would hide violations nobody chose to accept.
531        let text = problem(&format!("// {NEXT_LINE} reason: everything\n"));
532        assert!(text.contains("names no rules"), "{text}");
533    }
534
535    #[test]
536    fn a_bare_rule_id_is_malformed() {
537        // Namespacing is a one-way door, and a bare id here would silently silence nothing.
538        let text = problem(&format!("// {NEXT_LINE} no-default-export reason: x\n"));
539        assert!(text.contains("not a rule id"), "{text}");
540        assert!(text.contains("namespaced"), "{text}");
541    }
542
543    #[test]
544    fn an_unreadable_expiry_is_malformed() {
545        // An expiry that cannot be read would never expire, which is the one thing an
546        // expiry exists to prevent.
547        for bad in [
548            "31-12-2026",
549            "2026/12/31",
550            "soon",
551            "2026-13-01",
552            "2026-12-32",
553        ] {
554            let text = problem(&format!(
555                "// {WHOLE_FILE} local/a reason: x expires: {bad}\n"
556            ));
557            assert!(text.contains("unreadable"), "`{bad}` gave: {text}");
558        }
559    }
560
561    #[test]
562    fn prose_mentioning_the_directive_does_not_match() {
563        // §10: the directive must be a standalone token.
564        for prose in [
565            format!("// use {NEXT_LINE}r for this\n"),
566            format!("// see {WHOLE_FILE}-format docs\n"),
567            format!("// x{WHOLE_FILE} local/a reason: x\n"),
568        ] {
569            let found = parse(&prose);
570            assert!(
571                found.is_empty(),
572                "prose matched as a directive: {prose:?} -> {found:?}"
573            );
574        }
575    }
576
577    #[test]
578    fn a_directive_is_found_wherever_it_sits_on_the_line() {
579        let found = only(&format!("const a = 1; // {NEXT_LINE} local/a reason: x\n"));
580        assert_eq!(found.line, 1);
581        assert!(found.column > 1, "column should point at the directive");
582    }
583
584    #[test]
585    fn several_directives_in_one_file_all_parse() {
586        let found = parse(&format!(
587            "// {WHOLE_FILE} local/a reason: one\n\
588             const x = 1;\n\
589             // {NEXT_LINE} local/b reason: two\n\
590             const y = 2;\n"
591        ));
592        assert_eq!(found.valid.len(), 2);
593        assert_eq!(found.valid[0].line, 1);
594        assert_eq!(found.valid[1].line, 3);
595    }
596
597    #[test]
598    fn a_malformed_directive_does_not_stop_the_others() {
599        // One bad comment must not stop a file from being checked.
600        let found = parse(&format!(
601            "// {NEXT_LINE} local/a\n\
602             const x = 1;\n\
603             // {NEXT_LINE} local/b reason: fine\n"
604        ));
605        assert_eq!(found.valid.len(), 1);
606        assert_eq!(found.malformed.len(), 1);
607    }
608
609    // --- what a directive covers ---------------------------------------------------------
610
611    #[test]
612    fn next_line_covers_the_following_line_only() {
613        let found = only(&format!("// {NEXT_LINE} local/a reason: x\nconst y = 1;\n"));
614        assert!(found.covers(&rule("local/a"), 2));
615        assert!(!found.covers(&rule("local/a"), 1), "not its own line");
616        assert!(
617            !found.covers(&rule("local/a"), 3),
618            "not the line after that"
619        );
620    }
621
622    #[test]
623    fn a_directive_covers_only_the_rules_it_names() {
624        let found = only(&format!("// {NEXT_LINE} local/a reason: x\n"));
625        assert!(found.covers(&rule("local/a"), 2));
626        assert!(!found.covers(&rule("local/b"), 2));
627    }
628
629    #[test]
630    fn file_scope_covers_every_line() {
631        let found = only(&format!("// {WHOLE_FILE} local/a reason: x\n"));
632        for line in [1, 2, 500] {
633            assert!(found.covers(&rule("local/a"), line));
634        }
635    }
636
637    #[test]
638    fn covering_reports_which_directive_matched() {
639        // The index, not a boolean, so a caller can tell which directives went unused.
640        let found = parse(&format!(
641            "// {NEXT_LINE} local/a reason: one\n\
642             const x = 1;\n\
643             // {NEXT_LINE} local/b reason: two\n\
644             const y = 2;\n"
645        ));
646        assert_eq!(found.covering(&rule("local/a"), 2), Some(0));
647        assert_eq!(found.covering(&rule("local/b"), 4), Some(1));
648        assert_eq!(found.covering(&rule("local/c"), 2), None);
649    }
650
651    // --- dates ----------------------------------------------------------------------------
652
653    #[test]
654    fn dates_compare_chronologically() {
655        let earlier = Date::parse("2026-01-31").expect("valid");
656        let later = Date::parse("2026-02-01").expect("valid");
657        assert!(earlier < later);
658
659        let next_year = Date::parse("2027-01-01").expect("valid");
660        assert!(later < next_year);
661    }
662
663    #[test]
664    fn known_epochs_convert_correctly() {
665        // Fixed points, including a leap day and a century boundary, so the conversion is
666        // checked against something other than itself.
667        for (days, expected) in [
668            (0, "1970-01-01"),
669            (18_993, "2022-01-01"),
670            (19_051, "2022-02-28"),
671            (11_016, "2000-02-29"),
672            (20_666, "2026-08-01"),
673        ] {
674            assert_eq!(from_unix_days(days).to_string(), expected, "day {days}");
675        }
676    }
677
678    #[test]
679    fn today_is_a_plausible_date() {
680        let now = today();
681        assert!(now.year >= 2024 && now.year < 2200, "{now}");
682        assert!((1..=12).contains(&now.month), "{now}");
683        assert!((1..=31).contains(&now.day), "{now}");
684    }
685
686    #[test]
687    fn a_date_renders_back_to_its_input() {
688        assert_eq!(
689            Date::parse("2026-08-01").expect("valid").to_string(),
690            "2026-08-01"
691        );
692    }
693
694    #[test]
695    fn add_days_moves_across_months_and_years() {
696        let start = Date::parse("2026-08-01").expect("valid");
697        assert_eq!(
698            start.add_days(90),
699            Date::parse("2026-10-30").expect("valid")
700        );
701        assert_eq!(start.add_days(0), start);
702
703        let new_year = Date::parse("2026-12-31").expect("valid");
704        assert_eq!(
705            new_year.add_days(1),
706            Date::parse("2027-01-01").expect("valid")
707        );
708    }
709
710    #[test]
711    fn add_days_handles_leap_years() {
712        let leap = Date::parse("2024-02-28").expect("valid");
713        assert_eq!(leap.add_days(1), Date::parse("2024-02-29").expect("valid"));
714        let common = Date::parse("2023-02-28").expect("valid");
715        assert_eq!(
716            common.add_days(1),
717            Date::parse("2023-03-01").expect("valid")
718        );
719    }
720}