use std::io::IsTerminal;
#[cfg(test)]
use std::sync::{Arc, Mutex};
use crate::api::ApiError;
pub fn use_color() -> bool {
std::io::stdout().is_terminal()
}
pub fn hyperlink(url: &str) -> String {
if use_color() {
format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\")
} else {
url.to_string()
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum OutputFormat {
Auto,
Text,
Json,
}
#[derive(Clone)]
pub struct OutputConfig {
pub json: bool,
pub quiet: bool,
#[cfg(test)]
pub captured: Option<Arc<Mutex<Vec<String>>>>,
}
impl OutputConfig {
pub fn new(format: OutputFormat, quiet: bool) -> Self {
let json = matches!(format, OutputFormat::Json)
|| (matches!(format, OutputFormat::Auto) && !std::io::stdout().is_terminal());
Self {
json,
quiet,
#[cfg(test)]
captured: None,
}
}
pub fn print_data(&self, data: &str) {
#[cfg(test)]
if let Some(ref buf) = self.captured {
buf.lock().unwrap().push(data.to_owned());
return;
}
println!("{data}");
}
pub fn print_message(&self, msg: &str) {
if !self.quiet {
eprintln!("{msg}");
}
}
pub fn print_result(&self, json_value: &serde_json::Value, human_message: &str) {
if self.json {
println!(
"{}",
serde_json::to_string_pretty(json_value).expect("failed to serialize JSON")
);
} else {
println!("{human_message}");
}
}
}
#[cfg(test)]
impl OutputConfig {
pub fn for_test() -> Self {
Self {
json: true,
quiet: true,
captured: None,
}
}
pub fn capturing() -> (Self, Arc<Mutex<Vec<String>>>) {
let buf = Arc::new(Mutex::new(Vec::new()));
let out = Self {
json: false,
quiet: true,
captured: Some(Arc::clone(&buf)),
};
(out, buf)
}
pub fn capturing_json() -> (Self, Arc<Mutex<Vec<String>>>) {
let buf = Arc::new(Mutex::new(Vec::new()));
let out = Self {
json: true,
quiet: true,
captured: Some(Arc::clone(&buf)),
};
(out, buf)
}
}
pub mod exit_codes {
use super::ApiError;
pub const SUCCESS: i32 = 0;
pub const GENERAL_ERROR: i32 = 1;
pub const CONFIG_ERROR: i32 = 2;
pub const NOT_FOUND: i32 = 3;
pub fn for_error(e: &ApiError) -> i32 {
match e {
ApiError::Auth(_) | ApiError::InvalidInput(_) | ApiError::ConfirmationRequired(_) => {
CONFIG_ERROR
}
ApiError::NotFound(_) => NOT_FOUND,
ApiError::Conflict(_) => GENERAL_ERROR,
_ => GENERAL_ERROR,
}
}
}
pub fn format_timestamp(ts: &str) -> String {
let inner = ts.strip_suffix('Z').unwrap_or(ts);
if let Some((date, time)) = inner.split_once('T') {
let hm = time.get(..5).unwrap_or(time);
return format!("{date} {hm}");
}
ts.to_string()
}
pub fn mask_credential(s: &str) -> String {
if s.len() <= 10 {
return "•".repeat(s.len());
}
format!("{}…{}", &s[..6], &s[s.len() - 4..])
}
pub fn kv_block(pairs: &[(&str, String)]) -> String {
let max_key = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
pairs
.iter()
.map(|(k, v)| format!("{:width$} {}", k, v, width = max_key))
.collect::<Vec<_>>()
.join("\n")
}
pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
let col_count = headers.len();
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < col_count {
widths[i] = widths[i].max(cell.len());
}
}
}
let header_line: String = headers
.iter()
.enumerate()
.map(|(i, h)| format!("{:width$}", h, width = widths[i]))
.collect::<Vec<_>>()
.join(" ");
let sep: String = widths
.iter()
.map(|w| "-".repeat(*w))
.collect::<Vec<_>>()
.join(" ");
let data_lines: Vec<String> = rows
.iter()
.map(|row| {
row.iter()
.enumerate()
.take(col_count)
.map(|(i, cell)| format!("{:width$}", cell, width = widths[i]))
.collect::<Vec<_>>()
.join(" ")
})
.collect();
let mut out = vec![header_line, sep];
out.extend(data_lines);
out.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kv_block_aligns_keys() {
let pairs = [("id", "123".into()), ("topic", "Standup".into())];
let out = kv_block(&pairs);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
let id_pos = lines[0].find("123").unwrap();
let topic_pos = lines[1].find("Standup").unwrap();
assert_eq!(id_pos, topic_pos, "values must be column-aligned");
}
#[test]
fn table_renders_header_and_separator() {
let headers = ["ID", "TOPIC", "DURATION"];
let rows = vec![
vec!["111".into(), "Standup".into(), "15".into()],
vec!["222".into(), "All Hands".into(), "60".into()],
];
let out = table(&headers, &rows);
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].contains("ID"));
assert!(lines[0].contains("TOPIC"));
assert!(lines[1].contains("---"));
assert!(lines[2].contains("Standup"));
assert!(lines[3].contains("All Hands"));
}
#[test]
fn table_pads_to_widest_cell() {
let headers = ["NAME"];
let rows = vec![vec!["short".into()], vec!["much longer name".into()]];
let out = table(&headers, &rows);
let lines: Vec<&str> = out.lines().collect();
assert!(lines[1].len() >= "much longer name".len());
}
#[test]
fn format_timestamp_formats_iso8601() {
assert_eq!(format_timestamp("2026-03-29T07:34:19Z"), "2026-03-29 07:34");
assert_eq!(format_timestamp("2020-04-06T17:15:00Z"), "2020-04-06 17:15");
}
#[test]
fn format_timestamp_passes_through_non_timestamps() {
assert_eq!(format_timestamp("-"), "-");
assert_eq!(format_timestamp(""), "");
}
#[test]
fn mask_credential_masks_long_values() {
assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
}
#[test]
fn mask_credential_dots_short_values() {
assert_eq!(mask_credential("short"), "•••••");
assert_eq!(mask_credential(""), "");
}
#[test]
fn exit_codes_for_error_maps_correctly() {
assert_eq!(
exit_codes::for_error(&ApiError::Auth("x".into())),
exit_codes::CONFIG_ERROR
);
assert_eq!(
exit_codes::for_error(&ApiError::NotFound("x".into())),
exit_codes::NOT_FOUND
);
assert_eq!(
exit_codes::for_error(&ApiError::RateLimit),
exit_codes::GENERAL_ERROR
);
assert_eq!(
exit_codes::for_error(&ApiError::ConfirmationRequired("x".into())),
exit_codes::CONFIG_ERROR
);
}
}