crates/cli/src/output.rs 4.4 KiB 151 lines Raw
1 #![forbid(unsafe_code)]2 #![allow(dead_code)]3 //! Output: compact JSON, --jq passthrough, thin human view.4 //! JSON is serde_json::to_string (no spaces). Empty is [] or {}.5 //! No ANSI color when stdout is not a tty.6 7 use serde::Serialize;8 use std::io::IsTerminal;9 use std::process::{Command, Stdio};10 11 /// Safe stdout write: BrokenPipe is clean end-of-output (exit 0, no panic/trace).12 /// All root-path prints must go through here. Errors stay on stderr.13 pub fn out(s: &str) {14     use std::io::Write;15     let r = (|| -> std::io::Result<()> {16         let mut o = std::io::stdout().lock();17         o.write_all(s.as_bytes())?;18         o.flush()?;19         Ok(())20     })();21     if let Err(e) = r {22         if e.kind() == std::io::ErrorKind::BrokenPipe {23             std::process::exit(0);24         }25         // Other stdout errors: no panic, no trace.26     }27 }28 29 /// Compact one-line JSON.30 pub fn json_line<T: Serialize>(v: &T) -> String {31     serde_json::to_string(v).unwrap_or_else(|_| "{}".to_string())32 }33 34 /// Print pre-rendered compact JSON, applying --jq when given.35 pub fn emit_str(json: &str, jq: Option<&str>) {36     match jq {37         Some(expr) => match run_jq(json, expr) {38             Ok(s) => out(&s),39             Err(note) => {40                 eprintln!("{note}");41                 out(&format!("{json}\n"));42             }43         },44         None => out(&format!("{json}\n")),45     }46 }47 48 /// Print a value as compact JSON.49 pub fn emit_json<T: Serialize>(v: &T, jq: Option<&str>) {50     emit_str(&json_line(v), jq);51 }52 53 /// Bulk pre-rendered lines: one write when raw (fast path for big lists),54 /// per-line passthrough when --jq is set (jq consumes one doc per call).55 pub fn emit_bulk(lines: &str, jq: Option<&str>) {56     match jq {57         Some(_) => {58             for line in lines.lines() {59                 emit_str(line, jq);60             }61         }62         None => out(lines),63     }64 }65 66 /// Empty results: no prose.67 pub fn emit_empty_array(jq: Option<&str>) {68     emit_str("[]", jq);69 }70 71 /// Empty object result.72 pub fn emit_empty_object(jq: Option<&str>) {73     emit_str("{}", jq);74 }75 76 /// M8 one-hop migration note: one line on stderr naming the file, keep77 /// going. None (current/absent schema) prints nothing.78 pub fn warn_schema(note: &Option<String>) {79     if let Some(n) = note {80         eprintln!("warning: {n}");81     }82 }83 84 /// Print plain text (help, skill), applying --jq only when it looks85 /// like JSON; else ignore jq (skill text is not JSON).86 pub fn emit_text(text: &str, jq: Option<&str>) {87     let t = text.trim_end_matches('\n');88     if jq.is_some() && (t.starts_with('{') || t.starts_with('[')) {89         emit_str(t, jq);90     } else {91         out(text);92         if !text.ends_with('\n') {93             out("\n");94         }95     }96 }97 98 /// Shell out to system jq via argv array (SEC-04). Missing jq ->99 /// one-line note, never a hand-rolled fallback parser.100 pub fn run_jq(json: &str, expr: &str) -> Result<String, String> {101     let mut child = Command::new("jq")102         .arg(expr)103         .stdin(Stdio::piped())104         .stdout(Stdio::piped())105         .stderr(Stdio::null())106         .spawn()107         .map_err(|_| "jq not found; showing raw json".to_string())?;108     use std::io::Write;109     if let Some(mut stdin) = child.stdin.take() {110         let _ = stdin.write_all(json.as_bytes());111     }112     let out = child.wait_with_output().map_err(|_| "jq failed; showing raw json".to_string())?;113     if !out.status.success() {114         return Err("jq failed; showing raw json".to_string());115     }116     String::from_utf8(out.stdout).map_err(|_| "jq failed; showing raw json".to_string())117 }118 119 /// True when stdout is a tty (color allowed; default off for agents).120 pub fn is_tty() -> bool {121     std::io::stdout().is_terminal()122 }123 124 /// Thin aligned human view of rows. No ANSI when not a tty.125 pub fn align(rows: &[Vec<String>]) -> String {126     if rows.is_empty() {127         return String::new();128     }129     let cols = rows.iter().map(Vec::len).max().unwrap_or(0);130     let mut width = vec![0usize; cols];131     for r in rows {132         for (i, c) in r.iter().enumerate() {133             width[i] = width[i].max(c.len());134         }135     }136     rows.iter()137         .map(|r| {138             r.iter()139                 .enumerate()140                 .map(|(i, c)| {141                     if i + 1 < r.len() {142                         format!("{:<w$}  ", c, w = width[i])143                     } else {144                         c.clone()145                     }146                 })147                 .collect::<String>()148         })149         .collect::<Vec<_>>()150         .join("\n")151 }