tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
//! The time mutation service (mission `per-issue-time-tracking-01M3C9A4`, WP02
//! T007–T012).
//!
//! Three intents over one issue's time entries — **create**, **edit**, **delete** —
//! behind one API that every later surface (CLI, TUI, MCP, portable) calls. The
//! *current projection* is the issue's `time_entries` list; the **History** is
//! append-only, and an applied mutation appends **exactly one** event naming the
//! entry and what changed (C-003, C-004, NFR-002).
//!
//! # Two halves, so a refusal cannot write
//!
//! [`intent`] is the pure half: it validates the whole request against the issue it
//! was handed and returns the one event the mutation *would* append, together with
//! the before/after values. It takes `&Issue` and writes nothing, so a refusal is
//! inert by construction rather than by care. [`apply`] is the writing half: it
//! resolves the issue, runs [`intent`], stages the projection and the event **in
//! memory**, and only then calls the store's existing write seam — once. A refusal
//! returns before that seam is reached, so the tree is byte-identical, and a store
//! failure on the single write leaves nothing behind because nothing else was
//! written (T011). The whole write is wrapped in [`Store::transaction`], the seam
//! every multi-step command uses, so the layout reconcile `Store::update` performs
//! cannot leave a half-moved file either.
//!
//! # One event grammar, one owner
//!
//! The `detail` of a time event is built here and nowhere else, and it is the same
//! grammar for all three kinds:
//!
//! ```text
//! create: entry <id>; created <value>[; reason: <reason>]
//! edit:   entry <id>; updated <value> -> <value>; reason: <reason>
//! delete: entry <id>; deleted <value>; reason: <reason>
//! value:  <minutes>m[ on <date>] by <author>[: <note>]
//! ```
//!
//! so a corrected or deleted entry's **prior value stays readable** in History and
//! no prior event is rewritten, reordered or dropped (C-004). `<id>` is the machine
//! field: [`entry_id_in`] recovers it from any time event, which is what the
//! never-reuse guard asks. `?m`/`by ?` mark a value a malformed entry did not carry
//! — never a silent zero (C-005).
//!
//! Two guarantees make that grammar hold for values this service did not author.
//! The rendering is **total**: a stored note, author or date may carry a line break
//! (a YAML literal block round-trips one), and [`entry_summary`] collapses it to one
//! line, so one applied mutation appends exactly one entry whatever the file holds —
//! refusing instead would make a malformed entry unremovable, which C-005 forbids
//! (see `one_line`). And an id a **create** mints or accepts is a clean token — one
//! line, no `;`, no surrounding whitespace (`check_entry_id`) — so the id a create
//! stores and the id an event records are the same string, which is what makes the
//! never-reuse guard exact (C-008).
//!
//! # What this service does not touch
//!
//! Nothing here changes an issue's status, ownership, estimate, body, relationships
//! or labels (C-008): a mutation assigns `time_entries`, appends one event, and
//! bumps `updated` — the field every write path bumps. An edit names the fields it
//! replaces and leaves the rest, including the entry's preserved unknown metadata,
//! exactly as they were (C-007). The comment this module builds is the
//! `History`-line safety rule; there is no clock read except the crate's one clock,
//! used only when the caller supplied no instant.

use std::fmt;

use crate::error::{Result, TixError};
use crate::model::{HistoryEvent, HistoryEventKind, Issue, utc_now_rfc3339};
use crate::storage::Store;
use crate::time_tracking::entry::{TimeEntry, TimeEntryError};

// ---------------------------------------------------------------------------
// The field patch
// ---------------------------------------------------------------------------

/// One field's requested change: leave it, clear it, or set it.
///
/// A three-state patch rather than `Option<Option<T>>` for the reason
/// [`crate::projects::Patch`] and [`crate::bulk::patch::PatchValue`] are: "the
/// caller did not mention this field" and "the caller asked to empty it" are
/// different requests, and an `Option` cannot tell them apart. Clearing an optional
/// field removes the key; clearing the required `author` produces an entry that
/// validation refuses by name, rather than a silent blank.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Patch<T> {
    /// Leave the field as it is.
    #[default]
    Unchanged,
    /// Remove the value.
    Clear,
    /// Replace the value.
    Set(T),
}

impl<T> Patch<T> {
    /// Whether this patch leaves the field alone.
    pub fn is_unchanged(&self) -> bool {
        matches!(self, Patch::Unchanged)
    }
}

// ---------------------------------------------------------------------------
// The intents (T007–T009)
// ---------------------------------------------------------------------------

/// A new entry to create, with the id optional.
///
/// The id is `Option` because `tix time add` has no `--entry` flag (contract
/// `cli-surface.md`): the service mints one through [`new_entry_id`] so the CLI,
/// the TUI and MCP cannot each invent their own shape. A caller that *does* own an
/// id — a portable import, a test — names it with [`NewTimeEntry::with_id`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewTimeEntry {
    /// The stable id, or `None` for one generated when the intent is built.
    pub id: Option<String>,
    /// The effort, in whole minutes at or above zero.
    pub duration_minutes: i64,
    /// The calendar date the effort belongs to, `YYYY-MM-DD`, when known.
    pub date: Option<String>,
    /// Who did the work.
    pub author: String,
    /// Optional free text.
    pub note: Option<String>,
}

