use std::collections::HashMap;
use std::fmt;
use crate::config::{
BoardConfiguration, CATEGORY_KEYS, COMPLETE_CATEGORY, Config, SAFE_DEFAULT_CATEGORY,
};
use crate::model::Issue;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum WorkflowCategory {
Backlog,
Unstarted,
Active,
Blocked,
Completed,
Canceled,
}
impl WorkflowCategory {
pub const ALL: [WorkflowCategory; 6] = [
WorkflowCategory::Backlog,
WorkflowCategory::Unstarted,
WorkflowCategory::Active,
WorkflowCategory::Blocked,
WorkflowCategory::Completed,
WorkflowCategory::Canceled,
];
pub fn key(self) -> &'static str {
match self {
WorkflowCategory::Backlog => "backlog",
WorkflowCategory::Unstarted => "unstarted",
WorkflowCategory::Active => "active",
WorkflowCategory::Blocked => "blocked",
WorkflowCategory::Completed => "completed",
WorkflowCategory::Canceled => "canceled",
}
}
pub fn from_key(key: &str) -> Option<Self> {
CATEGORY_KEYS
.iter()
.position(|k| *k == key)
.map(|i| Self::ALL[i])
}
pub fn is_terminal(self) -> bool {
matches!(
self,
WorkflowCategory::Completed | WorkflowCategory::Canceled
)
}
pub fn is_complete(self) -> bool {
self.key() == COMPLETE_CATEGORY
}
pub fn label(self) -> &'static str {
match self {
WorkflowCategory::Backlog => "Backlog",
WorkflowCategory::Unstarted => "Not started",
WorkflowCategory::Active => "In progress",
WorkflowCategory::Blocked => "Blocked",
WorkflowCategory::Completed => "Done",
WorkflowCategory::Canceled => "Canceled",
}
}
pub fn color_hint(self) -> Option<ColorHint> {
match self {
WorkflowCategory::Backlog => Some(ColorHint::Dim),
WorkflowCategory::Active => Some(ColorHint::Accent),
WorkflowCategory::Blocked => Some(ColorHint::Warn),
WorkflowCategory::Completed => Some(ColorHint::Ok),
WorkflowCategory::Canceled => Some(ColorHint::Muted),
WorkflowCategory::Unstarted => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorHint {
Dim,
Accent,
Warn,
Ok,
Muted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CategoryResolution {
by_column: HashMap<String, WorkflowCategory>,
order: Vec<String>,
pub unmapped: Vec<String>,
pub default_applied: bool,
pub explicit_policy: bool,
pub board: String,
}
impl CategoryResolution {
pub fn resolve(&self, column: &str) -> Option<WorkflowCategory> {
self.by_column.get(column).copied()
}
pub fn columns(&self) -> impl Iterator<Item = &str> {
self.order.iter().map(String::as_str)
}
pub fn column_list(&self) -> String {
self.order.join(", ")
}
pub fn has_completion(&self) -> bool {
self.by_column.values().any(|c| c.is_complete())
}
}
pub fn resolution(
config: &Config,
board: &BoardConfiguration,
board_name: &str,
) -> CategoryResolution {
let mut by_column = HashMap::with_capacity(board.columns.len());
let mut unmapped = Vec::new();
for column in &board.columns {
let key = board.category_for(column, &config.categories);
let explicit = board.category_map.contains_key(column);
if !explicit {
unmapped.push(column.clone());
}
let category = WorkflowCategory::from_key(key).unwrap_or_else(|| {
unreachable!("config load rejected the vocabulary, so `{key}` is in it")
});
by_column.insert(column.clone(), category);
}
CategoryResolution {
by_column,
order: board.columns.clone(),
default_applied: !unmapped.is_empty(),
explicit_policy: !board.category_map.is_empty(),
unmapped,
board: board_name.to_string(),
}
}
pub fn is_completed(issue: &Issue, resolution: &CategoryResolution) -> bool {
resolution
.resolve(issue.status.display())
.is_some_and(WorkflowCategory::is_complete)
}
pub fn completion_column(board: &BoardConfiguration, config: &Config) -> Option<String> {
let resolution = resolution(config, board, active_board_name(config));
board
.columns
.iter()
.find(|c| {
resolution
.resolve(c)
.is_some_and(WorkflowCategory::is_complete)
})
.cloned()
}
pub fn completion_column_or_default(board: &BoardConfiguration, config: &Config) -> String {
completion_column(board, config)
.or_else(|| board.effective_done_column().map(str::to_string))
.unwrap_or_else(|| "done".to_string())
}
pub fn category_of(issue: &Issue, resolution: &CategoryResolution) -> Option<WorkflowCategory> {
resolution.resolve(issue.status.display())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum DiagnosticKind {
MissingCompletionCategory,
UnknownCategory,
MappingForUnknownColumn,
UnmappedColumnDefault,
InvalidTransition,
UnknownColumn,
DirectEditPolicyViolation,
}
impl DiagnosticKind {
pub const ALL: [DiagnosticKind; 7] = [
DiagnosticKind::MissingCompletionCategory,
DiagnosticKind::UnknownCategory,
DiagnosticKind::MappingForUnknownColumn,
DiagnosticKind::UnmappedColumnDefault,
DiagnosticKind::InvalidTransition,
DiagnosticKind::UnknownColumn,
DiagnosticKind::DirectEditPolicyViolation,
];
pub fn as_str(self) -> &'static str {
match self {
DiagnosticKind::MissingCompletionCategory => "missing_completion_category",
DiagnosticKind::UnknownCategory => "unknown_category",
DiagnosticKind::MappingForUnknownColumn => "mapping_for_unknown_column",
DiagnosticKind::UnmappedColumnDefault => "unmapped_column_default",
DiagnosticKind::InvalidTransition => "invalid_transition",
DiagnosticKind::UnknownColumn => "unknown_column",
DiagnosticKind::DirectEditPolicyViolation => "direct_edit_policy_violation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkflowDiagnostic {
pub kind: DiagnosticKind,
pub subject: String,
pub message: String,
}
impl fmt::Display for DiagnosticKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Display for WorkflowDiagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}) for {}: {}", self.kind, self.subject, self.message)
}
}
pub fn validate_config(config: &Config) -> Vec<WorkflowDiagnostic> {
let mut out = Vec::new();
for (name, board) in &config.kanban.configurations {
let res = resolution(config, board, name);
if !res.has_completion() {
out.push(WorkflowDiagnostic {
kind: DiagnosticKind::MissingCompletionCategory,
subject: name.clone(),
message: format!(
"no column on board `{name}` resolves to `{COMPLETE_CATEGORY}`, so \\
nothing on it is ever complete"
),
});
}
for (column, key) in &board.category_map {
if !board.columns.iter().any(|c| c == column) {
out.push(WorkflowDiagnostic {
kind: DiagnosticKind::MappingForUnknownColumn,
subject: format!("{name}.{column}"),
message: format!("`{column}` is not a column on board `{name}`"),
});
}
if WorkflowCategory::from_key(key).is_none() {
out.push(WorkflowDiagnostic {
kind: DiagnosticKind::UnknownCategory,
subject: format!("{name}.{column}"),
message: format!("`{key}` is not in the global category vocabulary"),
});
}
}
for column in &res.unmapped {
let resolved = res.resolve(column);
if resolved.is_some_and(WorkflowCategory::is_complete) {
continue;
}
out.push(WorkflowDiagnostic {
kind: DiagnosticKind::UnmappedColumnDefault,
subject: format!("{name}.{column}"),
message: format!(
"column `{column}` has no `category_map` entry, so it resolves to the \\
safe default `{}`",
resolved
.map(|c| c.key())
.unwrap_or(config.categories.default.as_str())
),
});
}
}
out.sort_by(|a, b| {
a.subject
.cmp(&b.subject)
.then_with(|| a.kind.cmp(&b.kind))
.then_with(|| a.message.cmp(&b.message))
});
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransitionOverride {
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransitionOutcome {
Accepted {
from: String,
to: String,
from_category: WorkflowCategory,
to_category: WorkflowCategory,
changed: bool,
override_reason: Option<String>,
},
Rejected(WorkflowDiagnostic),
}
pub fn active_board_name(config: &Config) -> &str {
config
.kanban
.configurations
.keys()
.find(|name| name.as_str() == "default")
.map(String::as_str)
.or_else(|| {
config
.kanban
.configurations
.keys()
.next()
.map(String::as_str)
})
.unwrap_or("default")
}
pub fn active_resolution(config: &Config) -> (String, CategoryResolution) {
let name = active_board_name(config);
let resolution = resolution(config, config.default_config(), name);
(name.to_string(), resolution)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectEditFinding {
pub number: u32,
pub kind: DiagnosticKind,
pub recorded: Option<String>,
pub current: String,
pub message: String,
}
pub fn lint_direct_edits(
issues: &[Issue],
resolution: &CategoryResolution,
config: &Config,
) -> Vec<DirectEditFinding> {
let mut out: Vec<DirectEditFinding> = Vec::new();
for issue in issues {
let current = issue.status.display().to_string();
let decision = issue
.history
.events()
.iter()
.rev()
.filter(|e| e.kind == crate::model::HistoryEventKind::TransitionAccepted)
.find_map(|e| crate::model::TransitionDecision::from_event(e).ok())
.filter(|d| !d.is_no_op());
let Some(decision) = decision else {
continue;
};
if decision.to != current {
out.push(DirectEditFinding {
number: issue.number,
kind: DiagnosticKind::DirectEditPolicyViolation,
recorded: Some(decision.to.clone()),
current: current.clone(),
message: format!(
"status is `{current}` but this issue's History records the last \
accepted transition as landing in `{}`; the file was changed \
outside `tix`",
decision.to
),
});
continue;
}
let from = decision.from.clone();
let to = decision.to.clone();
if !is_compatible(&from, &to, resolution, config) {
out.push(DirectEditFinding {
number: issue.number,
kind: DiagnosticKind::DirectEditPolicyViolation,
recorded: Some(to.clone()),
current,
message: format!(
"the accepted transition recorded in this issue's History \
(`{from}` -> `{to}`) is no longer permitted by the current \
policy; the record is accurate but the board's category mapping \
has since changed"
),
});
}
}
out.sort_by(|a, b| {
a.number
.cmp(&b.number)
.then_with(|| a.current.cmp(&b.current))
.then_with(|| a.recorded.cmp(&b.recorded))
});
out
}
pub fn is_compatible(
from_column: &str,
to_column: &str,
resolution: &CategoryResolution,
config: &Config,
) -> bool {
let policy_applies = config.transitions.terminal_locked && resolution.explicit_policy;
if from_column == to_column {
return true;
}
let (Some(from), Some(to)) = (
resolution.resolve(from_column),
resolution.resolve(to_column),
) else {
return false;
};
if policy_applies && from.is_terminal() && !to.is_terminal() {
return false;
}
if !policy_applies {
return true;
}
let order = |c: WorkflowCategory| {
config
.categories
.order
.iter()
.position(|k| k == c.key())
.unwrap_or(usize::MAX)
};
order(to) >= order(from)
}
pub fn validate_transition(
issue: &Issue,
target_column: &str,
resolution: &CategoryResolution,
config: &Config,
override_reason: Option<&str>,
) -> TransitionOutcome {
let from_column = issue.status.display().to_string();
let reject = |kind: DiagnosticKind, message: String| {
TransitionOutcome::Rejected(WorkflowDiagnostic {
kind,
subject: format!("{from_column} -> {target_column}"),
message,
})
};
let Some(to_category) = resolution.resolve(target_column) else {
return reject(
DiagnosticKind::UnknownColumn,
format!(
"`{target_column}` is not a column on board `{}`; valid columns: {}",
resolution.board,
resolution.column_list()
),
);
};
let Some(from_category) = resolution.resolve(&from_column) else {
return reject(
DiagnosticKind::UnknownColumn,
format!(
"issue is in `{from_column}`, which is not a column on board `{}`; \
valid columns: {}",
resolution.board,
resolution.column_list()
),
);
};
if from_column == target_column {
return TransitionOutcome::Accepted {
from: from_column,
to: target_column.to_string(),
from_category,
to_category,
changed: false,
override_reason: None,
};
}
let reason = override_reason.map(str::trim).filter(|r| !r.is_empty());
if config.transitions.terminal_locked
&& resolution.explicit_policy
&& from_category.is_terminal()
&& !to_category.is_complete()
&& !to_category.is_terminal()
{
return match reason {
Some(reason) => TransitionOutcome::Accepted {
from: from_column,
to: target_column.to_string(),
from_category,
to_category,
changed: true,
override_reason: Some(reason.to_string()),
},
None => reject(
DiagnosticKind::InvalidTransition,
format!(
"issue is in terminal category `{}`; moving to `{target_column}` \
(`{}`) needs an explicit override with a reason \u{2014} \
`tix mv <issue> {target_column} --reason \"why\"`",
from_category.key(),
to_category.key()
),
),
};
}
if !is_compatible(&from_column, target_column, resolution, config) {
return reject(
DiagnosticKind::InvalidTransition,
format!(
"`{}` -> `{}` is not permitted by the category policy",
from_category.key(),
to_category.key()
),
);
}
TransitionOutcome::Accepted {
from: from_column,
to: target_column.to_string(),
from_category,
to_category,
changed: true,
override_reason: reason.map(str::to_string),
}
}
pub const SAFE_DEFAULT: &str = SAFE_DEFAULT_CATEGORY;
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::BoardConfiguration;
use indexmap::IndexMap;
fn board(columns: &[&str], done: &str, map: &[(&str, &str)]) -> BoardConfiguration {
let mut category_map = IndexMap::new();
for (column, category) in map {
category_map.insert((*column).to_string(), (*category).to_string());
}
BoardConfiguration {
columns: columns.iter().map(|c| (*c).to_string()).collect(),
done_column: Some(done.to_string()),
category_map,
hidden_columns: vec![],
show_mismatch: false,
wrap_titles: true,
title_lines: 3,
card_order: vec![],
order_by: crate::config::OrderBy::CreatedAtDesc,
wip: crate::wip::WipConfig::default(),
}
}
fn config_with(board: BoardConfiguration) -> Config {
let mut config = Config::default();
config
.kanban
.configurations
.insert("default".to_string(), board);
config
}
fn custom_config() -> Config {
config_with(board(
&["triage", "wip", "review", "shipped"],
"shipped",
&[
("triage", "backlog"),
("wip", "active"),
("review", "active"),
("shipped", "completed"),
],
))
}
fn issue_in(column: &str) -> Issue {
Issue {
projects: None,
id: None,
number: 1,
title: "T".into(),
status: crate::model::Status::new(column),
labels: vec![],
priority: crate::model::Priority::Medium,
assignee: None,
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: "t.md".into(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
}
}
#[test]
fn every_key_round_trips() {
for (i, category) in WorkflowCategory::ALL.iter().enumerate() {
assert_eq!(
WorkflowCategory::from_key(category.key()),
Some(*category),
"{:?} must parse back to itself",
category
);
assert_eq!(category.key(), CATEGORY_KEYS[i]);
}
assert_eq!(WorkflowCategory::ALL.len(), CATEGORY_KEYS.len());
}
#[test]
fn an_unknown_key_yields_none_and_never_a_default() {
for key in ["wontfix", "Done", "COMPLETED", "", " done", "unstarted "] {
assert_eq!(
WorkflowCategory::from_key(key),
None,
"`{key}` must not resolve to a category"
);
}
}
#[test]
fn only_completed_and_canceled_are_terminal() {
let terminal: Vec<&str> = WorkflowCategory::ALL
.iter()
.filter(|c| c.is_terminal())
.map(|c| c.key())
.collect();
assert_eq!(terminal, vec!["completed", "canceled"]);
assert!(WorkflowCategory::Canceled.is_terminal());
assert!(!WorkflowCategory::Canceled.is_complete());
assert!(WorkflowCategory::Completed.is_complete());
assert_eq!(COMPLETE_CATEGORY, "completed");
}
#[test]
fn labels_are_distinct() {
let mut labels: Vec<&str> = WorkflowCategory::ALL.iter().map(|c| c.label()).collect();
labels.sort_unstable();
let before = labels.len();
labels.dedup();
assert_eq!(labels.len(), before, "two categories share a display label");
}
#[test]
fn color_hint_is_inert_with_respect_to_completion() {
for category in WorkflowCategory::ALL {
let hint = category.color_hint();
if category.is_complete() {
assert_eq!(hint, Some(ColorHint::Ok));
}
assert_eq!(
category.is_complete(),
category.key() == COMPLETE_CATEGORY,
"{category:?}: the hint must not decide completion"
);
}
}
#[test]
fn an_explicit_mapping_resolves_and_nothing_is_unmapped() {
let config = custom_config();
let res = resolution(&config, config.default_config(), "default");
assert_eq!(res.resolve("triage"), Some(WorkflowCategory::Backlog));
assert_eq!(res.resolve("wip"), Some(WorkflowCategory::Active));
assert_eq!(res.resolve("shipped"), Some(WorkflowCategory::Completed));
assert_eq!(res.unmapped, [] as [std::string::String; 0]);
assert!(!res.default_applied);
assert!(res.has_completion());
}
#[test]
fn an_unmapped_column_resolves_to_the_safe_default_and_is_recorded() {
let config = config_with(board(
&["triage", "wip", "review", "shipped"],
"shipped",
&[
("triage", "backlog"),
("review", "active"),
("shipped", "completed"),
],
));
let res = resolution(&config, config.default_config(), "default");
assert_eq!(res.resolve("wip"), Some(WorkflowCategory::Unstarted));
assert_eq!(
res.unmapped,
vec!["wip".to_string()],
"recorded, in board order"
);
assert!(res.default_applied);
}
#[test]
fn a_custom_named_completion_column_resolves_to_completed() {
let config = custom_config();
let res = resolution(&config, config.default_config(), "default");
assert_ne!(
config.default_config().effective_done_column(),
Some("done")
);
assert_eq!(res.resolve("shipped"), Some(WorkflowCategory::Completed));
assert!(res.has_completion());
}
#[test]
fn an_unconfigured_column_yields_none() {
let config = custom_config();
let res = resolution(&config, config.default_config(), "default");
assert_eq!(res.resolve("nope"), None);
}
#[test]
fn a_legacy_board_with_no_map_completes_on_its_done_column() {
let config = config_with(board(
&["todo", "in progress", "in review", "done"],
"done",
&[],
));
let res = resolution(&config, config.default_config(), "default");
assert_eq!(res.resolve("done"), Some(WorkflowCategory::Completed));
assert_eq!(res.resolve("todo"), Some(WorkflowCategory::Unstarted));
assert!(res.has_completion());
}
#[test]
fn two_resolutions_of_the_same_config_are_equal() {
let config = custom_config();
let a = resolution(&config, config.default_config(), "default");
let b = resolution(&config, config.default_config(), "default");
assert_eq!(
a, b,
"resolution must be a function of the config, not of when it ran"
);
}
#[test]
fn completion_is_the_category_alone() {
let config = config_with(board(
&["triage", "wip", "dropped", "shipped"],
"shipped",
&[
("triage", "backlog"),
("wip", "active"),
("dropped", "canceled"),
("shipped", "completed"),
],
));
let res = resolution(&config, config.default_config(), "default");
assert!(is_completed(&issue_in("shipped"), &res));
assert!(!is_completed(&issue_in("dropped"), &res));
assert!(!is_completed(&issue_in("wip"), &res));
assert!(!is_completed(&issue_in("nope"), &res));
assert_eq!(
category_of(&issue_in("dropped"), &res),
Some(WorkflowCategory::Canceled)
);
assert_eq!(category_of(&issue_in("nope"), &res), None);
}
#[test]
fn a_compatible_transition_is_accepted() {
let config = custom_config();
let res = resolution(&config, config.default_config(), "default");
let outcome = validate_transition(&issue_in("triage"), "wip", &res, &config, None);
match outcome {
TransitionOutcome::Accepted {
from,
to,
changed,
override_reason,
from_category,
to_category,
} => {
assert_eq!((from.as_str(), to.as_str()), ("triage", "wip"));
assert_eq!(from_category, WorkflowCategory::Backlog);
assert_eq!(to_category, WorkflowCategory::Active);
assert!(changed);
assert_eq!(override_reason, None);
}
other @ TransitionOutcome::Rejected(_) => {
panic!("expected acceptance, got {other:?}")
}
}
}
#[test]
fn a_same_column_transition_is_idempotent_with_no_decision() {
let config = custom_config();
let res = resolution(&config, config.default_config(), "default");
for column in ["triage", "wip", "review", "shipped"] {
match validate_transition(&issue_in(column), column, &res, &config, None) {
TransitionOutcome::Accepted { changed, .. } => {
assert!(!changed, "`{column}` -> `{column}` must be a no-op")
}
TransitionOutcome::Rejected(other) => {
panic!("expected acceptance for `{column}`, got {other:?}")
}
}
}
}
#[test]
fn an_unknown_target_column_is_rejected() {
let config = custom_config();
let res = resolution(&config, config.default_config(), "default");
match validate_transition(&issue_in("wip"), "nowhere", &res, &config, None) {
TransitionOutcome::Rejected(d) => {
assert_eq!(d.kind, DiagnosticKind::UnknownColumn)
}
TransitionOutcome::Accepted { .. } => {
panic!("expected a rejection, got an accepted transition")
}
}
}
#[test]
fn a_terminal_exit_is_refused_without_a_reason_and_allowed_with_one() {
let config = config_with(board(
&["triage", "wip", "dropped", "shipped"],
"shipped",
&[
("triage", "backlog"),
("wip", "active"),
("dropped", "canceled"),
("shipped", "completed"),
],
));
let res = resolution(&config, config.default_config(), "default");
for from in ["dropped", "shipped"] {
match validate_transition(&issue_in(from), "wip", &res, &config, None) {
TransitionOutcome::Rejected(d) => {
assert_eq!(d.kind, DiagnosticKind::InvalidTransition, "from `{from}`")
}
TransitionOutcome::Accepted { .. } => {
panic!("expected a rejection from `{from}`")
}
}
match validate_transition(&issue_in(from), "wip", &res, &config, Some(" ")) {
TransitionOutcome::Rejected(_) => {}
TransitionOutcome::Accepted { .. } => {
panic!("a blank override must not unlock `{from}`")
}
}
match validate_transition(&issue_in(from), "wip", &res, &config, Some("wrong call")) {
TransitionOutcome::Accepted {
override_reason,
changed,
..
} => {
assert_eq!(override_reason.as_deref(), Some("wrong call"));
assert!(changed);
}
TransitionOutcome::Rejected(d) => {
panic!("a reasoned override must be accepted, got: {}", d.message)
}
}
}
}
#[test]
fn a_terminal_to_terminal_move_is_not_locked() {
let config = config_with(board(
&["triage", "dropped", "shipped"],
"shipped",
&[
("triage", "backlog"),
("dropped", "canceled"),
("shipped", "completed"),
],
));
let res = resolution(&config, config.default_config(), "default");
assert!(matches!(
validate_transition(&issue_in("shipped"), "dropped", &res, &config, None),
TransitionOutcome::Accepted { .. }
));
match validate_transition(&issue_in("dropped"), "shipped", &res, &config, None) {
TransitionOutcome::Rejected(d) => {
assert_eq!(d.kind, DiagnosticKind::InvalidTransition);
assert!(
d.message.contains("category policy"),
"refused by adjacency, not the lock: {}",
d.message
);
}
TransitionOutcome::Accepted { .. } => {
panic!("expected a rejection, got an accepted transition")
}
}
}
#[test]
fn the_terminal_lock_can_be_switched_off() {
let mut config = config_with(board(
&["triage", "dropped", "shipped"],
"shipped",
&[
("triage", "backlog"),
("dropped", "canceled"),
("shipped", "completed"),
],
));
config.categories.order = vec![
"canceled".into(),
"backlog".into(),
"active".into(),
"completed".into(),
];
let res = resolution(&config, config.default_config(), "default");
let pos = |key: &str| {
config
.categories
.order
.iter()
.position(|k| k == key)
.expect("both keys are in the order")
};
assert!(
pos("canceled") < pos("backlog"),
"precondition: the move is forward"
);
assert!(matches!(
validate_transition(&issue_in("dropped"), "triage", &res, &config, None),
TransitionOutcome::Rejected { .. }
));
config.transitions.terminal_locked = false;
let res = resolution(&config, config.default_config(), "default");
assert!(matches!(
validate_transition(&issue_in("dropped"), "triage", &res, &config, None),
TransitionOutcome::Accepted { changed: true, .. }
));
}
#[test]
fn a_backwards_category_move_is_rejected() {
let config = custom_config();
let res = resolution(&config, config.default_config(), "default");
assert!(!is_compatible("review", "triage", &res, &config));
assert!(is_compatible("triage", "review", &res, &config));
assert!(is_compatible("wip", "review", &res, &config));
match validate_transition(&issue_in("review"), "triage", &res, &config, None) {
TransitionOutcome::Rejected(d) => assert_eq!(d.kind, DiagnosticKind::InvalidTransition),
TransitionOutcome::Accepted { .. } => {
panic!("expected a rejection, got an accepted transition")
}
}
}
#[test]
fn a_rejection_carries_a_diagnostic_and_nothing_else() {
let config = custom_config();
let res = resolution(&config, config.default_config(), "default");
let outcome = validate_transition(&issue_in("review"), "triage", &res, &config, None);
let TransitionOutcome::Rejected(d) = outcome else {
panic!("expected a rejection")
};
assert_ne!(d.message, "");
assert_ne!(d.subject, "");
}
#[test]
fn a_clean_config_reports_nothing() {
let config = custom_config();
assert_eq!(validate_config(&config), vec![]);
}
#[test]
fn a_board_with_no_completion_column_is_reported() {
let config = config_with(board(
&["triage", "wip", "shipped"],
"shipped",
&[("triage", "backlog"), ("wip", "active")],
));
let found: Vec<DiagnosticKind> = validate_config(&config)
.into_iter()
.map(|d| d.kind)
.collect();
assert!(
found.contains(&DiagnosticKind::MissingCompletionCategory),
"got {found:?}"
);
}
#[test]
fn an_unmapped_column_is_reported_but_is_not_an_error() {
let config = config_with(board(
&["triage", "wip", "shipped"],
"shipped",
&[("shipped", "completed")],
));
let found: Vec<DiagnosticKind> = validate_config(&config)
.into_iter()
.map(|d| d.kind)
.collect();
assert!(
found.contains(&DiagnosticKind::UnmappedColumnDefault),
"got {found:?}"
);
assert!(!found.contains(&DiagnosticKind::UnknownColumn));
}
#[test]
fn an_unknown_column_and_category_are_reported() {
let config = config_with(board(
&["triage", "shipped"],
"shipped",
&[
("triage", "backlog"),
("shipped", "completed"),
("nowhere", "active"),
],
));
let found: Vec<DiagnosticKind> = validate_config(&config)
.into_iter()
.map(|d| d.kind)
.collect();
assert!(
found.contains(&DiagnosticKind::MappingForUnknownColumn),
"{found:?}"
);
}
#[test]
fn two_runs_return_equal_results() {
let config = config_with(board(
&["triage", "wip", "shipped"],
"shipped",
&[("shipped", "completed"), ("nowhere", "wontfix")],
));
assert_eq!(validate_config(&config), validate_config(&config));
let subjects: Vec<String> = validate_config(&config)
.into_iter()
.map(|d| d.subject)
.collect();
let mut sorted = subjects.clone();
sorted.sort();
assert_eq!(subjects, sorted, "diagnostics must be sorted by subject");
}
#[test]
fn the_diagnostic_kind_set_is_the_contract_set() {
let names: Vec<&str> = DiagnosticKind::ALL.iter().map(|k| k.as_str()).collect();
assert_eq!(
names,
vec![
"missing_completion_category",
"unknown_category",
"mapping_for_unknown_column",
"unmapped_column_default",
"invalid_transition",
"unknown_column",
"direct_edit_policy_violation",
],
"contract §6 fixes these names; every surface shares them"
);
for kind in DiagnosticKind::ALL {
assert_ne!(kind.as_str(), "");
}
}
#[test]
fn the_per_issue_path_is_a_lookup_not_a_rebuild() {
let config = custom_config();
let board = config.default_config();
let res = resolution(&config, board, "default");
let build = std::time::Instant::now();
for _ in 0..2_000 {
let _ = resolution(&config, board, "default");
}
let rebuild_cost = build.elapsed();
let lookup = std::time::Instant::now();
for _ in 0..2_000 {
let _ = res.resolve("wip");
}
let lookup_cost = lookup.elapsed();
assert!(
lookup_cost < rebuild_cost,
"2,000 lookups ({lookup_cost:?}) must cost less than 2,000 rebuilds \
({rebuild_cost:?}) — resolution is built once and read many times"
);
}
}