Skip to main content

tix/time_tracking/
entry.rs

1//! The typed time entry and its field-aware validation (mission
2//! `per-issue-time-tracking-01M3C9A4`, WP01 T001–T003/T005).
3//!
4//! A [`TimeEntry`] is **actual effort**, never an estimate and never a schedule:
5//! one stable id, a duration in whole **minutes**, an optional `YYYY-MM-DD`
6//! calendar date, a required author, an optional note, and whatever unknown
7//! metadata a newer `tix` wrote beside them. There is no start/stop state, no
8//! clock, no timezone conversion and no overlap rule (C-002, C-009): the model
9//! cannot express them, so no surface can drift into one. [`crate::model::validate_date`]
10//! is reused for the date, and `duration_minutes` stays distinct from the
11//! issue's free-form `estimate` — estimates are not converted here (C-005).
12//!
13//! # Precision and refusals
14//!
15//! Duration is a whole number of minutes held in `i64` rather than an unsigned
16//! integer, for the reason [`CapacityEntry`](crate::capacity::CapacityEntry)
17//! holds its ceiling in `i64`: a value a file can carry but the mission refuses
18//! (`-5`) must be **refused with its field path**, not by failing in the YAML
19//! parser, where no diagnostic could name the entry. A *non-integer* token
20//! (`duration_minutes: 1.5`) is a frontmatter type error and is refused by the
21//! parser, exactly as every other typed frontmatter field is (`parent: ""` is
22//! that precedent, and it is pinned by its own test); it is never coerced to
23//! zero.
24//!
25//! # Malformed entries stay readable
26//!
27//! [`TimeEntry::problems`] reports every field-level problem and
28//! [`TimeEntry::validate`] refuses the entry, but neither makes the entry
29//! unreadable. A malformed entry stays in the issue's frontmatter where it can
30//! be read, reported and repaired, rather than being dropped or silently treated
31//! as zero minutes (C-005, research R10). A missing field therefore deserializes
32//! to its empty form — a blank id or author, no duration — which validation then
33//! names, instead of failing the whole issue parse.
34
35use std::cmp::Ordering;
36use std::collections::BTreeMap;
37use std::fmt;
38
39use serde::{Deserialize, Serialize};
40
41use crate::model::validate_date;
42
43/// The frontmatter key the issue's current entries live under.
44///
45/// [`crate::model::Issue`] owns the field of the same name; the key is repeated
46/// here so a surface that formats or filters frontmatter names the one spelling,
47/// and `tests/time_entry.rs` pins the two together.
48pub const TIME_ENTRY_FRONTMATTER_KEY: &str = "time_entries";
49
50/// The fields [`TimeEntry`] reads as typed values.
51///
52/// This is the set serde treats as **known** for an entry — what survives
53/// through the typed struct rather than being swept into the preserved
54/// [`extensions`](TimeEntry::extensions) map. It must stay in step with the
55/// struct: a field added there and not here would let a preserved key shadow it
56/// and the next load would fail on the duplicate key, so
57/// [`TimeEntry::problems`] refuses such a key explicitly.
58pub const TIME_ENTRY_KNOWN_FIELDS: &[&str] = &["id", "duration_minutes", "date", "author", "note"];
59
60/// Unknown metadata carried by one [`TimeEntry`], preserved verbatim.
61///
62/// The rule this exists for (C-007, T005): a key a newer `tix` adds must survive
63/// an edit-and-save round trip instead of being dropped. Values are held as
64/// parsed YAML values rather than strings so a sequence, a mapping or a number
65/// written by another build is preserved as what it is, not rejected for not
66/// being a string. A `BTreeMap` keeps the render order deterministic, so a file
67/// that did not change does not gain a diff.
68///
69/// Equality is by canonical render rather than by the derived comparison,
70/// because `serde_yaml::Value` is not `Eq` (floats are not), while an entry — and
71/// therefore the [`Issue`](crate::model::Issue) that holds a list of them — must
72/// stay `Eq`. Two extension maps are equal exactly when they render identically,
73/// which is also what round-trip stability means here.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75#[serde(transparent)]
76pub struct TimeEntryExtensions(BTreeMap<String, serde_yaml::Value>);
77
78impl TimeEntryExtensions {
79    /// Whether no unknown metadata is present.
80    pub fn is_empty(&self) -> bool {
81        self.0.is_empty()
82    }
83
84    /// The number of preserved keys.
85    pub fn len(&self) -> usize {
86        self.0.len()
87    }
88
89    /// Look up a preserved key.
90    pub fn get(&self, key: &str) -> Option<&serde_yaml::Value> {
91        self.0.get(key)
92    }
93
94    /// The preserved keys, in canonical (sorted) order.
95    pub fn keys(&self) -> impl Iterator<Item = &str> {
96        self.0.keys().map(String::as_str)
97    }
98
99    /// Iterate the preserved `(key, value)` pairs in canonical order.
100    pub fn iter(&self) -> impl Iterator<Item = (&str, &serde_yaml::Value)> {
101        self.0.iter().map(|(key, value)| (key.as_str(), value))
102    }
103
104    /// The underlying map, for a caller that must serialize or splice it whole.
105    pub fn as_map(&self) -> &BTreeMap<String, serde_yaml::Value> {
106        &self.0
107    }
108
109    /// Preserve one `(key, value)` pair, returning the previous value if any.
110    pub fn insert(
111        &mut self,
112        key: impl Into<String>,
113        value: serde_yaml::Value,
114    ) -> Option<serde_yaml::Value> {
115        self.0.insert(key.into(), value)
116    }
117
118    /// The canonical YAML render, used for equality and for the tie-break of
119    /// [`TimeEntry`]'s ordering.
120    pub fn canonical_yaml(&self) -> String {
121        // The keys are `String`s, so the render cannot fail; a failure would
122        // still have to be *stable*, which `String::new` is.
123        serde_yaml::to_string(&self.0).unwrap_or_default()
124    }
125}
126
127impl PartialEq for TimeEntryExtensions {
128    fn eq(&self, other: &Self) -> bool {
129        self.canonical_yaml() == other.canonical_yaml()
130    }
131}
132
133impl Eq for TimeEntryExtensions {}
134
135impl From<BTreeMap<String, serde_yaml::Value>> for TimeEntryExtensions {
136    fn from(map: BTreeMap<String, serde_yaml::Value>) -> Self {
137        Self(map)
138    }
139}
140
141/// One current time entry: actual effort recorded against one issue.
142///
143/// The fields are the mission's confirmed shape (data-model §1, C-002):
144/// `id` is the **stable identity** that is never reused, `duration_minutes` the
145/// effort at documented minute precision, `date` an optional calendar date,
146/// `author` the required attribution, `note` optional free text, and
147/// [`extensions`](Self::extensions) whatever else the file carries. An entry is
148/// not an estimate, a status or an ownership value, and no operation on one may
149/// change those (C-008).
150///
151/// `Eq`/`Ord` are the file's own notions: equality is field-by-field (with the
152/// extensions compared by canonical render) and ordering is
153/// [`order_key`](Self::order_key) — dated entries oldest first, undated last,
154/// ties by stable id — with every remaining field compared afterwards so the
155/// order is a total order consistent with equality. Surfaces that display
156/// entries in a deterministic order (data-model §6) sort by that order.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct TimeEntry {
159    /// The stable id: identity for edits, deletions and History references.
160    ///
161    /// Required for a valid entry and refused when blank, but `default`'d on
162    /// read so an entry a hand edit emptied still parses and is reported
163    /// instead of failing the issue (see the module docs).
164    #[serde(default)]
165    pub id: String,
166    /// The effort, in whole minutes at or above zero.
167    ///
168    /// `Option` for the same diagnosability reason: a valid entry always
169    /// carries one, and an absent one is refused by name rather than read as
170    /// zero minutes.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub duration_minutes: Option<i64>,
173    /// The calendar date the effort belongs to, `YYYY-MM-DD`.
174    ///
175    /// Optional and skipped when absent, so an entry without a date round-trips
176    /// without gaining a key, and so an issue that carries no entry at all is
177    /// unaffected by this field's existence.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub date: Option<String>,
180    /// Who recorded the effort: an identity in the shape an issue's `assignee`
181    /// tolerates (a bare handle, or `Name <email>`), verbatim.
182    #[serde(default)]
183    pub author: String,
184    /// Optional free text, preserved verbatim.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub note: Option<String>,
187    /// Unknown metadata, preserved verbatim.
188    #[serde(flatten)]
189    pub extensions: TimeEntryExtensions,
190}
191
192impl TimeEntry {
193    /// Build a valid entry, refusing the same values [`Self::validate`] refuses.
194    ///
195    /// Returns **every** problem at once, in field order, rather than the first:
196    /// a caller fixing an entry should not have to resubmit it once per field.
197    pub fn new(
198        id: impl Into<String>,
199        duration_minutes: i64,
200        date: Option<String>,
201        author: impl Into<String>,
202        note: Option<String>,
203    ) -> Result<Self, Vec<TimeEntryError>> {
204        let entry = Self {
205            id: id.into(),
206            duration_minutes: Some(duration_minutes),
207            date,
208            author: author.into(),
209            note,
210            extensions: TimeEntryExtensions::default(),
211        };
212        entry.validate().map(|()| entry)
213    }
214
215    /// The duration when it is present and at or above zero.
216    ///
217    /// `None` is exactly the malformed state [`Self::problems`] reports, so a
218    /// caller that has validated the entry can add this to a total and a caller
219    /// that has not cannot mistake a missing duration for zero minutes.
220    pub fn minutes(&self) -> Option<i64> {
221        self.duration_minutes.filter(|minutes| *minutes >= 0)
222    }
223
224    /// The deterministic display order (data-model §6).
225    ///
226    /// Dated entries oldest first, undated last, ties broken by the stable id.
227    /// Comparing the whole tuple is how [`Ord`] keeps its order consistent with
228    /// equality.
229    pub fn order_key(&self) -> (bool, Option<&str>, &str) {
230        (self.date.is_none(), self.date.as_deref(), self.id.as_str())
231    }
232
233    /// Every field-level problem, in field order; empty means valid.
234    pub fn problems(&self) -> Vec<TimeEntryError> {
235        let mut problems = Vec::new();
236        if self.id.trim().is_empty() {
237            problems.push(TimeEntryError::MissingId {
238                id: self.id.clone(),
239            });
240        }
241        if self.minutes().is_none() {
242            problems.push(TimeEntryError::InvalidDuration {
243                duration_minutes: self.duration_minutes,
244            });
245        }
246        if let Some(date) = &self.date
247            && !validate_date(date)
248        {
249            problems.push(TimeEntryError::InvalidDate { date: date.clone() });
250        }
251        if self.author.trim().is_empty() {
252            problems.push(TimeEntryError::MissingAuthor);
253        }
254        if let Some(note) = &self.note
255            && note.contains(['\n', '\r'])
256        {
257            problems.push(TimeEntryError::NoteNotSingleLine);
258        }
259        for key in self.extensions.keys() {
260            if TIME_ENTRY_KNOWN_FIELDS.contains(&key) {
261                problems.push(TimeEntryError::ExtensionShadowsField {
262                    key: key.to_string(),
263                });
264            }
265        }
266        problems
267    }
268
269    /// Whether the entry has no field-level problems.
270    pub fn is_valid(&self) -> bool {
271        self.problems().is_empty()
272    }
273
274    /// Refuse the entry unless every field is valid.
275    ///
276    /// `Err` carries all of [`Self::problems`], not just the first, so a
277    /// diagnostic surface can report the complete field set.
278    pub fn validate(&self) -> Result<(), Vec<TimeEntryError>> {
279        let problems = self.problems();
280        if problems.is_empty() {
281            Ok(())
282        } else {
283            Err(problems)
284        }
285    }
286}
287
288impl PartialOrd for TimeEntry {
289    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
290        Some(self.cmp(other))
291    }
292}
293
294impl Ord for TimeEntry {
295    fn cmp(&self, other: &Self) -> Ordering {
296        // `order_key` first (the display order), then every remaining field, so
297        // two entries compare `Equal` exactly when they are `Eq`: a partial
298        // order that called different entries equal would break every `Ord`
299        // contract this type is used under.
300        self.order_key()
301            .cmp(&other.order_key())
302            .then_with(|| self.duration_minutes.cmp(&other.duration_minutes))
303            .then_with(|| self.author.cmp(&other.author))
304            .then_with(|| self.note.cmp(&other.note))
305            .then_with(|| {
306                self.extensions
307                    .canonical_yaml()
308                    .cmp(&other.extensions.canonical_yaml())
309            })
310    }
311}
312
313/// One field-level problem with a [`TimeEntry`].
314///
315/// The variants carry the offending value so a surface can name it, and
316/// [`kind`](Self::kind) maps each onto the closed diagnostic vocabulary
317/// `data-model.md` §5 declares — one vocabulary rather than one per surface, so
318/// the CLI, TUI, MCP and portable readers cannot disagree about what went wrong
319/// (C-006). Diagnostics never mutate the entry or the issue.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum TimeEntryError {
322    /// The entry has no stable id, or only whitespace.
323    MissingId { id: String },
324    /// The duration is absent, or present and negative.
325    InvalidDuration { duration_minutes: Option<i64> },
326    /// The date is present and is not a `YYYY-MM-DD` calendar date.
327    InvalidDate { date: String },
328    /// The author is absent, or only whitespace.
329    MissingAuthor,
330    /// The note carries a line break and cannot be one History detail line.
331    NoteNotSingleLine,
332    /// A preserved extension key would shadow a typed field.
333    ExtensionShadowsField { key: String },
334}
335
336impl TimeEntryError {
337    /// The stable diagnostic name from `data-model.md` §5.
338    pub fn kind(&self) -> &'static str {
339        match self {
340            Self::InvalidDuration { .. } => "invalid_duration",
341            Self::InvalidDate { .. } => "invalid_date",
342            Self::MissingAuthor => "missing_author",
343            // The vocabulary's catch-all for a malformed *entry* rather than a
344            // malformed field value: an id is never a value the mission can
345            // re-spell, and a note or an extension key is structural.
346            Self::MissingId { .. }
347            | Self::NoteNotSingleLine
348            | Self::ExtensionShadowsField { .. } => "malformed_entry",
349        }
350    }
351
352    /// The frontmatter field the problem belongs to.
353    pub fn field(&self) -> &'static str {
354        match self {
355            Self::MissingId { .. } => "id",
356            Self::InvalidDuration { .. } => "duration_minutes",
357            Self::InvalidDate { .. } => "date",
358            Self::MissingAuthor => "author",
359            Self::NoteNotSingleLine => "note",
360            Self::ExtensionShadowsField { .. } => "extensions",
361        }
362    }
363}
364
365impl fmt::Display for TimeEntryError {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        match self {
368            Self::MissingId { .. } => {
369                f.write_str("the entry has no stable id; give it one that is never reused")
370            }
371            Self::InvalidDuration {
372                duration_minutes: Some(minutes),
373            } => write!(
374                f,
375                "duration_minutes {minutes} is negative; write a non-negative number of whole minutes"
376            ),
377            Self::InvalidDuration {
378                duration_minutes: None,
379            } => f.write_str(
380                "duration_minutes is missing; write a non-negative number of whole minutes",
381            ),
382            Self::InvalidDate { date } => write!(
383                f,
384                "date {date:?} is not a `YYYY-MM-DD` calendar date (year >= 1970)"
385            ),
386            Self::MissingAuthor => f.write_str(
387                "the entry has no author; an entry without attribution is not auditable",
388            ),
389            Self::NoteNotSingleLine => {
390                f.write_str("note contains a line break; the History detail is one line")
391            }
392            Self::ExtensionShadowsField { key } => write!(
393                f,
394                "preserved key {key:?} would shadow a typed field; rename it, or model it"
395            ),
396        }
397    }
398}
399
400impl std::error::Error for TimeEntryError {}