impl NewTimeEntry {
    /// A new entry of `duration_minutes` minutes, attributed to `author`.
    pub fn new(duration_minutes: i64, author: impl Into<String>) -> Self {
        Self {
            id: None,
            duration_minutes,
            date: None,
            author: author.into(),
            note: None,
        }
    }

    /// Create under this stable id instead of a generated one.
    pub fn with_id(mut self, id: impl Into<String>) -> Self {
        self.id = Some(id.into());
        self
    }

    /// Record the calendar date the effort belongs to.
    pub fn on(mut self, date: impl Into<String>) -> Self {
        self.date = Some(date.into());
        self
    }

    /// Attach free text.
    pub fn with_note(mut self, note: impl Into<String>) -> Self {
        self.note = Some(note.into());
        self
    }

    /// The [`TimeEntry`] this intent describes, with an id minted when none was
    /// given.
    fn resolve(self) -> TimeEntry {
        TimeEntry {
            id: self.id.unwrap_or_else(new_entry_id),
            duration_minutes: Some(self.duration_minutes),
            date: self.date,
            author: self.author,
            note: self.note,
            extensions: crate::time_tracking::TimeEntryExtensions::default(),
        }
    }
}

/// The fields of an entry edit: each left alone unless it is named (T008).
///
/// A patch rather than a replacement entry, because `tix time edit … --minutes 30`
/// names one field and must not silently drop the entry's date, note or preserved
/// unknown metadata. The merge is `apply_to`, so the CLI, the TUI and MCP
/// share one definition of "what an edit means" instead of three.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TimeEntryEdit {
    /// The replacement duration, in whole minutes.
    pub duration_minutes: Patch<i64>,
    /// The replacement date, or `Clear` for none.
    pub date: Patch<String>,
    /// The replacement author. `Clear` is refused: an entry without attribution is
    /// not auditable.
    pub author: Patch<String>,
    /// The replacement note, or `Clear` for none.
    pub note: Patch<String>,
}

impl TimeEntryEdit {
    /// An edit that names no field yet.
    pub fn new() -> Self {
        Self::default()
    }

    /// Replace the duration.
    pub fn duration(mut self, duration_minutes: i64) -> Self {
        self.duration_minutes = Patch::Set(duration_minutes);
        self
    }

    /// Replace the date.
    pub fn date(mut self, date: impl Into<String>) -> Self {
        self.date = Patch::Set(date.into());
        self
    }

    /// Remove the date.
    pub fn clear_date(mut self) -> Self {
        self.date = Patch::Clear;
        self
    }

    /// Replace the author.
    pub fn author(mut self, author: impl Into<String>) -> Self {
        self.author = Patch::Set(author.into());
        self
    }

    /// Replace the note.
    pub fn note(mut self, note: impl Into<String>) -> Self {
        self.note = Patch::Set(note.into());
        self
    }

    /// Remove the note.
    pub fn clear_note(mut self) -> Self {
        self.note = Patch::Clear;
        self
    }

    /// Whether the edit names no field at all.
    pub fn is_empty(&self) -> bool {
        self.duration_minutes.is_unchanged()
            && self.date.is_unchanged()
            && self.author.is_unchanged()
            && self.note.is_unchanged()
    }

    /// Apply this patch to `before`, preserving the id and the preserved unknown
    /// metadata (C-007).
    ///
    /// The id is the entry's identity and the extensions are another build's data:
    /// neither is a field this edit can name, so both survive by construction.
    fn apply_to(&self, before: &TimeEntry) -> TimeEntry {
        let mut after = before.clone();
        if let Patch::Set(minutes) = self.duration_minutes {
            after.duration_minutes = Some(minutes);
        }
        match &self.date {
            Patch::Unchanged => {}
            Patch::Clear => after.date = None,
            Patch::Set(date) => after.date = Some(date.clone()),
        }
        match &self.author {
            Patch::Unchanged => {}
            Patch::Clear => after.author = String::new(),
            Patch::Set(author) => after.author = author.clone(),
        }
        match &self.note {
            Patch::Unchanged => {}
            Patch::Clear => after.note = None,
            Patch::Set(note) => after.note = Some(note.clone()),
        }
        after
    }
}

/// One mutation intent (data-model §2): the create, edit or delete being asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeIntent {
    /// Add an entry to the current projection.
    Create(NewTimeEntry),
    /// Correct the entry `entry_id`, naming the fields to replace.
    Edit {
        /// The stable id of the entry to correct.
        entry_id: String,
        /// The fields to replace, leave and clear.
        change: TimeEntryEdit,
    },
    /// Remove `entry_id` from the current projection.
    Delete {
        /// The stable id of the entry to remove.
        entry_id: String,
    },
}

impl TimeIntent {
    /// The id this intent targets, when it names one. A create names one only when
    /// the caller supplied it ([`NewTimeEntry::with_id`]).
    pub fn entry_id(&self) -> Option<&str> {
        match self {
            TimeIntent::Create(new) => new.id.as_deref(),
            TimeIntent::Edit { entry_id, .. } | TimeIntent::Delete { entry_id } => {
                Some(entry_id.as_str())
            }
        }
    }
}

/// Which of the three mutations an intent is.
///
/// A private total mapping onto the model's pre-declared kinds, so the kind and the
/// `detail` verb are chosen once rather than per call site (WP01 pre-declared
/// `TimeEntryCreated`/`Updated`/`Deleted` precisely so this file consumes them).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeAction {
    Create,
    Edit,
    Delete,
}

