Skip to main content

warden/output/
mod.rs

1//! The shared presentation layer: one table renderer, one JSON envelope.
2//!
3//! Every report goes out through [`emit`], so no command branches on `--json`
4//! itself and the two surfaces cannot drift apart. A report's job is to build a
5//! [`Report`]; how it reaches a terminal or a harness is this module's job.
6
7pub mod envelope;
8pub mod table;
9
10pub use envelope::{iso8601_ms, Envelope, Period};
11pub use table::{format_count, format_money, Cell, Style, Table, UNSUPPORTED};
12
13use std::io::{self, Write};
14
15use crate::cli::TimeWindow;
16
17/// One report, rendered either way.
18///
19/// `rows` (the table) and `json_rows` (the envelope) are kept separate on
20/// purpose: the table is formatted for a human, the JSON stays raw so a harness
21/// can do its own arithmetic on it.
22#[derive(Debug, Clone)]
23pub struct Report {
24    /// The stable report name, e.g. `"projects"`.
25    pub name: String,
26    pub window: TimeWindow,
27    pub table: Table,
28    pub json_rows: Vec<serde_json::Value>,
29    pub notes: Vec<String>,
30    /// Pre-rendered human form, for commands whose output is prose rather than
31    /// a table (`ingest`, `doctor`). When set it replaces the table in non-JSON
32    /// mode; the envelope is unaffected either way.
33    pub text: Option<String>,
34}
35
36impl Report {
37    pub fn new(name: impl Into<String>, window: TimeWindow, table: Table) -> Self {
38        Self {
39            name: name.into(),
40            window,
41            table,
42            json_rows: Vec::new(),
43            notes: Vec::new(),
44            text: None,
45        }
46    }
47
48    /// A report whose human form is prose. It still goes out through [`emit`],
49    /// so `--json` remains a single parseable document with no table alongside.
50    pub fn prose(name: impl Into<String>, window: TimeWindow, text: String) -> Self {
51        Self {
52            text: Some(text),
53            ..Self::new(name, window, Table::new(Vec::<String>::new()))
54        }
55    }
56
57    pub fn with_json_rows(mut self, rows: Vec<serde_json::Value>) -> Self {
58        self.json_rows = rows;
59        self
60    }
61
62    pub fn with_notes<S: Into<String>>(mut self, notes: impl IntoIterator<Item = S>) -> Self {
63        self.notes = notes.into_iter().map(Into::into).collect();
64        self
65    }
66
67    /// The `--json` form, without printing it.
68    pub fn envelope(&self) -> Envelope {
69        Envelope::new(self.name.clone(), self.window, self.json_rows.clone())
70            .with_notes(self.notes.clone())
71    }
72}
73
74/// Print a report to stdout: the envelope when `json`, otherwise the table.
75///
76/// ANSI escapes are only ever emitted for the table on a TTY.
77pub fn emit(report: &Report, json: bool) -> io::Result<()> {
78    let stdout = io::stdout();
79    let style = if json { Style::plain() } else { Style::auto() };
80    let mut lock = stdout.lock();
81    write_report(&mut lock, report, json, style)?;
82    lock.flush()
83}
84
85/// [`emit`] against an arbitrary writer, for tests and for callers that capture
86/// output. Never emits escape codes unless `style` allows them.
87pub fn write_report<W: Write>(
88    out: &mut W,
89    report: &Report,
90    json: bool,
91    style: Style,
92) -> io::Result<()> {
93    if json {
94        let body = serde_json::to_string_pretty(&report.envelope())
95            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
96        writeln!(out, "{body}")
97    } else if let Some(text) = &report.text {
98        write!(out, "{text}")
99    } else {
100        write!(out, "{}", report.table.render(style))
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    fn report() -> Report {
109        let table = Table::new(["project", "sessions", "est. cost"]).with_row(vec![
110            Cell::text("acme-api"),
111            Cell::Int(41),
112            Cell::money_est(12.40),
113        ]);
114        Report::new("projects", TimeWindow::new(0, 86_400_000), table)
115            .with_json_rows(vec![
116                serde_json::json!({"project": "acme-api", "cost_est": 12.4}),
117            ])
118            .with_notes(["cost figures are estimates"])
119    }
120
121    fn render(json: bool) -> String {
122        let mut buf = Vec::new();
123        write_report(&mut buf, &report(), json, Style::plain()).unwrap();
124        String::from_utf8(buf).unwrap()
125    }
126
127    #[test]
128    fn json_mode_emits_the_envelope_and_no_table() {
129        let out = render(true);
130        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
131        assert_eq!(v["report"], "projects");
132        assert_eq!(v["rows"][0]["project"], "acme-api");
133        assert!(!out.contains("PROJECT"));
134        assert!(!out.contains('\x1b'));
135    }
136
137    #[test]
138    fn a_prose_report_prints_its_text_but_still_emits_the_envelope() {
139        let report = Report::prose(
140            "ingest",
141            TimeWindow::all(),
142            "claude-code   1 files\n".into(),
143        )
144        .with_json_rows(vec![serde_json::json!({"adapter": "claude-code"})]);
145
146        let mut human = Vec::new();
147        write_report(&mut human, &report, false, Style::plain()).unwrap();
148        assert_eq!(String::from_utf8(human).unwrap(), "claude-code   1 files\n");
149
150        let mut json = Vec::new();
151        write_report(&mut json, &report, true, Style::plain()).unwrap();
152        let out = String::from_utf8(json).unwrap();
153        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
154        assert_eq!(v["report"], "ingest");
155        assert_eq!(v["rows"][0]["adapter"], "claude-code");
156        assert!(!out.contains("claude-code   1 files"), "no prose in --json");
157    }
158
159    #[test]
160    fn table_mode_emits_the_table_and_no_json() {
161        let out = render(false);
162        assert!(out.starts_with("PROJECT"));
163        assert!(out.contains("$12.40 ~"));
164        assert!(out.trim_end().ends_with("~ estimated"));
165        assert!(!out.contains("warden_version"));
166    }
167}