pub mod envelope;
pub mod table;
pub use envelope::{iso8601_ms, Envelope, Period};
pub use table::{format_count, format_money, Cell, Style, Table, UNSUPPORTED};
use std::io::{self, Write};
use crate::cli::TimeWindow;
#[derive(Debug, Clone)]
pub struct Report {
pub name: String,
pub window: TimeWindow,
pub table: Table,
pub json_rows: Vec<serde_json::Value>,
pub notes: Vec<String>,
pub text: Option<String>,
}
impl Report {
pub fn new(name: impl Into<String>, window: TimeWindow, table: Table) -> Self {
Self {
name: name.into(),
window,
table,
json_rows: Vec::new(),
notes: Vec::new(),
text: None,
}
}
pub fn prose(name: impl Into<String>, window: TimeWindow, text: String) -> Self {
Self {
text: Some(text),
..Self::new(name, window, Table::new(Vec::<String>::new()))
}
}
pub fn with_json_rows(mut self, rows: Vec<serde_json::Value>) -> Self {
self.json_rows = rows;
self
}
pub fn with_notes<S: Into<String>>(mut self, notes: impl IntoIterator<Item = S>) -> Self {
self.notes = notes.into_iter().map(Into::into).collect();
self
}
pub fn envelope(&self) -> Envelope {
Envelope::new(self.name.clone(), self.window, self.json_rows.clone())
.with_notes(self.notes.clone())
}
}
pub fn emit(report: &Report, json: bool) -> io::Result<()> {
let stdout = io::stdout();
let style = if json { Style::plain() } else { Style::auto() };
let mut lock = stdout.lock();
write_report(&mut lock, report, json, style)?;
lock.flush()
}
pub fn write_report<W: Write>(
out: &mut W,
report: &Report,
json: bool,
style: Style,
) -> io::Result<()> {
if json {
let body = serde_json::to_string_pretty(&report.envelope())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
writeln!(out, "{body}")
} else if let Some(text) = &report.text {
write!(out, "{text}")
} else {
write!(out, "{}", report.table.render(style))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn report() -> Report {
let table = Table::new(["project", "sessions", "est. cost"]).with_row(vec![
Cell::text("acme-api"),
Cell::Int(41),
Cell::money_est(12.40),
]);
Report::new("projects", TimeWindow::new(0, 86_400_000), table)
.with_json_rows(vec![
serde_json::json!({"project": "acme-api", "cost_est": 12.4}),
])
.with_notes(["cost figures are estimates"])
}
fn render(json: bool) -> String {
let mut buf = Vec::new();
write_report(&mut buf, &report(), json, Style::plain()).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn json_mode_emits_the_envelope_and_no_table() {
let out = render(true);
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["report"], "projects");
assert_eq!(v["rows"][0]["project"], "acme-api");
assert!(!out.contains("PROJECT"));
assert!(!out.contains('\x1b'));
}
#[test]
fn a_prose_report_prints_its_text_but_still_emits_the_envelope() {
let report = Report::prose(
"ingest",
TimeWindow::all(),
"claude-code 1 files\n".into(),
)
.with_json_rows(vec![serde_json::json!({"adapter": "claude-code"})]);
let mut human = Vec::new();
write_report(&mut human, &report, false, Style::plain()).unwrap();
assert_eq!(String::from_utf8(human).unwrap(), "claude-code 1 files\n");
let mut json = Vec::new();
write_report(&mut json, &report, true, Style::plain()).unwrap();
let out = String::from_utf8(json).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["report"], "ingest");
assert_eq!(v["rows"][0]["adapter"], "claude-code");
assert!(!out.contains("claude-code 1 files"), "no prose in --json");
}
#[test]
fn table_mode_emits_the_table_and_no_json() {
let out = render(false);
assert!(out.starts_with("PROJECT"));
assert!(out.contains("$12.40 ~"));
assert!(out.trim_end().ends_with("~ estimated"));
assert!(!out.contains("warden_version"));
}
}