impl TimeAction {
    fn of(intent: &TimeIntent) -> Self {
        match intent {
            TimeIntent::Create(_) => TimeAction::Create,
            TimeIntent::Edit { .. } => TimeAction::Edit,
            TimeIntent::Delete { .. } => TimeAction::Delete,
        }
    }

    fn kind(self) -> HistoryEventKind {
        match self {
            TimeAction::Create => HistoryEventKind::TimeEntryCreated,
            TimeAction::Edit => HistoryEventKind::TimeEntryUpdated,
            TimeAction::Delete => HistoryEventKind::TimeEntryDeleted,
        }
    }

    /// The verb the `detail` grammar uses.
    fn word(self) -> &'static str {
        match self {
            TimeAction::Create => "created",
            TimeAction::Edit => "updated",
            TimeAction::Delete => "deleted",
        }
    }

    /// The label a refusal names when this action is missing its reason.
    fn label(self) -> &'static str {
        match self {
            TimeAction::Create => "create",
            TimeAction::Edit => "edit",
            TimeAction::Delete => "delete",
        }
    }
}

// ---------------------------------------------------------------------------
// Attribution (T010)
// ---------------------------------------------------------------------------

/// Who is making the mutation, when, and why (data-model §2's `actor`,
/// `timestamp`, `reason`).
///
/// `None` for the actor and the instant is not an omission: it means the crate's
/// own answers, [`crate::storage::actor_identity`] and
/// [`crate::model::utc_now_rfc3339`], so a surface that has no opinion attributes
/// the mutation the same way every other `tix` write does, and a *test* can pin the
/// line without a clock.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TimeAttribution {
    /// Who performed the mutation; `None` for the configured git identity.
    pub actor: Option<String>,
    /// The instant recorded, RFC 3339; `None` for now.
    pub timestamp: Option<String>,
    /// Why a correction or a deletion was made. Required for those two actions
    /// (`contracts/cli-surface.md` spells `--reason <text>` non-optional), and
    /// optional for a create.
    pub reason: Option<String>,
}

impl TimeAttribution {
    /// Attribution with no explicit actor, instant or reason.
    pub fn new() -> Self {
        Self::default()
    }

    /// Record `actor` instead of the configured identity.
    pub fn by(mut self, actor: impl Into<String>) -> Self {
        self.actor = Some(actor.into());
        self
    }

    /// Record `timestamp` instead of now.
    pub fn at(mut self, timestamp: impl Into<String>) -> Self {
        self.timestamp = Some(timestamp.into());
        self
    }

    /// Record why the mutation is being made.
    pub fn because(mut self, reason: impl Into<String>) -> Self {
        self.reason = Some(reason.into());
        self
    }

    /// The actor to record, or the crate's own identity.
    fn actor(&self) -> std::result::Result<String, TimeMutationError> {
        let actor = match self.actor.as_deref() {
            Some(actor) => actor.trim().to_string(),
            None => crate::storage::actor_identity(),
        };
        if actor.is_empty() {
            return Err(TimeMutationError::MissingActor);
        }
        check_history_text("actor", &actor, false)?;
        Ok(actor)
    }

    /// The instant to record: the supplied one, shape-checked, or now.
    ///
    /// The shape check reuses the crate's one instant validator
    /// ([`crate::cycle_schedule::snapshot::validate_timestamp`]) rather than
    /// re-implementing it, exactly as [`TimeEntry`] reuses
    /// [`crate::model::validate_date`]: a caller-supplied instant that is not the
    /// crate's `YYYY-MM-DDTHH:MM:SSZ` shape would be stored as evidence that reads
    /// like a timestamp and is not one.
    fn timestamp(&self) -> std::result::Result<String, TimeMutationError> {
        match self.timestamp.as_deref().map(str::trim) {
            Some(timestamp) => {
                let timestamp = timestamp.to_string();
                if !crate::cycle_schedule::snapshot::validate_timestamp(&timestamp) {
                    return Err(TimeMutationError::InvalidTimestamp { timestamp });
                }
                Ok(timestamp)
            }
            None => Ok(utc_now_rfc3339()),
        }
    }

    /// The reason for `action`, which an edit and a delete require.
    fn reason(&self, action: TimeAction) -> std::result::Result<Option<String>, TimeMutationError> {
        let reason = self
            .reason
            .as_deref()
            .map(str::trim)
            .filter(|reason| !reason.is_empty());
        match (reason, action) {
            (Some(reason), _) => {
                check_history_text("reason", reason, false)?;
                Ok(Some(reason.to_string()))
            }
            (None, TimeAction::Create) => Ok(None),
            (None, action) => Err(TimeMutationError::MissingReason {
                action: action.label(),
            }),
        }
    }
}

/// One mutation request: the issue to resolve, the intent, and its attribution.
///
/// [`Self::edit`] and [`Self::delete`] take the reason as an argument rather than an
/// optional setter, because the contract requires one for those two actions: a
/// caller cannot forget it and then wonder why nothing happened. Validation still
/// refuses a blank one, so an empty flag does not become an empty audit line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeMutationRequest {
    /// The issue selector, exactly as the caller typed it.
    pub issue: String,
    /// What to do to its current entries.
    pub intent: TimeIntent,
    /// Who, when and why.
    pub attribution: TimeAttribution,
}

