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
11pub 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 #[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 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 pub fn print_message(&self, msg: &str) {
60 if !self.quiet {
61 eprintln!("{msg}");
62 }
63 }
64
65 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 pub fn for_test() -> Self {
85 Self {
86 json: true,
87 quiet: true,
88 captured: None,
89 }
90 }
91
92 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 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
115pub mod exit_codes {
117 use super::ApiError;
118
119 pub const SUCCESS: i32 = 0;
120 pub const GENERAL_ERROR: i32 = 1;
122 pub const CONFIG_ERROR: i32 = 2;
124 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
139pub 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
152pub 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
164pub 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
174pub 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}