Skip to main content

tix/commands/
status.rs

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/// The board summary (FR-009): one `(column, count)` pair per configured
11/// column and the total issue count, computed by the [`board`] core for the
12/// `board_status` MCP tool (WP04) and rendered by the CLI [`run`].
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct BoardSummary {
15    /// Each configured column with its issue count, in config order.
16    pub columns: Vec<(String, usize)>,
17    /// Total number of issues in the store.
18    pub total: usize,
19}
20
21/// One configured column and how many counted issues sit in it.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct ColumnCount {
24    pub column: String,
25    pub count: usize,
26}
27
28/// The JSON board document: `tix status --json` stdout and MCP
29/// `board_status` `structuredContent`, built once here.
30///
31/// The fields are declared in the contracted order rather than assembled through
32/// `json!`: `serde_json` is built without `preserve_order`, so a `json!` map would
33/// print alphabetically (`columns`, `health`, `total`). `health` is skipped
34/// entirely when absent, so a consumer can tell "not asked for" from "empty".
35#[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/// What [`evaluate`] returns: the table the CLI has always printed, and the
44/// document both surfaces expose.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct StatusOutcome {
47    pub text: String,
48    pub document: StatusDocument,
49}
50
51/// `tix status`: the board summary (`contracts/cli.md`, FR-009) — a header
52/// row, one row per configured column with its issue count, and a `Total`
53/// row. Columns separated by exactly two spaces.
54///
55/// With `json`, the same board is printed as one JSON object
56/// ([`StatusDocument`]) instead. The table is untouched by this flag.
57pub 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        // One write of the fully built document: a strict parser sees exactly one
72        // value, and no second print can slip in between.
73        println!("{text}");
74    } else {
75        print!("{}", outcome.text);
76    }
77    Ok(())
78}
79
80/// The one evaluation behind both status surfaces.
81///
82/// `tix status` and MCP's `board_status` must answer with the same counts, so the
83/// whole decision sequence — list, filter, sort, count, optional health — lives
84/// here and each caller only chooses how to render the result. The text branch is
85/// the pre-existing [`render`]; the document is built beside it from the same
86/// [`BoardSummary`], so changing one cannot silently change the other.
87///
88/// A refused filter (or an unavailable health document) is an `Err` returned
89/// **before** either rendering exists, which is what keeps the CLI from printing a
90/// partial object and MCP from reporting a half-built answer.
91pub 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        // `parent:` is a selector and `category:` is derived; neither can be matched
102        // from the file's bytes. Both are resolved here into the pair
103        // `matches_issue` needs (WP08 T035). Going through `query_matches` would
104        // report **unfiltered** counts for a filter the user believes is applied —
105        // a summary that looks like an answer.
106        //
107        // The same two lines appear in `commands::list::query` and
108        // `commands::search::find`. They are repeated rather than shared because
109        // each takes its filter from a different argument and `this` module's
110        // `sort_key`/`group_key` below still read the **outer** `filter` for
111        // `sort:`/`group:`; a helper spanning four call-site-specific shapes is a
112        // helper nobody reads. The inner names are suffixed so shadowing cannot make
113        // the `control_token` calls below silently start reading a rewritten query.
114        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    // Opt-in, and built from the one health document (C-007): the summary counts the
136    // same scopes the TUI and MCP render, so a state cannot be counted twice on one
137    // surface and once on another.
138    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        // The same summary the text renderer just consumed: one health document,
165        // two renderings, so the JSON block and the sentence cannot disagree.
166        health: health_summary.as_ref().map(health_value),
167    };
168    Ok(StatusOutcome { text, document })
169}
170
171/// The `health` object both surfaces publish: MCP's `board_status` has returned
172/// this shape since the health summary existed, and the CLI's `--json` mode is
173/// required to match it field for field.
174fn 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
184/// Render the board summary, as text.
185///
186/// WP04: **one** renderer for both surfaces. `tix status` and MCP's `board_status`
187/// are required to be byte-identical (NFR-005, `surface-parity.md`), and until this
188/// existed they were two hand-maintained copies of the same table — each with its own
189/// `Overdue` calculation, its own group rollup, and its own idea of which column
190/// means done. The parity test caught the divergence the moment the CLI's label
191/// changed to `complete` and MCP's did not, which is exactly what a duplicated
192/// renderer invites.
193///
194/// The parameters are the *decisions* rather than the raw flags, so a caller cannot
195/// present a combination the CLI would not: `show_controls` and `group_key` are
196/// already resolved from `filter`/`sort`/`group` by [`run`].
197pub 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    // WP04 T019: completion through the service (C-004). This used to compare against
208    // `effective_done_column().unwrap_or("done")` — a column *name* resolved in the
209    // command, with a literal `"done"` as the fallback.
210    //
211    // The fallback was the defect, not a convenience. A board that configures no
212    // `done_column` resolved to the literal string `done`, so an issue in a column
213    // actually named `done` counted as complete on a board that had declared nothing,
214    // while the same board's `tix mv` would not have recorded that move as reaching
215    // completion. `is_completed` never errors: an issue in a column the board does not
216    // configure is simply not complete, which is the honest answer for a board that
217    // declared no completion column.
218    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    // WP02 of `configurable-wip-limits-01M3C6WX`: when the board configures limits,
237    // a column row carries its usage beside its count. One shared snapshot for the
238    // whole table, and a board that configures **no** limit renders exactly what it
239    // rendered before this mission — which is what keeps `tix status` and MCP's
240    // `board_status` byte-identical (NFR-005) for every repository that does not opt
241    // in.
242    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        // The project partition's labels are the service's, read once from the shared
267        // index, so `tix status --group project` buckets a project exactly as
268        // `tix list --group project` and the board do (WP09 item D, C-006).
269        let memberships = if group_key == "project" {
270            crate::projects::ProjectIndex::build(config, issues).resolved_memberships()
271        } else {
272            std::collections::HashMap::new()
273        };
274        // The epic partition's labels come from the same shared inverse-membership
275        // helper the board's partition and `tix list --group epic` read, so the three
276        // surfaces cannot bucket an epic two ways (WP09 item C).
277        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            // The service's completion decision, for the same reason as the `Overdue`
311            // row: a group header must mean the same thing as a `--progress` figure
312            // and as `tix list`'s rollup.
313            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            // WP04: the word is `complete`, not `done`. The old label named a column,
320            // and a board whose completion column is `shipped` was reporting `3/4 done`
321            // — describing a column the board does not have.
322            out.push_str(&format!(
323                "{group_key} {group}: {done}/{total} complete ({pct}%)\n"
324            ));
325        }
326    }
327    // The `show_progress` gate lives here rather than in the caller, so the two
328    // surfaces cannot disagree about when the section appears.
329    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                // `complete`, not `done`: the figure already comes from the service
338                // (`Planner::is_done_issue` delegates to `workflow::is_completed`), so
339                // the label was the last thing still naming a column.
340                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    // The health summary last, and only when it was asked for: the board is about
357    // columns, and health is metadata about the work (C-006). The line itself is
358    // [`HealthSummary::render`]'s, so every surface that has a summary prints the
359    // same sentence.
360    if let Some(health) = health {
361        out.push_str(&health.render());
362    }
363    out
364}
365
366/// The status core (WP03): count issues per configured column plus the total.
367///
368/// This is what MCP's `board_status` tool (WP04) calls. Side-effect policy
369/// (C-005): the core returns the [`BoardSummary`] data structure; the CLI
370/// renders it in [`run`] with the exact layout as before.
371pub fn board(config: &Config, store: &Store) -> Result<BoardSummary> {
372    let issues = store.list()?;
373    Ok(board_for_issues(config, &issues))
374}
375
376/// The summary for a given issue set.
377///
378/// WP04: `pub` because MCP's `board_status` used to keep its own copy of this
379/// counting, alongside its own copy of the *rendering*. Two copies of the counting
380/// and two of the rendering is four places for one table to drift; the parity test
381/// caught the rendering pair the moment the CLI's label changed.
382pub 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        // `archived:` takes a boolean; `1` is refused before anything is counted.
605        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        // `Store::list` — the board's view — skips archived files from the directory
615        // entry, before opening them. The health document reads **every** issue, so a
616        // broken archived file is invisible to the table and fatal to the health
617        // summary: that isolates the health branch instead of merely failing the read
618        // the board already performs.
619        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}