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)]
21pub struct OutputConfig {
22 pub json: bool,
23 pub quiet: bool,
24 #[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 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 pub fn print_message(&self, msg: &str) {
52 if !self.quiet {
53 eprintln!("{msg}");
54 }
55 }
56
57 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 pub fn for_test() -> Self {
77 Self {
78 json: true,
79 quiet: true,
80 captured: None,
81 }
82 }
83
84 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
96pub mod exit_codes {
98 use super::ApiError;
99
100 pub const SUCCESS: i32 = 0;
101 pub const GENERAL_ERROR: i32 = 1;
103 pub const CONFIG_ERROR: i32 = 2;
105 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
117pub 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
130pub 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
142pub 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
152pub 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}