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