//! Output rendering for the CLI. //! //! Two formats: //! * **TSV** (default): aligned columns separated by `\t`. Stays //! pipe-friendly — `pic apps ls | awk -F'\t' '{print $1}'` works //! without parsing box-drawing. //! * **JSON**: array of `{column: value, …}` objects (for tables) or //! a flat object (for single-row `show`/`whoami`). Designed to be //! `jq`-friendly without escaping the table column names. //! //! Mode is set globally by the top-level `--output` flag and threaded //! through every command. Single-row commands (`whoami`, `apps show`) //! use `KvBlock`; everything plural uses `Table`. use std::io::{self, Write}; use clap::ValueEnum; use serde_json::{Map, Value}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] #[clap(rename_all = "lowercase")] pub enum OutputMode { #[default] Tsv, Json, } // ---------------------------------------------------------------------------- // Table — list views (`apps ls`, `scripts ls`, `logs`) // ---------------------------------------------------------------------------- pub struct Table { headers: Vec, rows: Vec>, } impl Table { pub fn new(headers: I) -> Self where I: IntoIterator, S: Into, { Self { headers: headers.into_iter().map(Into::into).collect(), rows: Vec::new(), } } pub fn row(&mut self, cells: I) -> &mut Self where I: IntoIterator, S: Into, { self.rows.push(cells.into_iter().map(Into::into).collect()); self } pub fn render_tsv(&self) -> String { let mut widths: Vec = self.headers.iter().map(String::len).collect(); for row in &self.rows { for (i, cell) in row.iter().enumerate() { if i >= widths.len() { widths.push(cell.len()); } else if cell.len() > widths[i] { widths[i] = cell.len(); } } } let mut out = String::new(); write_row(&mut out, &self.headers, &widths); for row in &self.rows { write_row(&mut out, row, &widths); } out } /// JSON form: `[{header: cell, …}, …]`. Cells go in as strings even /// when they happen to look like numbers — the CLI doesn't carry /// type information all the way through (e.g., `version` is already /// `to_string`'d at the call site). Consumers that need typed /// numbers should parse `jq -r '.[].version|tonumber'`. pub fn render_json(&self) -> String { let arr: Vec = self .rows .iter() .map(|row| { let mut obj = Map::new(); for (i, header) in self.headers.iter().enumerate() { let cell = row.get(i).cloned().unwrap_or_default(); obj.insert(header.clone(), Value::String(cell)); } Value::Object(obj) }) .collect(); serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string()) } pub fn print(&self, mode: OutputMode) { let s = match mode { OutputMode::Tsv => self.render_tsv(), OutputMode::Json => { let mut s = self.render_json(); s.push('\n'); s } }; // Best-effort write — broken pipe from `| head` etc. shouldn't // surface as an error. let _ = io::stdout().write_all(s.as_bytes()); } } fn write_row(out: &mut String, row: &[String], widths: &[usize]) { for (i, cell) in row.iter().enumerate() { if i > 0 { out.push('\t'); } out.push_str(cell); // Right-pad with spaces so tabs land on the column grid for // human readers. Skip on the final column. if i + 1 < row.len() { let w = widths.get(i).copied().unwrap_or(cell.len()); for _ in cell.len()..w { out.push(' '); } } } out.push('\n'); } // ---------------------------------------------------------------------------- // KvBlock — single-row views (`whoami`, `apps show`) // ---------------------------------------------------------------------------- /// One row's worth of fields, rendered as aligned `key: value` lines in /// TSV mode (one line per field — easier on the eye than a 1-row table) /// or a flat JSON object. pub struct KvBlock { fields: Vec<(String, String)>, } impl KvBlock { pub fn new() -> Self { Self { fields: Vec::new() } } pub fn field(&mut self, key: impl Into, value: impl Into) -> &mut Self { self.fields.push((key.into(), value.into())); self } pub fn render_tsv(&self) -> String { let key_width = self.fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0); let mut out = String::new(); for (k, v) in &self.fields { out.push_str(k); for _ in k.len()..key_width { out.push(' '); } out.push('\t'); out.push_str(v); out.push('\n'); } out } pub fn render_json(&self) -> String { let mut obj = Map::new(); for (k, v) in &self.fields { obj.insert(k.clone(), Value::String(v.clone())); } serde_json::to_string_pretty(&Value::Object(obj)).unwrap_or_else(|_| "{}".to_string()) } pub fn print(&self, mode: OutputMode) { let s = match mode { OutputMode::Tsv => self.render_tsv(), OutputMode::Json => { let mut s = self.render_json(); s.push('\n'); s } }; let _ = io::stdout().write_all(s.as_bytes()); } } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- pub fn print_error(err: &anyhow::Error) { let mut stderr = io::stderr(); let _ = writeln!(stderr, "error: {err:#}"); } // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn table_aligns_columns_tsv() { let mut t = Table::new(["slug", "name"]); t.row(["a", "Alpha"]).row(["bravo", "B"]); let out = t.render_tsv(); assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n"); } #[test] fn table_empty_rows_tsv() { let t = Table::new(["a", "b"]); assert_eq!(t.render_tsv(), "a\tb\n"); } #[test] fn table_render_json_is_array_of_objects() { let mut t = Table::new(["slug", "name"]); t.row(["a", "Alpha"]).row(["bravo", "B"]); let raw = t.render_json(); let v: Value = serde_json::from_str(&raw).expect("valid JSON"); let arr = v.as_array().expect("array"); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["slug"], "a"); assert_eq!(arr[0]["name"], "Alpha"); assert_eq!(arr[1]["slug"], "bravo"); assert_eq!(arr[1]["name"], "B"); } #[test] fn kv_block_tsv_aligns_keys() { let mut b = KvBlock::new(); b.field("username", "admin").field("role", "owner"); let out = b.render_tsv(); // username (8 chars) defines the key width. assert_eq!(out, "username\tadmin\nrole \towner\n"); } #[test] fn kv_block_json_is_flat_object() { let mut b = KvBlock::new(); b.field("username", "admin").field("role", "owner"); let raw = b.render_json(); let v: Value = serde_json::from_str(&raw).expect("valid JSON"); assert_eq!(v["username"], "admin"); assert_eq!(v["role"], "owner"); } }