tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Immutable closed-cycle snapshots (WP05, T024/T028; `data-model.md` §4).
//!
//! # What a snapshot is, and why it is a file of its own
//!
//! Closing a generated cycle captures **historical evidence**: what the cycle was,
//! when it ran, who closed it and when. That evidence has to survive the schedule it
//! came from — a later `start_date` edit, a cadence change, a rename of the schedule
//! or of the cycle — so it cannot live inside the cycle record (which *is* rewritten
//! by an extend, a skip or an `adjust-future`) or inside `tix.toml` (which is
//! rewritten whenever a cursor moves). It is its own file under
//! `docs/cycle_snapshots/`, one per closed cycle, written **once** and never
//! rewritten afterwards (C-003/C-007/FR-005).
//!
//! # Identity is the generated cycle id, never the name
//!
//! A snapshot is keyed by [`GeneratedCycleId`] — `{schedule-id}:{sequence}` — the
//! same stable identity the rest of the boundary uses. Its `name` (the value
//! [`HasName`] reports) *is* that id, so a rename of the display label changes what a
//! human reads and nothing else. The display label is deliberately not stored: a
//! snapshot that carried a mutable label would invite a consumer to key on it.
//!
//! # The filename transformation, and why there is one
//!
//! A [`CycleScheduleId`] may legally contain `:` (`cs-1:2` is a valid schedule id,
//! since `:` is not a structural TOML key character), so a generated cycle id can be
//! `cs-1:2:000007` — and `:` is not portable in a path (it is the stream separator on
//! Windows and several tools). The stem is therefore
//! [documented](CycleSnapshotStore::filename_for) as the cycle id with every `:`
//! replaced by `.`, plus `.md`: `cs-1:2:000007` → `cs-1.2.000007.md`.
//!
//! **The transformation is injective**, and that is the point. A `.` is a structural
//! [`StableId`](crate::config::StableId) character and can therefore never appear
//! inside a cycle id, so replacing the only separator (`:`) with the only character
//! the id alphabet cannot contain is a one-to-one map over the id alphabet: two
//! distinct ids can never share a stem. (The earlier `:`→`-`-then-`slugify` form was
//! not: `slugify` folds `:` *and* `-` to `-`, so `cs-1:2:000007` and `cs-1-2:000007`
//! both became `cs-1-2-000007`, and the second cycle could never be closed at all.)
//! The result is also path-safe without `slugify`: the id alphabet is letters, digits,
//! `_`, `-` and `:`, so the stem is letters, digits, `_`, `-` and `.`.
//!
//! # A path is not an identity, so the path is guarded
//!
//! Following WP04's idiom, [`CycleSnapshotStore::capture`] refuses to write over an
//! existing file that is not this cycle's own snapshot — a collision is a refusal
//! naming the path and the occupant, never a silent overwrite that destroys another
//! cycle's evidence. The same reasoning drives [`SnapshotOccupant`], which lets the
//! lifecycle layer tell "already captured" from "occupied by someone else" without
//! guessing.
//!
//! # No clock
//!
//! A snapshot's `closed_at` is whatever [`CycleSnapshot::closed_at`] holds — the
//! caller's explicit timestamp string, validated by
//! [`validate_timestamp`](crate::cycle_schedule::snapshot::validate_timestamp) at
//! the lifecycle seam. Nothing here reads `SystemTime`; the module only stores and
//! reads back the string it is handed.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::cycle_schedule::config::CycleScheduleId;
use crate::cycle_schedule::identity::GeneratedCycleId;
use crate::error::{Result, TixError};
use crate::records::{HasName, RecordStore};

/// Metadata a snapshot carries for consumers this build does not implement —
/// capacity measurements, later history annotations — preserved verbatim.
///
/// The data model calls `measurements` an optional *typed* snapshot and
/// `extensions` optional unknown data. This build has no capacity calculation to
/// produce the former, so both are modelled as an opaque string-keyed map: a
/// consumer can read what a later writer put there, and a round trip never drops a
/// key this build has never heard of (the same "preserve unknown metadata" rule
/// [`crate::cycle_schedule::config::CycleScheduleExtensions`] follows for schedules).
pub type CycleSnapshotMetadata = BTreeMap<String, serde_yaml::Value>;

