tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Roadmap ordering: chronological target dates, stable-ID tie-breaks (WP02, FR-003).
//!
//! One pass over the registry ([`filter_and_order`]) applies the normalized
//! [`RoadmapFilter`] and sorts the survivors. Nothing here reads a file, touches
//! the clock, or mutates the registry: the registry is already in memory, and
//! filtering and ordering are read-only derived views (contract
//! `roadmap-projection.md`).
//!
//! # The ordering rule
//!
//! Target date **ascending**, then stable initiative **id** ascending. A validated
//! `YYYY-MM-DD` date compares chronologically as a plain string (fixed-width,
//! zero-padded), so the date key is the string itself — no parsed date type and no
//! second date engine. The id tie-break makes equal dates deterministic: ids are
//! unique and never reused (C-002), so the order of a given registry is total and
//! independent of declaration order.
//!
//! # The missing-date state
//!
//! An initiative with no target date is **not** filtered out and is **not** given
//! an invented period ("today", the epoch, `0000-00-00`). It is the explicit
//! [`TargetDateState::Undated`] state on its row, and it sorts **after** every dated
//! initiative. A dated initiative always sorts before an undated one; undated
//! initiatives keep their relative order by stable id. Rendering that state is the
//! projection's job (WP03) — this module only guarantees it is discoverable per row
//! and that it never collides with a real date.
//!
//! A *period filter* is a different matter from ordering: an undated initiative
//! cannot satisfy a calendar bound and is excluded by a bounded filter
//! ([`RoadmapFilter::matches`]), because inventing a period for it is exactly what
//! C-003 forbids.

use crate::config::{Config, InitiativeConfiguration};
use crate::error::Result;
use crate::roadmap::filter::RoadmapFilter;

/// The explicit ordering state of one initiative's target date.
///
/// The two states are exhaustive and never conflated: [`Self::Dated`] carries a
/// validated `YYYY-MM-DD` calendar date, and [`Self::Undated`] is a record that
/// carries no target date at all. There is no third "unknown date" value and no
/// sentinel date, so a surface cannot render an undated initiative as if it were
/// scheduled (C-003).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TargetDateState<'a> {
    /// The record carries a `YYYY-MM-DD` calendar target date.
    Dated(&'a str),
    /// The record carries no target date; it sorts after every dated initiative.
    Undated,
}

impl<'a> TargetDateState<'a> {
    /// The target date, when the record carries one.
    #[must_use]
    pub fn date(self) -> Option<&'a str> {
        match self {
            Self::Dated(date) => Some(date),
            Self::Undated => None,
        }
    }

    /// Whether the record carries no target date.
    #[must_use]
    pub fn is_undated(self) -> bool {
        matches!(self, Self::Undated)
    }

    /// The sort key: dated states sort before the undated one, then by date.
    ///
    /// `false < true` puts every dated state first; within the dated states the
    /// key is the calendar date string, which is chronological for the validated
    /// format. The undated key's empty date is irrelevant — an undated state is
    /// only ever compared with another undated state, where the id tie-break decides.
    fn sort_key(self) -> (bool, &'a str) {
        match self {
            Self::Dated(date) => (false, date),
            Self::Undated => (true, ""),
        }
    }
}

/// One initiative selected for the roadmap, with its stable id and ordering state.
///
/// Deliberately a *view*: it borrows the registry record rather than copying it, and
/// it resolves the target-date state once (at construction) so sorting never
/// re-inspects the record. The projection (WP03) owns the displayable row; this is
/// the filter/order layer's input to it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RoadmapEntry<'a> {
    /// The stable initiative id (the registry key), never the display name.
    pub id: &'a str,
    /// The registry record this entry refers to.
    pub initiative: &'a InitiativeConfiguration,
    /// The explicit target-date ordering state, computed once.
    pub target_date: TargetDateState<'a>,
}

impl<'a> RoadmapEntry<'a> {
    /// Build an entry for `initiative`, resolving its target-date state.
    #[must_use]
    pub fn new(id: &'a str, initiative: &'a InitiativeConfiguration) -> Self {
        let target_date = match initiative.target_date.as_deref() {
            Some(date) => TargetDateState::Dated(date),
            None => TargetDateState::Undated,
        };
        Self {
            id,
            initiative,
            target_date,
        }
    }
}

/// Sort `entries` in place by target date ascending, then stable id ascending.
///
/// The sort is stable, and the id tie-break makes the order total for a registry
/// whose ids are unique, so the result is deterministic even for equal dates. A
/// missing target date sorts after every dated entry.
pub fn order(entries: &mut [RoadmapEntry<'_>]) {
    entries.sort_by(|a, b| {
        a.target_date
            .sort_key()
            .cmp(&b.target_date.sort_key())
            .then_with(|| a.id.cmp(b.id))
    });
}

/// Filter the registry and order the survivors: the one read-only roadmap pass.
///
/// Validates the filter's syntax **first**, so a malformed bound refuses before any
/// record is inspected; then walks `config.initiatives` once, keeps every record the
/// filter matches, and sorts. Equivalent filters therefore produce equivalent
/// ordered id sets on every surface (C-007, NFR-003), because there is exactly one
/// predicate and one sort.
///
/// `config` is borrowed immutably: the pass can neither mutate nor persist an
/// initiative.
pub fn filter_and_order<'a>(
    config: &'a Config,
    filter: &RoadmapFilter,
) -> Result<Vec<RoadmapEntry<'a>>> {
    filter.validate()?;
    let mut entries: Vec<RoadmapEntry<'a>> = config
        .initiatives
        .iter()
        .filter(|(_, initiative)| filter.matches(initiative))
        .map(|(id, initiative)| RoadmapEntry::new(id.as_str(), initiative))
        .collect();
    order(&mut entries);
    Ok(entries)
}