Skip to main content

zoom_cli/
output.rs

1use std::io::IsTerminal;
2
3use crate::api::ApiError;
4
5pub fn use_color() -> bool {
6    std::io::stdout().is_terminal()
7}
8
9/// Format a URL as a clickable OSC 8 hyperlink in terminals that support it.
10pub fn hyperlink(url: &str) -> String {
11    if use_color() {
12        format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\")
13    } else {
14        url.to_string()
15    }
16}
17
18#[derive(Clone, Copy)]
19pub struct OutputConfig {
20    pub json: bool,
21    pub quiet: bool,
22}
23
24impl OutputConfig {
25    pub fn new(json_flag: bool, quiet: bool) -> Self {
26        let json = json_flag || !std::io::stdout().is_terminal();
27        Self { json, quiet }
28    }
29
30    /// Print data to stdout (tables, JSON, or single values). Always shown.
31    pub fn print_data(&self, data: &str) {
32        println!("{data}");
33    }
34
35    /// Print an informational message to stderr. Suppressed by --quiet.
36    pub fn print_message(&self, msg: &str) {
37        if !self.quiet {
38            eprintln!("{msg}");
39        }
40    }
41
42    /// Print the result of a mutation (create/update/delete).
43    ///
44    /// JSON mode: prints structured JSON to stdout.
45    /// Human mode: prints the human message to stdout so callers can capture it.
46    pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
47        if self.json {
48            println!(
49                "{}",
50                serde_json::to_string_pretty(json_value).expect("failed to serialize JSON")
51            );
52        } else {
53            println!("{human_message}");
54        }
55    }
56}
57
58/// Exit codes for agent-friendly error handling.
59pub mod exit_codes {
60    use super::ApiError;
61
62    pub const SUCCESS: i32 = 0;
63    /// General / unexpected error.
64    pub const GENERAL_ERROR: i32 = 1;
65    /// Config or auth error (missing credentials, bad profile).
66    pub const CONFIG_ERROR: i32 = 2;
67    /// Resource not found.
68    pub const NOT_FOUND: i32 = 3;
69
70    pub fn for_error(e: &ApiError) -> i32 {
71        match e {
72            ApiError::Auth(_) | ApiError::InvalidInput(_) => CONFIG_ERROR,
73            ApiError::NotFound(_) => NOT_FOUND,
74            _ => GENERAL_ERROR,
75        }
76    }
77}
78
79/// Format an ISO 8601 UTC timestamp for human display.
80///
81/// `"2026-03-29T07:34:19Z"` → `"2026-03-29 07:34"`
82/// Strings that don't match the pattern (e.g. `"-"`) are returned unchanged.
83pub fn format_timestamp(ts: &str) -> String {
84    let inner = ts.strip_suffix('Z').unwrap_or(ts);
85    if let Some((date, time)) = inner.split_once('T') {
86        let hm = time.get(..5).unwrap_or(time);
87        return format!("{date} {hm}");
88    }
89    ts.to_string()
90}
91
92/// Render a simple two-column key/value block for single-resource output.
93pub fn kv_block(pairs: &[(&str, String)]) -> String {
94    let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
95    pairs
96        .iter()
97        .map(|(k, v)| format!("{:width$}  {}", k, v, width = max_key))
98        .collect::<Vec<_>>()
99        .join("\n")
100}
101
102/// Render a simple table with a header row and data rows.
103pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
104    let col_count = headers.len();
105    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
106    for row in rows {
107        for (i, cell) in row.iter().enumerate() {
108            if i < col_count {
109                widths[i] = widths[i].max(cell.len());
110            }
111        }
112    }
113
114    let header_line: String = headers
115        .iter()
116        .enumerate()
117        .map(|(i, h)| format!("{:width$}", h, width = widths[i]))
118        .collect::<Vec<_>>()
119        .join("  ");
120
121    let sep: String = widths
122        .iter()
123        .map(|w| "-".repeat(*w))
124        .collect::<Vec<_>>()
125        .join("  ");
126
127    let data_lines: Vec<String> = rows
128        .iter()
129        .map(|row| {
130            row.iter()
131                .enumerate()
132                .take(col_count)
133                .map(|(i, cell)| format!("{:width$}", cell, width = widths[i]))
134                .collect::<Vec<_>>()
135                .join("  ")
136        })
137        .collect();
138
139    let mut out = vec![header_line, sep];
140    out.extend(data_lines);
141    out.join("\n")
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn kv_block_aligns_keys() {
150        let pairs = [("id", "123".into()), ("topic", "Standup".into())];
151        let out = kv_block(&pairs);
152        let lines: Vec<&str> = out.lines().collect();
153        assert_eq!(lines.len(), 2);
154        let id_pos = lines[0].find("123").unwrap();
155        let topic_pos = lines[1].find("Standup").unwrap();
156        assert_eq!(id_pos, topic_pos, "values must be column-aligned");
157    }
158
159    #[test]
160    fn table_renders_header_and_separator() {
161        let headers = ["ID", "TOPIC", "DURATION"];
162        let rows = vec![
163            vec!["111".into(), "Standup".into(), "15".into()],
164            vec!["222".into(), "All Hands".into(), "60".into()],
165        ];
166        let out = table(&headers, &rows);
167        let lines: Vec<&str> = out.lines().collect();
168        assert!(lines[0].contains("ID"));
169        assert!(lines[0].contains("TOPIC"));
170        assert!(lines[1].contains("---"));
171        assert!(lines[2].contains("Standup"));
172        assert!(lines[3].contains("All Hands"));
173    }
174
175    #[test]
176    fn table_pads_to_widest_cell() {
177        let headers = ["NAME"];
178        let rows = vec![vec!["short".into()], vec!["much longer name".into()]];
179        let out = table(&headers, &rows);
180        let lines: Vec<&str> = out.lines().collect();
181        assert!(lines[1].len() >= "much longer name".len());
182    }
183
184    #[test]
185    fn format_timestamp_formats_iso8601() {
186        assert_eq!(format_timestamp("2026-03-29T07:34:19Z"), "2026-03-29 07:34");
187        assert_eq!(format_timestamp("2020-04-06T17:15:00Z"), "2020-04-06 17:15");
188    }
189
190    #[test]
191    fn format_timestamp_passes_through_non_timestamps() {
192        assert_eq!(format_timestamp("-"), "-");
193        assert_eq!(format_timestamp(""), "");
194    }
195
196    #[test]
197    fn exit_codes_for_error_maps_correctly() {
198        assert_eq!(
199            exit_codes::for_error(&ApiError::Auth("x".into())),
200            exit_codes::CONFIG_ERROR
201        );
202        assert_eq!(
203            exit_codes::for_error(&ApiError::NotFound("x".into())),
204            exit_codes::NOT_FOUND
205        );
206        assert_eq!(
207            exit_codes::for_error(&ApiError::RateLimit),
208            exit_codes::GENERAL_ERROR
209        );
210    }
211}