Skip to main content

tix/roadmap/
order.rs

1//! Roadmap ordering: chronological target dates, stable-ID tie-breaks (WP02, FR-003).
2//!
3//! One pass over the registry ([`filter_and_order`]) applies the normalized
4//! [`RoadmapFilter`] and sorts the survivors. Nothing here reads a file, touches
5//! the clock, or mutates the registry: the registry is already in memory, and
6//! filtering and ordering are read-only derived views (contract
7//! `roadmap-projection.md`).
8//!
9//! # The ordering rule
10//!
11//! Target date **ascending**, then stable initiative **id** ascending. A validated
12//! `YYYY-MM-DD` date compares chronologically as a plain string (fixed-width,
13//! zero-padded), so the date key is the string itself — no parsed date type and no
14//! second date engine. The id tie-break makes equal dates deterministic: ids are
15//! unique and never reused (C-002), so the order of a given registry is total and
16//! independent of declaration order.
17//!
18//! # The missing-date state
19//!
20//! An initiative with no target date is **not** filtered out and is **not** given
21//! an invented period ("today", the epoch, `0000-00-00`). It is the explicit
22//! [`TargetDateState::Undated`] state on its row, and it sorts **after** every dated
23//! initiative. A dated initiative always sorts before an undated one; undated
24//! initiatives keep their relative order by stable id. Rendering that state is the
25//! projection's job (WP03) — this module only guarantees it is discoverable per row
26//! and that it never collides with a real date.
27//!
28//! A *period filter* is a different matter from ordering: an undated initiative
29//! cannot satisfy a calendar bound and is excluded by a bounded filter
30//! ([`RoadmapFilter::matches`]), because inventing a period for it is exactly what
31//! C-003 forbids.
32
33use crate::config::{Config, InitiativeConfiguration};
34use crate::error::Result;
35use crate::roadmap::filter::RoadmapFilter;
36
37/// The explicit ordering state of one initiative's target date.
38///
39/// The two states are exhaustive and never conflated: [`Self::Dated`] carries a
40/// validated `YYYY-MM-DD` calendar date, and [`Self::Undated`] is a record that
41/// carries no target date at all. There is no third "unknown date" value and no
42/// sentinel date, so a surface cannot render an undated initiative as if it were
43/// scheduled (C-003).
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum TargetDateState<'a> {
46    /// The record carries a `YYYY-MM-DD` calendar target date.
47    Dated(&'a str),
48    /// The record carries no target date; it sorts after every dated initiative.
49    Undated,
50}
51
52impl<'a> TargetDateState<'a> {
53    /// The target date, when the record carries one.
54    #[must_use]
55    pub fn date(self) -> Option<&'a str> {
56        match self {
57            Self::Dated(date) => Some(date),
58            Self::Undated => None,
59        }
60    }
61
62    /// Whether the record carries no target date.
63    #[must_use]
64    pub fn is_undated(self) -> bool {
65        matches!(self, Self::Undated)
66    }
67
68    /// The sort key: dated states sort before the undated one, then by date.
69    ///
70    /// `false < true` puts every dated state first; within the dated states the
71    /// key is the calendar date string, which is chronological for the validated
72    /// format. The undated key's empty date is irrelevant — an undated state is
73    /// only ever compared with another undated state, where the id tie-break decides.
74    fn sort_key(self) -> (bool, &'a str) {
75        match self {
76            Self::Dated(date) => (false, date),
77            Self::Undated => (true, ""),
78        }
79    }
80}
81
82/// One initiative selected for the roadmap, with its stable id and ordering state.
83///
84/// Deliberately a *view*: it borrows the registry record rather than copying it, and
85/// it resolves the target-date state once (at construction) so sorting never
86/// re-inspects the record. The projection (WP03) owns the displayable row; this is
87/// the filter/order layer's input to it.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct RoadmapEntry<'a> {
90    /// The stable initiative id (the registry key), never the display name.
91    pub id: &'a str,
92    /// The registry record this entry refers to.
93    pub initiative: &'a InitiativeConfiguration,
94    /// The explicit target-date ordering state, computed once.
95    pub target_date: TargetDateState<'a>,
96}
97
98impl<'a> RoadmapEntry<'a> {
99    /// Build an entry for `initiative`, resolving its target-date state.
100    #[must_use]
101    pub fn new(id: &'a str, initiative: &'a InitiativeConfiguration) -> Self {
102        let target_date = match initiative.target_date.as_deref() {
103            Some(date) => TargetDateState::Dated(date),
104            None => TargetDateState::Undated,
105        };
106        Self {
107            id,
108            initiative,
109            target_date,
110        }
111    }
112}
113
114/// Sort `entries` in place by target date ascending, then stable id ascending.
115///
116/// The sort is stable, and the id tie-break makes the order total for a registry
117/// whose ids are unique, so the result is deterministic even for equal dates. A
118/// missing target date sorts after every dated entry.
119pub fn order(entries: &mut [RoadmapEntry<'_>]) {
120    entries.sort_by(|a, b| {
121        a.target_date
122            .sort_key()
123            .cmp(&b.target_date.sort_key())
124            .then_with(|| a.id.cmp(b.id))
125    });
126}
127
128/// Filter the registry and order the survivors: the one read-only roadmap pass.
129///
130/// Validates the filter's syntax **first**, so a malformed bound refuses before any
131/// record is inspected; then walks `config.initiatives` once, keeps every record the
132/// filter matches, and sorts. Equivalent filters therefore produce equivalent
133/// ordered id sets on every surface (C-007, NFR-003), because there is exactly one
134/// predicate and one sort.
135///
136/// `config` is borrowed immutably: the pass can neither mutate nor persist an
137/// initiative.
138pub fn filter_and_order<'a>(
139    config: &'a Config,
140    filter: &RoadmapFilter,
141) -> Result<Vec<RoadmapEntry<'a>>> {
142    filter.validate()?;
143    let mut entries: Vec<RoadmapEntry<'a>> = config
144        .initiatives
145        .iter()
146        .filter(|(_, initiative)| filter.matches(initiative))
147        .map(|(id, initiative)| RoadmapEntry::new(id.as_str(), initiative))
148        .collect();
149    order(&mut entries);
150    Ok(entries)
151}