Skip to main content

zoom_cli/
output.rs

1use std::io::IsTerminal;
2#[cfg(test)]
3use std::sync::{Arc, Mutex};
4
5use crate::api::ApiError;
6
7pub fn use_color() -> bool {
8    std::io::stdout().is_terminal()
9}
10
11/// Format a URL as a clickable OSC 8 hyperlink in terminals that support it.
12pub fn hyperlink(url: &str) -> String {
13    if use_color() {
14        format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\")
15    } else {
16        url.to_string()
17    }
18}
19
20#[derive(Clone, Copy, PartialEq)]
21pub enum OutputFormat {
22    Auto,
23    Text,
24    Json,
25}
26
27#[derive(Clone)]
28pub struct OutputConfig {
29    pub json: bool,
30    pub quiet: bool,
31    /// When set in tests, `print_data` appends to this buffer instead of stdout.
32    #[cfg(test)]
33    pub captured: Option<Arc<Mutex<Vec<String>>>>,
34}
35
36impl OutputConfig {
37    pub fn new(format: OutputFormat, quiet: bool) -> Self {
38        let json = matches!(format, OutputFormat::Json)
39            || (matches!(format, OutputFormat::Auto) && !std::io::stdout().is_terminal());
40        Self {
41            json,
42            quiet,
43            #[cfg(test)]
44            captured: None,
45        }
46    }
47
48    /// Print data to stdout (tables, JSON, or single values). Always shown.
49    pub fn print_data(&self, data: &str) {
50        #[cfg(test)]
51        if let Some(ref buf) = self.captured {
52            buf.lock().unwrap().push(data.to_owned());
53            return;
54        }
55        println!("{data}");
56    }
57
58    /// Print an informational message to stderr. Suppressed by --quiet.
59    pub fn print_message(&self, msg: &str) {
60        if !self.quiet {
61            eprintln!("{msg}");
62        }
63    }
64
65    /// Print the result of a mutation (create/update/delete).
66    ///
67    /// JSON mode: prints structured JSON to stdout.
68    /// Human mode: prints the human message to stdout so callers can capture it.
69    pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
70        if self.json {
71            println!(
72                "{}",
73                serde_json::to_string_pretty(json_value).expect("failed to serialize JSON")
74            );
75        } else {
76            println!("{human_message}");
77        }
78    }
79}
80
81#[cfg(test)]
82impl OutputConfig {
83    /// Non-capturing test config (JSON mode, quiet).
84    pub fn for_test() -> Self {
85        Self {
86            json: true,
87            quiet: true,
88            captured: None,
89        }
90    }
91
92    /// Human-mode config that captures all `print_data` calls to the returned buffer.
93    pub fn capturing() -> (Self, Arc<Mutex<Vec<String>>>) {
94        let buf = Arc::new(Mutex::new(Vec::new()));
95        let out = Self {
96            json: false,
97            quiet: true,
98            captured: Some(Arc::clone(&buf)),
99        };
100        (out, buf)
101    }
102
103    /// JSON-mode config that captures all `print_data` calls to the returned buffer.
104    pub fn capturing_json() -> (Self, Arc<Mutex<Vec<String>>>) {
105        let buf = Arc::new(Mutex::new(Vec::new()));
106        let out = Self {
107            json: true,
108            quiet: true,
109            captured: Some(Arc::clone(&buf)),
110        };
111        (out, buf)
112    }
113}
114
115/// Exit codes for agent-friendly error handling.
116pub mod exit_codes {
117    use super::ApiError;
118
119    pub const SUCCESS: i32 = 0;
120    /// General / unexpected error.
121    pub const GENERAL_ERROR: i32 = 1;
122    /// Config or auth error (missing credentials, bad profile).
123    pub const CONFIG_ERROR: i32 = 2;
124    /// Resource not found.
125    pub const NOT_FOUND: i32 = 3;
126
127    pub fn for_error(e: &ApiError) -> i32 {
128        match e {
129            ApiError::Auth(_) | ApiError::InvalidInput(_) | ApiError::ConfirmationRequired(_) => {
130                CONFIG_ERROR
131            }
132            ApiError::NotFound(_) => NOT_FOUND,
133            ApiError::Conflict(_) => GENERAL_ERROR,
134            _ => GENERAL_ERROR,
135        }
136    }
137}
138
139/// Format an ISO 8601 UTC timestamp for human display.
140///
141/// `"2026-03-29T07:34:19Z"` → `"2026-03-29 07:34"`
142/// Strings that don't match the pattern (e.g. `"-"`) are returned unchanged.
143pub fn format_timestamp(ts: &str) -> String {
144    let inner = ts.strip_suffix('Z').unwrap_or(ts);
145    if let Some((date, time)) = inner.split_once('T') {
146        let hm = time.get(..5).unwrap_or(time);
147        return format!("{date} {hm}");
148    }
149    ts.to_string()
150}
151
152/// Mask a credential string for safe display.
153///
154/// Keeps the first 6 and last 4 characters for long values so users can
155/// verify which credential is in use without exposing the full secret.
156/// Short values (≤ 10 chars) are fully obscured.
157pub fn mask_credential(s: &str) -> String {
158    if s.len() <= 10 {
159        return "•".repeat(s.len());
160    }
161    format!("{}…{}", &s[..6], &s[s.len() - 4..])
162}
163
164/// Render a simple two-column key/value block for single-resource output.
165pub fn kv_block(pairs: &[(&str, String)]) -> String {
166    let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
167    pairs
168        .iter()
169        .map(|(k, v)| format!("{:width$}  {}", k, v, width = max_key))
170        .collect::<Vec<_>>()
171        .join("\n")
172}
173
174/// Render a simple table with a header row and data rows.
175pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
176    let col_count = headers.len();
177    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
178    for row in rows {
179        for (i, cell) in row.iter().enumerate() {
180            if i < col_count {
181                widths[i] = widths[i].max(cell.len());
182            }
183        }
184    }
185
186    let header_line: String = headers
187        .iter()
188        .enumerate()
189        .map(|(i, h)| format!("{:width$}", h, width = widths[i]))
190        .collect::<Vec<_>>()
191        .join("  ");
192
193    let sep: String = widths
194        .iter()
195        .map(|w| "-".repeat(*w))
196        .collect::<Vec<_>>()
197        .join("  ");
198
199    let data_lines: Vec<String> = rows
200        .iter()
201        .map(|row| {
202            row.iter()
203                .enumerate()
204                .take(col_count)
205                .map(|(i, cell)| format!("{:width$}", cell, width = widths[i]))
206                .collect::<Vec<_>>()
207                .join("  ")
208        })
209        .collect();
210
211    let mut out = vec![header_line, sep];
212    out.extend(data_lines);
213    out.join("\n")
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn kv_block_aligns_keys() {
222        let pairs = [("id", "123".into()), ("topic", "Standup".into())];
223        let out = kv_block(&pairs);
224        let lines: Vec<&str> = out.lines().collect();
225        assert_eq!(lines.len(), 2);
226        let id_pos = lines[0].find("123").unwrap();
227        let topic_pos = lines[1].find("Standup").unwrap();
228        assert_eq!(id_pos, topic_pos, "values must be column-aligned");
229    }
230
231    #[test]
232    fn table_renders_header_and_separator() {
233        let headers = ["ID", "TOPIC", "DURATION"];
234        let rows = vec![
235            vec!["111".into(), "Standup".into(), "15".into()],
236            vec!["222".into(), "All Hands".into(), "60".into()],
237        ];
238        let out = table(&headers, &rows);
239        let lines: Vec<&str> = out.lines().collect();
240        assert!(lines[0].contains("ID"));
241        assert!(lines[0].contains("TOPIC"));
242        assert!(lines[1].contains("---"));
243        assert!(lines[2].contains("Standup"));
244        assert!(lines[3].contains("All Hands"));
245    }
246
247    #[test]
248    fn table_pads_to_widest_cell() {
249        let headers = ["NAME"];
250        let rows = vec![vec!["short".into()], vec!["much longer name".into()]];
251        let out = table(&headers, &rows);
252        let lines: Vec<&str> = out.lines().collect();
253        assert!(lines[1].len() >= "much longer name".len());
254    }
255
256    #[test]
257    fn format_timestamp_formats_iso8601() {
258        assert_eq!(format_timestamp("2026-03-29T07:34:19Z"), "2026-03-29 07:34");
259        assert_eq!(format_timestamp("2020-04-06T17:15:00Z"), "2020-04-06 17:15");
260    }
261
262    #[test]
263    fn format_timestamp_passes_through_non_timestamps() {
264        assert_eq!(format_timestamp("-"), "-");
265        assert_eq!(format_timestamp(""), "");
266    }
267
268    #[test]
269    fn mask_credential_masks_long_values() {
270        assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
271    }
272
273    #[test]
274    fn mask_credential_dots_short_values() {
275        assert_eq!(mask_credential("short"), "•••••");
276        assert_eq!(mask_credential(""), "");
277    }
278
279    #[test]
280    fn exit_codes_for_error_maps_correctly() {
281        assert_eq!(
282            exit_codes::for_error(&ApiError::Auth("x".into())),
283            exit_codes::CONFIG_ERROR
284        );
285        assert_eq!(
286            exit_codes::for_error(&ApiError::NotFound("x".into())),
287            exit_codes::NOT_FOUND
288        );
289        assert_eq!(
290            exit_codes::for_error(&ApiError::RateLimit),
291            exit_codes::GENERAL_ERROR
292        );
293        assert_eq!(
294            exit_codes::for_error(&ApiError::ConfirmationRequired("x".into())),
295            exit_codes::CONFIG_ERROR
296        );
297    }
298}