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    #[allow(clippy::wildcard_imports)]
919    use super::*;
920    use crate::config::BoardConfiguration;
921    use indexmap::IndexMap;
922
923    fn board(columns: &[&str], done: &str, map: &[(&str, &str)]) -> BoardConfiguration {
924        let mut category_map = IndexMap::new();
925        for (column, category) in map {
926            category_map.insert((*column).to_string(), (*category).to_string());
927        }
928        BoardConfiguration {
929            columns: columns.iter().map(|c| (*c).to_string()).collect(),
930            done_column: Some(done.to_string()),
931            category_map,
932            hidden_columns: vec![],
933            show_mismatch: false,
934            wrap_titles: true,
935            title_lines: 3,
936            card_order: vec![],
937            order_by: crate::config::OrderBy::CreatedAtDesc,
938            wip: crate::wip::WipConfig::default(),
939        }
940    }
941
942    fn config_with(board: BoardConfiguration) -> Config {
943        let mut config = Config::default();
944        config
945            .kanban
946            .configurations
947            .insert("default".to_string(), board);
948        config
949    }
950
951    /// A board with its own column names, fully mapped.
952    fn custom_config() -> Config {
953        config_with(board(
954            &["triage", "wip", "review", "shipped"],
955            "shipped",
956            &[
957                ("triage", "backlog"),
958                ("wip", "active"),
959                ("review", "active"),
960                ("shipped", "completed"),
961            ],
962        ))
963    }
964
965    /// An issue in `column`. `Issue` has no `Default`, so it is written out: a
966    /// derived default would hide a field added to the model behind a silently
967    /// zero value, which is the same failure as a test that measures nothing.
968    fn issue_in(column: &str) -> Issue {
969        Issue {
970            projects: None,
971            id: None,
972            number: 1,
973            title: "T".into(),
974            status: crate::model::Status::new(column),
975            labels: vec![],
976            priority: crate::model::Priority::Medium,
977            assignee: None,
978            estimate: None,
979            commit: None,
980            gitlab_id: None,
981            linear_id: None,
982            mission: None,
983            task: None,
984            relationships: vec![],
985            related_issues: vec![],
986            files: vec![],
987            cycles: None,
988            milestones: None,
989            reviewer: None,
990            start_date: None,
991            due_date: None,
992            created: "2026-01-01".into(),
993            type_: None,
994            updated: "2026-01-01".into(),
995            filename: "t.md".into(),
996            body: String::new(),
997            history: crate::model::History::new(),
998            has_retired_links_key: false,
999            time_entries: vec![],
1000            parent: None,
1001            series: None,
1002            health: None,
1003            epics: None,
1004            extra_frontmatter: std::collections::BTreeMap::new(),
1005            archived: false,
1006            entered_at: None,
1007            source: None,
1008        }
1009    }
1010
1011    // -- T006: the vocabulary ------------------------------------------------
1012
1013    #[test]
1014    fn every_key_round_trips() {
1015        for (i, category) in WorkflowCategory::ALL.iter().enumerate() {
1016            assert_eq!(
1017                WorkflowCategory::from_key(category.key()),
1018                Some(*category),
1019                "{:?} must parse back to itself",
1020                category
1021            );
1022            // And the order agrees with the vocabulary constant WP01 exported, so
1023            // the two cannot drift.
1024            assert_eq!(category.key(), CATEGORY_KEYS[i]);
1025        }
1026        assert_eq!(WorkflowCategory::ALL.len(), CATEGORY_KEYS.len());
1027    }
1028
1029    #[test]
1030    fn an_unknown_key_yields_none_and_never_a_default() {
1031        // The `Option` is the point: a typo must be an error, not a silent
1032        // fallback to `unstarted`.
1033        for key in ["wontfix", "Done", "COMPLETED", "", " done", "unstarted "] {
1034            assert_eq!(
1035                WorkflowCategory::from_key(key),
1036                None,
1037                "`{key}` must not resolve to a category"
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn only_completed_and_canceled_are_terminal() {
1044        let terminal: Vec<&str> = WorkflowCategory::ALL
1045            .iter()
1046            .filter(|c| c.is_terminal())
1047            .map(|c| c.key())
1048            .collect();
1049        assert_eq!(terminal, vec!["completed", "canceled"]);
1050        // Terminal is not the same claim as complete.
1051        assert!(WorkflowCategory::Canceled.is_terminal());
1052        assert!(!WorkflowCategory::Canceled.is_complete());
1053        assert!(WorkflowCategory::Completed.is_complete());
1054        assert_eq!(COMPLETE_CATEGORY, "completed");
1055    }
1056
1057    #[test]
1058    fn labels_are_distinct() {
1059        let mut labels: Vec<&str> = WorkflowCategory::ALL.iter().map(|c| c.label()).collect();
1060        labels.sort_unstable();
1061        let before = labels.len();
1062        labels.dedup();
1063        assert_eq!(labels.len(), before, "two categories share a display label");
1064    }
1065
1066    /// A colour is presentation. Asserted as inert rather than merely present: the
1067    /// point of the contract clause is that no decision reads it.
1068    #[test]
1069    fn color_hint_is_inert_with_respect_to_completion() {
1070        for category in WorkflowCategory::ALL {
1071            let hint = category.color_hint();
1072            if category.is_complete() {
1073                assert_eq!(hint, Some(ColorHint::Ok));
1074            }
1075            // Whatever the hint, the completion answer is the category's alone.
1076            assert_eq!(
1077                category.is_complete(),
1078                category.key() == COMPLETE_CATEGORY,
1079                "{category:?}: the hint must not decide completion"
1080            );
1081        }
1082    }
1083
1084    // -- T007: resolution ----------------------------------------------------
1085
1086    #[test]
1087    fn an_explicit_mapping_resolves_and_nothing_is_unmapped() {
1088        let config = custom_config();
1089        let res = resolution(&config, config.default_config(), "default");
1090        assert_eq!(res.resolve("triage"), Some(WorkflowCategory::Backlog));
1091        assert_eq!(res.resolve("wip"), Some(WorkflowCategory::Active));
1092        assert_eq!(res.resolve("shipped"), Some(WorkflowCategory::Completed));
1093        assert_eq!(res.unmapped, [] as [std::string::String; 0]);
1094        assert!(!res.default_applied);
1095        assert!(res.has_completion());
1096    }
1097
1098    #[test]
1099    fn an_unmapped_column_resolves_to_the_safe_default_and_is_recorded() {
1100        let config = config_with(board(
1101            &["triage", "wip", "review", "shipped"],
1102            "shipped",
1103            &[
1104                ("triage", "backlog"),
1105                ("review", "active"),
1106                ("shipped", "completed"),
1107            ],
1108        ));
1109        let res = resolution(&config, config.default_config(), "default");
1110        assert_eq!(res.resolve("wip"), Some(WorkflowCategory::Unstarted));
1111        assert_eq!(
1112            res.unmapped,
1113            vec!["wip".to_string()],
1114            "recorded, in board order"
1115        );
1116        assert!(res.default_applied);
1117    }
1118
1119    #[test]
1120    fn a_custom_named_completion_column_resolves_to_completed() {
1121        let config = custom_config();
1122        let res = resolution(&config, config.default_config(), "default");
1123        // The column is called `shipped`, not `done`. Completion is the category,
1124        // not the name — which is the whole point of the mapping.
1125        assert_ne!(
1126            config.default_config().effective_done_column(),
1127            Some("done")
1128        );
1129        assert_eq!(res.resolve("shipped"), Some(WorkflowCategory::Completed));
1130        assert!(res.has_completion());
1131    }
1132
1133    #[test]
1134    fn an_unconfigured_column_yields_none() {
1135        let config = custom_config();
1136        let res = resolution(&config, config.default_config(), "default");
1137        assert_eq!(res.resolve("nope"), None);
1138    }
1139
1140    /// A legacy board with no `category_map`: `done_column` completes, everything
1141    /// else is the safe default. WP01 owns the fallback; this asserts the service
1142    /// reads it rather than duplicating it.
1143    #[test]
1144    fn a_legacy_board_with_no_map_completes_on_its_done_column() {
1145        let config = config_with(board(
1146            &["todo", "in progress", "in review", "done"],
1147            "done",
1148            &[],
1149        ));
1150        let res = resolution(&config, config.default_config(), "default");
1151        assert_eq!(res.resolve("done"), Some(WorkflowCategory::Completed));
1152        assert_eq!(res.resolve("todo"), Some(WorkflowCategory::Unstarted));
1153        assert!(res.has_completion());
1154    }
1155
1156    #[test]
1157    fn two_resolutions_of_the_same_config_are_equal() {
1158        let config = custom_config();
1159        let a = resolution(&config, config.default_config(), "default");
1160        let b = resolution(&config, config.default_config(), "default");
1161        assert_eq!(
1162            a, b,
1163            "resolution must be a function of the config, not of when it ran"
1164        );
1165    }
1166
1167    // -- T008: the single completion decision ---------------------------------
1168
1169    #[test]
1170    fn completion_is_the_category_alone() {
1171        let config = config_with(board(
1172            &["triage", "wip", "dropped", "shipped"],
1173            "shipped",
1174            &[
1175                ("triage", "backlog"),
1176                ("wip", "active"),
1177                ("dropped", "canceled"),
1178                ("shipped", "completed"),
1179            ],
1180        ));
1181        let res = resolution(&config, config.default_config(), "default");
1182        assert!(is_completed(&issue_in("shipped"), &res));
1183        // `canceled` is terminal and **not** complete — the distinction triage's
1184        // decline action depends on.
1185        assert!(!is_completed(&issue_in("dropped"), &res));
1186        assert!(!is_completed(&issue_in("wip"), &res));
1187        // An unconfigured column is not complete, and does not error.
1188        assert!(!is_completed(&issue_in("nope"), &res));
1189        assert_eq!(
1190            category_of(&issue_in("dropped"), &res),
1191            Some(WorkflowCategory::Canceled)
1192        );
1193        assert_eq!(category_of(&issue_in("nope"), &res), None);
1194    }
1195
1196    // -- T009: transition policy ---------------------------------------------
1197
1198    #[test]
1199    fn a_compatible_transition_is_accepted() {
1200        let config = custom_config();
1201        let res = resolution(&config, config.default_config(), "default");
1202        let outcome = validate_transition(&issue_in("triage"), "wip", &res, &config, None);
1203        match outcome {
1204            TransitionOutcome::Accepted {
1205                from,
1206                to,
1207                changed,
1208                override_reason,
1209                from_category,
1210                to_category,
1211            } => {
1212                assert_eq!((from.as_str(), to.as_str()), ("triage", "wip"));
1213                assert_eq!(from_category, WorkflowCategory::Backlog);
1214                assert_eq!(to_category, WorkflowCategory::Active);
1215                assert!(changed);
1216                assert_eq!(override_reason, None);
1217            }
1218            other @ TransitionOutcome::Rejected(_) => {
1219                panic!("expected acceptance, got {other:?}")
1220            }
1221        }
1222    }
1223
1224    /// Contract §3.2 and the intake's third invariant: a same-column transition is
1225    /// permitted, idempotent, and produces no decision to append.
1226    #[test]
1227    fn a_same_column_transition_is_idempotent_with_no_decision() {
1228        let config = custom_config();
1229        let res = resolution(&config, config.default_config(), "default");
1230        for column in ["triage", "wip", "review", "shipped"] {
1231            match validate_transition(&issue_in(column), column, &res, &config, None) {
1232                TransitionOutcome::Accepted { changed, .. } => {
1233                    assert!(!changed, "`{column}` -> `{column}` must be a no-op")
1234                }
1235                TransitionOutcome::Rejected(other) => {
1236                    panic!("expected acceptance for `{column}`, got {other:?}")
1237                }
1238            }
1239        }
1240    }
1241
1242    #[test]
1243    fn an_unknown_target_column_is_rejected() {
1244        let config = custom_config();
1245        let res = resolution(&config, config.default_config(), "default");
1246        match validate_transition(&issue_in("wip"), "nowhere", &res, &config, None) {
1247            TransitionOutcome::Rejected(d) => {
1248                assert_eq!(d.kind, DiagnosticKind::UnknownColumn)
1249            }
1250            TransitionOutcome::Accepted { .. } => {
1251                panic!("expected a rejection, got an accepted transition")
1252            }
1253        }
1254    }
1255
1256    /// The terminal lock, and the reasoned escape hatch. The override must carry a
1257    /// reason: a bare "I did it anyway" is indistinguishable from a mistake.
1258    #[test]
1259    fn a_terminal_exit_is_refused_without_a_reason_and_allowed_with_one() {
1260        let config = config_with(board(
1261            &["triage", "wip", "dropped", "shipped"],
1262            "shipped",
1263            &[
1264                ("triage", "backlog"),
1265                ("wip", "active"),
1266                ("dropped", "canceled"),
1267                ("shipped", "completed"),
1268            ],
1269        ));
1270        let res = resolution(&config, config.default_config(), "default");
1271
1272        // Out of a terminal category into a non-terminal one: refused.
1273        for from in ["dropped", "shipped"] {
1274            match validate_transition(&issue_in(from), "wip", &res, &config, None) {
1275                TransitionOutcome::Rejected(d) => {
1276                    assert_eq!(d.kind, DiagnosticKind::InvalidTransition, "from `{from}`")
1277                }
1278                TransitionOutcome::Accepted { .. } => {
1279                    panic!("expected a rejection from `{from}`")
1280                }
1281            }
1282            // A blank reason is not a reason.
1283            match validate_transition(&issue_in(from), "wip", &res, &config, Some("   ")) {
1284                TransitionOutcome::Rejected(_) => {}
1285                TransitionOutcome::Accepted { .. } => {
1286                    panic!("a blank override must not unlock `{from}`")
1287                }
1288            }
1289            // A real one is accepted and recorded.
1290            match validate_transition(&issue_in(from), "wip", &res, &config, Some("wrong call")) {
1291                TransitionOutcome::Accepted {
1292                    override_reason,
1293                    changed,
1294                    ..
1295                } => {
1296                    assert_eq!(override_reason.as_deref(), Some("wrong call"));
1297                    assert!(changed);
1298                }
1299                TransitionOutcome::Rejected(d) => {
1300                    panic!("a reasoned override must be accepted, got: {}", d.message)
1301                }
1302            }
1303        }
1304    }
1305
1306    /// Terminal to terminal is not an **exit**, so the lock does not apply.
1307    ///
1308    /// The first version of this test moved `canceled -> completed` and failed. The
1309    /// lock was not what refused it: `canceled` is *after* `completed` in the
1310    /// declared order, so **adjacency** refused it. The two rules are independent
1311    /// and the test conflated them. `completed -> canceled` is terminal to
1312    /// terminal *and* forward, so the lock is the only rule that could have applied
1313    /// and it does not.
1314    #[test]
1315    fn a_terminal_to_terminal_move_is_not_locked() {
1316        let config = config_with(board(
1317            &["triage", "dropped", "shipped"],
1318            "shipped",
1319            &[
1320                ("triage", "backlog"),
1321                ("dropped", "canceled"),
1322                ("shipped", "completed"),
1323            ],
1324        ));
1325        let res = resolution(&config, config.default_config(), "default");
1326        assert!(matches!(
1327            validate_transition(&issue_in("shipped"), "dropped", &res, &config, None),
1328            TransitionOutcome::Accepted { .. }
1329        ));
1330        // And the backwards direction is still refused — by adjacency, which is a
1331        // different rule and a different answer for a different reason.
1332        match validate_transition(&issue_in("dropped"), "shipped", &res, &config, None) {
1333            TransitionOutcome::Rejected(d) => {
1334                assert_eq!(d.kind, DiagnosticKind::InvalidTransition);
1335                assert!(
1336                    d.message.contains("category policy"),
1337                    "refused by adjacency, not the lock: {}",
1338                    d.message
1339                );
1340            }
1341            TransitionOutcome::Accepted { .. } => {
1342                panic!("expected a rejection, got an accepted transition")
1343            }
1344        }
1345    }
1346
1347    /// The lock is a **separate** rule, and this is the only configuration where
1348    /// it is the *sole* thing preventing a move.
1349    ///
1350    /// With the declared order (`... completed, canceled` last), every exit from a
1351    /// terminal category is also *backwards*, so adjacency refuses it regardless and
1352    /// the lock is redundant. Putting `canceled` first in the order makes the exit
1353    /// forward, which isolates the lock: with it on the move is refused, with it off
1354    /// the move is permitted. Without this ordering the switch-off would prove
1355    /// nothing — the move would be refused either way, and the test would pass
1356    /// while measuring adjacency.
1357    #[test]
1358    fn the_terminal_lock_can_be_switched_off() {
1359        let mut config = config_with(board(
1360            &["triage", "dropped", "shipped"],
1361            "shipped",
1362            &[
1363                ("triage", "backlog"),
1364                ("dropped", "canceled"),
1365                ("shipped", "completed"),
1366            ],
1367        ));
1368        config.categories.order = vec![
1369            "canceled".into(),
1370            "backlog".into(),
1371            "active".into(),
1372            "completed".into(),
1373        ];
1374        let res = resolution(&config, config.default_config(), "default");
1375
1376        // Precondition, stated on the *order* rather than on `is_compatible`:
1377        // `is_compatible` applies the lock too, so asking it "is adjacency
1378        // permitting this?" cannot be done through it. With `canceled` before
1379        // `backlog`, adjacency permits the move and the lock is the only thing
1380        // standing in its way.
1381        let pos = |key: &str| {
1382            config
1383                .categories
1384                .order
1385                .iter()
1386                .position(|k| k == key)
1387                .expect("both keys are in the order")
1388        };
1389        assert!(
1390            pos("canceled") < pos("backlog"),
1391            "precondition: the move is forward"
1392        );
1393        assert!(matches!(
1394            validate_transition(&issue_in("dropped"), "triage", &res, &config, None),
1395            TransitionOutcome::Rejected { .. }
1396        ));
1397
1398        config.transitions.terminal_locked = false;
1399        let res = resolution(&config, config.default_config(), "default");
1400        assert!(matches!(
1401            validate_transition(&issue_in("dropped"), "triage", &res, &config, None),
1402            TransitionOutcome::Accepted { changed: true, .. }
1403        ));
1404    }
1405
1406    /// Adjacency is expressed on the *category* order, so a board with custom
1407    /// column names needs no per-pair configuration for it to behave.
1408    #[test]
1409    fn a_backwards_category_move_is_rejected() {
1410        let config = custom_config();
1411        let res = resolution(&config, config.default_config(), "default");
1412        // `active` is later than `backlog` in the vocabulary, so going back is not
1413        // permitted; a same-category move between two active columns is.
1414        assert!(!is_compatible("review", "triage", &res, &config));
1415        assert!(is_compatible("triage", "review", &res, &config));
1416        assert!(is_compatible("wip", "review", &res, &config));
1417        match validate_transition(&issue_in("review"), "triage", &res, &config, None) {
1418            TransitionOutcome::Rejected(d) => assert_eq!(d.kind, DiagnosticKind::InvalidTransition),
1419            TransitionOutcome::Accepted { .. } => {
1420                panic!("expected a rejection, got an accepted transition")
1421            }
1422        }
1423    }
1424
1425    /// Rejection is inert: the service returns a diagnostic and writes nothing.
1426    /// Asserted as a property of the return type's shape — there is no file, no
1427    /// store and no `Result` in the signature to write through.
1428    #[test]
1429    fn a_rejection_carries_a_diagnostic_and_nothing_else() {
1430        let config = custom_config();
1431        let res = resolution(&config, config.default_config(), "default");
1432        let outcome = validate_transition(&issue_in("review"), "triage", &res, &config, None);
1433        let TransitionOutcome::Rejected(d) = outcome else {
1434            panic!("expected a rejection")
1435        };
1436        assert_ne!(d.message, "");
1437        assert_ne!(d.subject, "");
1438    }
1439
1440    // -- T010: configuration diagnostics -------------------------------------
1441
1442    #[test]
1443    fn a_clean_config_reports_nothing() {
1444        let config = custom_config();
1445        assert_eq!(validate_config(&config), vec![]);
1446    }
1447
1448    #[test]
1449    fn a_board_with_no_completion_column_is_reported() {
1450        // Built directly, since config load refuses it — the service reports the
1451        // same condition under the same name rather than being the second rule.
1452        let config = config_with(board(
1453            &["triage", "wip", "shipped"],
1454            "shipped",
1455            &[("triage", "backlog"), ("wip", "active")],
1456        ));
1457        let found: Vec<DiagnosticKind> = validate_config(&config)
1458            .into_iter()
1459            .map(|d| d.kind)
1460            .collect();
1461        assert!(
1462            found.contains(&DiagnosticKind::MissingCompletionCategory),
1463            "got {found:?}"
1464        );
1465    }
1466
1467    #[test]
1468    fn an_unmapped_column_is_reported_but_is_not_an_error() {
1469        let config = config_with(board(
1470            &["triage", "wip", "shipped"],
1471            "shipped",
1472            &[("shipped", "completed")],
1473        ));
1474        let found: Vec<DiagnosticKind> = validate_config(&config)
1475            .into_iter()
1476            .map(|d| d.kind)
1477            .collect();
1478        assert!(
1479            found.contains(&DiagnosticKind::UnmappedColumnDefault),
1480            "got {found:?}"
1481        );
1482        // Reported, never fatal: `validate_config` returns a value and does not
1483        // error, and a defaulted column resolves (WP01 proved that at load).
1484        assert!(!found.contains(&DiagnosticKind::UnknownColumn));
1485    }
1486
1487    #[test]
1488    fn an_unknown_column_and_category_are_reported() {
1489        let config = config_with(board(
1490            &["triage", "shipped"],
1491            "shipped",
1492            &[
1493                ("triage", "backlog"),
1494                ("shipped", "completed"),
1495                ("nowhere", "active"),
1496            ],
1497        ));
1498        let found: Vec<DiagnosticKind> = validate_config(&config)
1499            .into_iter()
1500            .map(|d| d.kind)
1501            .collect();
1502        assert!(
1503            found.contains(&DiagnosticKind::MappingForUnknownColumn),
1504            "{found:?}"
1505        );
1506    }
1507
1508    #[test]
1509    fn two_runs_return_equal_results() {
1510        let config = config_with(board(
1511            &["triage", "wip", "shipped"],
1512            "shipped",
1513            &[("shipped", "completed"), ("nowhere", "wontfix")],
1514        ));
1515        assert_eq!(validate_config(&config), validate_config(&config));
1516        // And deterministically ordered, not merely equal run to run.
1517        let subjects: Vec<String> = validate_config(&config)
1518            .into_iter()
1519            .map(|d| d.subject)
1520            .collect();
1521        let mut sorted = subjects.clone();
1522        sorted.sort();
1523        assert_eq!(subjects, sorted, "diagnostics must be sorted by subject");
1524    }
1525
1526    #[test]
1527    fn the_diagnostic_kind_set_is_the_contract_set() {
1528        let names: Vec<&str> = DiagnosticKind::ALL.iter().map(|k| k.as_str()).collect();
1529        assert_eq!(
1530            names,
1531            vec![
1532                "missing_completion_category",
1533                "unknown_category",
1534                "mapping_for_unknown_column",
1535                "unmapped_column_default",
1536                "invalid_transition",
1537                "unknown_column",
1538                "direct_edit_policy_violation",
1539            ],
1540            "contract §6 fixes these names; every surface shares them"
1541        );
1542        // And they round-trip through nothing: `as_str` is the only spelling.
1543        for kind in DiagnosticKind::ALL {
1544            assert_ne!(kind.as_str(), "");
1545        }
1546    }
1547
1548    /// NFR-001's gate: the per-issue path is a hash lookup, not a scan. Measured
1549    /// against a deliberately wasteful re-resolution, which is the failure mode.
1550    #[test]
1551    fn the_per_issue_path_is_a_lookup_not_a_rebuild() {
1552        let config = custom_config();
1553        let board = config.default_config();
1554        let res = resolution(&config, board, "default");
1555        let build = std::time::Instant::now();
1556        for _ in 0..2_000 {
1557            let _ = resolution(&config, board, "default");
1558        }
1559        let rebuild_cost = build.elapsed();
1560        let lookup = std::time::Instant::now();
1561        for _ in 0..2_000 {
1562            let _ = res.resolve("wip");
1563        }
1564        let lookup_cost = lookup.elapsed();
1565        assert!(
1566            lookup_cost < rebuild_cost,
1567            "2,000 lookups ({lookup_cost:?}) must cost less than 2,000 rebuilds \
1568             ({rebuild_cost:?}) — resolution is built once and read many times"
1569        );
1570    }
1571}