/// The immutable historical record of one closed cycle (`data-model.md` §4).
///
/// Written once at close, never rewritten. Every field is the value the close
/// captured; nothing is recomputed from the live schedule, because the live schedule
/// is exactly what is allowed to change afterwards.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CycleSnapshot {
    /// The stable generated cycle identity. The snapshot's key.
    pub cycle_id: GeneratedCycleId,
    /// The schedule the cycle came from, by stable id.
    pub schedule_id: CycleScheduleId,
    /// The historical sequence within the schedule.
    pub sequence: u32,
    /// The effective start date at close, `YYYY-MM-DD`.
    pub start_date: String,
    /// The effective due date at close, `YYYY-MM-DD`.
    pub due_date: String,
    /// The explicit close time, RFC 3339 `YYYY-MM-DDTHH:MM:SSZ`.
    pub closed_at: String,
    /// The audit actor who closed the cycle.
    pub closed_by: String,
    /// The lifecycle context at close (`closed`; `ended`/`skipped` would be recorded
    /// by a future action that captures them).
    pub state_summary: String,
    /// Capacity/history evidence, when a caller supplied any. Empty for a plain close.
    #[serde(default, skip_serializing_if = "CycleSnapshotMetadata::is_empty")]
    pub measurements: CycleSnapshotMetadata,
    /// Unknown keys, preserved verbatim so a later `tix` can extend the shape without
    /// this build dropping the new fields on a read/rewrite.
    #[serde(flatten)]
    pub extensions: CycleSnapshotMetadata,
}

impl HasName for CycleSnapshot {
    /// The frontmatter identity: the stable cycle id.
    ///
    /// Deliberately not a display name — see the module note.
    fn name(&self) -> &str {
        self.cycle_id.as_str()
    }
}

/// What already occupies a cycle's snapshot path.
///
/// The three answers are what let a caller distinguish the three things that look
/// alike: nothing is there yet, this cycle's snapshot is already there (a repeat, or
/// a crashed close being completed), or *someone else's* file is there (a collision
/// that must never be overwritten).
#[derive(Debug, Clone, PartialEq)]
pub enum SnapshotOccupant {
    /// The path is free.
    Absent,
    /// This cycle's own snapshot is already at the path.
    Same(CycleSnapshot),
    /// A file is at the path but it is not this cycle's snapshot.
    Foreign {
        /// The filename that is in the way.
        filename: String,
    },
}

/// The directory of immutable closed-cycle snapshots (`docs/cycle_snapshots/`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CycleSnapshotStore {
    dir: PathBuf,
}

impl CycleSnapshotStore {
    /// Bind the store to `root/docs/cycle_snapshots`. The directory is not created
    /// here; only a capture makes it exist.
    #[must_use]
    pub fn new(root: &Path) -> Self {
        Self {
            dir: root.join("docs/cycle_snapshots"),
        }
    }

    /// The directory backing this store.
    #[must_use]
    pub fn dir(&self) -> &Path {
        &self.dir
    }

    /// The filename a cycle's snapshot lives in.
    ///
    /// The cycle id with every `:` replaced by `.`, plus `.md` (see the module note
    /// for why the replacement is necessary and why it is injective). Stable for a
    /// given id and independent of any display label.
    #[must_use]
    pub fn filename_for(cycle_id: &GeneratedCycleId) -> String {
        format!("{}.md", cycle_id.as_str().replace(':', "."))
    }

    /// The path a cycle's snapshot lives at.
    #[must_use]
    pub fn path_for(&self, cycle_id: &GeneratedCycleId) -> PathBuf {
        self.dir.join(Self::filename_for(cycle_id))
    }

    /// What is already at `cycle_id`'s snapshot path (T024).
    ///
    /// Reads the directory once and looks the file up **by filename**, so an
    /// unparseable or foreign occupant is reported rather than mistaken for a free
    /// slot — the same reason WP04 checks the path, not just the key.
    pub fn occupant(&self, cycle_id: &GeneratedCycleId) -> Result<SnapshotOccupant> {
        let filename = Self::filename_for(cycle_id);
        let found = RecordStore::new(&self.dir)
            .list::<CycleSnapshot>()?
            .into_iter()
            .find(|record| record.filename == filename);
        match found {
            Some(record) if record.value.cycle_id == *cycle_id => {
                Ok(SnapshotOccupant::Same(record.value))
            }
            Some(_) => Ok(SnapshotOccupant::Foreign { filename }),
            None => {
                if self.path_for(cycle_id).exists() {
                    // Present but not a readable snapshot: still occupied.
                    Ok(SnapshotOccupant::Foreign { filename })
                } else {
                    Ok(SnapshotOccupant::Absent)
                }
            }
        }
    }