impl TimeMutationRequest {
    /// A create request.
    pub fn create(issue: impl Into<String>, entry: NewTimeEntry) -> Self {
        Self {
            issue: issue.into(),
            intent: TimeIntent::Create(entry),
            attribution: TimeAttribution::new(),
        }
    }

    /// An edit request. The reason is required by the contract and is carried here.
    pub fn edit(
        issue: impl Into<String>,
        entry_id: impl Into<String>,
        change: TimeEntryEdit,
        reason: impl Into<String>,
    ) -> Self {
        Self {
            issue: issue.into(),
            intent: TimeIntent::Edit {
                entry_id: entry_id.into(),
                change,
            },
            attribution: TimeAttribution::new().because(reason),
        }
    }

    /// A delete request. The reason is required by the contract and is carried here.
    pub fn delete(
        issue: impl Into<String>,
        entry_id: impl Into<String>,
        reason: impl Into<String>,
    ) -> Self {
        Self {
            issue: issue.into(),
            intent: TimeIntent::Delete {
                entry_id: entry_id.into(),
            },
            attribution: TimeAttribution::new().because(reason),
        }
    }

    /// Attribute the mutation to `actor` instead of the configured identity.
    pub fn by(mut self, actor: impl Into<String>) -> Self {
        self.attribution = self.attribution.by(actor);
        self
    }

    /// Record `timestamp` instead of now.
    pub fn at(mut self, timestamp: impl Into<String>) -> Self {
        self.attribution = self.attribution.at(timestamp);
        self
    }
}

// ---------------------------------------------------------------------------
// Refusals (T010)
// ---------------------------------------------------------------------------

/// Why a mutation was refused, before anything was written.
///
/// Every variant is raised by [`intent`], which cannot write: reaching one of these
/// *is* the guarantee that the issue's bytes and History are untouched. A replacement
/// entry's own field problems are carried as WP01's [`TimeEntryError`] values rather
/// than re-spelled, so the CLI, the TUI, MCP and `tix lint` report one condition
/// under one name (C-006).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimeMutationError {
    /// The issue carries no entry with this id, so there is nothing to edit or
    /// delete.
    UnknownEntry {
        /// The id exactly as the caller wrote it.
        entry_id: String,
    },
    /// A create named an id already in use — in the current projection, or in any
    /// time event this issue's History records.
    ///
    /// The History half is what makes "ids are never reused" true rather than
    /// aspirational (C-008): a deleted entry leaves no frontmatter trace, and
    /// re-adding its id would make the audit trail read as a correction of an entry
    /// that no longer exists.
    DuplicateEntryId {
        /// The id that is already taken.
        entry_id: String,
    },
    /// A create named an id padded with leading or trailing whitespace.
    ///
    /// An id is an identity, and padding makes two *different* strings out of one
    /// entry: [`entry_id_in`] trims what it recovers from an event, so a stored
    /// `e-abc ` and a stored `e-abc` would both answer `Some("e-abc")` — the second
    /// create is let through while the stored ids disagree, and an id that was
    /// deleted comes back under the padded spelling. The crate already refuses a
    /// padded name where the name becomes an identity
    /// ([`crate::projects::duplicate_name_reason`], `crate::config`'s id rule).
    PaddedEntryId {
        /// The id as supplied, padding and all.
        entry_id: String,
    },
    /// An edit named no field, so it asks for nothing.
    EmptyEdit {
        /// The entry the empty edit targeted.
        entry_id: String,
    },
    /// The replacement entry failed validation; the problems are field-aware.
    InvalidEntry {
        /// The entry id being created or corrected.
        entry_id: String,
        /// Every field-level problem, in field order.
        problems: Vec<TimeEntryError>,
    },
    /// The mutation has no actor to attribute it to.
    MissingActor,
    /// The supplied instant is not the crate's RFC 3339 shape.
    InvalidTimestamp {
        /// The off-shape value, as supplied.
        timestamp: String,
    },
    /// An edit or a delete carried no reason, which the contract requires.
    MissingReason {
        /// The action that requires one: `edit` or `delete`.
        action: &'static str,
    },
    /// Text that cannot enter a one-line `## History` entry.
    UnsafeHistoryText {
        /// The field it came from.
        field: &'static str,
        /// What about it cannot be written.
        hazard: &'static str,
    },
}

