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 kv_block(pairs: &[(&str, String)]) -> String {
94 let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
95 pairs
96 .iter()
97 .map(|(k, v)| format!("{:width$} {}", k, v, width = max_key))
98 .collect::<Vec<_>>()
99 .join("\n")
100}
101
102pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
104 let col_count = headers.len();
105 let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
106 for row in rows {
107 for (i, cell) in row.iter().enumerate() {
108 if i < col_count {
109 widths[i] = widths[i].max(cell.len());
110 }
111 }
112 }
113
114 let header_line: String = headers
115 .iter()
116 .enumerate()
117 .map(|(i, h)| format!("{:width$}", h, width = widths[i]))
118 .collect::<Vec<_>>()
119 .join(" ");
120
121 let sep: String = widths
122 .iter()
123 .map(|w| "-".repeat(*w))
124 .collect::<Vec<_>>()
125 .join(" ");
126
127 let data_lines: Vec<String> = rows
128 .iter()
129 .map(|row| {
130 row.iter()
131 .enumerate()
132 .take(col_count)
133 .map(|(i, cell)| format!("{:width$}", cell, width = widths[i]))
134 .collect::<Vec<_>>()
135 .join(" ")
136 })
137 .collect();
138
139 let mut out = vec![header_line, sep];
140 out.extend(data_lines);
141 out.join("\n")
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn kv_block_aligns_keys() {
150 let pairs = [("id", "123".into()), ("topic", "Standup".into())];
151 let out = kv_block(&pairs);
152 let lines: Vec<&str> = out.lines().collect();
153 assert_eq!(lines.len(), 2);
154 let id_pos = lines[0].find("123").unwrap();
155 let topic_pos = lines[1].find("Standup").unwrap();
156 assert_eq!(id_pos, topic_pos, "values must be column-aligned");
157 }
158
159 #[test]
160 fn table_renders_header_and_separator() {
161 let headers = ["ID", "TOPIC", "DURATION"];
162 let rows = vec![
163 vec!["111".into(), "Standup".into(), "15".into()],
164 vec!["222".into(), "All Hands".into(), "60".into()],
165 ];
166 let out = table(&headers, &rows);
167 let lines: Vec<&str> = out.lines().collect();
168 assert!(lines[0].contains("ID"));
169 assert!(lines[0].contains("TOPIC"));
170 assert!(lines[1].contains("---"));
171 assert!(lines[2].contains("Standup"));
172 assert!(lines[3].contains("All Hands"));
173 }
174
175 #[test]
176 fn table_pads_to_widest_cell() {
177 let headers = ["NAME"];
178 let rows = vec![vec!["short".into()], vec!["much longer name".into()]];
179 let out = table(&headers, &rows);
180 let lines: Vec<&str> = out.lines().collect();
181 assert!(lines[1].len() >= "much longer name".len());
182 }
183
184 #[test]
185 fn format_timestamp_formats_iso8601() {
186 assert_eq!(format_timestamp("2026-03-29T07:34:19Z"), "2026-03-29 07:34");
187 assert_eq!(format_timestamp("2020-04-06T17:15:00Z"), "2020-04-06 17:15");
188 }
189
190 #[test]
191 fn format_timestamp_passes_through_non_timestamps() {
192 assert_eq!(format_timestamp("-"), "-");
193 assert_eq!(format_timestamp(""), "");
194 }
195
196 #[test]
197 fn exit_codes_for_error_maps_correctly() {
198 assert_eq!(
199 exit_codes::for_error(&ApiError::Auth("x".into())),
200 exit_codes::CONFIG_ERROR
201 );
202 assert_eq!(
203 exit_codes::for_error(&ApiError::NotFound("x".into())),
204 exit_codes::NOT_FOUND
205 );
206 assert_eq!(
207 exit_codes::for_error(&ApiError::RateLimit),
208 exit_codes::GENERAL_ERROR
209 );
210 }
211}