    /// Read a cycle's snapshot, when one is present and readable.
    pub fn read(&self, cycle_id: &GeneratedCycleId) -> Result<Option<CycleSnapshot>> {
        Ok(match self.occupant(cycle_id)? {
            SnapshotOccupant::Same(snapshot) => Some(snapshot),
            SnapshotOccupant::Absent | SnapshotOccupant::Foreign { .. } => None,
        })
    }

    /// Every stored snapshot, sorted by filename.
    pub fn list(&self) -> Result<Vec<CycleSnapshot>> {
        Ok(RecordStore::new(&self.dir)
            .list::<CycleSnapshot>()?
            .into_iter()
            .map(|record| record.value)
            .collect())
    }

    /// Capture `snapshot`, creating the directory on demand.
    ///
    /// Refuses to overwrite anything: a path that is already occupied is an error,
    /// which is what makes a snapshot immutable (T024/C-007). The caller is expected
    /// to have asked [`Self::occupant`] first so it can report the refusal as a
    /// stable diagnostic rather than an I/O error.
    pub fn capture(&self, snapshot: &CycleSnapshot) -> Result<PathBuf> {
        let filename = Self::filename_for(&snapshot.cycle_id);
        if self.dir.join(&filename).exists() {
            return Err(TixError::InvalidInput(format!(
                "`docs/cycle_snapshots/{filename}` already exists; a closed-cycle snapshot is \
                 immutable and is never rewritten"
            )));
        }
        RecordStore::new(&self.dir).write(&filename, snapshot, None)
    }

    /// Remove a snapshot this call just wrote, for compensation on a later failure.
    ///
    /// Deliberately not a general delete: it refuses to touch a snapshot unless the
    /// file at the path is the one `cycle_id` names, so a rollback cannot destroy
    /// another cycle's evidence.
    pub fn discard(&self, cycle_id: &GeneratedCycleId) -> Result<()> {
        match self.occupant(cycle_id)? {
            SnapshotOccupant::Same(_) => {
                RecordStore::new(&self.dir).delete_file(&Self::filename_for(cycle_id))
            }
            SnapshotOccupant::Absent => Ok(()),
            SnapshotOccupant::Foreign { filename } => Err(TixError::InvalidInput(format!(
                "refusing to remove `docs/cycle_snapshots/{filename}`: it is not `{cycle_id}`'s \
                 own snapshot"
            ))),
        }
    }
}

