Skip to main content

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}