tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The typed time entry and its field-aware validation (mission
//! `per-issue-time-tracking-01M3C9A4`, WP01 T001–T003/T005).
//!
//! A [`TimeEntry`] is **actual effort**, never an estimate and never a schedule:
//! one stable id, a duration in whole **minutes**, an optional `YYYY-MM-DD`
//! calendar date, a required author, an optional note, and whatever unknown
//! metadata a newer `tix` wrote beside them. There is no start/stop state, no
//! clock, no timezone conversion and no overlap rule (C-002, C-009): the model
//! cannot express them, so no surface can drift into one. [`crate::model::validate_date`]
//! is reused for the date, and `duration_minutes` stays distinct from the
//! issue's free-form `estimate` — estimates are not converted here (C-005).
//!
//! # Precision and refusals
//!
//! Duration is a whole number of minutes held in `i64` rather than an unsigned
//! integer, for the reason [`CapacityEntry`](crate::capacity::CapacityEntry)
//! holds its ceiling in `i64`: a value a file can carry but the mission refuses
//! (`-5`) must be **refused with its field path**, not by failing in the YAML
//! parser, where no diagnostic could name the entry. A *non-integer* token
//! (`duration_minutes: 1.5`) is a frontmatter type error and is refused by the
//! parser, exactly as every other typed frontmatter field is (`parent: ""` is
//! that precedent, and it is pinned by its own test); it is never coerced to
//! zero.
//!
//! # Malformed entries stay readable
//!
//! [`TimeEntry::problems`] reports every field-level problem and
//! [`TimeEntry::validate`] refuses the entry, but neither makes the entry
//! unreadable. A malformed entry stays in the issue's frontmatter where it can
//! be read, reported and repaired, rather than being dropped or silently treated
//! as zero minutes (C-005, research R10). A missing field therefore deserializes
//! to its empty form — a blank id or author, no duration — which validation then
//! names, instead of failing the whole issue parse.

use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt;

use serde::{Deserialize, Serialize};

use crate::model::validate_date;

/// The frontmatter key the issue's current entries live under.
///
/// [`crate::model::Issue`] owns the field of the same name; the key is repeated
/// here so a surface that formats or filters frontmatter names the one spelling,
/// and `tests/time_entry.rs` pins the two together.
pub const TIME_ENTRY_FRONTMATTER_KEY: &str = "time_entries";

/// The fields [`TimeEntry`] reads as typed values.
///
/// This is the set serde treats as **known** for an entry — what survives
/// through the typed struct rather than being swept into the preserved
/// [`extensions`](TimeEntry::extensions) map. It must stay in step with the
/// struct: a field added there and not here would let a preserved key shadow it
/// and the next load would fail on the duplicate key, so
/// [`TimeEntry::problems`] refuses such a key explicitly.
pub const TIME_ENTRY_KNOWN_FIELDS: &[&str] = &["id", "duration_minutes", "date", "author", "note"];

/// Unknown metadata carried by one [`TimeEntry`], preserved verbatim.
///
/// The rule this exists for (C-007, T005): a key a newer `tix` adds must survive
/// an edit-and-save round trip instead of being dropped. Values are held as
/// parsed YAML values rather than strings so a sequence, a mapping or a number
/// written by another build is preserved as what it is, not rejected for not
/// being a string. A `BTreeMap` keeps the render order deterministic, so a file
/// that did not change does not gain a diff.
///
/// Equality is by canonical render rather than by the derived comparison,
/// because `serde_yaml::Value` is not `Eq` (floats are not), while an entry — and
/// therefore the [`Issue`](crate::model::Issue) that holds a list of them — must
/// stay `Eq`. Two extension maps are equal exactly when they render identically,
/// which is also what round-trip stability means here.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct TimeEntryExtensions(BTreeMap<String, serde_yaml::Value>);

impl TimeEntryExtensions {
    /// Whether no unknown metadata is present.
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// The number of preserved keys.
    pub fn len(&self) -> usize {
        self.0.len()
    }

    /// Look up a preserved key.
    pub fn get(&self, key: &str) -> Option<&serde_yaml::Value> {
        self.0.get(key)
    }

    /// The preserved keys, in canonical (sorted) order.
    pub fn keys(&self) -> impl Iterator<Item = &str> {
        self.0.keys().map(String::as_str)
    }

    /// Iterate the preserved `(key, value)` pairs in canonical order.
    pub fn iter(&self) -> impl Iterator<Item = (&str, &serde_yaml::Value)> {
        self.0.iter().map(|(key, value)| (key.as_str(), value))
    }

    /// The underlying map, for a caller that must serialize or splice it whole.
    pub fn as_map(&self) -> &BTreeMap<String, serde_yaml::Value> {
        &self.0
    }

    /// Preserve one `(key, value)` pair, returning the previous value if any.
    pub fn insert(
        &mut self,
        key: impl Into<String>,
        value: serde_yaml::Value,
    ) -> Option<serde_yaml::Value> {
        self.0.insert(key.into(), value)
    }

    /// The canonical YAML render, used for equality and for the tie-break of
    /// [`TimeEntry`]'s ordering.
    pub fn canonical_yaml(&self) -> String {
        // The keys are `String`s, so the render cannot fail; a failure would
        // still have to be *stable*, which `String::new` is.
        serde_yaml::to_string(&self.0).unwrap_or_default()
    }
}

impl PartialEq for TimeEntryExtensions {
    fn eq(&self, other: &Self) -> bool {
        self.canonical_yaml() == other.canonical_yaml()
    }
}

impl Eq for TimeEntryExtensions {}

impl From<BTreeMap<String, serde_yaml::Value>> for TimeEntryExtensions {
    fn from(map: BTreeMap<String, serde_yaml::Value>) -> Self {
        Self(map)
    }
}

/// One current time entry: actual effort recorded against one issue.
///
/// The fields are the mission's confirmed shape (data-model §1, C-002):
/// `id` is the **stable identity** that is never reused, `duration_minutes` the
/// effort at documented minute precision, `date` an optional calendar date,
/// `author` the required attribution, `note` optional free text, and
/// [`extensions`](Self::extensions) whatever else the file carries. An entry is
/// not an estimate, a status or an ownership value, and no operation on one may
/// change those (C-008).
///
/// `Eq`/`Ord` are the file's own notions: equality is field-by-field (with the
/// extensions compared by canonical render) and ordering is
/// [`order_key`](Self::order_key) — dated entries oldest first, undated last,
/// ties by stable id — with every remaining field compared afterwards so the
/// order is a total order consistent with equality. Surfaces that display
/// entries in a deterministic order (data-model §6) sort by that order.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeEntry {
    /// The stable id: identity for edits, deletions and History references.
    ///
    /// Required for a valid entry and refused when blank, but `default`'d on
    /// read so an entry a hand edit emptied still parses and is reported
    /// instead of failing the issue (see the module docs).
    #[serde(default)]
    pub id: String,
    /// The effort, in whole minutes at or above zero.
    ///
    /// `Option` for the same diagnosability reason: a valid entry always
    /// carries one, and an absent one is refused by name rather than read as
    /// zero minutes.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_minutes: Option<i64>,
    /// The calendar date the effort belongs to, `YYYY-MM-DD`.
    ///
    /// Optional and skipped when absent, so an entry without a date round-trips
    /// without gaining a key, and so an issue that carries no entry at all is
    /// unaffected by this field's existence.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub date: Option<String>,
    /// Who recorded the effort: an identity in the shape an issue's `assignee`
    /// tolerates (a bare handle, or `Name <email>`), verbatim.
    #[serde(default)]
    pub author: String,
    /// Optional free text, preserved verbatim.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
    /// Unknown metadata, preserved verbatim.
    #[serde(flatten)]
    pub extensions: TimeEntryExtensions,
}

