Skip to main content

skiff_cli/
output.rs

1//! stdout is data only; diagnostics go to stderr.
2//!
3//! Supports JSON, raw, pretty, head, native TOON, max-bytes spill-to-spool,
4//! and light TTY sanitization for human (non-structured) paths.
5
6use std::io::{self, IsTerminal, Write};
7
8use serde_json::Value;
9
10use crate::coerce::apply_head;
11use crate::spool::{self, pointer_json, write_spool};
12
13#[derive(Debug, Clone, Default)]
14pub struct OutputOptions {
15    pub pretty: bool,
16    pub raw: bool,
17    pub toon: bool,
18    pub head: Option<usize>,
19    pub json_output: bool,
20    /// When set, spill rendered output larger than this to spool (None / Some(0) = never).
21    pub max_bytes: Option<usize>,
22    /// Force full inline output even if over max_bytes.
23    pub inline: bool,
24}
25
26/// Strip common ANSI CSI / OSC sequences for human TTY display.
27pub fn strip_ansi(s: &str) -> String {
28    let mut out = String::with_capacity(s.len());
29    let mut chars = s.chars().peekable();
30    while let Some(c) = chars.next() {
31        if c == '\u{1b}' {
32            match chars.peek() {
33                Some('[') => {
34                    chars.next();
35                    for c2 in chars.by_ref() {
36                        if c2.is_ascii_uppercase() || c2.is_ascii_lowercase() {
37                            break;
38                        }
39                    }
40                }
41                Some(']') => {
42                    chars.next();
43                    for c2 in chars.by_ref() {
44                        if c2 == '\u{7}' || c2 == '\u{1b}' {
45                            break;
46                        }
47                    }
48                }
49                Some(_) => {
50                    chars.next();
51                }
52                None => {}
53            }
54        } else if c == '\0' {
55            // drop NULs
56        } else {
57            out.push(c);
58        }
59    }
60    out
61}
62
63fn emit_json(data: &Value, pretty: bool) -> io::Result<()> {
64    let stdout = io::stdout();
65    let mut out = stdout.lock();
66    if pretty || io::stdout().is_terminal() {
67        serde_json::to_writer_pretty(&mut out, data)?;
68        writeln!(out)?;
69    } else {
70        serde_json::to_writer(&mut out, data)?;
71        writeln!(out)?;
72    }
73    Ok(())
74}
75
76fn render_json_bytes(data: &Value, pretty: bool) -> io::Result<Vec<u8>> {
77    if pretty {
78        let mut v = serde_json::to_vec_pretty(data)?;
79        v.push(b'\n');
80        Ok(v)
81    } else {
82        let mut v = serde_json::to_vec(data)?;
83        v.push(b'\n');
84        Ok(v)
85    }
86}
87
88fn encode_toon(data: &Value) -> Result<String, String> {
89    toon_format::encode(data, &toon_format::EncodeOptions::default()).map_err(|e| e.to_string())
90}
91
92fn should_spill(opts: &OutputOptions, len: usize) -> bool {
93    if opts.inline {
94        return false;
95    }
96    match opts.max_bytes {
97        Some(0) | None => false,
98        Some(n) => len > n,
99    }
100}
101
102fn spill_and_print_pointer(bytes: &[u8], kind: &str, preview: &str) -> io::Result<()> {
103    spool::maybe_clean_expired();
104    let path = write_spool(bytes, kind).map_err(io::Error::other)?;
105    eprintln!(
106        "skiff: output {} bytes exceeded limit; spooled to {}",
107        bytes.len(),
108        path.display()
109    );
110    let ptr = pointer_json(&path, bytes, preview);
111    // Pointer is always compact JSON for reliable agent parsing.
112    let stdout = io::stdout();
113    let mut out = stdout.lock();
114    serde_json::to_writer(&mut out, &ptr)?;
115    writeln!(out)?;
116    Ok(())
117}
118
119/// Print result respecting output flags (JSON / TOON / raw / head / spool).
120pub fn output_result(data: Value, opts: &OutputOptions) -> io::Result<()> {
121    let mut data = data;
122
123    if let Value::String(s) = &data {
124        if opts.json_output || opts.toon {
125            if let Ok(parsed) = serde_json::from_str::<Value>(s) {
126                data = parsed;
127            }
128        } else if !opts.raw {
129            match serde_json::from_str::<Value>(s) {
130                Ok(parsed) => data = parsed,
131                Err(_) => {
132                    let text = if io::stdout().is_terminal() {
133                        strip_ansi(s)
134                    } else {
135                        s.clone()
136                    };
137                    if should_spill(opts, text.len()) {
138                        return spill_and_print_pointer(text.as_bytes(), "txt", &text);
139                    }
140                    println!("{text}");
141                    return Ok(());
142                }
143            }
144        }
145    }
146
147    if opts.raw {
148        match data {
149            Value::String(s) => {
150                if should_spill(opts, s.len()) {
151                    return spill_and_print_pointer(s.as_bytes(), "txt", &s);
152                }
153                println!("{s}");
154                return Ok(());
155            }
156            other => {
157                let s = serde_json::to_string(&other)?;
158                if should_spill(opts, s.len()) {
159                    return spill_and_print_pointer(s.as_bytes(), "json", &s);
160                }
161                println!("{s}");
162                return Ok(());
163            }
164        }
165    }
166
167    if let Some(n) = opts.head {
168        data = apply_head(data, n);
169    }
170
171    if opts.toon {
172        match encode_toon(&data) {
173            Ok(toon) => {
174                let mut bytes = toon.into_bytes();
175                bytes.push(b'\n');
176                if should_spill(opts, bytes.len()) {
177                    let preview = String::from_utf8_lossy(&bytes).into_owned();
178                    return spill_and_print_pointer(&bytes, "toon", &preview);
179                }
180                let stdout = io::stdout();
181                let mut out = stdout.lock();
182                out.write_all(&bytes)?;
183                return Ok(());
184            }
185            Err(e) => {
186                eprintln!("Warning: --toon encode failed ({e}); falling back to JSON");
187                // fall through to JSON
188            }
189        }
190    }
191
192    // Structured JSON path (explicit --json, or default non-raw after parse)
193    if opts.json_output || opts.toon || !matches!(&data, Value::String(_)) {
194        let pretty = opts.pretty;
195        // Agents / pipes: compact unless --pretty or (TTY and not forced json for machines)
196        let use_pretty = pretty || (io::stdout().is_terminal() && !opts.json_output && !opts.toon);
197        let bytes = render_json_bytes(&data, use_pretty)?;
198        if should_spill(opts, bytes.len()) {
199            let preview = String::from_utf8_lossy(&bytes).into_owned();
200            return spill_and_print_pointer(&bytes, "json", &preview);
201        }
202        let stdout = io::stdout();
203        let mut out = stdout.lock();
204        out.write_all(&bytes)?;
205        return Ok(());
206    }
207
208    emit_json(&data, opts.pretty)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use serde_json::json;
215
216    #[test]
217    fn strip_ansi_removes_csi() {
218        let s = "\u{1b}[31mred\u{1b}[0m plain";
219        assert_eq!(strip_ansi(s), "red plain");
220    }
221
222    #[test]
223    fn encode_toon_uniform_array() {
224        let data = json!([{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]);
225        let t = encode_toon(&data).unwrap();
226        assert!(t.contains("id") && t.contains("name"));
227    }
228
229    #[test]
230    fn options_construct() {
231        let opts = OutputOptions {
232            pretty: true,
233            head: Some(2),
234            max_bytes: Some(100),
235            ..Default::default()
236        };
237        assert_eq!(opts.head, Some(2));
238        assert_eq!(opts.max_bytes, Some(100));
239    }
240}