/// Whether `value` is the crate's RFC 3339 instant shape (`2026-09-27T10:14:02Z`).
///
/// This is the exact shape [`crate::model::utc_now_rfc3339`] produces, but nothing
/// here reads a clock: the lifecycle request supplies the string, and this is the
/// validation of its **shape** — a real calendar date, a `T`, two-digit
/// hour/minute/second in range, and the trailing `Z`. An off-shape string is refused
/// by the caller rather than stored as fabricated evidence.
#[must_use]
pub fn validate_timestamp(value: &str) -> bool {
    let bytes = value.as_bytes();
    if bytes.len() != 20 || bytes[10] != b'T' || bytes[19] != b'Z' {
        return false;
    }
    if bytes[4] != b'-' || bytes[7] != b'-' || bytes[13] != b':' || bytes[16] != b':' {
        return false;
    }
    for index in [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18] {
        if !bytes[index].is_ascii_digit() {
            return false;
        }
    }
    if !crate::model::validate_date(&value[..10]) {
        return false;
    }
    let hour = value[11..13].parse::<u8>().unwrap_or(u8::MAX);
    let minute = value[14..16].parse::<u8>().unwrap_or(u8::MAX);
    let second = value[17..19].parse::<u8>().unwrap_or(u8::MAX);
    hour < 24 && minute < 60 && second < 60
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    fn schedule() -> CycleScheduleId {
        CycleScheduleId::new("cs-1:2").unwrap()
    }

    fn cycle() -> GeneratedCycleId {
        GeneratedCycleId::new(&schedule(), 7)
    }

    fn snapshot() -> CycleSnapshot {
        CycleSnapshot {
            cycle_id: cycle(),
            schedule_id: schedule(),
            sequence: 7,
            start_date: "2026-01-05".to_string(),
            due_date: "2026-01-11".to_string(),
            closed_at: "2026-01-12T09:00:00Z".to_string(),
            closed_by: "alice".to_string(),
            state_summary: "closed".to_string(),
            measurements: CycleSnapshotMetadata::new(),
            extensions: CycleSnapshotMetadata::new(),
        }
    }

    #[test]
    fn the_filename_replaces_colons_and_is_injective() {
        // A schedule id may itself contain a colon, so the cycle id has two.
        assert_eq!(cycle().as_str(), "cs-1:2:000007");
        assert_eq!(
            CycleSnapshotStore::filename_for(&cycle()),
            "cs-1.2.000007.md",
            "the transformation is documented and deterministic"
        );
        // Injectivity over the id alphabet: `cs-1:2:000007` and `cs-1-2:000007` are
        // distinct ids (`.` cannot appear inside an id, `-` can) and must map to
        // distinct stems — the earlier slugify form aliased them.
        let alias = GeneratedCycleId::parse("cs-1-2:000007").unwrap();
        assert_ne!(alias, cycle());
        assert_ne!(
            CycleSnapshotStore::filename_for(&alias),
            CycleSnapshotStore::filename_for(&cycle())
        );
        assert_eq!(CycleSnapshotStore::filename_for(&alias), "cs-1-2.000007.md");
        // A second schedule whose id lacks the colon maps distinctly too.
        let plain = GeneratedCycleId::new(&CycleScheduleId::new("cs-1").unwrap(), 7);
        assert_eq!(CycleSnapshotStore::filename_for(&plain), "cs-1.000007.md");
    }

    #[test]
    fn a_timestamp_must_have_the_crates_shape() {
        assert!(validate_timestamp("2026-09-27T10:14:02Z"));
        for bad in [
            "",
            "2026-09-27",
            "2026-09-27 10:14:02Z",
            "2026-09-27T10:14:02",
            "2026-13-01T10:14:02Z",
            "2026-02-30T10:14:02Z",
            "2026-09-27T24:00:00Z",
            "2026-09-27T10:60:00Z",
            "2026-09-27T10:14:60Z",
            "2026-09-27T1a:14:02Z",
        ] {
            assert!(!validate_timestamp(bad), "`{bad}` must be refused");
        }
    }

    #[test]
    fn a_snapshot_round_trips_through_the_record_store_and_never_rewrites() {
        let dir = tempfile::TempDir::new().unwrap();
        let store = CycleSnapshotStore::new(dir.path());
        assert_eq!(store.occupant(&cycle()).unwrap(), SnapshotOccupant::Absent);
        let path = store.capture(&snapshot()).unwrap();
        let bytes = std::fs::read(&path).unwrap();
        assert_eq!(store.read(&cycle()).unwrap(), Some(snapshot()));
        assert!(matches!(
            store.occupant(&cycle()).unwrap(),
            SnapshotOccupant::Same(_)
        ));
        // A second capture is refused outright, and the bytes are untouched.
        assert!(store.capture(&snapshot()).is_err());
        assert_eq!(std::fs::read(&path).unwrap(), bytes);
        // Discarding the cycle's own snapshot is allowed; a foreign path is not.
        store.discard(&cycle()).unwrap();
        assert_eq!(store.occupant(&cycle()).unwrap(), SnapshotOccupant::Absent);
    }

    #[test]
    fn unknown_keys_are_preserved_but_not_required() {
        let yaml = "cycle_id: cs-1:2:000007\n\
                    schedule_id: cs-1:2\n\
                    sequence: 7\n\
                    start_date: 2026-01-05\n\
                    due_date: 2026-01-11\n\
                    closed_at: 2026-01-12T09:00:00Z\n\
                    closed_by: alice\n\
                    state_summary: closed\n\
                    future_field: 42\n";
        let parsed: CycleSnapshot = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(
            parsed.extensions.get("future_field"),
            Some(&serde_yaml::from_str::<serde_yaml::Value>("42").unwrap())
        );
        assert!(parsed.measurements.is_empty());
    }
}