impl TimeEntry {
    /// Build a valid entry, refusing the same values [`Self::validate`] refuses.
    ///
    /// Returns **every** problem at once, in field order, rather than the first:
    /// a caller fixing an entry should not have to resubmit it once per field.
    pub fn new(
        id: impl Into<String>,
        duration_minutes: i64,
        date: Option<String>,
        author: impl Into<String>,
        note: Option<String>,
    ) -> Result<Self, Vec<TimeEntryError>> {
        let entry = Self {
            id: id.into(),
            duration_minutes: Some(duration_minutes),
            date,
            author: author.into(),
            note,
            extensions: TimeEntryExtensions::default(),
        };
        entry.validate().map(|()| entry)
    }

    /// The duration when it is present and at or above zero.
    ///
    /// `None` is exactly the malformed state [`Self::problems`] reports, so a
    /// caller that has validated the entry can add this to a total and a caller
    /// that has not cannot mistake a missing duration for zero minutes.
    pub fn minutes(&self) -> Option<i64> {
        self.duration_minutes.filter(|minutes| *minutes >= 0)
    }

    /// The deterministic display order (data-model §6).
    ///
    /// Dated entries oldest first, undated last, ties broken by the stable id.
    /// Comparing the whole tuple is how [`Ord`] keeps its order consistent with
    /// equality.
    pub fn order_key(&self) -> (bool, Option<&str>, &str) {
        (self.date.is_none(), self.date.as_deref(), self.id.as_str())
    }