impl TimeMutationError {
    /// The stable, snake_case name a surface reports this refusal under.
    ///
    /// A replacement entry's problem reports *its own* field-aware kind —
    /// `invalid_duration`, `invalid_date`, `missing_author`, `malformed_entry` from
    /// `data-model.md` §5 — so a bad duration is one condition with one name whether
    /// it was read from a file or supplied to a mutation.
    pub fn kind(&self) -> &'static str {
        match self {
            TimeMutationError::UnknownEntry { .. } => "unknown_entry",
            TimeMutationError::DuplicateEntryId { .. } => "duplicate_entry_id",
            TimeMutationError::PaddedEntryId { .. } => "padded_entry_id",
            TimeMutationError::EmptyEdit { .. } => "empty_edit",
            TimeMutationError::InvalidEntry { problems, .. } => problems
                .first()
                .map_or("malformed_entry", TimeEntryError::kind),
            TimeMutationError::MissingActor => "missing_actor",
            TimeMutationError::InvalidTimestamp { .. } => "invalid_timestamp",
            TimeMutationError::MissingReason { .. } => "missing_reason",
            TimeMutationError::UnsafeHistoryText { .. } => "unsafe_history_text",
        }
    }

    /// The offending input, when this refusal belongs to one.
    pub fn field(&self) -> &'static str {
        match self {
            TimeMutationError::UnknownEntry { .. } | TimeMutationError::EmptyEdit { .. } => "entry",
            TimeMutationError::DuplicateEntryId { .. }
            | TimeMutationError::PaddedEntryId { .. } => "id",
            TimeMutationError::InvalidEntry { problems, .. } => {
                problems.first().map_or("entry", TimeEntryError::field)
            }
            TimeMutationError::MissingActor => "actor",
            TimeMutationError::InvalidTimestamp { .. } => "timestamp",
            TimeMutationError::MissingReason { .. } => "reason",
            TimeMutationError::UnsafeHistoryText { field, .. } => field,
        }
    }

    /// The entry this refusal names, when it names one.
    pub fn entry_id(&self) -> Option<&str> {
        match self {
            TimeMutationError::UnknownEntry { entry_id }
            | TimeMutationError::DuplicateEntryId { entry_id }
            | TimeMutationError::PaddedEntryId { entry_id }
            | TimeMutationError::EmptyEdit { entry_id }
            | TimeMutationError::InvalidEntry { entry_id, .. } => Some(entry_id.as_str()),
            _ => None,
        }
    }

    /// The single-line explanation, without the kind prefix.
    pub fn message(&self) -> String {
        match self {
            TimeMutationError::UnknownEntry { entry_id } => format!(
                "no time entry `{entry_id}` on this issue; the current projection holds only the \
                 entries that were never deleted, and a deleted id is never reused"
            ),
            TimeMutationError::DuplicateEntryId { entry_id } => format!(
                "time entry `{entry_id}` already exists on this issue — in its current entries or \
                 in its History; ids are stable and never reused, so choose a new one"
            ),
            TimeMutationError::PaddedEntryId { entry_id } => format!(
                "time entry id {entry_id:?} has leading or trailing whitespace (the crate's rule \
                 for a name that becomes an identity); the stored id keeps its whitespace while a \
                 History event's id is recovered with it trimmed, so a padded spelling reads as \
                 the id it merely resembles and would let a deleted one back in"
            ),
            TimeMutationError::EmptyEdit { entry_id } => format!(
                "the edit of `{entry_id}` names no field (duration_minutes, date, author, note); \
                 there is nothing to change"
            ),
            TimeMutationError::InvalidEntry { entry_id, problems } => {
                let listed: Vec<String> = problems
                    .iter()
                    .map(|problem| format!("{} ({})", problem, problem.field()))
                    .collect();
                format!("time entry `{entry_id}` is invalid: {}", listed.join("; "))
            }
            TimeMutationError::MissingActor => {
                ("a time mutation records who made it; the actor is blank, and an entry without \
                 attribution is not auditable")
                    .to_string()
            }
            TimeMutationError::InvalidTimestamp { timestamp } => format!(
                "timestamp {timestamp:?} is not the crate's `YYYY-MM-DDTHH:MM:SSZ` instant shape; a \
                 mutation records the instant it happened rather than one that merely looks like \
                 evidence"
            ),
            TimeMutationError::MissingReason { action } => format!(
                "a time {action} requires `--reason`: the reason is what the audit trail keeps \
                 when the current value is replaced or removed"
            ),
            TimeMutationError::UnsafeHistoryText { field, hazard } => format!(
                "the {field} contains {hazard}; a `## History` entry is one line carrying the \
                 entry id, and text that breaks it would misread as an event nobody wrote"
            ),
        }
    }
}

impl fmt::Display for TimeMutationError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.kind(), self.message())
    }
}

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

/// A refusal becomes user input error, so `?` works at the call site.
///
/// `<kind>: <message>` is already a single-line diagnostic, so it takes the crate's
/// existing `invalid input:` prefix without doubling it — the same boundary
/// [`crate::projects::ProjectError`] crosses.
impl From<TimeMutationError> for TixError {
    fn from(err: TimeMutationError) -> TixError {
        TixError::InvalidInput(err.to_string())
    }
}

/// Refuse an id that cannot be written into, and recovered from, a History detail.
///
/// Asked only where a **new** identity is minted — a create — because that is the one
/// boundary where the id can be held to a shape. (An edit or a delete names an id the
/// file already holds and is matched on its exact bytes, so a hand-edited padded id
/// stays editable and removable rather than being stranded.) Two rules, both about
/// the id being the machine field [`entry_id_in`] recovers:
///
/// * one line and no `;` — [`check_history_text`]'s, since the detail separates the
///   id from the action with `; ` and a break would forge an entry;
/// * no leading or trailing whitespace — the crate's existing rule for a name that
///   becomes an identity (`crate::projects::duplicate_name_reason`, `crate::config`'s
///   id rule). It is not cosmetic here: [`entry_id_in`] trims what it recovers, so a
///   stored `e-abc ` and a stored `e-abc` would both answer `Some("e-abc")` — the
///   second create is let through while the stored ids disagree, and an id that was
///   deleted comes back under the padded spelling.
fn check_entry_id(entry_id: &str) -> std::result::Result<(), TimeMutationError> {
    check_history_text("id", entry_id, true)?;
    if entry_id.trim() != entry_id {
        return Err(TimeMutationError::PaddedEntryId {
            entry_id: entry_id.to_string(),
        });
    }
    Ok(())
}

