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(×tamp) {
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(¬e);
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}