Skip to main content

tix/model/
dates.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4/// Today's date as a local-free calendar date in `YYYY-MM-DD`.
5///
6/// Computed from the Unix epoch in UTC; `crate`-wide reproducible and
7/// dependency-free. Later WPs reuse it for `created`/`updated` and the
8/// date prefix of filenames.
9pub fn today() -> String {
10    let secs = SystemTime::now()
11        .duration_since(UNIX_EPOCH)
12        .map(|d| d.as_secs())
13        .unwrap_or(0);
14    let days = (secs / 86_400) as i64;
15    let (y, m, d) = civil_from_days(days);
16    format!("{y:04}-{m:02}-{d:02}")
17}
18
19/// The current UTC instant as RFC 3339 (`2026-09-27T10:14:02Z`), used for
20/// [`HistoryEvent`] timestamps.
21///
22/// Dependency-free like [`today`]: the epoch seconds are converted through
23/// [`civil_from_days`] and the time-of-day is derived arithmetically.
24pub fn utc_now_rfc3339() -> String {
25    let secs = SystemTime::now()
26        .duration_since(UNIX_EPOCH)
27        .map(|d| d.as_secs())
28        .unwrap_or(0);
29    let days = (secs / 86_400) as i64;
30    let rem = secs % 86_400;
31    let (y, m, d) = civil_from_days(days);
32    format!(
33        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
34        rem / 3600,
35        (rem % 3600) / 60,
36        rem % 60
37    )
38}
39
40/// A strict `YYYY-MM-DD` calendar-date check (shape + real month/day with
41/// per-month day counts and leap years, `year >= 1970`), so `2026-13-99`,
42/// `2026-02-30` and `1969-12-31` are all flagged as malformed. The shared
43/// validation path for date-bearing config and frontmatter fields (FR-007).
44pub fn validate_date(s: &str) -> bool {
45    let b = s.as_bytes();
46    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
47        return false;
48    }
49    for &c in &b[0..4] {
50        if !c.is_ascii_digit() {
51            return false;
52        }
53    }
54    for &c in &[b[5], b[6], b[8], b[9]] {
55        if !c.is_ascii_digit() {
56            return false;
57        }
58    }
59    let year = u32::from(b[0] - b'0') * 1000
60        + u32::from(b[1] - b'0') * 100
61        + u32::from(b[2] - b'0') * 10
62        + u32::from(b[3] - b'0');
63    let month = u32::from(b[5] - b'0') * 10 + u32::from(b[6] - b'0');
64    let day = u32::from(b[8] - b'0') * 10 + u32::from(b[9] - b'0');
65    if year < 1970 || !(1..=12).contains(&month) {
66        return false;
67    }
68    day >= 1 && day <= days_in_month(year, month)
69}
70
71/// Validate a `(start_date, due_date)` pair for an issue (FR-007, C-002).
72///
73/// Each present date must parse as a calendar date; when both are present,
74/// `due_date >= start_date` must hold. Returns a config-class error naming
75/// both fields on an ordering violation, matching
76/// `contracts/file-format-planning.md`.
77pub fn validate_issue_dates(start_date: Option<&str>, due_date: Option<&str>) -> Result<()> {
78    if let Some(start) = start_date
79        && validate_date_msg(start).is_err()
80    {
81        return Err(TixError::Config(format!(
82            "start_date {start:?} must be a `YYYY-MM-DD` calendar date"
83        )));
84    }
85    if let Some(due) = due_date {
86        if validate_date_msg(due).is_err() {
87            return Err(TixError::Config(format!(
88                "due_date {due:?} must be a `YYYY-MM-DD` calendar date"
89            )));
90        }
91        if let Some(start) = start_date
92            && !date_lte(start, due)
93        {
94            return Err(TixError::Config(format!(
95                "due_date {due:?} must be `>= start_date` {start:?}"
96            )));
97        }
98    }
99    Ok(())
100}
101
102/// Chronological order for two validated `YYYY-MM-DD` strings.
103///
104/// Plain lexicographic comparison equals chronological order for the
105/// formats [`validate_date`] accepts (fixed-width zero-padded fields), so
106/// later WPs (overdue, filters, cascade) reuse identical ordering logic.
107pub fn date_lte(a: &str, b: &str) -> bool {
108    a <= b
109}