1pub 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#[derive(Debug, Clone)]
23pub struct Report {
24 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 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 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 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
74pub 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
85pub 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}