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