use crate::config::Config;
use crate::error::Result;
use crate::model::Issue;
use crate::planning::Planner;
use crate::search::control_token;
use crate::storage::Store;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoardSummary {
pub columns: Vec<(String, usize)>,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ColumnCount {
pub column: String,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StatusDocument {
pub columns: Vec<ColumnCount>,
pub total: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub health: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusOutcome {
pub text: String,
pub document: StatusDocument,
}
pub fn run(
config: &Config,
store: &Store,
filter: Option<&str>,
sort: Option<&str>,
group: Option<&str>,
health: bool,
json: bool,
) -> Result<()> {
let outcome = evaluate(config, store, filter, sort, group, health)?;
if json {
let text = serde_json::to_string_pretty(&outcome.document).map_err(|error| {
crate::error::TixError::Parse(format!("board document cannot be serialized: {error}"))
})?;
println!("{text}");
} else {
print!("{}", outcome.text);
}
Ok(())
}
pub fn evaluate(
config: &Config,
store: &Store,
filter: Option<&str>,
sort: Option<&str>,
group: Option<&str>,
health: bool,
) -> Result<StatusOutcome> {
let mut issues = store.list()?;
if let Some(filter) = filter {
let (resolved, criteria) = crate::search::resolve_filter_criteria(store, filter)?;
let (_, resolution) = crate::workflow::active_resolution(config);
issues.retain(|i| {
crate::search::matches_issue(
&resolved,
&criteria,
i,
&i.to_file_contents(),
&resolution,
)
});
}
let sort_key = sort
.map(str::to_string)
.or_else(|| filter.and_then(|q| control_token(q, "sort")));
sort_issues(&mut issues, sort_key.as_deref());
let summary = board_for_issues(config, &issues);
let group_key = group
.map(str::to_string)
.or_else(|| filter.and_then(|q| control_token(q, "group")));
let show_controls = filter.is_some() || sort_key.is_some() || group_key.is_some();
let health_summary = if health {
Some(crate::commands::health::HealthSummary::of(
&crate::commands::export::run_health_core(config)?,
))
} else {
None
};
let text = render(
config,
store,
&issues,
&summary,
show_controls,
group_key.as_deref(),
health_summary.as_ref(),
);
let document = StatusDocument {
columns: summary
.columns
.iter()
.map(|(column, count)| ColumnCount {
column: column.clone(),
count: *count,
})
.collect(),
total: summary.total,
health: health_summary.as_ref().map(health_value),
};
Ok(StatusOutcome { text, document })
}
fn health_value(summary: &crate::commands::health::HealthSummary) -> serde_json::Value {
serde_json::json!({
"policy_version": summary.policy_version,
"issues": summary.issues,
"derived": summary.derived,
"diagnostics": summary.diagnostics,
"states": summary.by_state,
})
}
pub fn render(
config: &Config,
store: &Store,
issues: &[Issue],
summary: &BoardSummary,
show_controls: bool,
group_key: Option<&str>,
health: Option<&crate::commands::health::HealthSummary>,
) -> String {
let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
let (_, resolution) = crate::workflow::active_resolution(config);
let column_width = config
.default_config()
.columns
.iter()
.map(|c| c.chars().count())
.chain([6, 5])
.max()
.unwrap()
.min(30);
let count_width = summary.total.to_string().len().max(5).max(1);
let mut out = String::new();
let mut row = |column: String, count: String| {
out.push_str(&format!("{column:<column_width$} {count:<count_width$}\n"));
};
row("COLUMN".into(), "COUNT".into());
let wip = crate::wip::WipSnapshot::build(config.default_config(), issues);
for (column, count) in &summary.columns {
match wip.column(column).filter(|usage| usage.limit.is_some()) {
Some(usage) => row(column.clone(), format!("{count} {}", usage.cell())),
None => row(column.clone(), count.to_string()),
}
}
row("Total".into(), summary.total.to_string());
if show_controls {
let today = crate::model::today();
let overdue = issues
.iter()
.filter(|i| {
i.due_date
.as_deref()
.is_some_and(|due| due < today.as_str())
&& !crate::workflow::is_completed(i, &resolution)
})
.count();
row("Overdue".into(), overdue.to_string());
}
if let Some(group_key) =
group_key.filter(|g| *g == "cycle" || *g == "milestone" || *g == "project" || *g == "epic")
{
let memberships = if group_key == "project" {
crate::projects::ProjectIndex::build(config, issues).resolved_memberships()
} else {
std::collections::HashMap::new()
};
let epic_memberships = if group_key == "epic" {
crate::epics::memberships_by_issue(config, issues)
} else {
std::collections::HashMap::new()
};
let mut groups: BTreeMap<String, (usize, usize)> = BTreeMap::new();
for issue in issues {
let group = match group_key {
"cycle" => issue
.cycles
.as_ref()
.and_then(|v| v.first().cloned())
.unwrap_or_else(|| "(none)".to_string()),
"milestone" => issue
.milestones
.as_ref()
.and_then(|v| v.first().cloned())
.unwrap_or_else(|| "(none)".to_string()),
"project" => {
let ids = memberships.get(&issue.number).cloned().unwrap_or_default();
crate::projects::project_bucket_label(&ids)
}
"epic" => {
let ids = epic_memberships
.get(&issue.number)
.cloned()
.unwrap_or_default();
crate::search::epic_bucket_label(&ids)
}
_ => "(none)".to_string(),
};
let entry = groups.entry(group).or_insert((0, 0));
entry.1 += 1;
if crate::workflow::is_completed(issue, &resolution) {
entry.0 += 1;
}
}
for (group, (done, total)) in groups {
let pct = (done * 100).checked_div(total).unwrap_or(0);
out.push_str(&format!(
"{group_key} {group}: {done}/{total} complete ({pct}%)\n"
));
}
}
let show_progress = issues.iter().any(|i| {
i.cycles.as_ref().is_some_and(|v| !v.is_empty())
|| i.milestones.as_ref().is_some_and(|v| !v.is_empty())
});
if show_progress {
for cycle in planner.cycle_names() {
let progress = planner.cycle_progress(store, &cycle);
if progress.total > 0 {
out.push_str(&format!(
"cycle {cycle}: {}/{} complete\n",
progress.done, progress.total
));
}
}
for milestone in planner.milestone_names().unwrap_or_default() {
let progress = planner.milestone_progress(store, &milestone);
if progress.total > 0 {
out.push_str(&format!(
"milestone {milestone}: {}/{} complete\n",
progress.done, progress.total
));
}
}
}
if let Some(health) = health {
out.push_str(&health.render());
}
out
}
pub fn board(config: &Config, store: &Store) -> Result<BoardSummary> {
let issues = store.list()?;
Ok(board_for_issues(config, &issues))
}
pub fn board_for_issues(config: &Config, issues: &[Issue]) -> BoardSummary {
let total = issues.len();
let columns = config
.default_config()
.columns
.iter()
.map(|column| {
let count = issues.iter().filter(|i| i.status.is(column)).count();
(column.clone(), count)
})
.collect();
BoardSummary { columns, total }
}
fn sort_issues(issues: &mut [Issue], sort: Option<&str>) {
match sort.unwrap_or("created") {
"due" => issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number))),
"start" => issues.sort_by(|a, b| {
a.start_date
.cmp(&b.start_date)
.then(a.number.cmp(&b.number))
}),
"reviewer" => {
issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
}
"cycle" => issues.sort_by(|a, b| {
a.cycles
.as_ref()
.and_then(|v| v.first())
.cmp(&b.cycles.as_ref().and_then(|v| v.first()))
.then(a.number.cmp(&b.number))
}),
"milestone" => issues.sort_by(|a, b| {
a.milestones
.as_ref()
.and_then(|v| v.first())
.cmp(&b.milestones.as_ref().and_then(|v| v.first()))
.then(a.number.cmp(&b.number))
}),
"status" => issues.sort_by(|a, b| {
a.status
.display()
.cmp(b.status.display())
.then(a.number.cmp(&b.number))
}),
"priority" => issues.sort_by(|a, b| {
a.priority
.to_string()
.cmp(&b.priority.to_string())
.then(a.number.cmp(&b.number))
}),
_ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed(store: &Store, number: u32, title: &str, status: &str) {
let mut issue = Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: Status::new(status.to_string()),
labels: vec![],
priority: Priority::default(),
assignee: Some("alice".to_string()),
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".to_string(),
type_: None,
updated: "2026-01-01".to_string(),
filename: String::new(),
body: "body".to_string(),
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,
};
issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
}
#[test]
fn board_counts_columns_and_total() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "One", "todo");
seed(&store, 2, "Two", "todo");
seed(&store, 3, "Three", "in progress");
seed(&store, 4, "Four", "done");
let summary = board(&store.config, &store).unwrap();
assert_eq!(
summary
.columns
.iter()
.map(|(c, n)| (c.as_str(), *n))
.collect::<Vec<_>>(),
vec![
("todo", 2),
("in progress", 1),
("in review", 0),
("done", 1),
],
"columns in config order with per-column counts"
);
assert_eq!(summary.total, 4);
}
#[test]
fn document_columns_are_board_order_with_zeros() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "One", "todo");
seed(&store, 2, "Two", "in progress");
let outcome = evaluate(&store.config, &store, None, None, None, false).unwrap();
assert_eq!(
outcome
.document
.columns
.iter()
.map(|c| (c.column.as_str(), c.count))
.collect::<Vec<_>>(),
vec![
("todo", 1),
("in progress", 1),
("in review", 0),
("done", 0),
],
"every configured column, in board order, zeros included"
);
assert_eq!(outcome.document.total, 2);
}
#[test]
fn document_omits_health_key_unless_requested() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "One", "todo");
let plain = serde_json::to_value(
&evaluate(&store.config, &store, None, None, None, false)
.unwrap()
.document,
)
.unwrap();
assert!(
plain.get("health").is_none(),
"health is absent, not null, when it was not requested"
);
let with_health = serde_json::to_value(
&evaluate(&store.config, &store, None, None, None, true)
.unwrap()
.document,
)
.unwrap();
let health = with_health.get("health").expect("health object");
for key in [
"policy_version",
"issues",
"derived",
"diagnostics",
"states",
] {
assert!(health.get(key).is_some(), "health.{key} missing");
}
}
#[test]
fn evaluate_rejects_an_unresolvable_filter() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "One", "todo");
let error = evaluate(&store.config, &store, Some("archived:1"), None, None, false);
assert!(error.is_err(), "an unreadable filter must be refused");
}
#[test]
fn evaluate_propagates_a_health_failure() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "One", "todo");
fs::write(
store.issues_dir.join("alice/.broken.md"),
"---\nnumber: not-a-number\n---\n\nbody\n",
)
.unwrap();
assert!(store.list().is_ok(), "the board never reads archived files");
assert!(
evaluate(&store.config, &store, None, None, None, false).is_ok(),
"the plain board does not depend on the health document"
);
assert!(
evaluate(&store.config, &store, None, None, None, true).is_err(),
"an unavailable health document is an error, not an empty summary"
);
}
}