tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// Today's date as a local-free calendar date in `YYYY-MM-DD`.
///
/// Computed from the Unix epoch in UTC; `crate`-wide reproducible and
/// dependency-free. Later WPs reuse it for `created`/`updated` and the
/// date prefix of filenames.
pub fn today() -> String {
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let days = (secs / 86_400) as i64;
    let (y, m, d) = civil_from_days(days);
    format!("{y:04}-{m:02}-{d:02}")
}

/// The current UTC instant as RFC 3339 (`2026-09-27T10:14:02Z`), used for
/// [`HistoryEvent`] timestamps.
///
/// Dependency-free like [`today`]: the epoch seconds are converted through
/// [`civil_from_days`] and the time-of-day is derived arithmetically.
pub fn utc_now_rfc3339() -> String {
    let secs = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let days = (secs / 86_400) as i64;
    let rem = secs % 86_400;
    let (y, m, d) = civil_from_days(days);
    format!(
        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
        rem / 3600,
        (rem % 3600) / 60,
        rem % 60
    )
}

/// A strict `YYYY-MM-DD` calendar-date check (shape + real month/day with
/// per-month day counts and leap years, `year >= 1970`), so `2026-13-99`,
/// `2026-02-30` and `1969-12-31` are all flagged as malformed. The shared
/// validation path for date-bearing config and frontmatter fields (FR-007).
pub fn validate_date(s: &str) -> bool {
    let b = s.as_bytes();
    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
        return false;
    }
    for &c in &b[0..4] {
        if !c.is_ascii_digit() {
            return false;
        }
    }
    for &c in &[b[5], b[6], b[8], b[9]] {
        if !c.is_ascii_digit() {
            return false;
        }
    }
    let year = u32::from(b[0] - b'0') * 1000
        + u32::from(b[1] - b'0') * 100
        + u32::from(b[2] - b'0') * 10
        + u32::from(b[3] - b'0');
    let month = u32::from(b[5] - b'0') * 10 + u32::from(b[6] - b'0');
    let day = u32::from(b[8] - b'0') * 10 + u32::from(b[9] - b'0');
    if year < 1970 || !(1..=12).contains(&month) {
        return false;
    }
    day >= 1 && day <= days_in_month(year, month)
}

/// Validate a `(start_date, due_date)` pair for an issue (FR-007, C-002).
///
/// Each present date must parse as a calendar date; when both are present,
/// `due_date >= start_date` must hold. Returns a config-class error naming
/// both fields on an ordering violation, matching
/// `contracts/file-format-planning.md`.
pub fn validate_issue_dates(start_date: Option<&str>, due_date: Option<&str>) -> Result<()> {
    if let Some(start) = start_date
        && validate_date_msg(start).is_err()
    {
        return Err(TixError::Config(format!(
            "start_date {start:?} must be a `YYYY-MM-DD` calendar date"
        )));
    }
    if let Some(due) = due_date {
        if validate_date_msg(due).is_err() {
            return Err(TixError::Config(format!(
                "due_date {due:?} must be a `YYYY-MM-DD` calendar date"
            )));
        }
        if let Some(start) = start_date
            && !date_lte(start, due)
        {
            return Err(TixError::Config(format!(
                "due_date {due:?} must be `>= start_date` {start:?}"
            )));
        }
    }
    Ok(())
}

/// Chronological order for two validated `YYYY-MM-DD` strings.
///
/// Plain lexicographic comparison equals chronological order for the
/// formats [`validate_date`] accepts (fixed-width zero-padded fields), so
/// later WPs (overdue, filters, cascade) reuse identical ordering logic.
pub fn date_lte(a: &str, b: &str) -> bool {
    a <= b
}