1use crate::config::Config;
2use crate::error::Result;
3use crate::model::Issue;
4use crate::planning::Planner;
5use crate::search::control_token;
6use crate::storage::Store;
7use serde::Serialize;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct BoardSummary {
15 pub columns: Vec<(String, usize)>,
17 pub total: usize,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct ColumnCount {
24 pub column: String,
25 pub count: usize,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36pub struct StatusDocument {
37 pub columns: Vec<ColumnCount>,
38 pub total: usize,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub health: Option<serde_json::Value>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct StatusOutcome {
47 pub text: String,
48 pub document: StatusDocument,
49}
50
51pub fn run(
58 config: &Config,
59 store: &Store,
60 filter: Option<&str>,
61 sort: Option<&str>,
62 group: Option<&str>,
63 health: bool,
64 json: bool,
65) -> Result<()> {
66 let outcome = evaluate(config, store, filter, sort, group, health)?;
67 if json {
68 let text = serde_json::to_string_pretty(&outcome.document).map_err(|error| {
69 crate::error::TixError::Parse(format!("board document cannot be serialized: {error}"))
70 })?;
71 println!("{text}");
74 } else {
75 print!("{}", outcome.text);
76 }
77 Ok(())
78}
79
80pub fn evaluate(
92 config: &Config,
93 store: &Store,
94 filter: Option<&str>,
95 sort: Option<&str>,
96 group: Option<&str>,
97 health: bool,
98) -> Result<StatusOutcome> {
99 let mut issues = store.list()?;
100 if let Some(filter) = filter {
101 let (resolved, criteria) = crate::search::resolve_filter_criteria(store, filter)?;
115 let (_, resolution) = crate::workflow::active_resolution(config);
116 issues.retain(|i| {
117 crate::search::matches_issue(
118 &resolved,
119 &criteria,
120 i,
121 &i.to_file_contents(),
122 &resolution,
123 )
124 });
125 }
126 let sort_key = sort
127 .map(str::to_string)
128 .or_else(|| filter.and_then(|q| control_token(q, "sort")));
129 sort_issues(&mut issues, sort_key.as_deref());
130 let summary = board_for_issues(config, &issues);
131 let group_key = group
132 .map(str::to_string)
133 .or_else(|| filter.and_then(|q| control_token(q, "group")));
134 let show_controls = filter.is_some() || sort_key.is_some() || group_key.is_some();
135 let health_summary = if health {
139 Some(crate::commands::health::HealthSummary::of(
140 &crate::commands::export::run_health_core(config)?,
141 ))
142 } else {
143 None
144 };
145 let text = render(
146 config,
147 store,
148 &issues,
149 &summary,
150 show_controls,
151 group_key.as_deref(),
152 health_summary.as_ref(),
153 );
154 let document = StatusDocument {
155 columns: summary
156 .columns
157 .iter()
158 .map(|(column, count)| ColumnCount {
159 column: column.clone(),
160 count: *count,
161 })
162 .collect(),
163 total: summary.total,
164 health: health_summary.as_ref().map(health_value),
167 };
168 Ok(StatusOutcome { text, document })
169}
170
171fn health_value(summary: &crate::commands::health::HealthSummary) -> serde_json::Value {
175 serde_json::json!({
176 "policy_version": summary.policy_version,
177 "issues": summary.issues,
178 "derived": summary.derived,
179 "diagnostics": summary.diagnostics,
180 "states": summary.by_state,
181 })
182}
183
184pub fn render(
198 config: &Config,
199 store: &Store,
200 issues: &[Issue],
201 summary: &BoardSummary,
202 show_controls: bool,
203 group_key: Option<&str>,
204 health: Option<&crate::commands::health::HealthSummary>,
205) -> String {
206 let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
207 let (_, resolution) = crate::workflow::active_resolution(config);
219
220 let column_width = config
221 .default_config()
222 .columns
223 .iter()
224 .map(|c| c.chars().count())
225 .chain([6, 5])
226 .max()
227 .unwrap()
228 .min(30);
229 let count_width = summary.total.to_string().len().max(5).max(1);
230 let mut out = String::new();
231 let mut row = |column: String, count: String| {
232 out.push_str(&format!("{column:<column_width$} {count:<count_width$}\n"));
233 };
234
235 row("COLUMN".into(), "COUNT".into());
236 let wip = crate::wip::WipSnapshot::build(config.default_config(), issues);
243 for (column, count) in &summary.columns {
244 match wip.column(column).filter(|usage| usage.limit.is_some()) {
245 Some(usage) => row(column.clone(), format!("{count} {}", usage.cell())),
246 None => row(column.clone(), count.to_string()),
247 }
248 }
249 row("Total".into(), summary.total.to_string());
250 if show_controls {
251 let today = crate::model::today();
252 let overdue = issues
253 .iter()
254 .filter(|i| {
255 i.due_date
256 .as_deref()
257 .is_some_and(|due| due < today.as_str())
258 && !crate::workflow::is_completed(i, &resolution)
259 })
260 .count();
261 row("Overdue".into(), overdue.to_string());
262 }
263 if let Some(group_key) =
264 group_key.filter(|g| *g == "cycle" || *g == "milestone" || *g == "project" || *g == "epic")
265 {
266 let memberships = if group_key == "project" {
270 crate::projects::ProjectIndex::build(config, issues).resolved_memberships()
271 } else {
272 std::collections::HashMap::new()
273 };
274 let epic_memberships = if group_key == "epic" {
278 crate::epics::memberships_by_issue(config, issues)
279 } else {
280 std::collections::HashMap::new()
281 };
282 let mut groups: BTreeMap<String, (usize, usize)> = BTreeMap::new();
283 for issue in issues {
284 let group = match group_key {
285 "cycle" => issue
286 .cycles
287 .as_ref()
288 .and_then(|v| v.first().cloned())
289 .unwrap_or_else(|| "(none)".to_string()),
290 "milestone" => issue
291 .milestones
292 .as_ref()
293 .and_then(|v| v.first().cloned())
294 .unwrap_or_else(|| "(none)".to_string()),
295 "project" => {
296 let ids = memberships.get(&issue.number).cloned().unwrap_or_default();
297 crate::projects::project_bucket_label(&ids)
298 }
299 "epic" => {
300 let ids = epic_memberships
301 .get(&issue.number)
302 .cloned()
303 .unwrap_or_default();
304 crate::search::epic_bucket_label(&ids)
305 }
306 _ => "(none)".to_string(),
307 };
308 let entry = groups.entry(group).or_insert((0, 0));
309 entry.1 += 1;
310 if crate::workflow::is_completed(issue, &resolution) {
314 entry.0 += 1;
315 }
316 }
317 for (group, (done, total)) in groups {
318 let pct = (done * 100).checked_div(total).unwrap_or(0);
319 out.push_str(&format!(
323 "{group_key} {group}: {done}/{total} complete ({pct}%)\n"
324 ));
325 }
326 }
327 let show_progress = issues.iter().any(|i| {
330 i.cycles.as_ref().is_some_and(|v| !v.is_empty())
331 || i.milestones.as_ref().is_some_and(|v| !v.is_empty())
332 });
333 if show_progress {
334 for cycle in planner.cycle_names() {
335 let progress = planner.cycle_progress(store, &cycle);
336 if progress.total > 0 {
337 out.push_str(&format!(
341 "cycle {cycle}: {}/{} complete\n",
342 progress.done, progress.total
343 ));
344 }
345 }
346 for milestone in planner.milestone_names().unwrap_or_default() {
347 let progress = planner.milestone_progress(store, &milestone);
348 if progress.total > 0 {
349 out.push_str(&format!(
350 "milestone {milestone}: {}/{} complete\n",
351 progress.done, progress.total
352 ));
353 }
354 }
355 }
356 if let Some(health) = health {
361 out.push_str(&health.render());
362 }
363 out
364}
365
366pub fn board(config: &Config, store: &Store) -> Result<BoardSummary> {
372 let issues = store.list()?;
373 Ok(board_for_issues(config, &issues))
374}
375
376pub fn board_for_issues(config: &Config, issues: &[Issue]) -> BoardSummary {
383 let total = issues.len();
384 let columns = config
385 .default_config()
386 .columns
387 .iter()
388 .map(|column| {
389 let count = issues.iter().filter(|i| i.status.is(column)).count();
390 (column.clone(), count)
391 })
392 .collect();
393 BoardSummary { columns, total }
394}
395
396fn sort_issues(issues: &mut [Issue], sort: Option<&str>) {
397 match sort.unwrap_or("created") {
398 "due" => issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number))),
399 "start" => issues.sort_by(|a, b| {
400 a.start_date
401 .cmp(&b.start_date)
402 .then(a.number.cmp(&b.number))
403 }),
404 "reviewer" => {
405 issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
406 }
407 "cycle" => issues.sort_by(|a, b| {
408 a.cycles
409 .as_ref()
410 .and_then(|v| v.first())
411 .cmp(&b.cycles.as_ref().and_then(|v| v.first()))
412 .then(a.number.cmp(&b.number))
413 }),
414 "milestone" => issues.sort_by(|a, b| {
415 a.milestones
416 .as_ref()
417 .and_then(|v| v.first())
418 .cmp(&b.milestones.as_ref().and_then(|v| v.first()))
419 .then(a.number.cmp(&b.number))
420 }),
421 "status" => issues.sort_by(|a, b| {
422 a.status
423 .display()
424 .cmp(b.status.display())
425 .then(a.number.cmp(&b.number))
426 }),
427 "priority" => issues.sort_by(|a, b| {
428 a.priority
429 .to_string()
430 .cmp(&b.priority.to_string())
431 .then(a.number.cmp(&b.number))
432 }),
433 _ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 #[allow(clippy::wildcard_imports)]
440 use super::*;
441 use crate::model::{Issue, Priority, Status};
442 use std::fs;
443 use tempfile::TempDir;
444
445 fn make_store() -> (TempDir, Store) {
446 let tmp = TempDir::new().unwrap();
447 let dir = tmp.path().join("issues");
448 fs::create_dir_all(&dir).unwrap();
449 let config_path = tmp.path().join("tix.toml");
450 let config = crate::config::Config {
451 issues_dir: dir.clone(),
452 ..crate::config::Config::default()
453 };
454 config.save(&config_path).unwrap();
455 let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
456 (
457 tmp,
458 Store {
459 issues_dir: dir,
460 config,
461 },
462 )
463 }
464
465 fn seed(store: &Store, number: u32, title: &str, status: &str) {
466 let mut issue = Issue {
467 projects: None,
468 id: None,
469 number,
470 title: title.to_string(),
471 status: Status::new(status.to_string()),
472 labels: vec![],
473 priority: Priority::default(),
474 assignee: Some("alice".to_string()),
475 estimate: None,
476 commit: None,
477 gitlab_id: None,
478 linear_id: None,
479 mission: None,
480 task: None,
481 relationships: vec![],
482 related_issues: vec![],
483 files: vec![],
484 cycles: None,
485 milestones: None,
486 reviewer: None,
487 start_date: None,
488 due_date: None,
489 created: "2026-01-01".to_string(),
490 type_: None,
491 updated: "2026-01-01".to_string(),
492 filename: String::new(),
493 body: "body".to_string(),
494 history: crate::model::History::new(),
495 has_retired_links_key: false,
496 time_entries: vec![],
497 parent: None,
498 series: None,
499 health: None,
500 epics: None,
501 extra_frontmatter: std::collections::BTreeMap::new(),
502 archived: false,
503 entered_at: None,
504 source: None,
505 };
506 issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
507 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
508 fs::write(
509 store.issues_dir.join(&issue.filename),
510 issue.to_file_contents(),
511 )
512 .unwrap();
513 }
514
515 #[test]
516 fn board_counts_columns_and_total() {
517 let (tmp, store) = make_store();
518 let _ = tmp;
519 seed(&store, 1, "One", "todo");
520 seed(&store, 2, "Two", "todo");
521 seed(&store, 3, "Three", "in progress");
522 seed(&store, 4, "Four", "done");
523 let summary = board(&store.config, &store).unwrap();
524 assert_eq!(
525 summary
526 .columns
527 .iter()
528 .map(|(c, n)| (c.as_str(), *n))
529 .collect::<Vec<_>>(),
530 vec![
531 ("todo", 2),
532 ("in progress", 1),
533 ("in review", 0),
534 ("done", 1),
535 ],
536 "columns in config order with per-column counts"
537 );
538 assert_eq!(summary.total, 4);
539 }
540
541 #[test]
542 fn document_columns_are_board_order_with_zeros() {
543 let (tmp, store) = make_store();
544 let _ = tmp;
545 seed(&store, 1, "One", "todo");
546 seed(&store, 2, "Two", "in progress");
547 let outcome = evaluate(&store.config, &store, None, None, None, false).unwrap();
548 assert_eq!(
549 outcome
550 .document
551 .columns
552 .iter()
553 .map(|c| (c.column.as_str(), c.count))
554 .collect::<Vec<_>>(),
555 vec![
556 ("todo", 1),
557 ("in progress", 1),
558 ("in review", 0),
559 ("done", 0),
560 ],
561 "every configured column, in board order, zeros included"
562 );
563 assert_eq!(outcome.document.total, 2);
564 }
565
566 #[test]
567 fn document_omits_health_key_unless_requested() {
568 let (tmp, store) = make_store();
569 let _ = tmp;
570 seed(&store, 1, "One", "todo");
571 let plain = serde_json::to_value(
572 &evaluate(&store.config, &store, None, None, None, false)
573 .unwrap()
574 .document,
575 )
576 .unwrap();
577 assert!(
578 plain.get("health").is_none(),
579 "health is absent, not null, when it was not requested"
580 );
581 let with_health = serde_json::to_value(
582 &evaluate(&store.config, &store, None, None, None, true)
583 .unwrap()
584 .document,
585 )
586 .unwrap();
587 let health = with_health.get("health").expect("health object");
588 for key in [
589 "policy_version",
590 "issues",
591 "derived",
592 "diagnostics",
593 "states",
594 ] {
595 assert!(health.get(key).is_some(), "health.{key} missing");
596 }
597 }
598
599 #[test]
600 fn evaluate_rejects_an_unresolvable_filter() {
601 let (tmp, store) = make_store();
602 let _ = tmp;
603 seed(&store, 1, "One", "todo");
604 let error = evaluate(&store.config, &store, Some("archived:1"), None, None, false);
606 assert!(error.is_err(), "an unreadable filter must be refused");
607 }
608
609 #[test]
610 fn evaluate_propagates_a_health_failure() {
611 let (tmp, store) = make_store();
612 let _ = tmp;
613 seed(&store, 1, "One", "todo");
614 fs::write(
620 store.issues_dir.join("alice/.broken.md"),
621 "---\nnumber: not-a-number\n---\n\nbody\n",
622 )
623 .unwrap();
624 assert!(store.list().is_ok(), "the board never reads archived files");
625 assert!(
626 evaluate(&store.config, &store, None, None, None, false).is_ok(),
627 "the plain board does not depend on the health document"
628 );
629 assert!(
630 evaluate(&store.config, &store, None, None, None, true).is_err(),
631 "an unavailable health document is an error, not an empty summary"
632 );
633 }
634}