/// Refuse text that cannot enter a one-line `## History` entry.
///
/// A line break forges a second entry, so no field a **caller supplies** accepts one.
/// Text the **file already carries** is not refused but normalized by [`one_line`] at
/// the point it is rendered, because a stored value must never make a mutation
/// impossible; see that function for why the two directions differ. `;` is refused
/// only where it is the *id*'s separator (`check_semicolon`), because the id is the
/// one field [`entry_id_in`] recovers and an id carrying the separator would make the
/// never-reuse guard answer about the wrong entry.
///
/// The em dash (U+2014) is deliberately **allowed** here, where
/// [`crate::cycle_schedule::lifecycle`]'s `history_text_hazard` refuses it. The two
/// positions differ: a `detail` is the fourth of four fields, and
/// [`HistoryEvent::parse`] splits on its **first three** separators, so an em dash
/// inside a detail costs no parse. Refusing it would make the delete of an entry
/// whose note contains one impossible — and a delete must carry the prior value it
/// removes (C-004). `a_note_containing_an_em_dash_still_writes_one_line` pins both
/// halves of that.
fn check_history_text(
    field: &'static str,
    text: &str,
    check_semicolon: bool,
) -> std::result::Result<(), TimeMutationError> {
    if text.contains(['\n', '\r']) {
        return Err(TimeMutationError::UnsafeHistoryText {
            field,
            hazard: "a line break",
        });
    }
    if check_semicolon && text.contains(';') {
        return Err(TimeMutationError::UnsafeHistoryText {
            field,
            hazard: "the `;` that separates the entry id from its values",
        });
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// The event grammar (one owner)
// ---------------------------------------------------------------------------

/// The prefix every time-mutation detail opens with, so the entry id is the first
/// thing a reader (and [`entry_id_in`]) sees.
const DETAIL_PREFIX: &str = "entry ";

/// The separator between the id and the action.
const DETAIL_FIELD_SEP: &str = "; ";

/// The verb separating the entry id from its values.
const VALUE_ARROW: &str = " -> ";

/// The key the reason is recorded under.
const REASON_KEY: &str = "; reason: ";

/// One value as it is written **inside** a History detail: one line, always.
///
/// This renderer is **total** over whatever a file already holds, and that is the
/// whole point rather than tidiness. A stored `note` (or `author`, or `date`) may
/// legitimately carry an internal line break — a YAML literal block round-trips one
/// into the model — and a `## History` entry is one line whose fields are split
/// positionally. Written through verbatim, the second line of such a value parses as
/// an authentic event nobody wrote, attributed to an actor no surface ever
/// validated: one applied mutation would append two entries, and the log stops being
/// evidence (C-003, C-004, NFR-002).
///
/// The rule differs from [`check_history_text`]'s on purpose, and the difference is
/// load-bearing: a break in a value a **caller supplies** is refused, because a
/// caller can be asked to rephrase, while a break in a value the **file already
/// carries** is collapsed to a single space, because a stored value must never make
/// a mutation impossible — the contract requires a malformed entry to stay readable,
/// removable and repairable (C-005, C-004). Nothing is lost: the current projection
/// keeps the value exactly as it is; only the one-line rendering normalizes it.
fn one_line(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for part in value
        .split(['\n', '\r'])
        .map(str::trim)
        .filter(|part| !part.is_empty())
    {
        if !out.is_empty() {
            out.push(' ');
        }
        out.push_str(part);
    }
    out
}

/// One entry's values, as they are written into a History detail.
///
/// Readable first and exact where it must be: minutes at documented precision
/// (`?m` when a malformed entry carries none, never zero), the calendar date when
/// there is one, the author, and the note last so free text cannot be mistaken for
/// a field. This is the one rendering of a prior value the audit trail gets, which is
/// why a delete can keep one (C-004) and a correction can show before and after.
///
/// Every value goes through `one_line`, so the rendering is one line whatever the
/// entry holds — the guarantee is the renderer's, not the caller's, because a
/// delete renders a value no caller supplied.
pub fn entry_summary(entry: &TimeEntry) -> String {
    let mut out = match entry.minutes() {
        Some(minutes) => format!("{minutes}m"),
        None => "?m".to_string(),
    };
    if let Some(date) = entry
        .date
        .as_deref()
        .map(one_line)
        .filter(|d| !d.is_empty())
    {
        out.push_str(&format!(" on {date}"));
    }
    let author = one_line(&entry.author);
    out.push_str(" by ");
    out.push_str(if author.is_empty() { "?" } else { &author });
    if let Some(note) = entry
        .note
        .as_deref()
        .map(one_line)
        .filter(|n| !n.is_empty())
    {
        out.push_str(": ");
        out.push_str(&note);
    }
    out
}

/// The stable entry id a time-mutation detail names, when it names one.
///
/// The inverse half of the grammar above, and the reason the id is written first:
/// the never-reuse guard asks every time event in an issue's History which entry it
/// was about, and this is the one place that answer is read. The recovered id is
/// trimmed, and every id a create accepts is already trimmed (`check_entry_id`), so
/// the trimmed answer and the stored id agree for every id this service can mint —
/// which is exactly what a padded id would break.
pub fn entry_id_in(detail: &str) -> Option<&str> {
    let rest = detail.strip_prefix(DETAIL_PREFIX)?;
    let (entry_id, _) = rest.split_once(DETAIL_FIELD_SEP)?;
    let entry_id = entry_id.trim();
    if entry_id.is_empty() {
        None
    } else {
        Some(entry_id)
    }
}

/// Whether a kind is one of the three this service appends.
fn is_time_entry_kind(kind: HistoryEventKind) -> bool {
    matches!(
        kind,
        HistoryEventKind::TimeEntryCreated
            | HistoryEventKind::TimeEntryUpdated
            | HistoryEventKind::TimeEntryDeleted
    )
}

/// The one `detail` line for an applied mutation.
fn detail_for(
    action: TimeAction,
    entry_id: &str,
    before: Option<&TimeEntry>,
    after: Option<&TimeEntry>,
    reason: Option<&str>,
) -> String {
    let mut out = format!(
        "{DETAIL_PREFIX}{entry_id}{DETAIL_FIELD_SEP}{}",
        action.word()
    );
    match action {
        TimeAction::Create => {
            if let Some(after) = after {
                out.push(' ');
                out.push_str(&entry_summary(after));
            }
        }
        TimeAction::Edit => {
            let before = before.map_or_else(|| "?".to_string(), entry_summary);
            let after = after.map_or_else(|| "?".to_string(), entry_summary);
            out.push(' ');
            out.push_str(&before);
            out.push_str(VALUE_ARROW);
            out.push_str(&after);
        }
        TimeAction::Delete => {
            if let Some(before) = before {
                out.push(' ');
                out.push_str(&entry_summary(before));
            }
        }
    }
    if let Some(reason) = reason {
        out.push_str(REASON_KEY);
        out.push_str(reason);
    }
    out
}

// ---------------------------------------------------------------------------
// The pure half (T007–T010)
// ---------------------------------------------------------------------------

/// One validated intent: the single event it would append, the values it touches,
/// and whether it changes anything.
///
/// `changed == false` is the only case with `event == None`, and it means the request
/// asked for exactly what is already there: no event, no write, no `updated` bump —
/// the same no-op rule [`crate::commands::edit`] applies to `--parent` and the cycle
/// lifecycle applies to a repeated action. "Exactly one event per applied mutation"
/// therefore holds in both directions: a write means one event, an event means a
/// write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeIntentOutcome {
    /// The event kind this mutation appends.
    pub kind: HistoryEventKind,
    /// The entry this mutation is about.
    pub entry_id: String,
    /// The prior value, for an edit or a delete.
    pub before: Option<TimeEntry>,
    /// The new value, for a create or an edit.
    pub after: Option<TimeEntry>,
    /// The one event appended when this mutation is applied.
    pub event: Option<HistoryEvent>,
    /// Whether applying this mutation changes the issue at all.
    pub changed: bool,
}

/// Validate a mutation against the issue it targets and build its one event (T007–T010).
///
/// Takes `&Issue` and writes nothing: every refusal is inert by construction. The
/// caller supplies the issue because resolving the selector is a store read, and a
/// refusal must not be a read's side effect — [`apply`] is the pairing that does both.
pub fn intent(
    issue: &Issue,
    request: &TimeMutationRequest,
) -> std::result::Result<TimeIntentOutcome, TimeMutationError> {
    let action = TimeAction::of(&request.intent);
    let actor = request.attribution.actor()?;
    let timestamp = request.attribution.timestamp()?;
    let reason = request.attribution.reason(action)?;
    match &request.intent {
        TimeIntent::Create(new) => {
            let entry = new.clone().resolve();
            if let Some(problems) = validation_problems(&entry) {
                return Err(TimeMutationError::InvalidEntry {
                    entry_id: entry.id.clone(),
                    problems,
                });
            }
            check_entry_id(&entry.id)?;
            check_current_id_is_free(issue, &entry.id)?;
            let id = entry.id.clone();
            let event = HistoryEvent::new(
                timestamp,
                actor,
                action.kind(),
                detail_for(action, &id, None, Some(&entry), reason.as_deref()),
            );
            Ok(TimeIntentOutcome {
                kind: action.kind(),
                entry_id: id,
                before: None,
                after: Some(entry),
                event: Some(event),
                changed: true,
            })
        }
        TimeIntent::Edit { entry_id, change } => {
            check_history_text("id", entry_id, true)?;
            if change.is_empty() {
                return Err(TimeMutationError::EmptyEdit {
                    entry_id: entry_id.clone(),
                });
            }
            let before = current_entry(issue, entry_id)?.clone();
            let after = change.apply_to(&before);
            if let Some(problems) = validation_problems(&after) {
                return Err(TimeMutationError::InvalidEntry {
                    entry_id: entry_id.clone(),
                    problems,
                });
            }
            check_history_text("author", &after.author, false)?;
            if let Some(note) = after.note.as_deref() {
                check_history_text("note", note, false)?;
            }
            if after == before {
                return Ok(TimeIntentOutcome {
                    kind: action.kind(),
                    entry_id: entry_id.clone(),
                    before: Some(before),
                    after: Some(after),
                    event: None,
                    changed: false,
                });
            }
            let event = HistoryEvent::new(
                timestamp,
                actor,
                action.kind(),
                detail_for(
                    action,
                    entry_id,
                    Some(&before),
                    Some(&after),
                    reason.as_deref(),
                ),
            );
            Ok(TimeIntentOutcome {
                kind: action.kind(),
                entry_id: entry_id.clone(),
                before: Some(before),
                after: Some(after),
                event: Some(event),
                changed: true,
            })
        }
        TimeIntent::Delete { entry_id } => {
            check_history_text("id", entry_id, true)?;
            let before = current_entry(issue, entry_id)?.clone();
            let event = HistoryEvent::new(
                timestamp,
                actor,
                action.kind(),
                detail_for(action, entry_id, Some(&before), None, reason.as_deref()),
            );
            Ok(TimeIntentOutcome {
                kind: action.kind(),
                entry_id: entry_id.clone(),
                before: Some(before),
                after: None,
                event: Some(event),
                changed: true,
            })
        }
    }
}

/// The entry's field problems, or `None` when it is valid.
fn validation_problems(entry: &TimeEntry) -> Option<Vec<TimeEntryError>> {
    let problems = entry.problems();
    if problems.is_empty() {
        None
    } else {
        Some(problems)
    }
}

/// The index of the current entry with this id, if there is one.
fn current_index(issue: &Issue, entry_id: &str) -> Option<usize> {
    issue
        .time_entries
        .iter()
        .position(|entry| entry.id == entry_id)
}

/// The current entry with this id, or the refusal naming it (T009).
fn current_entry<'a>(
    issue: &'a Issue,
    entry_id: &str,
) -> std::result::Result<&'a TimeEntry, TimeMutationError> {
    current_index(issue, entry_id)
        .map(|index| &issue.time_entries[index])
        .ok_or_else(|| TimeMutationError::UnknownEntry {
            entry_id: entry_id.to_string(),
        })
}

