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}