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}