tix/storage/status.rs
1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl Store {
5 /// **The** status write path (WP03, C-003, FR-002, NFR-002).
6 ///
7 /// Every surface that changes an issue's column comes through here, so the
8 /// transition service is on the path *structurally* rather than by convention:
9 /// CLI (`tix mv`, `tix edit --status`), the TUI's move and edit actions, MCP's
10 /// `move_issue` and `update_issue`, and sync's import. A caller that judges a
11 /// transition itself is a parity defect, and the two that used to are the
12 /// reason this function had no production caller at all.
13 ///
14 /// ## The three outcomes
15 ///
16 /// | Result | Meaning | Written |
17 /// |---|---|---|
18 /// | `Ok(outcome)`, `outcome.written` | accepted, changed column | file + one decision |
19 /// | `Ok(outcome)`, `!outcome.written` | accepted, same column | **nothing** (idempotent) |
20 /// | `Err(TixError::Workflow(_))` | refused | **nothing at all** |
21 ///
22 /// The separation is in the type, not in a runtime check: a refusal is an
23 /// `Err` carrying the **named** diagnostic, so a caller cannot read it as a
24 /// write, and cannot mistake `unknown_column` (a typo) for `invalid_transition`
25 /// (policy) — one message shape for both would lose the distinction the
26 /// contract's shared vocabulary exists to keep (contract §6).
27 ///
28 /// ## Rejection is inert
29 ///
30 /// Nothing is touched before the service has accepted: no issue file, no
31 /// `tix.toml`, no History entry. The service is pure (`src/workflow.rs` is
32 /// filesystem-free by design), and the only write in this function is the
33 /// single `update` at the end — so there is no window in which a refusal
34 /// leaves a partial change behind.
35 ///
36 /// ## No repair path
37 ///
38 /// A file hand-edited into an inconsistent status is read, judged, and
39 /// reported. This function never corrects one (C-005, decision
40 /// `01M3C3XBMTR5WFV7059NXM04AJ`); WP06 owns the diagnosis. An auto-repair
41 /// sneaking in here would be an escalation trigger.
42 ///
43 /// Routes through [`Store::update`] rather than serialising directly, so the
44 /// retired `links:` disclosure happens on this path too. Cycle-5 Minor 9: this
45 /// used to write the file itself, which drops the retired `links:` key
46 /// because the key is not in the model — with **no disclosure**, since the
47 /// disclosure lives in `update`. It was dead code reachable only from tests,
48 /// so nothing caught it, and wiring it up would have silently destroyed the
49 /// key on every surface at once.
50 pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
51 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
52
53 // Once per config load, not per issue: the contract's O(V) sweep rests on
54 // this being a single hash lookup per column rather than a rebuild.
55 let (_, resolution) = crate::workflow::active_resolution(&self.config);
56
57 // The one transition evaluation (C-003). Everything below is the write this
58 // WP is responsible for; no rule of its own lives here.
59 let outcome = crate::workflow::validate_transition(
60 &issue,
61 request.column,
62 &resolution,
63 &self.config,
64 request.override_reason,
65 );
66 // The inert case. Return before any mutation, so there is nothing to undo
67 // and no History entry to retract.
68 let accepted = match TransitionAccepted::try_from(outcome) {
69 Ok(accepted) => accepted,
70 Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
71 };
72
73 // Idempotency: a same-column re-apply is accepted but writes nothing.
74 // `updated` is deliberately *not* bumped either — a no-op that touches the
75 // file is not a no-op, and bumping the date would make `tix edit --status`
76 // with the column it already has look like an edit.
77 if !accepted.changed {
78 return Ok(StatusOutcome {
79 issue,
80 decision: None,
81 written: false,
82 });
83 }
84
85 // The completion coupling, which used to live in `mv.rs`. It belongs here
86 // because it is a rule about the *status* column: reaching completion
87 // records a commit, and leaving it clears the one it recorded. Keeping it
88 // in the command layer is what let `tix edit --status` move an issue to
89 // done without recording a commit, i.e. a second definition of "done".
90 //
91 // WP04: both halves of this now ask the **service** whether a column is
92 // complete, rather than comparing against `effective_done_column()`.
93 //
94 // This was a live defect, not a tidy-up. WP03 wrote the coupling against the
95 // column name; WP04 moved the CLI's *prompt trigger* to the service's
96 // decision. The two then disagreed on any board whose `category_map` puts
97 // `completed` somewhere other than its `done_column`: the CLI correctly
98 // decided the move reached completion and asked for a commit hash, and the
99 // seam then compared the column to `done_column`, concluded it had not, and
100 // **silently dropped the hash the user had just typed**. The commit-prompt
101 // parity test is what found it.
102 //
103 // `was_complete` is computed before `issue.status` is reassigned below, so
104 // it describes where the issue *was*.
105 let was_complete = crate::workflow::is_completed(&issue, &resolution);
106 let reaches_complete = resolution
107 .resolve(&accepted.to)
108 .is_some_and(crate::workflow::WorkflowCategory::is_complete);
109 if reaches_complete {
110 issue.commit = request.commit.map(str::to_string);
111 } else if was_complete {
112 issue.commit = None;
113 }
114
115 issue.status = Status::new(accepted.to.clone());
116 issue.bump_updated();
117
118 // Exactly one decision per accepted transition (C-006), appended to the
119 // issue's existing History — never a second log.
120 let decision = TransitionDecision::new(
121 crate::model::utc_now_rfc3339(),
122 // The existing identity path, not a new one. `actor_identity` is the
123 // same function the relationship and hierarchy events use, so a
124 // decision's actor is spelled the way every other History entry's is
125 // and cannot drift from it (T012 step 4).
126 actor_identity(),
127 accepted.from.clone(),
128 accepted.to.clone(),
129 accepted.from_category.key(),
130 accepted.to_category.key(),
131 accepted.override_reason,
132 );
133 issue.history.append(decision.to_event());
134
135 issue.filename = self.update(filename, &issue)?;
136 Ok(StatusOutcome {
137 issue,
138 decision: Some(decision),
139 written: true,
140 })
141 }
142}