Skip to main content

tix/time_tracking/
service.rs

1//! The time mutation service (mission `per-issue-time-tracking-01M3C9A4`, WP02
2//! T007–T012).
3//!
4//! Three intents over one issue's time entries — **create**, **edit**, **delete** —
5//! behind one API that every later surface (CLI, TUI, MCP, portable) calls. The
6//! *current projection* is the issue's `time_entries` list; the **History** is
7//! append-only, and an applied mutation appends **exactly one** event naming the
8//! entry and what changed (C-003, C-004, NFR-002).
9//!
10//! # Two halves, so a refusal cannot write
11//!
12//! [`intent`] is the pure half: it validates the whole request against the issue it
13//! was handed and returns the one event the mutation *would* append, together with
14//! the before/after values. It takes `&Issue` and writes nothing, so a refusal is
15//! inert by construction rather than by care. [`apply`] is the writing half: it
16//! resolves the issue, runs [`intent`], stages the projection and the event **in
17//! memory**, and only then calls the store's existing write seam — once. A refusal
18//! returns before that seam is reached, so the tree is byte-identical, and a store
19//! failure on the single write leaves nothing behind because nothing else was
20//! written (T011). The whole write is wrapped in [`Store::transaction`], the seam
21//! every multi-step command uses, so the layout reconcile `Store::update` performs
22//! cannot leave a half-moved file either.
23//!
24//! # One event grammar, one owner
25//!
26//! The `detail` of a time event is built here and nowhere else, and it is the same
27//! grammar for all three kinds:
28//!
29//! ```text
30//! create: entry <id>; created <value>[; reason: <reason>]
31//! edit:   entry <id>; updated <value> -> <value>; reason: <reason>
32//! delete: entry <id>; deleted <value>; reason: <reason>
33//! value:  <minutes>m[ on <date>] by <author>[: <note>]
34//! ```
35//!
36//! so a corrected or deleted entry's **prior value stays readable** in History and
37//! no prior event is rewritten, reordered or dropped (C-004). `<id>` is the machine
38//! field: [`entry_id_in`] recovers it from any time event, which is what the
39//! never-reuse guard asks. `?m`/`by ?` mark a value a malformed entry did not carry
40//! — never a silent zero (C-005).
41//!
42//! Two guarantees make that grammar hold for values this service did not author.
43//! The rendering is **total**: a stored note, author or date may carry a line break
44//! (a YAML literal block round-trips one), and [`entry_summary`] collapses it to one
45//! line, so one applied mutation appends exactly one entry whatever the file holds —
46//! refusing instead would make a malformed entry unremovable, which C-005 forbids
47//! (see `one_line`). And an id a **create** mints or accepts is a clean token — one
48//! line, no `;`, no surrounding whitespace (`check_entry_id`) — so the id a create
49//! stores and the id an event records are the same string, which is what makes the
50//! never-reuse guard exact (C-008).
51//!
52//! # What this service does not touch
53//!
54//! Nothing here changes an issue's status, ownership, estimate, body, relationships
55//! or labels (C-008): a mutation assigns `time_entries`, appends one event, and
56//! bumps `updated` — the field every write path bumps. An edit names the fields it
57//! replaces and leaves the rest, including the entry's preserved unknown metadata,
58//! exactly as they were (C-007). The comment this module builds is the
59//! `History`-line safety rule; there is no clock read except the crate's one clock,
60//! used only when the caller supplied no instant.
61
62use std::fmt;
63
64use crate::error::{Result, TixError};
65use crate::model::{HistoryEvent, HistoryEventKind, Issue, utc_now_rfc3339};
66use crate::storage::Store;
67use crate::time_tracking::entry::{TimeEntry, TimeEntryError};
68
69// ---------------------------------------------------------------------------
70// The field patch
71// ---------------------------------------------------------------------------
72
73/// One field's requested change: leave it, clear it, or set it.
74///
75/// A three-state patch rather than `Option<Option<T>>` for the reason
76/// [`crate::projects::Patch`] and [`crate::bulk::patch::PatchValue`] are: "the
77/// caller did not mention this field" and "the caller asked to empty it" are
78/// different requests, and an `Option` cannot tell them apart. Clearing an optional
79/// field removes the key; clearing the required `author` produces an entry that
80/// validation refuses by name, rather than a silent blank.
81#[derive(Debug, Clone, PartialEq, Eq, Default)]
82pub enum Patch<T> {
83    /// Leave the field as it is.
84    #[default]
85    Unchanged,
86    /// Remove the value.
87    Clear,
88    /// Replace the value.
89    Set(T),
90}
91
92impl<T> Patch<T> {
93    /// Whether this patch leaves the field alone.
94    pub fn is_unchanged(&self) -> bool {
95        matches!(self, Patch::Unchanged)
96    }
97}
98
99// ---------------------------------------------------------------------------
100// The intents (T007–T009)
101// ---------------------------------------------------------------------------
102
103/// A new entry to create, with the id optional.
104///
105/// The id is `Option` because `tix time add` has no `--entry` flag (contract
106/// `cli-surface.md`): the service mints one through [`new_entry_id`] so the CLI,
107/// the TUI and MCP cannot each invent their own shape. A caller that *does* own an
108/// id — a portable import, a test — names it with [`NewTimeEntry::with_id`].
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct NewTimeEntry {
111    /// The stable id, or `None` for one generated when the intent is built.
112    pub id: Option<String>,
113    /// The effort, in whole minutes at or above zero.
114    pub duration_minutes: i64,
115    /// The calendar date the effort belongs to, `YYYY-MM-DD`, when known.
116    pub date: Option<String>,
117    /// Who did the work.
118    pub author: String,
119    /// Optional free text.
120    pub note: Option<String>,
121}
122
123impl NewTimeEntry {
124    /// A new entry of `duration_minutes` minutes, attributed to `author`.
125    pub fn new(duration_minutes: i64, author: impl Into<String>) -> Self {
126        Self {
127            id: None,
128            duration_minutes,
129            date: None,
130            author: author.into(),
131            note: None,
132        }
133    }
134
135    /// Create under this stable id instead of a generated one.
136    pub fn with_id(mut self, id: impl Into<String>) -> Self {
137        self.id = Some(id.into());
138        self
139    }
140
141    /// Record the calendar date the effort belongs to.
142    pub fn on(mut self, date: impl Into<String>) -> Self {
143        self.date = Some(date.into());
144        self
145    }
146
147    /// Attach free text.
148    pub fn with_note(mut self, note: impl Into<String>) -> Self {
149        self.note = Some(note.into());
150        self
151    }
152
153    /// The [`TimeEntry`] this intent describes, with an id minted when none was
154    /// given.
155    fn resolve(self) -> TimeEntry {
156        TimeEntry {
157            id: self.id.unwrap_or_else(new_entry_id),
158            duration_minutes: Some(self.duration_minutes),
159            date: self.date,
160            author: self.author,
161            note: self.note,
162            extensions: crate::time_tracking::TimeEntryExtensions::default(),
163        }
164    }
165}
166
167/// The fields of an entry edit: each left alone unless it is named (T008).
168///
169/// A patch rather than a replacement entry, because `tix time edit … --minutes 30`
170/// names one field and must not silently drop the entry's date, note or preserved
171/// unknown metadata. The merge is `apply_to`, so the CLI, the TUI and MCP
172/// share one definition of "what an edit means" instead of three.
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct TimeEntryEdit {
175    /// The replacement duration, in whole minutes.
176    pub duration_minutes: Patch<i64>,
177    /// The replacement date, or `Clear` for none.
178    pub date: Patch<String>,
179    /// The replacement author. `Clear` is refused: an entry without attribution is
180    /// not auditable.
181    pub author: Patch<String>,
182    /// The replacement note, or `Clear` for none.
183    pub note: Patch<String>,
184}
185
186impl TimeEntryEdit {
187    /// An edit that names no field yet.
188    pub fn new() -> Self {
189        Self::default()
190    }
191
192    /// Replace the duration.
193    pub fn duration(mut self, duration_minutes: i64) -> Self {
194        self.duration_minutes = Patch::Set(duration_minutes);
195        self
196    }
197
198    /// Replace the date.
199    pub fn date(mut self, date: impl Into<String>) -> Self {
200        self.date = Patch::Set(date.into());
201        self
202    }
203
204    /// Remove the date.
205    pub fn clear_date(mut self) -> Self {
206        self.date = Patch::Clear;
207        self
208    }
209
210    /// Replace the author.
211    pub fn author(mut self, author: impl Into<String>) -> Self {
212        self.author = Patch::Set(author.into());
213        self
214    }
215
216    /// Replace the note.
217    pub fn note(mut self, note: impl Into<String>) -> Self {
218        self.note = Patch::Set(note.into());
219        self
220    }
221
222    /// Remove the note.
223    pub fn clear_note(mut self) -> Self {
224        self.note = Patch::Clear;
225        self
226    }
227
228    /// Whether the edit names no field at all.
229    pub fn is_empty(&self) -> bool {
230        self.duration_minutes.is_unchanged()
231            && self.date.is_unchanged()
232            && self.author.is_unchanged()
233            && self.note.is_unchanged()
234    }
235
236    /// Apply this patch to `before`, preserving the id and the preserved unknown
237    /// metadata (C-007).
238    ///
239    /// The id is the entry's identity and the extensions are another build's data:
240    /// neither is a field this edit can name, so both survive by construction.
241    fn apply_to(&self, before: &TimeEntry) -> TimeEntry {
242        let mut after = before.clone();
243        if let Patch::Set(minutes) = self.duration_minutes {
244            after.duration_minutes = Some(minutes);
245        }
246        match &self.date {
247            Patch::Unchanged => {}
248            Patch::Clear => after.date = None,
249            Patch::Set(date) => after.date = Some(date.clone()),
250        }
251        match &self.author {
252            Patch::Unchanged => {}
253            Patch::Clear => after.author = String::new(),
254            Patch::Set(author) => after.author = author.clone(),
255        }
256        match &self.note {
257            Patch::Unchanged => {}
258            Patch::Clear => after.note = None,
259            Patch::Set(note) => after.note = Some(note.clone()),
260        }
261        after
262    }
263}
264
265/// One mutation intent (data-model §2): the create, edit or delete being asked for.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub enum TimeIntent {
268    /// Add an entry to the current projection.
269    Create(NewTimeEntry),
270    /// Correct the entry `entry_id`, naming the fields to replace.
271    Edit {
272        /// The stable id of the entry to correct.
273        entry_id: String,
274        /// The fields to replace, leave and clear.
275        change: TimeEntryEdit,
276    },
277    /// Remove `entry_id` from the current projection.
278    Delete {
279        /// The stable id of the entry to remove.
280        entry_id: String,
281    },
282}
283
284impl TimeIntent {
285    /// The id this intent targets, when it names one. A create names one only when
286    /// the caller supplied it ([`NewTimeEntry::with_id`]).
287    pub fn entry_id(&self) -> Option<&str> {
288        match self {
289            TimeIntent::Create(new) => new.id.as_deref(),
290            TimeIntent::Edit { entry_id, .. } | TimeIntent::Delete { entry_id } => {
291                Some(entry_id.as_str())
292            }
293        }
294    }
295}
296
297/// Which of the three mutations an intent is.
298///
299/// A private total mapping onto the model's pre-declared kinds, so the kind and the
300/// `detail` verb are chosen once rather than per call site (WP01 pre-declared
301/// `TimeEntryCreated`/`Updated`/`Deleted` precisely so this file consumes them).
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303enum TimeAction {
304    Create,
305    Edit,
306    Delete,
307}
308
309impl TimeAction {
310    fn of(intent: &TimeIntent) -> Self {
311        match intent {
312            TimeIntent::Create(_) => TimeAction::Create,
313            TimeIntent::Edit { .. } => TimeAction::Edit,
314            TimeIntent::Delete { .. } => TimeAction::Delete,
315        }
316    }
317
318    fn kind(self) -> HistoryEventKind {
319        match self {
320            TimeAction::Create => HistoryEventKind::TimeEntryCreated,
321            TimeAction::Edit => HistoryEventKind::TimeEntryUpdated,
322            TimeAction::Delete => HistoryEventKind::TimeEntryDeleted,
323        }
324    }
325
326    /// The verb the `detail` grammar uses.
327    fn word(self) -> &'static str {
328        match self {
329            TimeAction::Create => "created",
330            TimeAction::Edit => "updated",
331            TimeAction::Delete => "deleted",
332        }
333    }
334
335    /// The label a refusal names when this action is missing its reason.
336    fn label(self) -> &'static str {
337        match self {
338            TimeAction::Create => "create",
339            TimeAction::Edit => "edit",
340            TimeAction::Delete => "delete",
341        }
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Attribution (T010)
347// ---------------------------------------------------------------------------
348
349/// Who is making the mutation, when, and why (data-model §2's `actor`,
350/// `timestamp`, `reason`).
351///
352/// `None` for the actor and the instant is not an omission: it means the crate's
353/// own answers, [`crate::storage::actor_identity`] and
354/// [`crate::model::utc_now_rfc3339`], so a surface that has no opinion attributes
355/// the mutation the same way every other `tix` write does, and a *test* can pin the
356/// line without a clock.
357#[derive(Debug, Clone, Default, PartialEq, Eq)]
358pub struct TimeAttribution {
359    /// Who performed the mutation; `None` for the configured git identity.
360    pub actor: Option<String>,
361    /// The instant recorded, RFC 3339; `None` for now.
362    pub timestamp: Option<String>,
363    /// Why a correction or a deletion was made. Required for those two actions
364    /// (`contracts/cli-surface.md` spells `--reason <text>` non-optional), and
365    /// optional for a create.
366    pub reason: Option<String>,
367}
368
369impl TimeAttribution {
370    /// Attribution with no explicit actor, instant or reason.
371    pub fn new() -> Self {
372        Self::default()
373    }
374
375    /// Record `actor` instead of the configured identity.
376    pub fn by(mut self, actor: impl Into<String>) -> Self {
377        self.actor = Some(actor.into());
378        self
379    }
380
381    /// Record `timestamp` instead of now.
382    pub fn at(mut self, timestamp: impl Into<String>) -> Self {
383        self.timestamp = Some(timestamp.into());
384        self
385    }
386
387    /// Record why the mutation is being made.
388    pub fn because(mut self, reason: impl Into<String>) -> Self {
389        self.reason = Some(reason.into());
390        self
391    }
392
393    /// The actor to record, or the crate's own identity.
394    fn actor(&self) -> std::result::Result<String, TimeMutationError> {
395        let actor = match self.actor.as_deref() {
396            Some(actor) => actor.trim().to_string(),
397            None => crate::storage::actor_identity(),
398        };
399        if actor.is_empty() {
400            return Err(TimeMutationError::MissingActor);
401        }
402        check_history_text("actor", &actor, false)?;
403        Ok(actor)
404    }
405
406    /// The instant to record: the supplied one, shape-checked, or now.
407    ///
408    /// The shape check reuses the crate's one instant validator
409    /// ([`crate::cycle_schedule::snapshot::validate_timestamp`]) rather than
410    /// re-implementing it, exactly as [`TimeEntry`] reuses
411    /// [`crate::model::validate_date`]: a caller-supplied instant that is not the
412    /// crate's `YYYY-MM-DDTHH:MM:SSZ` shape would be stored as evidence that reads
413    /// like a timestamp and is not one.
414    fn timestamp(&self) -> std::result::Result<String, TimeMutationError> {
415        match self.timestamp.as_deref().map(str::trim) {
416            Some(timestamp) => {
417                let timestamp = timestamp.to_string();
418                if !crate::cycle_schedule::snapshot::validate_timestamp(&timestamp) {
419                    return Err(TimeMutationError::InvalidTimestamp { timestamp });
420                }
421                Ok(timestamp)
422            }
423            None => Ok(utc_now_rfc3339()),
424        }
425    }
426
427    /// The reason for `action`, which an edit and a delete require.
428    fn reason(&self, action: TimeAction) -> std::result::Result<Option<String>, TimeMutationError> {
429        let reason = self
430            .reason
431            .as_deref()
432            .map(str::trim)
433            .filter(|reason| !reason.is_empty());
434        match (reason, action) {
435            (Some(reason), _) => {
436                check_history_text("reason", reason, false)?;
437                Ok(Some(reason.to_string()))
438            }
439            (None, TimeAction::Create) => Ok(None),
440            (None, action) => Err(TimeMutationError::MissingReason {
441                action: action.label(),
442            }),
443        }
444    }
445}
446
447/// One mutation request: the issue to resolve, the intent, and its attribution.
448///
449/// [`Self::edit`] and [`Self::delete`] take the reason as an argument rather than an
450/// optional setter, because the contract requires one for those two actions: a
451/// caller cannot forget it and then wonder why nothing happened. Validation still
452/// refuses a blank one, so an empty flag does not become an empty audit line.
453#[derive(Debug, Clone, PartialEq, Eq)]
454pub struct TimeMutationRequest {
455    /// The issue selector, exactly as the caller typed it.
456    pub issue: String,
457    /// What to do to its current entries.
458    pub intent: TimeIntent,
459    /// Who, when and why.
460    pub attribution: TimeAttribution,
461}
462
463impl TimeMutationRequest {
464    /// A create request.
465    pub fn create(issue: impl Into<String>, entry: NewTimeEntry) -> Self {
466        Self {
467            issue: issue.into(),
468            intent: TimeIntent::Create(entry),
469            attribution: TimeAttribution::new(),
470        }
471    }
472
473    /// An edit request. The reason is required by the contract and is carried here.
474    pub fn edit(
475        issue: impl Into<String>,
476        entry_id: impl Into<String>,
477        change: TimeEntryEdit,
478        reason: impl Into<String>,
479    ) -> Self {
480        Self {
481            issue: issue.into(),
482            intent: TimeIntent::Edit {
483                entry_id: entry_id.into(),
484                change,
485            },
486            attribution: TimeAttribution::new().because(reason),
487        }
488    }
489
490    /// A delete request. The reason is required by the contract and is carried here.
491    pub fn delete(
492        issue: impl Into<String>,
493        entry_id: impl Into<String>,
494        reason: impl Into<String>,
495    ) -> Self {
496        Self {
497            issue: issue.into(),
498            intent: TimeIntent::Delete {
499                entry_id: entry_id.into(),
500            },
501            attribution: TimeAttribution::new().because(reason),
502        }
503    }
504
505    /// Attribute the mutation to `actor` instead of the configured identity.
506    pub fn by(mut self, actor: impl Into<String>) -> Self {
507        self.attribution = self.attribution.by(actor);
508        self
509    }
510
511    /// Record `timestamp` instead of now.
512    pub fn at(mut self, timestamp: impl Into<String>) -> Self {
513        self.attribution = self.attribution.at(timestamp);
514        self
515    }
516}
517
518// ---------------------------------------------------------------------------
519// Refusals (T010)
520// ---------------------------------------------------------------------------
521
522/// Why a mutation was refused, before anything was written.
523///
524/// Every variant is raised by [`intent`], which cannot write: reaching one of these
525/// *is* the guarantee that the issue's bytes and History are untouched. A replacement
526/// entry's own field problems are carried as WP01's [`TimeEntryError`] values rather
527/// than re-spelled, so the CLI, the TUI, MCP and `tix lint` report one condition
528/// under one name (C-006).
529#[derive(Debug, Clone, PartialEq, Eq)]
530pub enum TimeMutationError {
531    /// The issue carries no entry with this id, so there is nothing to edit or
532    /// delete.
533    UnknownEntry {
534        /// The id exactly as the caller wrote it.
535        entry_id: String,
536    },
537    /// A create named an id already in use — in the current projection, or in any
538    /// time event this issue's History records.
539    ///
540    /// The History half is what makes "ids are never reused" true rather than
541    /// aspirational (C-008): a deleted entry leaves no frontmatter trace, and
542    /// re-adding its id would make the audit trail read as a correction of an entry
543    /// that no longer exists.
544    DuplicateEntryId {
545        /// The id that is already taken.
546        entry_id: String,
547    },
548    /// A create named an id padded with leading or trailing whitespace.
549    ///
550    /// An id is an identity, and padding makes two *different* strings out of one
551    /// entry: [`entry_id_in`] trims what it recovers from an event, so a stored
552    /// `e-abc ` and a stored `e-abc` would both answer `Some("e-abc")` — the second
553    /// create is let through while the stored ids disagree, and an id that was
554    /// deleted comes back under the padded spelling. The crate already refuses a
555    /// padded name where the name becomes an identity
556    /// ([`crate::projects::duplicate_name_reason`], `crate::config`'s id rule).
557    PaddedEntryId {
558        /// The id as supplied, padding and all.
559        entry_id: String,
560    },
561    /// An edit named no field, so it asks for nothing.
562    EmptyEdit {
563        /// The entry the empty edit targeted.
564        entry_id: String,
565    },
566    /// The replacement entry failed validation; the problems are field-aware.
567    InvalidEntry {
568        /// The entry id being created or corrected.
569        entry_id: String,
570        /// Every field-level problem, in field order.
571        problems: Vec<TimeEntryError>,
572    },
573    /// The mutation has no actor to attribute it to.
574    MissingActor,
575    /// The supplied instant is not the crate's RFC 3339 shape.
576    InvalidTimestamp {
577        /// The off-shape value, as supplied.
578        timestamp: String,
579    },
580    /// An edit or a delete carried no reason, which the contract requires.
581    MissingReason {
582        /// The action that requires one: `edit` or `delete`.
583        action: &'static str,
584    },
585    /// Text that cannot enter a one-line `## History` entry.
586    UnsafeHistoryText {
587        /// The field it came from.
588        field: &'static str,
589        /// What about it cannot be written.
590        hazard: &'static str,
591    },
592}
593
594impl TimeMutationError {
595    /// The stable, snake_case name a surface reports this refusal under.
596    ///
597    /// A replacement entry's problem reports *its own* field-aware kind —
598    /// `invalid_duration`, `invalid_date`, `missing_author`, `malformed_entry` from
599    /// `data-model.md` §5 — so a bad duration is one condition with one name whether
600    /// it was read from a file or supplied to a mutation.
601    pub fn kind(&self) -> &'static str {
602        match self {
603            TimeMutationError::UnknownEntry { .. } => "unknown_entry",
604            TimeMutationError::DuplicateEntryId { .. } => "duplicate_entry_id",
605            TimeMutationError::PaddedEntryId { .. } => "padded_entry_id",
606            TimeMutationError::EmptyEdit { .. } => "empty_edit",
607            TimeMutationError::InvalidEntry { problems, .. } => problems
608                .first()
609                .map_or("malformed_entry", TimeEntryError::kind),
610            TimeMutationError::MissingActor => "missing_actor",
611            TimeMutationError::InvalidTimestamp { .. } => "invalid_timestamp",
612            TimeMutationError::MissingReason { .. } => "missing_reason",
613            TimeMutationError::UnsafeHistoryText { .. } => "unsafe_history_text",
614        }
615    }
616
617    /// The offending input, when this refusal belongs to one.
618    pub fn field(&self) -> &'static str {
619        match self {
620            TimeMutationError::UnknownEntry { .. } | TimeMutationError::EmptyEdit { .. } => "entry",
621            TimeMutationError::DuplicateEntryId { .. }
622            | TimeMutationError::PaddedEntryId { .. } => "id",
623            TimeMutationError::InvalidEntry { problems, .. } => {
624                problems.first().map_or("entry", TimeEntryError::field)
625            }
626            TimeMutationError::MissingActor => "actor",
627            TimeMutationError::InvalidTimestamp { .. } => "timestamp",
628            TimeMutationError::MissingReason { .. } => "reason",
629            TimeMutationError::UnsafeHistoryText { field, .. } => field,
630        }
631    }
632
633    /// The entry this refusal names, when it names one.
634    pub fn entry_id(&self) -> Option<&str> {
635        match self {
636            TimeMutationError::UnknownEntry { entry_id }
637            | TimeMutationError::DuplicateEntryId { entry_id }
638            | TimeMutationError::PaddedEntryId { entry_id }
639            | TimeMutationError::EmptyEdit { entry_id }
640            | TimeMutationError::InvalidEntry { entry_id, .. } => Some(entry_id.as_str()),
641            _ => None,
642        }
643    }
644
645    /// The single-line explanation, without the kind prefix.
646    pub fn message(&self) -> String {
647        match self {
648            TimeMutationError::UnknownEntry { entry_id } => format!(
649                "no time entry `{entry_id}` on this issue; the current projection holds only the \
650                 entries that were never deleted, and a deleted id is never reused"
651            ),
652            TimeMutationError::DuplicateEntryId { entry_id } => format!(
653                "time entry `{entry_id}` already exists on this issue — in its current entries or \
654                 in its History; ids are stable and never reused, so choose a new one"
655            ),
656            TimeMutationError::PaddedEntryId { entry_id } => format!(
657                "time entry id {entry_id:?} has leading or trailing whitespace (the crate's rule \
658                 for a name that becomes an identity); the stored id keeps its whitespace while a \
659                 History event's id is recovered with it trimmed, so a padded spelling reads as \
660                 the id it merely resembles and would let a deleted one back in"
661            ),
662            TimeMutationError::EmptyEdit { entry_id } => format!(
663                "the edit of `{entry_id}` names no field (duration_minutes, date, author, note); \
664                 there is nothing to change"
665            ),
666            TimeMutationError::InvalidEntry { entry_id, problems } => {
667                let listed: Vec<String> = problems
668                    .iter()
669                    .map(|problem| format!("{} ({})", problem, problem.field()))
670                    .collect();
671                format!("time entry `{entry_id}` is invalid: {}", listed.join("; "))
672            }
673            TimeMutationError::MissingActor => {
674                ("a time mutation records who made it; the actor is blank, and an entry without \
675                 attribution is not auditable")
676                    .to_string()
677            }
678            TimeMutationError::InvalidTimestamp { timestamp } => format!(
679                "timestamp {timestamp:?} is not the crate's `YYYY-MM-DDTHH:MM:SSZ` instant shape; a \
680                 mutation records the instant it happened rather than one that merely looks like \
681                 evidence"
682            ),
683            TimeMutationError::MissingReason { action } => format!(
684                "a time {action} requires `--reason`: the reason is what the audit trail keeps \
685                 when the current value is replaced or removed"
686            ),
687            TimeMutationError::UnsafeHistoryText { field, hazard } => format!(
688                "the {field} contains {hazard}; a `## History` entry is one line carrying the \
689                 entry id, and text that breaks it would misread as an event nobody wrote"
690            ),
691        }
692    }
693}
694
695impl fmt::Display for TimeMutationError {
696    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
697        write!(f, "{}: {}", self.kind(), self.message())
698    }
699}
700
701impl std::error::Error for TimeMutationError {}
702
703/// A refusal becomes user input error, so `?` works at the call site.
704///
705/// `<kind>: <message>` is already a single-line diagnostic, so it takes the crate's
706/// existing `invalid input:` prefix without doubling it — the same boundary
707/// [`crate::projects::ProjectError`] crosses.
708impl From<TimeMutationError> for TixError {
709    fn from(err: TimeMutationError) -> TixError {
710        TixError::InvalidInput(err.to_string())
711    }
712}
713
714/// Refuse an id that cannot be written into, and recovered from, a History detail.
715///
716/// Asked only where a **new** identity is minted — a create — because that is the one
717/// boundary where the id can be held to a shape. (An edit or a delete names an id the
718/// file already holds and is matched on its exact bytes, so a hand-edited padded id
719/// stays editable and removable rather than being stranded.) Two rules, both about
720/// the id being the machine field [`entry_id_in`] recovers:
721///
722/// * one line and no `;` — [`check_history_text`]'s, since the detail separates the
723///   id from the action with `; ` and a break would forge an entry;
724/// * no leading or trailing whitespace — the crate's existing rule for a name that
725///   becomes an identity (`crate::projects::duplicate_name_reason`, `crate::config`'s
726///   id rule). It is not cosmetic here: [`entry_id_in`] trims what it recovers, so a
727///   stored `e-abc ` and a stored `e-abc` would both answer `Some("e-abc")` — the
728///   second create is let through while the stored ids disagree, and an id that was
729///   deleted comes back under the padded spelling.
730fn check_entry_id(entry_id: &str) -> std::result::Result<(), TimeMutationError> {
731    check_history_text("id", entry_id, true)?;
732    if entry_id.trim() != entry_id {
733        return Err(TimeMutationError::PaddedEntryId {
734            entry_id: entry_id.to_string(),
735        });
736    }
737    Ok(())
738}
739
740/// Refuse text that cannot enter a one-line `## History` entry.
741///
742/// A line break forges a second entry, so no field a **caller supplies** accepts one.
743/// Text the **file already carries** is not refused but normalized by [`one_line`] at
744/// the point it is rendered, because a stored value must never make a mutation
745/// impossible; see that function for why the two directions differ. `;` is refused
746/// only where it is the *id*'s separator (`check_semicolon`), because the id is the
747/// one field [`entry_id_in`] recovers and an id carrying the separator would make the
748/// never-reuse guard answer about the wrong entry.
749///
750/// The em dash (U+2014) is deliberately **allowed** here, where
751/// [`crate::cycle_schedule::lifecycle`]'s `history_text_hazard` refuses it. The two
752/// positions differ: a `detail` is the fourth of four fields, and
753/// [`HistoryEvent::parse`] splits on its **first three** separators, so an em dash
754/// inside a detail costs no parse. Refusing it would make the delete of an entry
755/// whose note contains one impossible — and a delete must carry the prior value it
756/// removes (C-004). `a_note_containing_an_em_dash_still_writes_one_line` pins both
757/// halves of that.
758fn check_history_text(
759    field: &'static str,
760    text: &str,
761    check_semicolon: bool,
762) -> std::result::Result<(), TimeMutationError> {
763    if text.contains(['\n', '\r']) {
764        return Err(TimeMutationError::UnsafeHistoryText {
765            field,
766            hazard: "a line break",
767        });
768    }
769    if check_semicolon && text.contains(';') {
770        return Err(TimeMutationError::UnsafeHistoryText {
771            field,
772            hazard: "the `;` that separates the entry id from its values",
773        });
774    }
775    Ok(())
776}
777
778// ---------------------------------------------------------------------------
779// The event grammar (one owner)
780// ---------------------------------------------------------------------------
781
782/// The prefix every time-mutation detail opens with, so the entry id is the first
783/// thing a reader (and [`entry_id_in`]) sees.
784const DETAIL_PREFIX: &str = "entry ";
785
786/// The separator between the id and the action.
787const DETAIL_FIELD_SEP: &str = "; ";
788
789/// The verb separating the entry id from its values.
790const VALUE_ARROW: &str = " -> ";
791
792/// The key the reason is recorded under.
793const REASON_KEY: &str = "; reason: ";
794
795/// One value as it is written **inside** a History detail: one line, always.
796///
797/// This renderer is **total** over whatever a file already holds, and that is the
798/// whole point rather than tidiness. A stored `note` (or `author`, or `date`) may
799/// legitimately carry an internal line break — a YAML literal block round-trips one
800/// into the model — and a `## History` entry is one line whose fields are split
801/// positionally. Written through verbatim, the second line of such a value parses as
802/// an authentic event nobody wrote, attributed to an actor no surface ever
803/// validated: one applied mutation would append two entries, and the log stops being
804/// evidence (C-003, C-004, NFR-002).
805///
806/// The rule differs from [`check_history_text`]'s on purpose, and the difference is
807/// load-bearing: a break in a value a **caller supplies** is refused, because a
808/// caller can be asked to rephrase, while a break in a value the **file already
809/// carries** is collapsed to a single space, because a stored value must never make
810/// a mutation impossible — the contract requires a malformed entry to stay readable,
811/// removable and repairable (C-005, C-004). Nothing is lost: the current projection
812/// keeps the value exactly as it is; only the one-line rendering normalizes it.
813fn one_line(value: &str) -> String {
814    let mut out = String::with_capacity(value.len());
815    for part in value
816        .split(['\n', '\r'])
817        .map(str::trim)
818        .filter(|part| !part.is_empty())
819    {
820        if !out.is_empty() {
821            out.push(' ');
822        }
823        out.push_str(part);
824    }
825    out
826}
827
828/// One entry's values, as they are written into a History detail.
829///
830/// Readable first and exact where it must be: minutes at documented precision
831/// (`?m` when a malformed entry carries none, never zero), the calendar date when
832/// there is one, the author, and the note last so free text cannot be mistaken for
833/// a field. This is the one rendering of a prior value the audit trail gets, which is
834/// why a delete can keep one (C-004) and a correction can show before and after.
835///
836/// Every value goes through `one_line`, so the rendering is one line whatever the
837/// entry holds — the guarantee is the renderer's, not the caller's, because a
838/// delete renders a value no caller supplied.
839pub fn entry_summary(entry: &TimeEntry) -> String {
840    let mut out = match entry.minutes() {
841        Some(minutes) => format!("{minutes}m"),
842        None => "?m".to_string(),
843    };
844    if let Some(date) = entry
845        .date
846        .as_deref()
847        .map(one_line)
848        .filter(|d| !d.is_empty())
849    {
850        out.push_str(&format!(" on {date}"));
851    }
852    let author = one_line(&entry.author);
853    out.push_str(" by ");
854    out.push_str(if author.is_empty() { "?" } else { &author });
855    if let Some(note) = entry
856        .note
857        .as_deref()
858        .map(one_line)
859        .filter(|n| !n.is_empty())
860    {
861        out.push_str(": ");
862        out.push_str(&note);
863    }
864    out
865}
866
867/// The stable entry id a time-mutation detail names, when it names one.
868///
869/// The inverse half of the grammar above, and the reason the id is written first:
870/// the never-reuse guard asks every time event in an issue's History which entry it
871/// was about, and this is the one place that answer is read. The recovered id is
872/// trimmed, and every id a create accepts is already trimmed (`check_entry_id`), so
873/// the trimmed answer and the stored id agree for every id this service can mint —
874/// which is exactly what a padded id would break.
875pub fn entry_id_in(detail: &str) -> Option<&str> {
876    let rest = detail.strip_prefix(DETAIL_PREFIX)?;
877    let (entry_id, _) = rest.split_once(DETAIL_FIELD_SEP)?;
878    let entry_id = entry_id.trim();
879    if entry_id.is_empty() {
880        None
881    } else {
882        Some(entry_id)
883    }
884}
885
886/// Whether a kind is one of the three this service appends.
887fn is_time_entry_kind(kind: HistoryEventKind) -> bool {
888    matches!(
889        kind,
890        HistoryEventKind::TimeEntryCreated
891            | HistoryEventKind::TimeEntryUpdated
892            | HistoryEventKind::TimeEntryDeleted
893    )
894}
895
896/// The one `detail` line for an applied mutation.
897fn detail_for(
898    action: TimeAction,
899    entry_id: &str,
900    before: Option<&TimeEntry>,
901    after: Option<&TimeEntry>,
902    reason: Option<&str>,
903) -> String {
904    let mut out = format!(
905        "{DETAIL_PREFIX}{entry_id}{DETAIL_FIELD_SEP}{}",
906        action.word()
907    );
908    match action {
909        TimeAction::Create => {
910            if let Some(after) = after {
911                out.push(' ');
912                out.push_str(&entry_summary(after));
913            }
914        }
915        TimeAction::Edit => {
916            let before = before.map_or_else(|| "?".to_string(), entry_summary);
917            let after = after.map_or_else(|| "?".to_string(), entry_summary);
918            out.push(' ');
919            out.push_str(&before);
920            out.push_str(VALUE_ARROW);
921            out.push_str(&after);
922        }
923        TimeAction::Delete => {
924            if let Some(before) = before {
925                out.push(' ');
926                out.push_str(&entry_summary(before));
927            }
928        }
929    }
930    if let Some(reason) = reason {
931        out.push_str(REASON_KEY);
932        out.push_str(reason);
933    }
934    out
935}
936
937// ---------------------------------------------------------------------------
938// The pure half (T007–T010)
939// ---------------------------------------------------------------------------
940
941/// One validated intent: the single event it would append, the values it touches,
942/// and whether it changes anything.
943///
944/// `changed == false` is the only case with `event == None`, and it means the request
945/// asked for exactly what is already there: no event, no write, no `updated` bump —
946/// the same no-op rule [`crate::commands::edit`] applies to `--parent` and the cycle
947/// lifecycle applies to a repeated action. "Exactly one event per applied mutation"
948/// therefore holds in both directions: a write means one event, an event means a
949/// write.
950#[derive(Debug, Clone, PartialEq, Eq)]
951pub struct TimeIntentOutcome {
952    /// The event kind this mutation appends.
953    pub kind: HistoryEventKind,
954    /// The entry this mutation is about.
955    pub entry_id: String,
956    /// The prior value, for an edit or a delete.
957    pub before: Option<TimeEntry>,
958    /// The new value, for a create or an edit.
959    pub after: Option<TimeEntry>,
960    /// The one event appended when this mutation is applied.
961    pub event: Option<HistoryEvent>,
962    /// Whether applying this mutation changes the issue at all.
963    pub changed: bool,
964}
965
966/// Validate a mutation against the issue it targets and build its one event (T007–T010).
967///
968/// Takes `&Issue` and writes nothing: every refusal is inert by construction. The
969/// caller supplies the issue because resolving the selector is a store read, and a
970/// refusal must not be a read's side effect — [`apply`] is the pairing that does both.
971pub fn intent(
972    issue: &Issue,
973    request: &TimeMutationRequest,
974) -> std::result::Result<TimeIntentOutcome, TimeMutationError> {
975    let action = TimeAction::of(&request.intent);
976    let actor = request.attribution.actor()?;
977    let timestamp = request.attribution.timestamp()?;
978    let reason = request.attribution.reason(action)?;
979    match &request.intent {
980        TimeIntent::Create(new) => {
981            let entry = new.clone().resolve();
982            if let Some(problems) = validation_problems(&entry) {
983                return Err(TimeMutationError::InvalidEntry {
984                    entry_id: entry.id.clone(),
985                    problems,
986                });
987            }
988            check_entry_id(&entry.id)?;
989            check_current_id_is_free(issue, &entry.id)?;
990            let id = entry.id.clone();
991            let event = HistoryEvent::new(
992                timestamp,
993                actor,
994                action.kind(),
995                detail_for(action, &id, None, Some(&entry), reason.as_deref()),
996            );
997            Ok(TimeIntentOutcome {
998                kind: action.kind(),
999                entry_id: id,
1000                before: None,
1001                after: Some(entry),
1002                event: Some(event),
1003                changed: true,
1004            })
1005        }
1006        TimeIntent::Edit { entry_id, change } => {
1007            check_history_text("id", entry_id, true)?;
1008            if change.is_empty() {
1009                return Err(TimeMutationError::EmptyEdit {
1010                    entry_id: entry_id.clone(),
1011                });
1012            }
1013            let before = current_entry(issue, entry_id)?.clone();
1014            let after = change.apply_to(&before);
1015            if let Some(problems) = validation_problems(&after) {
1016                return Err(TimeMutationError::InvalidEntry {
1017                    entry_id: entry_id.clone(),
1018                    problems,
1019                });
1020            }
1021            check_history_text("author", &after.author, false)?;
1022            if let Some(note) = after.note.as_deref() {
1023                check_history_text("note", note, false)?;
1024            }
1025            if after == before {
1026                return Ok(TimeIntentOutcome {
1027                    kind: action.kind(),
1028                    entry_id: entry_id.clone(),
1029                    before: Some(before),
1030                    after: Some(after),
1031                    event: None,
1032                    changed: false,
1033                });
1034            }
1035            let event = HistoryEvent::new(
1036                timestamp,
1037                actor,
1038                action.kind(),
1039                detail_for(
1040                    action,
1041                    entry_id,
1042                    Some(&before),
1043                    Some(&after),
1044                    reason.as_deref(),
1045                ),
1046            );
1047            Ok(TimeIntentOutcome {
1048                kind: action.kind(),
1049                entry_id: entry_id.clone(),
1050                before: Some(before),
1051                after: Some(after),
1052                event: Some(event),
1053                changed: true,
1054            })
1055        }
1056        TimeIntent::Delete { entry_id } => {
1057            check_history_text("id", entry_id, true)?;
1058            let before = current_entry(issue, entry_id)?.clone();
1059            let event = HistoryEvent::new(
1060                timestamp,
1061                actor,
1062                action.kind(),
1063                detail_for(action, entry_id, Some(&before), None, reason.as_deref()),
1064            );
1065            Ok(TimeIntentOutcome {
1066                kind: action.kind(),
1067                entry_id: entry_id.clone(),
1068                before: Some(before),
1069                after: None,
1070                event: Some(event),
1071                changed: true,
1072            })
1073        }
1074    }
1075}
1076
1077/// The entry's field problems, or `None` when it is valid.
1078fn validation_problems(entry: &TimeEntry) -> Option<Vec<TimeEntryError>> {
1079    let problems = entry.problems();
1080    if problems.is_empty() {
1081        None
1082    } else {
1083        Some(problems)
1084    }
1085}
1086
1087/// The index of the current entry with this id, if there is one.
1088fn current_index(issue: &Issue, entry_id: &str) -> Option<usize> {
1089    issue
1090        .time_entries
1091        .iter()
1092        .position(|entry| entry.id == entry_id)
1093}
1094
1095/// The current entry with this id, or the refusal naming it (T009).
1096fn current_entry<'a>(
1097    issue: &'a Issue,
1098    entry_id: &str,
1099) -> std::result::Result<&'a TimeEntry, TimeMutationError> {
1100    current_index(issue, entry_id)
1101        .map(|index| &issue.time_entries[index])
1102        .ok_or_else(|| TimeMutationError::UnknownEntry {
1103            entry_id: entry_id.to_string(),
1104        })
1105}
1106
1107/// Refuse an id that is already spoken for, now or anywhere in History (C-008).
1108fn check_current_id_is_free(
1109    issue: &Issue,
1110    entry_id: &str,
1111) -> std::result::Result<(), TimeMutationError> {
1112    let taken_now = current_index(issue, entry_id).is_some();
1113    let taken_before = issue.history.events().into_iter().any(|event| {
1114        is_time_entry_kind(event.kind) && entry_id_in(&event.detail) == Some(entry_id)
1115    });
1116    if taken_now || taken_before {
1117        return Err(TimeMutationError::DuplicateEntryId {
1118            entry_id: entry_id.to_string(),
1119        });
1120    }
1121    Ok(())
1122}
1123
1124// ---------------------------------------------------------------------------
1125// The writing half (T011)
1126// ---------------------------------------------------------------------------
1127
1128/// One applied mutation: the issue as written, and what was appended.
1129#[derive(Debug, Clone, PartialEq, Eq)]
1130pub struct AppliedTimeMutation {
1131    /// The issue after the mutation, with `filename` the path it now lives at.
1132    pub issue: Issue,
1133    /// The validated intent that was applied.
1134    pub outcome: TimeIntentOutcome,
1135}
1136
1137impl AppliedTimeMutation {
1138    /// The one event appended, or `None` when the request changed nothing.
1139    pub fn event(&self) -> Option<&HistoryEvent> {
1140        self.outcome.event.as_ref()
1141    }
1142}
1143
1144/// Apply a mutation: validate, stage in memory, and write once (T011).
1145///
1146/// The order is the contract. The issue is resolved, [`intent`] validates and builds
1147/// the one event, the projection and the event are staged on the in-memory issue, and
1148/// only then does the store's own write seam run — inside a transaction, so the
1149/// layout reconcile it performs rolls back with the write. A refusal returns before
1150/// any of that, so the file's bytes and its History are unchanged; a store failure
1151/// leaves nothing, because the staged issue was never partially written. The
1152/// mutation bumps `updated` exactly as every other write path does, and touches no
1153/// other issue field (C-008).
1154pub fn apply(store: &Store, request: &TimeMutationRequest) -> Result<AppliedTimeMutation> {
1155    let mut issue = store.resolve_issue(&request.issue)?;
1156    let outcome = intent(&issue, request).map_err(TixError::from)?;
1157    if outcome.changed {
1158        stage(&mut issue, &outcome);
1159        issue.bump_updated();
1160        issue.filename = store.transaction(|| store.update(&issue.filename, &issue))?;
1161    }
1162    Ok(AppliedTimeMutation { issue, outcome })
1163}
1164
1165/// Stage an outcome on the issue: the current projection, then the one event.
1166///
1167/// The projection keeps the file's own order: a create appends, an edit replaces the
1168/// entry at the index it found, and a delete removes it. That is the order the file
1169/// already had, so an unrelated mutation writes the smallest diff; the *display* order
1170/// is [`TimeEntry::order_key`], which surfaces sort by (data-model §6).
1171fn stage(issue: &mut Issue, outcome: &TimeIntentOutcome) {
1172    match (&outcome.after, current_index(issue, &outcome.entry_id)) {
1173        (Some(after), Some(index)) => issue.time_entries[index] = after.clone(),
1174        (Some(after), None) => issue.time_entries.push(after.clone()),
1175        (None, Some(index)) => {
1176            issue.time_entries.remove(index);
1177        }
1178        // Validation refuses a delete of an unknown entry and a create of a taken
1179        // id, so this arm is unreachable for every request that reaches `stage`.
1180        (None, None) => {}
1181    }
1182    if let Some(event) = &outcome.event {
1183        issue.history.append(event.clone());
1184    }
1185}
1186
1187/// A fresh stable entry id.
1188///
1189/// `e-` plus the crate's collision-resistant id
1190/// ([`crate::id::generate_issue_id`], Crockford base32, never all digits), so an
1191/// entry id is generated without a second random source and can never be read as an
1192/// issue number. Minted once, at the intent, so the id the projection carries and the
1193/// id the event names are the same string by construction.
1194pub fn new_entry_id() -> String {
1195    format!("e-{}", crate::id::generate_issue_id())
1196}