tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

impl Store {
    /// **The** status write path (WP03, C-003, FR-002, NFR-002).
    ///
    /// Every surface that changes an issue's column comes through here, so the
    /// transition service is on the path *structurally* rather than by convention:
    /// CLI (`tix mv`, `tix edit --status`), the TUI's move and edit actions, MCP's
    /// `move_issue` and `update_issue`, and sync's import. A caller that judges a
    /// transition itself is a parity defect, and the two that used to are the
    /// reason this function had no production caller at all.
    ///
    /// ## The three outcomes
    ///
    /// | Result | Meaning | Written |
    /// |---|---|---|
    /// | `Ok(outcome)`, `outcome.written` | accepted, changed column | file + one decision |
    /// | `Ok(outcome)`, `!outcome.written` | accepted, same column | **nothing** (idempotent) |
    /// | `Err(TixError::Workflow(_))` | refused | **nothing at all** |
    ///
    /// The separation is in the type, not in a runtime check: a refusal is an
    /// `Err` carrying the **named** diagnostic, so a caller cannot read it as a
    /// write, and cannot mistake `unknown_column` (a typo) for `invalid_transition`
    /// (policy) — one message shape for both would lose the distinction the
    /// contract's shared vocabulary exists to keep (contract §6).
    ///
    /// ## Rejection is inert
    ///
    /// Nothing is touched before the service has accepted: no issue file, no
    /// `tix.toml`, no History entry. The service is pure (`src/workflow.rs` is
    /// filesystem-free by design), and the only write in this function is the
    /// single `update` at the end — so there is no window in which a refusal
    /// leaves a partial change behind.
    ///
    /// ## No repair path
    ///
    /// A file hand-edited into an inconsistent status is read, judged, and
    /// reported. This function never corrects one (C-005, decision
    /// `01M3C3XBMTR5WFV7059NXM04AJ`); WP06 owns the diagnosis. An auto-repair
    /// sneaking in here would be an escalation trigger.
    ///
    /// Routes through [`Store::update`] rather than serialising directly, so the
    /// retired `links:` disclosure happens on this path too. Cycle-5 Minor 9: this
    /// used to write the file itself, which drops the retired `links:` key
    /// because the key is not in the model — with **no disclosure**, since the
    /// disclosure lives in `update`. It was dead code reachable only from tests,
    /// so nothing caught it, and wiring it up would have silently destroyed the
    /// key on every surface at once.
    pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;

        // Once per config load, not per issue: the contract's O(V) sweep rests on
        // this being a single hash lookup per column rather than a rebuild.
        let (_, resolution) = crate::workflow::active_resolution(&self.config);

        // The one transition evaluation (C-003). Everything below is the write this
        // WP is responsible for; no rule of its own lives here.
        let outcome = crate::workflow::validate_transition(
            &issue,
            request.column,
            &resolution,
            &self.config,
            request.override_reason,
        );
        // The inert case. Return before any mutation, so there is nothing to undo
        // and no History entry to retract.
        let accepted = match TransitionAccepted::try_from(outcome) {
            Ok(accepted) => accepted,
            Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
        };

        // Idempotency: a same-column re-apply is accepted but writes nothing.
        // `updated` is deliberately *not* bumped either — a no-op that touches the
        // file is not a no-op, and bumping the date would make `tix edit --status`
        // with the column it already has look like an edit.
        if !accepted.changed {
            return Ok(StatusOutcome {
                issue,
                decision: None,
                written: false,
            });
        }

        // The completion coupling, which used to live in `mv.rs`. It belongs here
        // because it is a rule about the *status* column: reaching completion
        // records a commit, and leaving it clears the one it recorded. Keeping it
        // in the command layer is what let `tix edit --status` move an issue to
        // done without recording a commit, i.e. a second definition of "done".
        //
        // WP04: both halves of this now ask the **service** whether a column is
        // complete, rather than comparing against `effective_done_column()`.
        //
        // This was a live defect, not a tidy-up. WP03 wrote the coupling against the
        // column name; WP04 moved the CLI's *prompt trigger* to the service's
        // decision. The two then disagreed on any board whose `category_map` puts
        // `completed` somewhere other than its `done_column`: the CLI correctly
        // decided the move reached completion and asked for a commit hash, and the
        // seam then compared the column to `done_column`, concluded it had not, and
        // **silently dropped the hash the user had just typed**. The commit-prompt
        // parity test is what found it.
        //
        // `was_complete` is computed before `issue.status` is reassigned below, so
        // it describes where the issue *was*.
        let was_complete = crate::workflow::is_completed(&issue, &resolution);
        let reaches_complete = resolution
            .resolve(&accepted.to)
            .is_some_and(crate::workflow::WorkflowCategory::is_complete);
        if reaches_complete {
            issue.commit = request.commit.map(str::to_string);
        } else if was_complete {
            issue.commit = None;
        }

        issue.status = Status::new(accepted.to.clone());
        issue.bump_updated();

        // Exactly one decision per accepted transition (C-006), appended to the
        // issue's existing History — never a second log.
        let decision = TransitionDecision::new(
            crate::model::utc_now_rfc3339(),
            // The existing identity path, not a new one. `actor_identity` is the
            // same function the relationship and hierarchy events use, so a
            // decision's actor is spelled the way every other History entry's is
            // and cannot drift from it (T012 step 4).
            actor_identity(),
            accepted.from.clone(),
            accepted.to.clone(),
            accepted.from_category.key(),
            accepted.to_category.key(),
            accepted.override_reason,
        );
        issue.history.append(decision.to_event());

        issue.filename = self.update(filename, &issue)?;
        Ok(StatusOutcome {
            issue,
            decision: Some(decision),
            written: true,
        })
    }
}