/// Refuse an id that is already spoken for, now or anywhere in History (C-008).
fn check_current_id_is_free(
    issue: &Issue,
    entry_id: &str,
) -> std::result::Result<(), TimeMutationError> {
    let taken_now = current_index(issue, entry_id).is_some();
    let taken_before = issue.history.events().into_iter().any(|event| {
        is_time_entry_kind(event.kind) && entry_id_in(&event.detail) == Some(entry_id)
    });
    if taken_now || taken_before {
        return Err(TimeMutationError::DuplicateEntryId {
            entry_id: entry_id.to_string(),
        });
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// The writing half (T011)
// ---------------------------------------------------------------------------

/// One applied mutation: the issue as written, and what was appended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedTimeMutation {
    /// The issue after the mutation, with `filename` the path it now lives at.
    pub issue: Issue,
    /// The validated intent that was applied.
    pub outcome: TimeIntentOutcome,
}

impl AppliedTimeMutation {
    /// The one event appended, or `None` when the request changed nothing.
    pub fn event(&self) -> Option<&HistoryEvent> {
        self.outcome.event.as_ref()
    }
}

/// Apply a mutation: validate, stage in memory, and write once (T011).
///
/// The order is the contract. The issue is resolved, [`intent`] validates and builds
/// the one event, the projection and the event are staged on the in-memory issue, and
/// only then does the store's own write seam run — inside a transaction, so the
/// layout reconcile it performs rolls back with the write. A refusal returns before
/// any of that, so the file's bytes and its History are unchanged; a store failure
/// leaves nothing, because the staged issue was never partially written. The
/// mutation bumps `updated` exactly as every other write path does, and touches no
/// other issue field (C-008).
pub fn apply(store: &Store, request: &TimeMutationRequest) -> Result<AppliedTimeMutation> {
    let mut issue = store.resolve_issue(&request.issue)?;
    let outcome = intent(&issue, request).map_err(TixError::from)?;
    if outcome.changed {
        stage(&mut issue, &outcome);
        issue.bump_updated();
        issue.filename = store.transaction(|| store.update(&issue.filename, &issue))?;
    }
    Ok(AppliedTimeMutation { issue, outcome })
}

/// Stage an outcome on the issue: the current projection, then the one event.
///
/// The projection keeps the file's own order: a create appends, an edit replaces the
/// entry at the index it found, and a delete removes it. That is the order the file
/// already had, so an unrelated mutation writes the smallest diff; the *display* order
/// is [`TimeEntry::order_key`], which surfaces sort by (data-model §6).
fn stage(issue: &mut Issue, outcome: &TimeIntentOutcome) {
    match (&outcome.after, current_index(issue, &outcome.entry_id)) {
        (Some(after), Some(index)) => issue.time_entries[index] = after.clone(),
        (Some(after), None) => issue.time_entries.push(after.clone()),
        (None, Some(index)) => {
            issue.time_entries.remove(index);
        }
        // Validation refuses a delete of an unknown entry and a create of a taken
        // id, so this arm is unreachable for every request that reaches `stage`.
        (None, None) => {}
    }
    if let Some(event) = &outcome.event {
        issue.history.append(event.clone());
    }
}

/// A fresh stable entry id.
///
/// `e-` plus the crate's collision-resistant id
/// ([`crate::id::generate_issue_id`], Crockford base32, never all digits), so an
/// entry id is generated without a second random source and can never be read as an
/// issue number. Minted once, at the intent, so the id the projection carries and the
/// id the event names are the same string by construction.
pub fn new_entry_id() -> String {
    format!("e-{}", crate::id::generate_issue_id())
}