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 {}