Skip to main content

tix/
workflow.rs

1//! The single owner of category resolution, completion, and transition legality.
2//!
3//! **Filesystem-free on purpose.** Nothing here reads or writes an issue file, and
4//! nothing here takes a `Store`. The module returns the accepted transition and
5//! the decision to append; WP03's storage seam performs the write. That is what
6//! lets the CLI, the TUI, MCP, sync and every analytic share one implementation
7//! instead of five that agree today (contract §2).
8//!
9//! **`blocked` here is a workflow column position, not blocked *state*.** The
10//! derived blocked state — an open inbound `blocks` edge — is a graph fact owned
11//! by issue 169's Mission and lives in `relationships.rs`. The two are different
12//! things and the contract names conflating them a rejected alternative
13//! (`category-vocabulary.md` §6).
14//!
15//! **Resolution is built once**, at config load, and is not rebuilt per issue or
16//! per surface. The per-issue path is a single hash lookup, which is what holds
17//! NFR-001 for a 10,000-issue sweep (contract §7). Rebuilding it per issue is the
18//! failure mode this design exists to prevent, and `resolution` is built from a
19//! board rather than from an issue so that it cannot be.
20
21use std::collections::HashMap;
22use std::fmt;
23
24use crate::config::{
25    BoardConfiguration, CATEGORY_KEYS, COMPLETE_CATEGORY, Config, SAFE_DEFAULT_CATEGORY,
26};
27use crate::model::Issue;
28
29/// A category in the fixed global vocabulary (C-002).
30///
31/// The vocabulary is global and closed: a board maps its columns *into* these
32/// keys and does not define its own. The spelling comes from WP01's
33/// [`CATEGORY_KEYS`] rather than from string literals here, so the config
34/// validator and this service cannot disagree about what a category is.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub enum WorkflowCategory {
37    Backlog,
38    Unstarted,
39    Active,
40    Blocked,
41    Completed,
42    Canceled,
43}
44
45impl WorkflowCategory {
46    /// Every category, in the vocabulary's order.
47    pub const ALL: [WorkflowCategory; 6] = [
48        WorkflowCategory::Backlog,
49        WorkflowCategory::Unstarted,
50        WorkflowCategory::Active,
51        WorkflowCategory::Blocked,
52        WorkflowCategory::Completed,
53        WorkflowCategory::Canceled,
54    ];
55
56    /// The canonical spelling used in config and in output.
57    pub fn key(self) -> &'static str {
58        match self {
59            WorkflowCategory::Backlog => "backlog",
60            WorkflowCategory::Unstarted => "unstarted",
61            WorkflowCategory::Active => "active",
62            WorkflowCategory::Blocked => "blocked",
63            WorkflowCategory::Completed => "completed",
64            WorkflowCategory::Canceled => "canceled",
65        }
66    }
67
68    /// Parse a key, or `None` for anything outside the vocabulary.
69    ///
70    /// The `Option` is load-bearing: an unknown key is an error
71    /// (`unknown_category`), never a coerced default. A `from_key` that fell back
72    /// to `unstarted` would turn a typo in a config into a board where a column
73    /// silently means something else.
74    pub fn from_key(key: &str) -> Option<Self> {
75        CATEGORY_KEYS
76            .iter()
77            .position(|k| *k == key)
78            .map(|i| Self::ALL[i])
79    }
80
81    /// Whether this category ends the workflow.
82    ///
83    /// True for `completed` **and** `canceled` — which is exactly why completion is
84    /// not `is_terminal()`. A category can end the work without the work having
85    /// been completed.
86    pub fn is_terminal(self) -> bool {
87        matches!(
88            self,
89            WorkflowCategory::Completed | WorkflowCategory::Canceled
90        )
91    }
92
93    /// Whether this category means the work is done.
94    ///
95    /// The **only** definition of completion in the crate. `canceled` is terminal
96    /// and not complete.
97    pub fn is_complete(self) -> bool {
98        self.key() == COMPLETE_CATEGORY
99    }
100
101    /// Display text. Presentation only; never consulted for a decision.
102    pub fn label(self) -> &'static str {
103        match self {
104            WorkflowCategory::Backlog => "Backlog",
105            WorkflowCategory::Unstarted => "Not started",
106            WorkflowCategory::Active => "In progress",
107            WorkflowCategory::Blocked => "Blocked",
108            WorkflowCategory::Completed => "Done",
109            WorkflowCategory::Canceled => "Canceled",
110        }
111    }
112
113    /// A presentation hint for the TUI, or `None` where there is nothing to say.
114    ///
115    /// **Inert.** It never affects resolution, policy, or reporting — a category
116    /// with a colour and a category without one resolve identically. A colour that
117    /// changed a decision would make the board's appearance load-bearing.
118    pub fn color_hint(self) -> Option<ColorHint> {
119        match self {
120            WorkflowCategory::Backlog => Some(ColorHint::Dim),
121            WorkflowCategory::Active => Some(ColorHint::Accent),
122            WorkflowCategory::Blocked => Some(ColorHint::Warn),
123            WorkflowCategory::Completed => Some(ColorHint::Ok),
124            WorkflowCategory::Canceled => Some(ColorHint::Muted),
125            // Deliberately `None`: "not started" is the absence of work, and
126            // giving it a colour would make an untouched card look like a
127            // deliberate state.
128            WorkflowCategory::Unstarted => None,
129        }
130    }
131}
132
133/// A presentation hint. See [`WorkflowCategory::color_hint`].
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum ColorHint {
136    Dim,
137    Accent,
138    Warn,
139    Ok,
140    Muted,
141}
142
143/// The built once-per-load derivation from column name to category
144/// (`data-model.md` §3).
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct CategoryResolution {
147    /// Every configured column and its category. Looked up, never scanned.
148    by_column: HashMap<String, WorkflowCategory>,
149    /// Every configured column, in **board order**.
150    ///
151    /// WP03. `by_column` is a `HashMap`, so iterating it yields keys in an
152    /// unspecified order. That was harmless while `columns()` had no callers, but
153    /// [`Self::columns`] already *documented* board order while being unable to
154    /// deliver it, and WP03's widened `unknown_column` message prints the valid
155    /// columns to the user. A `HashMap` order would make that list differ between
156    /// two runs over the same config — an unstable error message, and a test that
157    /// passes or fails on the allocator's whim.
158    ///
159    /// A second copy of the key set, not a replacement: lookups stay `O(1)` on
160    /// `by_column`, and this vector exists only to make iteration deterministic.
161    order: Vec<String>,
162    /// Configured columns that resolved by the safe default rather than by an
163    /// explicit mapping, in board order.
164    ///
165    /// Recorded so a surface can report `unmapped_column_default` "where ambiguity
166    /// matters". A column silently defaulting is the thing the contract is most
167    /// careful about, and it cannot be reported from the map alone.
168    pub unmapped: Vec<String>,
169    /// Whether any column relied on the default. Lets a caller skip the
170    /// diagnostic entirely on a fully-mapped board.
171    pub default_applied: bool,
172    /// Whether the board **declared** a `category_map`, as opposed to resolving
173    /// every column through the safe default.
174    ///
175    /// WP03. This is the signal that separates a board whose author described its
176    /// categories from one that never did, and it is what gates the **terminal
177    /// lock**. Without it, WP01's legacy reconciliation (`done_column` resolves to
178    /// `completed` so completion detection works, FR-003) would silently hand every
179    /// existing repository a transition policy it never configured: moving an issue
180    /// back out of `done` would start failing on every board in the wild, and on the
181    /// TUI there is no flag or key to override it with for two more work packages.
182    ///
183    /// Deliberately distinct from [`Self::default_applied`]: a board may declare a
184    /// `category_map` and still leave some columns to the default, so
185    /// `default_applied` is "some column is unmapped" while this is "a map was
186    /// written at all". The lock is about intent, and intent is expressed by the
187    /// presence of the section.
188    ///
189    /// Note the consequence, since it is a real one: a legacy board gets the
190    /// **completion** definition and the **commit-clearing** rule, but not the
191    /// terminal lock. Those are different things — completion answers "is this
192    /// done?", the lock answers "may this move?" — and the contract treats them in
193    /// different sections for that reason.
194    pub explicit_policy: bool,
195    /// The board's name, for diagnostics.
196    pub board: String,
197}
198
199impl CategoryResolution {
200    /// The category `column` resolves to, or `None` for a column this board does
201    /// not configure.
202    ///
203    /// A hash lookup. A caller that needs to distinguish "not configured" from
204    /// "configured but defaulted" uses [`Self::unmapped`].
205    pub fn resolve(&self, column: &str) -> Option<WorkflowCategory> {
206        self.by_column.get(column).copied()
207    }
208
209    /// Every column this board configures, in board order.
210    ///
211    /// Reads [`Self::order`], not `by_column`, so the sequence is the board's own
212    /// declaration order and is the same on every run (see the field's note).
213    pub fn columns(&self) -> impl Iterator<Item = &str> {
214        self.order.iter().map(String::as_str)
215    }
216
217    /// The board's columns joined for a message, in board order.
218    ///
219    /// WP03. A `unknown_column` refusal is the one diagnostic a user reads to work
220    /// out what to type instead, so it must list the valid columns. Rendering that
221    /// list in one place keeps every `unknown_column` message agreeing on it.
222    pub fn column_list(&self) -> String {
223        self.order.join(", ")
224    }
225
226    /// Whether any column resolves to `completed`.
227    pub fn has_completion(&self) -> bool {
228        self.by_column.values().any(|c| c.is_complete())
229    }
230}
231
232/// Build the resolution for `board`.
233///
234/// **Once per config load.** The per-issue path is a lookup against the result.
235///
236/// The legacy reconciliation lives in [`BoardConfiguration::category_for`], which
237/// WP01 wrote and this module does not duplicate: a board with no `category_map`
238/// resolves its `done_column` to `completed` and everything else to the safe
239/// default. Duplicating that fallback here is how a board ends up with two
240/// notions of its own default.
241pub fn resolution(
242    config: &Config,
243    board: &BoardConfiguration,
244    board_name: &str,
245) -> CategoryResolution {
246    let mut by_column = HashMap::with_capacity(board.columns.len());
247    let mut unmapped = Vec::new();
248    for column in &board.columns {
249        let key = board.category_for(column, &config.categories);
250        // An unmapped-and-not-done column is the one that relied on the default.
251        // `category_for` returns the safe default for those, and `category_map`
252        // says whether the entry was explicit.
253        let explicit = board.category_map.contains_key(column);
254        if !explicit {
255            unmapped.push(column.clone());
256        }
257        // `category_for` is total over `columns` because the fallback covers
258        // everything, so this cannot be `None`; `from_key` cannot be `None`
259        // because config load validated the vocabulary. Both are `expect`ed with
260        // the reason rather than defaulted, so a violated invariant is loud.
261        let category = WorkflowCategory::from_key(key).unwrap_or_else(|| {
262            unreachable!("config load rejected the vocabulary, so `{key}` is in it")
263        });
264        by_column.insert(column.clone(), category);
265    }
266    CategoryResolution {
267        by_column,
268        order: board.columns.clone(),
269        default_applied: !unmapped.is_empty(),
270        explicit_policy: !board.category_map.is_empty(),
271        unmapped,
272        board: board_name.to_string(),
273    }
274}
275
276/// The single completion decision (contract §4).
277///
278/// True only when the issue's column maps to `completed`. `canceled` is terminal
279/// and **not** complete, and an unmapped column resolves to the safe default, so
280/// it is never silently counted as done.
281///
282/// Never errors: an issue in a column the board does not configure is not
283/// complete. That is a board that failed validation, not a reason to fail a read.
284pub fn is_completed(issue: &Issue, resolution: &CategoryResolution) -> bool {
285    resolution
286        .resolve(issue.status.display())
287        .is_some_and(WorkflowCategory::is_complete)
288}
289
290/// The board's **completion column**, chosen by category rather than by
291/// `done_column` (WP09).
292///
293/// ## Why this exists
294///
295/// `BoardConfiguration::effective_done_column()` answers a *config* question — "what
296/// did the author name?" — and on a board with a `category_map` it is not the same
297/// question as "which column is complete?". A board whose map puts `completed` on
298/// `shipped` while `done_column` is `done` has two different answers, and every
299/// consumer that asked the config question got the wrong one.
300///
301/// That is not hypothetical. The blocked-move guard — the previous mission's central
302/// feature — read `done_column` and early-returned for any other column, so on such
303/// a board:
304///
305/// - a **blocked** issue moved into `shipped` (genuinely complete) got **no
306///   warning, no `--yes` requirement, and no `blocked_move_acknowledged` History
307///   entry**; and
308/// - moving it to `done` (not complete at all) **did** demand confirmation, because
309///   that is the column the config named.
310///
311/// The guard was silently disabled on exactly the boards this mission exists to
312/// support. Found by the call-site audit T038 step 4 asks for, which is the argument
313/// for having one: the pattern is five lines long and the consequence is a safety
314/// check that does not run.
315///
316/// ## Which column, when several are complete
317///
318/// The **first** configured column whose category is complete, in board order. A
319/// board with two completed columns has no single completion column, and the first is
320/// the only choice that does not depend on iteration order. `None` only for a board
321/// that failed validation — nothing resolves to `completed` — which the callers
322/// handle rather than this function panicking on a config the caller could not have
323/// built.
324pub fn completion_column(board: &BoardConfiguration, config: &Config) -> Option<String> {
325    let resolution = resolution(config, board, active_board_name(config));
326    board
327        .columns
328        .iter()
329        .find(|c| {
330            resolution
331                .resolve(c)
332                .is_some_and(WorkflowCategory::is_complete)
333        })
334        .cloned()
335}
336
337/// The board's completion column, **total**: the first complete column, else the
338/// configured `done_column`, else the literal `"done"` (WP09).
339///
340/// ## Why the fallback lives here and not at each call site
341///
342/// Three callers need "the completion column, and I cannot have a `None`" because
343/// they hand the name to a service that takes it as a parameter
344/// (`RelationshipGraph`, `hierarchy_cells`, `blocked_reasons`, `child_markers`).
345/// Writing the fallback at each site meant four copies of
346/// `.or_else(done_column).unwrap_or("done")` — and one of them drifted, which is how
347/// the TUI's blocked marker ended up computed against `done_column` while the CLI's
348/// used the service. One function, one fallback, one place to be right.
349///
350/// The fallback order is deliberate: the service's answer is the **meaning**, a
351/// configured `done_column` is a reasonable second guess for a board that failed
352/// validation, and the literal is a last resort so a caller that must produce a name
353/// always can. Only the first is authoritative, and only the first is ever reached by
354/// a config that loads.
355pub fn completion_column_or_default(board: &BoardConfiguration, config: &Config) -> String {
356    completion_column(board, config)
357        .or_else(|| board.effective_done_column().map(str::to_string))
358        .unwrap_or_else(|| "done".to_string())
359}
360
361/// The issue's category, or `None` for a column the board does not configure.
362pub fn category_of(issue: &Issue, resolution: &CategoryResolution) -> Option<WorkflowCategory> {
363    resolution.resolve(issue.status.display())
364}
365
366/// The fixed diagnostic vocabulary (contract §6).
367///
368/// One set, so the CLI, TUI, MCP and lint report a violation under the same name
369/// rather than four names for one condition.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
371pub enum DiagnosticKind {
372    MissingCompletionCategory,
373    UnknownCategory,
374    MappingForUnknownColumn,
375    UnmappedColumnDefault,
376    InvalidTransition,
377    UnknownColumn,
378    DirectEditPolicyViolation,
379}
380
381impl DiagnosticKind {
382    pub const ALL: [DiagnosticKind; 7] = [
383        DiagnosticKind::MissingCompletionCategory,
384        DiagnosticKind::UnknownCategory,
385        DiagnosticKind::MappingForUnknownColumn,
386        DiagnosticKind::UnmappedColumnDefault,
387        DiagnosticKind::InvalidTransition,
388        DiagnosticKind::UnknownColumn,
389        DiagnosticKind::DirectEditPolicyViolation,
390    ];
391
392    pub fn as_str(self) -> &'static str {
393        match self {
394            DiagnosticKind::MissingCompletionCategory => "missing_completion_category",
395            DiagnosticKind::UnknownCategory => "unknown_category",
396            DiagnosticKind::MappingForUnknownColumn => "mapping_for_unknown_column",
397            DiagnosticKind::UnmappedColumnDefault => "unmapped_column_default",
398            DiagnosticKind::InvalidTransition => "invalid_transition",
399            DiagnosticKind::UnknownColumn => "unknown_column",
400            DiagnosticKind::DirectEditPolicyViolation => "direct_edit_policy_violation",
401        }
402    }
403}
404
405/// One finding. The service never writes, so a diagnostic is the only output a
406/// refusal produces.
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub struct WorkflowDiagnostic {
409    pub kind: DiagnosticKind,
410    /// The board, column, or issue the finding is about, rendered for a message.
411    pub subject: String,
412    pub message: String,
413}
414
415impl fmt::Display for DiagnosticKind {
416    /// The contract's fixed name, so a surface that renders a refusal names the
417    /// condition the same way every other surface does (contract §6).
418    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419        f.write_str(self.as_str())
420    }
421}
422
423impl fmt::Display for WorkflowDiagnostic {
424    /// `(<kind>) for <subject>: <message>`.
425    ///
426    /// WP03. The kind leads, in parentheses, because the *name* is the part a
427    /// caller can match on — a user reporting a bug, a test asserting the
428    /// condition, and a grep over logs all key off it. `subject` sits in the
429    /// middle because it says what was attempted, and `message` says why not.
430    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
431        write!(f, "({}) for {}: {}", self.kind, self.subject, self.message)
432    }
433}
434
435/// Configuration findings across every board.
436///
437/// **Never errors.** It returns a value so lint, the CLI and MCP can consume it
438/// directly, and so a caller can decide for itself which findings are fatal.
439/// WP01 already refuses the fatal ones at load; what is added here is the
440/// board-level reporting WP01 cannot express — chiefly `unmapped_column_default`.
441///
442/// Deterministic: sorted by board, then column, then kind, so two runs over the
443/// same config produce the same list and a test can compare it directly.
444pub fn validate_config(config: &Config) -> Vec<WorkflowDiagnostic> {
445    let mut out = Vec::new();
446    for (name, board) in &config.kanban.configurations {
447        let res = resolution(config, board, name);
448        if !res.has_completion() {
449            out.push(WorkflowDiagnostic {
450                kind: DiagnosticKind::MissingCompletionCategory,
451                subject: name.clone(),
452                message: format!(
453                    "no column on board `{name}` resolves to `{COMPLETE_CATEGORY}`, so \\
454                     nothing on it is ever complete"
455                ),
456            });
457        }
458        for (column, key) in &board.category_map {
459            if !board.columns.iter().any(|c| c == column) {
460                out.push(WorkflowDiagnostic {
461                    kind: DiagnosticKind::MappingForUnknownColumn,
462                    subject: format!("{name}.{column}"),
463                    message: format!("`{column}` is not a column on board `{name}`"),
464                });
465            }
466            if WorkflowCategory::from_key(key).is_none() {
467                out.push(WorkflowDiagnostic {
468                    kind: DiagnosticKind::UnknownCategory,
469                    subject: format!("{name}.{column}"),
470                    message: format!("`{key}` is not in the global category vocabulary"),
471                });
472            }
473        }
474        for column in &res.unmapped {
475            // WP04: a column whose *default* is the completion category is not a
476            // finding. That is the legacy reconciliation this mission settled on
477            // (WP01): a board with no `category_map` resolves its `done_column` to
478            // `completed` so completion detection works on every existing
479            // repository.
480            //
481            // Reporting it as `unmapped_column_default` — as WP02 did — produced one
482            // finding per column on every board in the world, including the column
483            // the board *intends* to be done, and each message said it "resolves to
484            // the safe default `unstarted`" about a column that had resolved to
485            // `completed`. The diagnostic pointed the user at the exact thing that
486            // was working.
487            //
488            // It still belongs in `CategoryResolution::unmapped` — the **policy**
489            // opt-in reads that, not this diagnostic. What changed is only whether
490            // it is worth telling someone about.
491            let resolved = res.resolve(column);
492            if resolved.is_some_and(WorkflowCategory::is_complete) {
493                continue;
494            }
495            out.push(WorkflowDiagnostic {
496                kind: DiagnosticKind::UnmappedColumnDefault,
497                subject: format!("{name}.{column}"),
498                message: format!(
499                    "column `{column}` has no `category_map` entry, so it resolves to the \\
500                     safe default `{}`",
501                    resolved
502                        .map(|c| c.key())
503                        .unwrap_or(config.categories.default.as_str())
504                ),
505            });
506        }
507    }
508    out.sort_by(|a, b| {
509        a.subject
510            .cmp(&b.subject)
511            .then_with(|| a.kind.cmp(&b.kind))
512            .then_with(|| a.message.cmp(&b.message))
513    });
514    out
515}
516
517/// An explicit override out of a terminal category.
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub struct TransitionOverride {
520    /// Why. Required: an override with no reason is not recorded as one, because
521    /// a bare "I did it anyway" is indistinguishable from a mistake.
522    pub reason: String,
523}
524
525/// The outcome of judging a transition.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub enum TransitionOutcome {
528    /// Legal. `changed` is false for a same-column transition, which is permitted
529    /// and idempotent — no decision to append, no write.
530    Accepted {
531        from: String,
532        to: String,
533        from_category: WorkflowCategory,
534        to_category: WorkflowCategory,
535        changed: bool,
536        /// The override, when one was supplied and used.
537        override_reason: Option<String>,
538    },
539    /// Refused. Nothing is written, including no History entry (contract §3.5).
540    Rejected(WorkflowDiagnostic),
541}
542
543/// The name of the board [`Config::default_config`] resolves to.
544///
545/// WP04. `default_config` is a lookup of `"default"` falling back to the first
546/// declared board, and re-deriving that at a call site would be a second copy of
547/// a rule that decides *which* board the whole program operates on. A diagnostic
548/// naming a different board than the one consulted would be worse than no name.
549///
550/// Returns `"default"` when the config has no board at all, which
551/// `Config::validate` already rejects; the fallback exists so a diagnostic is
552/// never the thing that panics.
553pub fn active_board_name(config: &Config) -> &str {
554    config
555        .kanban
556        .configurations
557        .keys()
558        .find(|name| name.as_str() == "default")
559        .map(String::as_str)
560        .or_else(|| {
561            config
562                .kanban
563                .configurations
564                .keys()
565                .next()
566                .map(String::as_str)
567        })
568        .unwrap_or("default")
569}
570
571/// The [`CategoryResolution`] for the active board, plus that board's name.
572///
573/// WP04. Every surface needs the same pair — the seam to judge a transition, and
574/// `tix status` / `tix list` / `tix workflow show` to render a category — and
575/// building the resolution ad hoc at each site is how one of them ends up
576/// consulting a different board than the others. The name comes back alongside
577/// because every one of those renderers has to name the board it consulted.
578pub fn active_resolution(config: &Config) -> (String, CategoryResolution) {
579    let name = active_board_name(config);
580    let resolution = resolution(config, config.default_config(), name);
581    (name.to_string(), resolution)
582}
583
584/// One direct-edit finding: a file whose `status` no longer agrees with its History.
585///
586/// WP06. Separate from [`WorkflowDiagnostic`] because it has to carry **both** sides
587/// of the contradiction — the recorded `from` and the current column — and a message
588/// that names the rule. A `WorkflowDiagnostic` reports a condition; this reports a
589/// disagreement between a record and a file, which is a different thing to say.
590#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct DirectEditFinding {
592    /// The issue number, so a caller can find the file without re-parsing.
593    pub number: u32,
594    pub kind: DiagnosticKind,
595    /// The column the accepted decision landed in, or `None` when the file records
596    /// no usable decision at all.
597    pub recorded: Option<String>,
598    /// The column the file says now.
599    pub current: String,
600    /// The rule that was broken, in words.
601    pub message: String,
602}
603
604/// Report files whose `status` contradicts their own History (contract §5, C-005).
605///
606/// ## What it does and does not conclude
607///
608/// Two conditions, and both are conclusions about the *record*, not about intent:
609///
610/// 1. **The status diverged from the last accepted decision.** The file's History
611///    says a transition landed in `recorded`; the file says `current`. Something
612///    changed the column without going through the seam.
613/// 2. **The recorded decision contradicts the current policy.** The decision was
614///    accepted; re-evaluating it now is refused — the terminal lock, or adjacency.
615///    A board's `category_map` may have been tightened since, and the historical
616///    decision is still a true record of what happened then.
617///
618/// ## What is deliberately *not* a violation
619///
620/// **A file with no decision at all.** An issue created directly into a column has
621/// no transition to record, and creating one in a terminal column is legal — there
622/// is no `from` to judge. Reporting every such file would fire on every issue in a
623/// repository that has never been moved, which is lint's characteristic failure mode:
624/// a finding nobody reads.
625///
626/// A **same-column** decision record is likewise not a violation. The seam never
627/// writes one, but a hand-edited file can contain one, and it records no move.
628///
629/// ## Never writes
630///
631/// This function, and everything that calls it, is **observational**. It does not
632/// repair the file, does not append a History entry, does not rewrite existing
633/// History, and offers no "fix" affordance (C-005, decision
634/// `01M3C3XBMTR5WFV7059NXM04AJ`). A hand-edited file stays exactly as the user left
635/// it and stays readable; the contradiction is theirs to resolve.
636///
637/// ## One pass, deterministic order
638///
639/// A single sweep, not one per rule, and each file reported **once** — a file that
640/// diverged *and* whose decision now contradicts policy is one finding naming the
641/// stronger of the two, not two findings for one root cause.
642pub fn lint_direct_edits(
643    issues: &[Issue],
644    resolution: &CategoryResolution,
645    config: &Config,
646) -> Vec<DirectEditFinding> {
647    let mut out: Vec<DirectEditFinding> = Vec::new();
648    for issue in issues {
649        let current = issue.status.display().to_string();
650        // The last *usable* decision. `History::events()` skips lines it could not
651        // parse, so a corrupted `transition_accepted` line reads here as "no
652        // decision" — which is why the no-decision case is not a violation. The
653        // alternative would be a diagnostic for a corrupt audit line, and the
654        // contract's seven names do not include one; inventing an eighth is
655        // exactly the kind of claim the shared vocabulary exists to prevent.
656        let decision = issue
657            .history
658            .events()
659            .iter()
660            .rev()
661            .filter(|e| e.kind == crate::model::HistoryEventKind::TransitionAccepted)
662            .find_map(|e| crate::model::TransitionDecision::from_event(e).ok())
663            .filter(|d| !d.is_no_op());
664
665        let Some(decision) = decision else {
666            continue;
667        };
668
669        if decision.to != current {
670            out.push(DirectEditFinding {
671                number: issue.number,
672                kind: DiagnosticKind::DirectEditPolicyViolation,
673                recorded: Some(decision.to.clone()),
674                current: current.clone(),
675                message: format!(
676                    "status is `{current}` but this issue's History records the last \
677                     accepted transition as landing in `{}`; the file was changed \
678                     outside `tix`",
679                    decision.to
680                ),
681            });
682            continue;
683        }
684
685        // The status agrees with the record, so the only remaining question is
686        // whether the recorded move still satisfies policy. Re-evaluated against the
687        // *current* config, which is the point: a tightened `category_map` makes a
688        // historical decision look wrong, and that is worth knowing even though the
689        // record is accurate.
690        //
691        // `is_compatible(from, to, …)`, **not** `validate_transition(issue, from, …)`.
692        // The first version used the latter and evaluated the *current* column back
693        // to the recorded `from` — so it judged the reverse of the decision that was
694        // actually recorded, and reported every legal forward move in the repository
695        // as a policy violation. `is_compatible` is the predicate for exactly this
696        // question: "may this pair of columns move?" without inventing an issue.
697        let from = decision.from.clone();
698        let to = decision.to.clone();
699        if !is_compatible(&from, &to, resolution, config) {
700            out.push(DirectEditFinding {
701                number: issue.number,
702                kind: DiagnosticKind::DirectEditPolicyViolation,
703                recorded: Some(to.clone()),
704                current,
705                message: format!(
706                    "the accepted transition recorded in this issue's History \
707                     (`{from}` -> `{to}`) is no longer permitted by the current \
708                     policy; the record is accurate but the board's category mapping \
709                     has since changed"
710                ),
711            });
712        }
713    }
714    // Deterministic: by issue number, then by the columns involved. Two runs over the
715    // same tree must produce the same list, byte for byte (contract §5).
716    out.sort_by(|a, b| {
717        a.number
718            .cmp(&b.number)
719            .then_with(|| a.current.cmp(&b.current))
720            .then_with(|| a.recorded.cmp(&b.recorded))
721    });
722    out
723}
724
725/// The pure compatibility predicate behind [`validate_transition`].
726///
727/// Separated so the policy is testable without constructing a decision, and so a
728/// caller that only wants to know "may this move?" does not have to invent a
729/// decision to ask. Never errors: an unconfigured column is simply incompatible.
730pub fn is_compatible(
731    from_column: &str,
732    to_column: &str,
733    resolution: &CategoryResolution,
734    config: &Config,
735) -> bool {
736    // Category policy — the terminal lock and adjacency alike — is **opt in**,
737    // gated on `explicit_policy`. WP03: a board that never declared a
738    // `category_map` gets the completion definition and the commit-clearing rule
739    // and nothing else, because those are about *what* a column means while
740    // policy is about *what may change*.
741    //
742    // The two are gated together deliberately, and they have to be. On a legacy
743    // board every non-done column resolves to the single `unstarted` category, so
744    // adjacency's "may not move backwards in the declared order" is exactly the
745    // terminal lock minus the reasoned override — `completed` is last in the
746    // vocabulary, so every exit from it is also backwards. Gating only the lock
747    // would therefore have left adjacency to refuse the very moves the lock was
748    // relaxed to allow, with a message that does not mention an override. The
749    // first version of this change did exactly that, and its two TUI tests stayed
750    // red.
751    let policy_applies = config.transitions.terminal_locked && resolution.explicit_policy;
752
753    // Same column is permitted and idempotent, whatever the category (contract §3.2).
754    if from_column == to_column {
755        return true;
756    }
757    let (Some(from), Some(to)) = (
758        resolution.resolve(from_column),
759        resolution.resolve(to_column),
760    ) else {
761        return false;
762    };
763    // The terminal lock is category-level, so it is written once here rather than
764    // per column pair.
765    if policy_applies && from.is_terminal() && !to.is_terminal() {
766        return false;
767    }
768    // Adjacency: a category may move to itself or forward through the declared
769    // order. Going backwards is what a policy rule restricts, and expressing it on
770    // the order means a board with custom column names needs no configuration for
771    // it to behave.
772    if !policy_applies {
773        return true;
774    }
775    let order = |c: WorkflowCategory| {
776        config
777            .categories
778            .order
779            .iter()
780            .position(|k| k == c.key())
781            .unwrap_or(usize::MAX)
782    };
783    order(to) >= order(from)
784}
785
786/// The single transition evaluation (contract §3).
787///
788/// The only place legality is decided. A surface that judges a transition on its
789/// own is a parity defect.
790pub fn validate_transition(
791    issue: &Issue,
792    target_column: &str,
793    resolution: &CategoryResolution,
794    config: &Config,
795    override_reason: Option<&str>,
796) -> TransitionOutcome {
797    let from_column = issue.status.display().to_string();
798    let reject = |kind: DiagnosticKind, message: String| {
799        TransitionOutcome::Rejected(WorkflowDiagnostic {
800            kind,
801            subject: format!("{from_column} -> {target_column}"),
802            message,
803        })
804    };
805
806    // 1. Unknown column.
807    //
808    // WP03: the valid-column list moved in from `mv.rs`, which used to carry its
809    // own copy of this check so it could print the list. Two copies of the rule
810    // is what C-003 forbids, and the copy in `mv.rs` is gone — but its message was
811    // the more useful of the two, because a user who mistypes a column needs to be
812    // told what to type instead. So the list is *widened into* the service rather
813    // than dropped with it: one rule, and the better message.
814    let Some(to_category) = resolution.resolve(target_column) else {
815        return reject(
816            DiagnosticKind::UnknownColumn,
817            format!(
818                "`{target_column}` is not a column on board `{}`; valid columns: {}",
819                resolution.board,
820                resolution.column_list()
821            ),
822        );
823    };
824    let Some(from_category) = resolution.resolve(&from_column) else {
825        return reject(
826            DiagnosticKind::UnknownColumn,
827            format!(
828                "issue is in `{from_column}`, which is not a column on board `{}`; \
829                 valid columns: {}",
830                resolution.board,
831                resolution.column_list()
832            ),
833        );
834    };
835
836    // 2. Same column: permitted, idempotent, and no decision to append.
837    if from_column == target_column {
838        return TransitionOutcome::Accepted {
839            from: from_column,
840            to: target_column.to_string(),
841            from_category,
842            to_category,
843            changed: false,
844            override_reason: None,
845        };
846    }
847
848    // 3. Terminal lock, with the reasoned escape hatch.
849    //
850    // Gated on `explicit_policy`, together with adjacency in `is_compatible` —
851    // see that function's note for why the two cannot be gated separately.
852    let reason = override_reason.map(str::trim).filter(|r| !r.is_empty());
853    if config.transitions.terminal_locked
854        && resolution.explicit_policy
855        && from_category.is_terminal()
856        && !to_category.is_complete()
857        && !to_category.is_terminal()
858    {
859        return match reason {
860            Some(reason) => TransitionOutcome::Accepted {
861                from: from_column,
862                to: target_column.to_string(),
863                from_category,
864                to_category,
865                changed: true,
866                override_reason: Some(reason.to_string()),
867            },
868            None => reject(
869                DiagnosticKind::InvalidTransition,
870                // WP04: the message names the **spelling** of the override.
871                //
872                // It previously ended at "needs an explicit override with a reason",
873                // which tells a user the rule and leaves them to guess the flag. The
874                // rendered string also carried a Rust line-continuation `\` into the
875                // output, because the `\\` in the source literal survives into the
876                // format string — so the message a user saw contained a stray
877                // backslash in the middle of a sentence. Both are fixed by writing
878                // the sentence as one line with an explicit em dash.
879                format!(
880                    "issue is in terminal category `{}`; moving to `{target_column}` \
881                     (`{}`) needs an explicit override with a reason \u{2014} \
882                     `tix mv <issue> {target_column} --reason \"why\"`",
883                    from_category.key(),
884                    to_category.key()
885                ),
886            ),
887        };
888    }
889
890    // 4. Adjacency.
891    if !is_compatible(&from_column, target_column, resolution, config) {
892        return reject(
893            DiagnosticKind::InvalidTransition,
894            format!(
895                "`{}` -> `{}` is not permitted by the category policy",
896                from_category.key(),
897                to_category.key()
898            ),
899        );
900    }
901
902    TransitionOutcome::Accepted {
903        from: from_column,
904        to: target_column.to_string(),
905        from_category,
906        to_category,
907        changed: true,
908        override_reason: reason.map(str::to_string),
909    }
910}
911
912/// The documented safe default, re-exported so a surface reporting a defaulted
913/// column quotes the same constant the resolution used.
914pub const SAFE_DEFAULT: &str = SAFE_DEFAULT_CATEGORY;
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919    use crate::config::BoardConfiguration;
920    use indexmap::IndexMap;
921
922    fn board(columns: &[&str], done: &str, map: &[(&str, &str)]) -> BoardConfiguration {
923        let mut category_map = IndexMap::new();
924        for (column, category) in map {
925            category_map.insert((*column).to_string(), (*category).to_string());
926        }
927        BoardConfiguration {
928            columns: columns.iter().map(|c| (*c).to_string()).collect(),
929            done_column: Some(done.to_string()),
930            category_map,
931            hidden_columns: vec![],
932            show_mismatch: false,
933            wrap_titles: true,
934            title_lines: 3,
935            card_order: vec![],
936            order_by: crate::config::OrderBy::CreatedAtDesc,
937        }
938    }
939
940    fn config_with(board: BoardConfiguration) -> Config {
941        let mut config = Config::default();
942        config
943            .kanban
944            .configurations
945            .insert("default".to_string(), board);
946        config
947    }
948
949    /// A board with its own column names, fully mapped.
950    fn custom_config() -> Config {
951        config_with(board(
952            &["triage", "wip", "review", "shipped"],
953            "shipped",
954            &[
955                ("triage", "backlog"),
956                ("wip", "active"),
957                ("review", "active"),
958                ("shipped", "completed"),
959            ],
960        ))
961    }
962
963    /// An issue in `column`. `Issue` has no `Default`, so it is written out: a
964    /// derived default would hide a field added to the model behind a silently
965    /// zero value, which is the same failure as a test that measures nothing.
966    fn issue_in(column: &str) -> Issue {
967        Issue {
968            number: 1,
969            title: "T".into(),
970            status: crate::model::Status::new(column),
971            labels: vec![],
972            priority: crate::model::Priority::Medium,
973            assignee: None,
974            estimate: None,
975            commit: None,
976            gitlab_id: None,
977            linear_id: None,
978            mission: None,
979            task: None,
980            relationships: vec![],
981            related_issues: vec![],
982            files: vec![],
983            cycles: None,
984            milestones: None,
985            reviewer: None,
986            start_date: None,
987            due_date: None,
988            created: "2026-01-01".into(),
989            type_: None,
990            updated: "2026-01-01".into(),
991            filename: "t.md".into(),
992            body: String::new(),
993            history: crate::model::History::new(),
994            has_retired_links_key: false,
995            parent: None,
996            extra_frontmatter: std::collections::BTreeMap::new(),
997            archived: false,
998            entered_at: None,
999            source: None,
1000        }
1001    }
1002
1003    // -- T006: the vocabulary ------------------------------------------------
1004
1005    #[test]
1006    fn every_key_round_trips() {
1007        for (i, category) in WorkflowCategory::ALL.iter().enumerate() {
1008            assert_eq!(
1009                WorkflowCategory::from_key(category.key()),
1010                Some(*category),
1011                "{:?} must parse back to itself",
1012                category
1013            );
1014            // And the order agrees with the vocabulary constant WP01 exported, so
1015            // the two cannot drift.
1016            assert_eq!(category.key(), CATEGORY_KEYS[i]);
1017        }
1018        assert_eq!(WorkflowCategory::ALL.len(), CATEGORY_KEYS.len());
1019    }
1020
1021    #[test]
1022    fn an_unknown_key_yields_none_and_never_a_default() {
1023        // The `Option` is the point: a typo must be an error, not a silent
1024        // fallback to `unstarted`.
1025        for key in ["wontfix", "Done", "COMPLETED", "", " done", "unstarted "] {
1026            assert_eq!(
1027                WorkflowCategory::from_key(key),
1028                None,
1029                "`{key}` must not resolve to a category"
1030            );
1031        }
1032    }
1033
1034    #[test]
1035    fn only_completed_and_canceled_are_terminal() {
1036        let terminal: Vec<&str> = WorkflowCategory::ALL
1037            .iter()
1038            .filter(|c| c.is_terminal())
1039            .map(|c| c.key())
1040            .collect();
1041        assert_eq!(terminal, vec!["completed", "canceled"]);
1042        // Terminal is not the same claim as complete.
1043        assert!(WorkflowCategory::Canceled.is_terminal());
1044        assert!(!WorkflowCategory::Canceled.is_complete());
1045        assert!(WorkflowCategory::Completed.is_complete());
1046        assert_eq!(COMPLETE_CATEGORY, "completed");
1047    }
1048
1049    #[test]
1050    fn labels_are_distinct() {
1051        let mut labels: Vec<&str> = WorkflowCategory::ALL.iter().map(|c| c.label()).collect();
1052        labels.sort_unstable();
1053        let before = labels.len();
1054        labels.dedup();
1055        assert_eq!(labels.len(), before, "two categories share a display label");
1056    }
1057
1058    /// A colour is presentation. Asserted as inert rather than merely present: the
1059    /// point of the contract clause is that no decision reads it.
1060    #[test]
1061    fn color_hint_is_inert_with_respect_to_completion() {
1062        for category in WorkflowCategory::ALL {
1063            let hint = category.color_hint();
1064            if category.is_complete() {
1065                assert_eq!(hint, Some(ColorHint::Ok));
1066            }
1067            // Whatever the hint, the completion answer is the category's alone.
1068            assert_eq!(
1069                category.is_complete(),
1070                category.key() == COMPLETE_CATEGORY,
1071                "{category:?}: the hint must not decide completion"
1072            );
1073        }
1074    }
1075
1076    // -- T007: resolution ----------------------------------------------------
1077
1078    #[test]
1079    fn an_explicit_mapping_resolves_and_nothing_is_unmapped() {
1080        let config = custom_config();
1081        let res = resolution(&config, config.default_config(), "default");
1082        assert_eq!(res.resolve("triage"), Some(WorkflowCategory::Backlog));
1083        assert_eq!(res.resolve("wip"), Some(WorkflowCategory::Active));
1084        assert_eq!(res.resolve("shipped"), Some(WorkflowCategory::Completed));
1085        assert!(res.unmapped.is_empty());
1086        assert!(!res.default_applied);
1087        assert!(res.has_completion());
1088    }
1089
1090    #[test]
1091    fn an_unmapped_column_resolves_to_the_safe_default_and_is_recorded() {
1092        let config = config_with(board(
1093            &["triage", "wip", "review", "shipped"],
1094            "shipped",
1095            &[
1096                ("triage", "backlog"),
1097                ("review", "active"),
1098                ("shipped", "completed"),
1099            ],
1100        ));
1101        let res = resolution(&config, config.default_config(), "default");
1102        assert_eq!(res.resolve("wip"), Some(WorkflowCategory::Unstarted));
1103        assert_eq!(
1104            res.unmapped,
1105            vec!["wip".to_string()],
1106            "recorded, in board order"
1107        );
1108        assert!(res.default_applied);
1109    }
1110
1111    #[test]
1112    fn a_custom_named_completion_column_resolves_to_completed() {
1113        let config = custom_config();
1114        let res = resolution(&config, config.default_config(), "default");
1115        // The column is called `shipped`, not `done`. Completion is the category,
1116        // not the name — which is the whole point of the mapping.
1117        assert_ne!(
1118            config.default_config().effective_done_column(),
1119            Some("done")
1120        );
1121        assert_eq!(res.resolve("shipped"), Some(WorkflowCategory::Completed));
1122        assert!(res.has_completion());
1123    }
1124
1125    #[test]
1126    fn an_unconfigured_column_yields_none() {
1127        let config = custom_config();
1128        let res = resolution(&config, config.default_config(), "default");
1129        assert_eq!(res.resolve("nope"), None);
1130    }
1131
1132    /// A legacy board with no `category_map`: `done_column` completes, everything
1133    /// else is the safe default. WP01 owns the fallback; this asserts the service
1134    /// reads it rather than duplicating it.
1135    #[test]
1136    fn a_legacy_board_with_no_map_completes_on_its_done_column() {
1137        let config = config_with(board(
1138            &["todo", "in progress", "in review", "done"],
1139            "done",
1140            &[],
1141        ));
1142        let res = resolution(&config, config.default_config(), "default");
1143        assert_eq!(res.resolve("done"), Some(WorkflowCategory::Completed));
1144        assert_eq!(res.resolve("todo"), Some(WorkflowCategory::Unstarted));
1145        assert!(res.has_completion());
1146    }
1147
1148    #[test]
1149    fn two_resolutions_of_the_same_config_are_equal() {
1150        let config = custom_config();
1151        let a = resolution(&config, config.default_config(), "default");
1152        let b = resolution(&config, config.default_config(), "default");
1153        assert_eq!(
1154            a, b,
1155            "resolution must be a function of the config, not of when it ran"
1156        );
1157    }
1158
1159    // -- T008: the single completion decision ---------------------------------
1160
1161    #[test]
1162    fn completion_is_the_category_alone() {
1163        let config = config_with(board(
1164            &["triage", "wip", "dropped", "shipped"],
1165            "shipped",
1166            &[
1167                ("triage", "backlog"),
1168                ("wip", "active"),
1169                ("dropped", "canceled"),
1170                ("shipped", "completed"),
1171            ],
1172        ));
1173        let res = resolution(&config, config.default_config(), "default");
1174        assert!(is_completed(&issue_in("shipped"), &res));
1175        // `canceled` is terminal and **not** complete — the distinction triage's
1176        // decline action depends on.
1177        assert!(!is_completed(&issue_in("dropped"), &res));
1178        assert!(!is_completed(&issue_in("wip"), &res));
1179        // An unconfigured column is not complete, and does not error.
1180        assert!(!is_completed(&issue_in("nope"), &res));
1181        assert_eq!(
1182            category_of(&issue_in("dropped"), &res),
1183            Some(WorkflowCategory::Canceled)
1184        );
1185        assert_eq!(category_of(&issue_in("nope"), &res), None);
1186    }
1187
1188    // -- T009: transition policy ---------------------------------------------
1189
1190    #[test]
1191    fn a_compatible_transition_is_accepted() {
1192        let config = custom_config();
1193        let res = resolution(&config, config.default_config(), "default");
1194        let outcome = validate_transition(&issue_in("triage"), "wip", &res, &config, None);
1195        match outcome {
1196            TransitionOutcome::Accepted {
1197                from,
1198                to,
1199                changed,
1200                override_reason,
1201                from_category,
1202                to_category,
1203            } => {
1204                assert_eq!((from.as_str(), to.as_str()), ("triage", "wip"));
1205                assert_eq!(from_category, WorkflowCategory::Backlog);
1206                assert_eq!(to_category, WorkflowCategory::Active);
1207                assert!(changed);
1208                assert_eq!(override_reason, None);
1209            }
1210            other @ TransitionOutcome::Rejected(_) => {
1211                panic!("expected acceptance, got {other:?}")
1212            }
1213        }
1214    }
1215
1216    /// Contract §3.2 and the intake's third invariant: a same-column transition is
1217    /// permitted, idempotent, and produces no decision to append.
1218    #[test]
1219    fn a_same_column_transition_is_idempotent_with_no_decision() {
1220        let config = custom_config();
1221        let res = resolution(&config, config.default_config(), "default");
1222        for column in ["triage", "wip", "review", "shipped"] {
1223            match validate_transition(&issue_in(column), column, &res, &config, None) {
1224                TransitionOutcome::Accepted { changed, .. } => {
1225                    assert!(!changed, "`{column}` -> `{column}` must be a no-op")
1226                }
1227                TransitionOutcome::Rejected(other) => {
1228                    panic!("expected acceptance for `{column}`, got {other:?}")
1229                }
1230            }
1231        }
1232    }
1233
1234    #[test]
1235    fn an_unknown_target_column_is_rejected() {
1236        let config = custom_config();
1237        let res = resolution(&config, config.default_config(), "default");
1238        match validate_transition(&issue_in("wip"), "nowhere", &res, &config, None) {
1239            TransitionOutcome::Rejected(d) => {
1240                assert_eq!(d.kind, DiagnosticKind::UnknownColumn)
1241            }
1242            TransitionOutcome::Accepted { .. } => {
1243                panic!("expected a rejection, got an accepted transition")
1244            }
1245        }
1246    }
1247
1248    /// The terminal lock, and the reasoned escape hatch. The override must carry a
1249    /// reason: a bare "I did it anyway" is indistinguishable from a mistake.
1250    #[test]
1251    fn a_terminal_exit_is_refused_without_a_reason_and_allowed_with_one() {
1252        let config = config_with(board(
1253            &["triage", "wip", "dropped", "shipped"],
1254            "shipped",
1255            &[
1256                ("triage", "backlog"),
1257                ("wip", "active"),
1258                ("dropped", "canceled"),
1259                ("shipped", "completed"),
1260            ],
1261        ));
1262        let res = resolution(&config, config.default_config(), "default");
1263
1264        // Out of a terminal category into a non-terminal one: refused.
1265        for from in ["dropped", "shipped"] {
1266            match validate_transition(&issue_in(from), "wip", &res, &config, None) {
1267                TransitionOutcome::Rejected(d) => {
1268                    assert_eq!(d.kind, DiagnosticKind::InvalidTransition, "from `{from}`")
1269                }
1270                TransitionOutcome::Accepted { .. } => {
1271                    panic!("expected a rejection from `{from}`")
1272                }
1273            }
1274            // A blank reason is not a reason.
1275            match validate_transition(&issue_in(from), "wip", &res, &config, Some("   ")) {
1276                TransitionOutcome::Rejected(_) => {}
1277                TransitionOutcome::Accepted { .. } => {
1278                    panic!("a blank override must not unlock `{from}`")
1279                }
1280            }
1281            // A real one is accepted and recorded.
1282            match validate_transition(&issue_in(from), "wip", &res, &config, Some("wrong call")) {
1283                TransitionOutcome::Accepted {
1284                    override_reason,
1285                    changed,
1286                    ..
1287                } => {
1288                    assert_eq!(override_reason.as_deref(), Some("wrong call"));
1289                    assert!(changed);
1290                }
1291                TransitionOutcome::Rejected(d) => {
1292                    panic!("a reasoned override must be accepted, got: {}", d.message)
1293                }
1294            }
1295        }
1296    }
1297
1298    /// Terminal to terminal is not an **exit**, so the lock does not apply.
1299    ///
1300    /// The first version of this test moved `canceled -> completed` and failed. The
1301    /// lock was not what refused it: `canceled` is *after* `completed` in the
1302    /// declared order, so **adjacency** refused it. The two rules are independent
1303    /// and the test conflated them. `completed -> canceled` is terminal to
1304    /// terminal *and* forward, so the lock is the only rule that could have applied
1305    /// and it does not.
1306    #[test]
1307    fn a_terminal_to_terminal_move_is_not_locked() {
1308        let config = config_with(board(
1309            &["triage", "dropped", "shipped"],
1310            "shipped",
1311            &[
1312                ("triage", "backlog"),
1313                ("dropped", "canceled"),
1314                ("shipped", "completed"),
1315            ],
1316        ));
1317        let res = resolution(&config, config.default_config(), "default");
1318        assert!(matches!(
1319            validate_transition(&issue_in("shipped"), "dropped", &res, &config, None),
1320            TransitionOutcome::Accepted { .. }
1321        ));
1322        // And the backwards direction is still refused — by adjacency, which is a
1323        // different rule and a different answer for a different reason.
1324        match validate_transition(&issue_in("dropped"), "shipped", &res, &config, None) {
1325            TransitionOutcome::Rejected(d) => {
1326                assert_eq!(d.kind, DiagnosticKind::InvalidTransition);
1327                assert!(
1328                    d.message.contains("category policy"),
1329                    "refused by adjacency, not the lock: {}",
1330                    d.message
1331                );
1332            }
1333            TransitionOutcome::Accepted { .. } => {
1334                panic!("expected a rejection, got an accepted transition")
1335            }
1336        }
1337    }
1338
1339    /// The lock is a **separate** rule, and this is the only configuration where
1340    /// it is the *sole* thing preventing a move.
1341    ///
1342    /// With the declared order (`... completed, canceled` last), every exit from a
1343    /// terminal category is also *backwards*, so adjacency refuses it regardless and
1344    /// the lock is redundant. Putting `canceled` first in the order makes the exit
1345    /// forward, which isolates the lock: with it on the move is refused, with it off
1346    /// the move is permitted. Without this ordering the switch-off would prove
1347    /// nothing — the move would be refused either way, and the test would pass
1348    /// while measuring adjacency.
1349    #[test]
1350    fn the_terminal_lock_can_be_switched_off() {
1351        let mut config = config_with(board(
1352            &["triage", "dropped", "shipped"],
1353            "shipped",
1354            &[
1355                ("triage", "backlog"),
1356                ("dropped", "canceled"),
1357                ("shipped", "completed"),
1358            ],
1359        ));
1360        config.categories.order = vec![
1361            "canceled".into(),
1362            "backlog".into(),
1363            "active".into(),
1364            "completed".into(),
1365        ];
1366        let res = resolution(&config, config.default_config(), "default");
1367
1368        // Precondition, stated on the *order* rather than on `is_compatible`:
1369        // `is_compatible` applies the lock too, so asking it "is adjacency
1370        // permitting this?" cannot be done through it. With `canceled` before
1371        // `backlog`, adjacency permits the move and the lock is the only thing
1372        // standing in its way.
1373        let pos = |key: &str| {
1374            config
1375                .categories
1376                .order
1377                .iter()
1378                .position(|k| k == key)
1379                .expect("both keys are in the order")
1380        };
1381        assert!(
1382            pos("canceled") < pos("backlog"),
1383            "precondition: the move is forward"
1384        );
1385        assert!(matches!(
1386            validate_transition(&issue_in("dropped"), "triage", &res, &config, None),
1387            TransitionOutcome::Rejected { .. }
1388        ));
1389
1390        config.transitions.terminal_locked = false;
1391        let res = resolution(&config, config.default_config(), "default");
1392        assert!(matches!(
1393            validate_transition(&issue_in("dropped"), "triage", &res, &config, None),
1394            TransitionOutcome::Accepted { changed: true, .. }
1395        ));
1396    }
1397
1398    /// Adjacency is expressed on the *category* order, so a board with custom
1399    /// column names needs no per-pair configuration for it to behave.
1400    #[test]
1401    fn a_backwards_category_move_is_rejected() {
1402        let config = custom_config();
1403        let res = resolution(&config, config.default_config(), "default");
1404        // `active` is later than `backlog` in the vocabulary, so going back is not
1405        // permitted; a same-category move between two active columns is.
1406        assert!(!is_compatible("review", "triage", &res, &config));
1407        assert!(is_compatible("triage", "review", &res, &config));
1408        assert!(is_compatible("wip", "review", &res, &config));
1409        match validate_transition(&issue_in("review"), "triage", &res, &config, None) {
1410            TransitionOutcome::Rejected(d) => assert_eq!(d.kind, DiagnosticKind::InvalidTransition),
1411            TransitionOutcome::Accepted { .. } => {
1412                panic!("expected a rejection, got an accepted transition")
1413            }
1414        }
1415    }
1416
1417    /// Rejection is inert: the service returns a diagnostic and writes nothing.
1418    /// Asserted as a property of the return type's shape — there is no file, no
1419    /// store and no `Result` in the signature to write through.
1420    #[test]
1421    fn a_rejection_carries_a_diagnostic_and_nothing_else() {
1422        let config = custom_config();
1423        let res = resolution(&config, config.default_config(), "default");
1424        let outcome = validate_transition(&issue_in("review"), "triage", &res, &config, None);
1425        let TransitionOutcome::Rejected(d) = outcome else {
1426            panic!("expected a rejection")
1427        };
1428        assert!(!d.message.is_empty());
1429        assert!(!d.subject.is_empty());
1430    }
1431
1432    // -- T010: configuration diagnostics -------------------------------------
1433
1434    #[test]
1435    fn a_clean_config_reports_nothing() {
1436        let config = custom_config();
1437        assert_eq!(validate_config(&config), vec![]);
1438    }
1439
1440    #[test]
1441    fn a_board_with_no_completion_column_is_reported() {
1442        // Built directly, since config load refuses it — the service reports the
1443        // same condition under the same name rather than being the second rule.
1444        let config = config_with(board(
1445            &["triage", "wip", "shipped"],
1446            "shipped",
1447            &[("triage", "backlog"), ("wip", "active")],
1448        ));
1449        let found: Vec<DiagnosticKind> = validate_config(&config)
1450            .into_iter()
1451            .map(|d| d.kind)
1452            .collect();
1453        assert!(
1454            found.contains(&DiagnosticKind::MissingCompletionCategory),
1455            "got {found:?}"
1456        );
1457    }
1458
1459    #[test]
1460    fn an_unmapped_column_is_reported_but_is_not_an_error() {
1461        let config = config_with(board(
1462            &["triage", "wip", "shipped"],
1463            "shipped",
1464            &[("shipped", "completed")],
1465        ));
1466        let found: Vec<DiagnosticKind> = validate_config(&config)
1467            .into_iter()
1468            .map(|d| d.kind)
1469            .collect();
1470        assert!(
1471            found.contains(&DiagnosticKind::UnmappedColumnDefault),
1472            "got {found:?}"
1473        );
1474        // Reported, never fatal: `validate_config` returns a value and does not
1475        // error, and a defaulted column resolves (WP01 proved that at load).
1476        assert!(!found.contains(&DiagnosticKind::UnknownColumn));
1477    }
1478
1479    #[test]
1480    fn an_unknown_column_and_category_are_reported() {
1481        let config = config_with(board(
1482            &["triage", "shipped"],
1483            "shipped",
1484            &[
1485                ("triage", "backlog"),
1486                ("shipped", "completed"),
1487                ("nowhere", "active"),
1488            ],
1489        ));
1490        let found: Vec<DiagnosticKind> = validate_config(&config)
1491            .into_iter()
1492            .map(|d| d.kind)
1493            .collect();
1494        assert!(
1495            found.contains(&DiagnosticKind::MappingForUnknownColumn),
1496            "{found:?}"
1497        );
1498    }
1499
1500    #[test]
1501    fn two_runs_return_equal_results() {
1502        let config = config_with(board(
1503            &["triage", "wip", "shipped"],
1504            "shipped",
1505            &[("shipped", "completed"), ("nowhere", "wontfix")],
1506        ));
1507        assert_eq!(validate_config(&config), validate_config(&config));
1508        // And deterministically ordered, not merely equal run to run.
1509        let subjects: Vec<String> = validate_config(&config)
1510            .into_iter()
1511            .map(|d| d.subject)
1512            .collect();
1513        let mut sorted = subjects.clone();
1514        sorted.sort();
1515        assert_eq!(subjects, sorted, "diagnostics must be sorted by subject");
1516    }
1517
1518    #[test]
1519    fn the_diagnostic_kind_set_is_the_contract_set() {
1520        let names: Vec<&str> = DiagnosticKind::ALL.iter().map(|k| k.as_str()).collect();
1521        assert_eq!(
1522            names,
1523            vec![
1524                "missing_completion_category",
1525                "unknown_category",
1526                "mapping_for_unknown_column",
1527                "unmapped_column_default",
1528                "invalid_transition",
1529                "unknown_column",
1530                "direct_edit_policy_violation",
1531            ],
1532            "contract §6 fixes these names; every surface shares them"
1533        );
1534        // And they round-trip through nothing: `as_str` is the only spelling.
1535        for kind in DiagnosticKind::ALL {
1536            assert!(!kind.as_str().is_empty());
1537        }
1538    }
1539
1540    /// NFR-001's gate: the per-issue path is a hash lookup, not a scan. Measured
1541    /// against a deliberately wasteful re-resolution, which is the failure mode.
1542    #[test]
1543    fn the_per_issue_path_is_a_lookup_not_a_rebuild() {
1544        let config = custom_config();
1545        let board = config.default_config();
1546        let res = resolution(&config, board, "default");
1547        let build = std::time::Instant::now();
1548        for _ in 0..2_000 {
1549            let _ = resolution(&config, board, "default");
1550        }
1551        let rebuild_cost = build.elapsed();
1552        let lookup = std::time::Instant::now();
1553        for _ in 0..2_000 {
1554            let _ = res.resolve("wip");
1555        }
1556        let lookup_cost = lookup.elapsed();
1557        assert!(
1558            lookup_cost < rebuild_cost,
1559            "2,000 lookups ({lookup_cost:?}) must cost less than 2,000 rebuilds \
1560             ({rebuild_cost:?}) — resolution is built once and read many times"
1561        );
1562    }
1563}