1use std::io::IsTerminal;
2
3use crate::api::ApiError;
4
5pub fn use_color() -> bool {
6 std::io::stdout().is_terminal()
7}
8
9pub 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 pub fn print_data(&self, data: &str) {
32 println!("{data}");
33 }
34
35 pub fn print_message(&self, msg: &str) {
37 if !self.quiet {
38 eprintln!("{msg}");
39 }
40 }
41
42 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
58pub mod exit_codes {
60 use super::ApiError;
61
62 pub const SUCCESS: i32 = 0;
63 pub const GENERAL_ERROR: i32 = 1;
65 pub const CONFIG_ERROR: i32 = 2;
67 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
79pub 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
92pub 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
104pub 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
114pub 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}