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/// Mask a credential string for safe display.
93///
94/// Keeps the first 6 and last 4 characters for long values so users can
95/// verify which credential is in use without exposing the full secret.
96/// Short values (≤ 10 chars) are fully obscured.
97pub fn mask_credential(s: &str) -> String {
98    if s.len() <= 10 {
99        return "•".repeat(s.len());
100    }
101    format!("{}…{}", &s[..6], &s[s.len() - 4..])
102}
103
104/// Render a simple two-column key/value block for single-resource output.
105pub fn kv_block(pairs: &[(&str, String)]) -> String {
106    let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
107    pairs
108        .iter()
109        .map(|(k, v)| format!("{:width$}  {}", k, v, width = max_key))
110        .collect::<Vec<_>>()
111        .join("\n")
112}
113
114/// Render a simple table with a header row and data rows.
115pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
116    let col_count = headers.len();
117    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
118    for row in rows {
119        for (i, cell) in row.iter().enumerate() {
120            if i < col_count {
121                widths[i] = widths[i].max(cell.len());
122            }
123        }
124    }
125
126    let header_line: String = headers
127        .iter()
128        .enumerate()
129        .map(|(i, h)| format!("{:width$}", h, width = widths[i]))
130        .collect::<Vec<_>>()
131        .join("  ");
132
133    let sep: String = widths
134        .iter()
135        .map(|w| "-".repeat(*w))
136        .collect::<Vec<_>>()
137        .join("  ");
138
139    let data_lines: Vec<String> = rows
140        .iter()
141        .map(|row| {
142            row.iter()
143                .enumerate()
144                .take(col_count)
145                .map(|(i, cell)| format!("{:width$}", cell, width = widths[i]))
146                .collect::<Vec<_>>()
147                .join("  ")
148        })
149        .collect();
150
151    let mut out = vec![header_line, sep];
152    out.extend(data_lines);
153    out.join("\n")
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn kv_block_aligns_keys() {
162        let pairs = [("id", "123".into()), ("topic", "Standup".into())];
163        let out = kv_block(&pairs);
164        let lines: Vec<&str> = out.lines().collect();
165        assert_eq!(lines.len(), 2);
166        let id_pos = lines[0].find("123").unwrap();
167        let topic_pos = lines[1].find("Standup").unwrap();
168        assert_eq!(id_pos, topic_pos, "values must be column-aligned");
169    }
170
171    #[test]
172    fn table_renders_header_and_separator() {
173        let headers = ["ID", "TOPIC", "DURATION"];
174        let rows = vec![
175            vec!["111".into(), "Standup".into(), "15".into()],
176            vec!["222".into(), "All Hands".into(), "60".into()],
177        ];
178        let out = table(&headers, &rows);
179        let lines: Vec<&str> = out.lines().collect();
180        assert!(lines[0].contains("ID"));
181        assert!(lines[0].contains("TOPIC"));
182        assert!(lines[1].contains("---"));
183        assert!(lines[2].contains("Standup"));
184        assert!(lines[3].contains("All Hands"));
185    }
186
187    #[test]
188    fn table_pads_to_widest_cell() {
189        let headers = ["NAME"];
190        let rows = vec![vec!["short".into()], vec!["much longer name".into()]];
191        let out = table(&headers, &rows);
192        let lines: Vec<&str> = out.lines().collect();
193        assert!(lines[1].len() >= "much longer name".len());
194    }
195
196    #[test]
197    fn format_timestamp_formats_iso8601() {
198        assert_eq!(format_timestamp("2026-03-29T07:34:19Z"), "2026-03-29 07:34");
199        assert_eq!(format_timestamp("2020-04-06T17:15:00Z"), "2020-04-06 17:15");
200    }
201
202    #[test]
203    fn format_timestamp_passes_through_non_timestamps() {
204        assert_eq!(format_timestamp("-"), "-");
205        assert_eq!(format_timestamp(""), "");
206    }
207
208    #[test]
209    fn mask_credential_masks_long_values() {
210        assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
211    }
212
213    #[test]
214    fn mask_credential_dots_short_values() {
215        assert_eq!(mask_credential("short"), "•••••");
216        assert_eq!(mask_credential(""), "");
217    }
218
219    #[test]
220    fn exit_codes_for_error_maps_correctly() {
221        assert_eq!(
222            exit_codes::for_error(&ApiError::Auth("x".into())),
223            exit_codes::CONFIG_ERROR
224        );
225        assert_eq!(
226            exit_codes::for_error(&ApiError::NotFound("x".into())),
227            exit_codes::NOT_FOUND
228        );
229        assert_eq!(
230            exit_codes::for_error(&ApiError::RateLimit),
231            exit_codes::GENERAL_ERROR
232        );
233    }
234}