Skip to main content

browser_control/cli/
output.rs

1//! Output helpers for CLI subcommands: aligned tables and JSON.
2
3use serde::Serialize;
4use std::io::Write;
5
6const FALLBACK_TERMINAL_WIDTH: usize = 120;
7
8/// Best-effort terminal width for human table formatting.
9pub fn terminal_width() -> usize {
10    if let Some(width) = terminal_width_from_env() {
11        return width;
12    }
13    terminal_width_from_stdout().unwrap_or(FALLBACK_TERMINAL_WIDTH)
14}
15
16fn terminal_width_from_env() -> Option<usize> {
17    std::env::var("COLUMNS")
18        .ok()
19        .and_then(|s| s.parse::<usize>().ok())
20        .filter(|w| *w > 0)
21}
22
23#[cfg(unix)]
24fn terminal_width_from_stdout() -> Option<usize> {
25    let mut size = libc::winsize {
26        ws_row: 0,
27        ws_col: 0,
28        ws_xpixel: 0,
29        ws_ypixel: 0,
30    };
31    let rc = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut size) };
32    if rc == 0 && size.ws_col > 0 {
33        Some(size.ws_col as usize)
34    } else {
35        None
36    }
37}
38
39#[cfg(not(unix))]
40fn terminal_width_from_stdout() -> Option<usize> {
41    None
42}
43
44/// Print rows as a simple aligned table.
45///
46/// Column widths are computed from the headers plus all cell contents.
47/// Columns are separated by a two-space gutter, and a separator line of
48/// dashes is printed between the header row and the data rows.
49pub fn print_table<W: Write>(
50    out: &mut W,
51    headers: &[&str],
52    rows: &[Vec<String>],
53) -> std::io::Result<()> {
54    let ncols = headers.len();
55    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
56    for row in rows {
57        for (i, cell) in row.iter().enumerate().take(ncols) {
58            if cell.len() > widths[i] {
59                widths[i] = cell.len();
60            }
61        }
62    }
63
64    write_row(out, headers.iter().copied(), &widths)?;
65    let sep: Vec<String> = widths.iter().map(|w| "-".repeat(*w)).collect();
66    write_row(out, sep.iter().map(|s| s.as_str()), &widths)?;
67    for row in rows {
68        write_row(
69            out,
70            (0..ncols).map(|i| row.get(i).map(|s| s.as_str()).unwrap_or("")),
71            &widths,
72        )?;
73    }
74    Ok(())
75}
76
77fn write_row<'a, W: Write, I: Iterator<Item = &'a str>>(
78    out: &mut W,
79    cells: I,
80    widths: &[usize],
81) -> std::io::Result<()> {
82    let cells: Vec<&str> = cells.collect();
83    let last = cells.len().saturating_sub(1);
84    for (i, cell) in cells.iter().enumerate() {
85        if i == last {
86            // Don't pad the last column.
87            write!(out, "{}", cell)?;
88        } else {
89            write!(out, "{:<width$}  ", cell, width = widths[i])?;
90        }
91    }
92    writeln!(out)
93}
94
95/// Print a serializable value as pretty JSON, followed by a newline.
96pub fn print_json<W: Write, T: Serialize>(out: &mut W, value: &T) -> anyhow::Result<()> {
97    let s = serde_json::to_string_pretty(value)?;
98    out.write_all(s.as_bytes())?;
99    out.write_all(b"\n")?;
100    Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn empty_rows_produces_headers_and_separator() {
109        let mut buf: Vec<u8> = Vec::new();
110        print_table(&mut buf, &["A", "BB"], &[]).unwrap();
111        let s = String::from_utf8(buf).unwrap();
112        let lines: Vec<&str> = s.lines().collect();
113        assert_eq!(lines.len(), 2, "got: {:?}", lines);
114        assert!(lines[0].starts_with("A "));
115        assert!(lines[0].contains("BB"));
116        assert!(lines[1].starts_with("-"));
117        assert!(lines[1].contains("--"));
118    }
119
120    #[test]
121    fn column_widths_grow_to_longest_cell() {
122        let mut buf: Vec<u8> = Vec::new();
123        let rows = vec![
124            vec!["short".to_string(), "x".to_string()],
125            vec!["a-very-long-cell".to_string(), "y".to_string()],
126        ];
127        print_table(&mut buf, &["K", "V"], &rows).unwrap();
128        let s = String::from_utf8(buf).unwrap();
129        let lines: Vec<&str> = s.lines().collect();
130        // First column width should be at least len("a-very-long-cell") = 16.
131        // Header line has "K" left-padded to 16, then two spaces, then "V".
132        assert!(
133            lines[0].starts_with("K               "),
134            "header pad wrong: {:?}",
135            lines[0]
136        );
137        // Separator first column should be 16 dashes.
138        assert!(
139            lines[1].starts_with(&"-".repeat(16)),
140            "sep wrong: {:?}",
141            lines[1]
142        );
143        // Data row 1: "short" padded to 16.
144        assert!(lines[2].starts_with("short           "));
145        assert!(lines[3].starts_with("a-very-long-cell  y"));
146    }
147
148    #[test]
149    fn json_output_is_valid_json() {
150        #[derive(Serialize)]
151        struct X {
152            a: u32,
153            b: Vec<String>,
154        }
155        let mut buf: Vec<u8> = Vec::new();
156        let x = X {
157            a: 7,
158            b: vec!["hi".into(), "there".into()],
159        };
160        print_json(&mut buf, &x).unwrap();
161        let s = String::from_utf8(buf).unwrap();
162        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
163        assert_eq!(v["a"], 7);
164        assert_eq!(v["b"][1], "there");
165    }
166}