    /// Every field-level problem, in field order; empty means valid.
    pub fn problems(&self) -> Vec<TimeEntryError> {
        let mut problems = Vec::new();
        if self.id.trim().is_empty() {
            problems.push(TimeEntryError::MissingId {
                id: self.id.clone(),
            });
        }
        if self.minutes().is_none() {
            problems.push(TimeEntryError::InvalidDuration {
                duration_minutes: self.duration_minutes,
            });
        }
        if let Some(date) = &self.date
            && !validate_date(date)
        {
            problems.push(TimeEntryError::InvalidDate { date: date.clone() });
        }
        if self.author.trim().is_empty() {
            problems.push(TimeEntryError::MissingAuthor);
        }
        if let Some(note) = &self.note
            && note.contains(['\n', '\r'])
        {
            problems.push(TimeEntryError::NoteNotSingleLine);
        }
        for key in self.extensions.keys() {
            if TIME_ENTRY_KNOWN_FIELDS.contains(&key) {
                problems.push(TimeEntryError::ExtensionShadowsField {
                    key: key.to_string(),
                });
            }
        }
        problems
    }

    /// Whether the entry has no field-level problems.
    pub fn is_valid(&self) -> bool {
        self.problems().is_empty()
    }

    /// Refuse the entry unless every field is valid.
    ///
    /// `Err` carries all of [`Self::problems`], not just the first, so a
    /// diagnostic surface can report the complete field set.
    pub fn validate(&self) -> Result<(), Vec<TimeEntryError>> {
        let problems = self.problems();
        if problems.is_empty() {
            Ok(())
        } else {
            Err(problems)
        }
    }
}

impl PartialOrd for TimeEntry {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for TimeEntry {
    fn cmp(&self, other: &Self) -> Ordering {
        // `order_key` first (the display order), then every remaining field, so
        // two entries compare `Equal` exactly when they are `Eq`: a partial
        // order that called different entries equal would break every `Ord`
        // contract this type is used under.
        self.order_key()
            .cmp(&other.order_key())
            .then_with(|| self.duration_minutes.cmp(&other.duration_minutes))
            .then_with(|| self.author.cmp(&other.author))
            .then_with(|| self.note.cmp(&other.note))
            .then_with(|| {
                self.extensions
                    .canonical_yaml()
                    .cmp(&other.extensions.canonical_yaml())
            })
    }
}

/// One field-level problem with a [`TimeEntry`].
///
/// The variants carry the offending value so a surface can name it, and
/// [`kind`](Self::kind) maps each onto the closed diagnostic vocabulary
/// `data-model.md` §5 declares — one vocabulary rather than one per surface, so
/// the CLI, TUI, MCP and portable readers cannot disagree about what went wrong
/// (C-006). Diagnostics never mutate the entry or the issue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeEntryError {
    /// The entry has no stable id, or only whitespace.
    MissingId { id: String },
    /// The duration is absent, or present and negative.
    InvalidDuration { duration_minutes: Option<i64> },
    /// The date is present and is not a `YYYY-MM-DD` calendar date.
    InvalidDate { date: String },
    /// The author is absent, or only whitespace.
    MissingAuthor,
    /// The note carries a line break and cannot be one History detail line.
    NoteNotSingleLine,
    /// A preserved extension key would shadow a typed field.
    ExtensionShadowsField { key: String },
}

impl TimeEntryError {
    /// The stable diagnostic name from `data-model.md` §5.
    pub fn kind(&self) -> &'static str {
        match self {
            Self::InvalidDuration { .. } => "invalid_duration",
            Self::InvalidDate { .. } => "invalid_date",
            Self::MissingAuthor => "missing_author",
            // The vocabulary's catch-all for a malformed *entry* rather than a
            // malformed field value: an id is never a value the mission can
            // re-spell, and a note or an extension key is structural.
            Self::MissingId { .. }
            | Self::NoteNotSingleLine
            | Self::ExtensionShadowsField { .. } => "malformed_entry",
        }
    }

    /// The frontmatter field the problem belongs to.
    pub fn field(&self) -> &'static str {
        match self {
            Self::MissingId { .. } => "id",
            Self::InvalidDuration { .. } => "duration_minutes",
            Self::InvalidDate { .. } => "date",
            Self::MissingAuthor => "author",
            Self::NoteNotSingleLine => "note",
            Self::ExtensionShadowsField { .. } => "extensions",
        }
    }
}

impl fmt::Display for TimeEntryError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingId { .. } => {
                f.write_str("the entry has no stable id; give it one that is never reused")
            }
            Self::InvalidDuration {
                duration_minutes: Some(minutes),
            } => write!(
                f,
                "duration_minutes {minutes} is negative; write a non-negative number of whole minutes"
            ),
            Self::InvalidDuration {
                duration_minutes: None,
            } => f.write_str(
                "duration_minutes is missing; write a non-negative number of whole minutes",
            ),
            Self::InvalidDate { date } => write!(
                f,
                "date {date:?} is not a `YYYY-MM-DD` calendar date (year >= 1970)"
            ),
            Self::MissingAuthor => f.write_str(
                "the entry has no author; an entry without attribution is not auditable",
            ),
            Self::NoteNotSingleLine => {
                f.write_str("note contains a line break; the History detail is one line")
            }
            Self::ExtensionShadowsField { key } => write!(
                f,
                "preserved key {key:?} would shadow a typed field; rename it, or model it"
            ),
        }
    }
}

impl std::error::Error for TimeEntryError {}