use super::aggregate::WipSnapshot;
use super::policy::WipDecision;
pub fn render_decision(decision: &WipDecision) -> String {
let mut out = format!(
"{}: `{}` -> `{}`",
decision.as_str(),
decision.from,
decision.to
);
if let Some(limit) = &decision.limit {
out.push_str(&format!(" ({})", limit_summary(limit)));
}
if decision.diagnostics.is_empty() {
return out;
}
out.push('\n');
for diagnostic in &decision.diagnostics {
out.push_str(&diagnostic.render());
out.push('\n');
}
out
}
pub fn render_snapshot(snapshot: &WipSnapshot) -> String {
let mut out = String::new();
for diagnostic in snapshot.diagnostics() {
out.push_str(&diagnostic.render());
out.push('\n');
}
for usage in snapshot.columns() {
if matches!(usage.state(), super::aggregate::LimitState::Within) {
continue;
}
match usage.state() {
super::aggregate::LimitState::Over => out.push_str(&format!(
"{}: `{}` holds {} against {}\n",
super::config::WipDiagnosticKind::OverLimit.as_str(),
usage.column,
usage.cell(),
usage.limit.as_ref().map_or_else(String::new, limit_summary)
)),
super::aggregate::LimitState::Unknown => out.push_str(&format!(
"{}: `{}` cannot be counted ({}) — a limit cannot be judged\n",
super::config::WipDiagnosticKind::AmbiguousConversion.as_str(),
usage.column,
usage.cell()
)),
super::aggregate::LimitState::Within => {}
}
}
out
}
fn limit_summary(limit: &super::config::ColumnLimit) -> String {
let mut parts = Vec::new();
if let Some(max) = limit.max_items {
parts.push(format!("max_items {max}"));
}
if let Some(max) = limit.max_estimate {
match limit.unit.as_deref() {
Some(unit) => parts.push(format!("max_estimate {max}{unit}")),
None => parts.push(format!("max_estimate {max}")),
}
}
match limit.policy {
Some(policy) => parts.push(policy.as_str().to_string()),
None => parts.push("no policy".to_string()),
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::Config;
use crate::model::Issue;
use crate::wip::{
ColumnLimit, LimitPolicy, MoveContext, MoveRequest, WipDiagnostic, WipDiagnosticKind,
WipOutcome, WipSnapshot,
};
fn issue(number: u32, status: &str) -> Issue {
let contents = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
);
let mut issue = Issue::parse(&contents).expect("the fixture parses");
issue.filename = format!("issue-{number}.md");
issue
}
#[test]
fn wip_diagnostics_render_a_decision_and_its_snapshot() {
let mut config = Config::default();
config
.kanban
.configurations
.get_mut("default")
.expect("the default board")
.columns = vec![
"todo".to_string(),
"in progress".to_string(),
"done".to_string(),
];
{
let board = config
.kanban
.configurations
.get_mut("default")
.expect("the default board");
board.wip.limits.insert(
"in progress".to_string(),
ColumnLimit::items(1, LimitPolicy::Block),
);
board.wip.units.insert("d".to_string(), 1);
}
let issues = vec![issue(1, "in progress"), issue(2, "in progress")];
let context = MoveContext::new(&config, &issues);
let moving = issue(3, "todo");
let decision = crate::wip::policy::evaluate(
&MoveContext::new(&config, &issues),
&MoveRequest::new(&moving, "in progress"),
);
assert_eq!(decision.outcome, WipOutcome::Block);
let rendered = render_decision(&decision);
assert!(
rendered.starts_with("block: `todo` -> `in progress`"),
"{rendered}"
);
assert!(rendered.contains("override_required"), "{rendered}");
assert!(rendered.contains("max_items 1, block"), "{rendered}");
let snapshot = WipSnapshot::build(config.default_config(), &issues);
let report = render_snapshot(&snapshot);
assert!(
report.contains("over_limit: `in progress` holds 2/1!"),
"{report}"
);
let _ = context;
let quiet = crate::wip::policy::evaluate(
&MoveContext::new(&config, &issues),
&MoveRequest::new(&issue(4, "todo"), "done"),
);
assert_eq!(quiet.outcome, WipOutcome::Allow);
assert_eq!(render_decision(&quiet), "allow: `todo` -> `done`");
assert!(
WipDiagnostic::new(WipDiagnosticKind::OverLimit, "x")
.render()
.contains("over_limit")
);
}
}