use crate::config::Config;
use crate::error::Result;
use crate::workflow::{DiagnosticKind, WorkflowCategory, active_board_name, validate_config};
pub fn categories(config: &Config) -> Result<()> {
let row = |left: &str, middle: &str, right: &str| {
println!("{left:<18} {middle:<10} {right}");
};
row("KEY", "LABEL", "TERMINAL");
for key in &config.categories.order {
let Some(category) = WorkflowCategory::from_key(key) else {
continue;
};
row(
category.key(),
category.label(),
if category.is_terminal() { "yes" } else { "" },
);
}
println!();
println!("Order: {}", config.categories.order.join(" -> "));
println!("Safe default: {}", config.categories.default);
println!(
"Complete: {}",
WorkflowCategory::from_key(crate::config::COMPLETE_CATEGORY)
.map(|c| c.label())
.unwrap_or(crate::config::COMPLETE_CATEGORY)
);
if config.categories.order.len() == crate::config::CATEGORY_KEYS.len() {
println!("(the built-in vocabulary, unchanged)");
} else {
println!(
"(custom order; the built-in vocabulary has {} keys)",
crate::config::CATEGORY_KEYS.len()
);
}
Ok(())
}
pub fn show(config: &Config, board_name: Option<&str>) -> Result<()> {
let name = match board_name {
Some(requested) => match config.kanban.configurations.get(requested) {
Some(_) => requested,
None => {
let known: Vec<&str> = config
.kanban
.configurations
.keys()
.map(String::as_str)
.collect();
return Err(crate::error::TixError::NotFound(format!(
"board `{requested}`; configured boards: {}",
if known.is_empty() {
"(none)".to_string()
} else {
known.join(", ")
}
)));
}
},
None => active_board_name(config),
};
let board =
config.kanban.configurations.get(name).expect(
"the name came from this same map, or `active_board_name` returned a key from it",
);
let resolution = crate::workflow::resolution(config, board, name);
let is_active = name == active_board_name(config);
println!("Board: {name}{}", if is_active { " (active)" } else { "" });
println!(
"Completion column: {}",
board
.effective_done_column()
.map(|c| c.to_string())
.unwrap_or_else(|| "(none configured)".to_string())
);
println!(
"Category policy: {}",
if resolution.explicit_policy {
"declared (`category_map` present) — terminal lock and adjacency apply"
} else {
"not declared (no `category_map`) — completion only, no transition rules"
}
);
println!();
println!(
"{:<20} {:<12} {:<14} TERMINAL",
"COLUMN", "CATEGORY", "SOURCE"
);
for column in board.columns.iter() {
let category = resolution.resolve(column);
let source = if board.category_map.contains_key(column) {
"category_map"
} else {
"safe default"
};
println!(
"{:<20} {:<12} {:<14} {}",
column,
category.map(|c| c.key()).unwrap_or("(unmapped)"),
source,
category.is_some_and(|c| c.is_terminal())
);
}
let findings: Vec<_> = validate_config(config)
.into_iter()
.filter(|d| d.subject.starts_with(name) || d.subject == name)
.collect();
if !findings.is_empty() {
println!();
println!("Diagnostics:");
for finding in &findings {
println!(" {}: {}", finding.kind, finding.message);
}
}
if !resolution.unmapped.is_empty() {
println!();
let settable: Vec<&String> = resolution
.unmapped
.iter()
.filter(|c| {
!resolution
.resolve(c)
.is_some_and(WorkflowCategory::is_complete)
})
.collect();
if settable.is_empty() {
println!(
"Every column resolves through the board's completion column; there is \\
nothing left to map."
);
} else {
println!(
"Resolved by the safe default ({} column(s)): {}",
settable.len(),
settable
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
println!(" → to map them explicitly, add a [kanban.{name}.category_map] table.");
}
println!(
" → a board with no column resolving to `completed` cannot record a \
completed move: {}.",
DiagnosticKind::MissingCompletionCategory
);
}
Ok(())
}
pub fn transitions(config: &Config) -> Result<()> {
let (_, resolution) = crate::workflow::active_resolution(config);
let t = &config.transitions;
println!("Effective transition policy for board {}", resolution.board);
println!();
println!(
"Terminal lock: {}",
if t.terminal_locked { "on" } else { "off" }
);
println!(
" Applies: {}",
if t.terminal_locked && resolution.explicit_policy {
"yes — this board declares a `category_map`, so an issue in a terminal \
category cannot leave it without a reasoned override"
} else if t.terminal_locked {
"no — `terminal_locked` is on, but this board declares no `category_map`, \
so there is no terminal policy to apply"
} else {
"no — `terminal_locked` is off in `tix.toml`"
}
);
println!();
println!(
"Category adjacency: {}",
if resolution.explicit_policy {
"on"
} else {
"off"
}
);
println!(" A category may move to itself or forward through the declared order.");
println!(" Order: {}", config.categories.order.join(" -> "));
println!(
" A move backwards is refused as `{}`.",
DiagnosticKind::InvalidTransition
);
println!();
println!("Escape hatch: a reasoned override");
println!(" tix mv <issue> <column> --reason \"why\"");
println!(" tix edit <issue> --status <column> --reason \"why\"");
println!(
" A blank reason is not a reason and is refused. The reason is recorded in \
the issue's History as a `transition_accepted` decision."
);
println!();
println!("Refusals write nothing: no issue file, no tix.toml, no History entry.");
Ok(())
}