| 1 | //! How results are shown. |
| 2 | //! |
| 3 | //! On a terminal: aligned tables and short summaries, in color unless |
| 4 | //! `NO_COLOR` is set. Piped: one record a line, tab-separated, with no |
| 5 | //! header and no color, so `cut` and `awk` work. With `--json`: the API's |
| 6 | //! own objects, compact and with their keys in a stable order; `--json |
| 7 | //! a,b` keeps only those keys, and `--jq` runs a jq expression over it. |
| 8 | |
| 9 | use serde_json::{Map, Value}; |
| 10 | use std::io::{IsTerminal, Write}; |
| 11 | |
| 12 | use crate::error::{Error, Result}; |
| 13 | |
| 14 | #[derive(Clone, Debug, Default)] |
| 15 | pub struct Output { |
| 16 | /// `--json`: `Some(vec![])` for every field. |
| 17 | pub json: Option<Vec<String>>, |
| 18 | pub jq: Option<String>, |
| 19 | pub tty: bool, |
| 20 | pub color: bool, |
| 21 | pub width: usize, |
| 22 | } |
| 23 | |
| 24 | impl Output { |
| 25 | pub fn detect(json: Option<String>, jq: Option<String>) -> Output { |
| 26 | let tty = std::io::stdout().is_terminal(); |
| 27 | let forced = std::env::var("CLICOLOR_FORCE").is_ok_and(|v| !v.is_empty() && v != "0"); |
| 28 | let no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()); |
| 29 | let color = !no_color && (forced || tty) && enable_ansi(); |
| 30 | let width = std::env::var("COLUMNS") |
| 31 | .ok() |
| 32 | .and_then(|c| c.parse().ok()) |
| 33 | .or_else(|| terminal_size::terminal_size().map(|(w, _)| w.0 as usize)) |
| 34 | .unwrap_or(120); |
| 35 | let json = json.map(|fields| { |
| 36 | fields |
| 37 | .split(',') |
| 38 | .map(|f| f.trim().to_owned()) |
| 39 | .filter(|f| !f.is_empty()) |
| 40 | .collect() |
| 41 | }); |
| 42 | Output { |
| 43 | json, |
| 44 | jq, |
| 45 | tty, |
| 46 | color, |
| 47 | width, |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /// Whether JSON is wanted instead of the human form. |
| 52 | pub fn structured(&self) -> bool { |
| 53 | self.json.is_some() || self.jq.is_some() |
| 54 | } |
| 55 | |
| 56 | /// Prints `value` as `--json`/`--jq` ask. |
| 57 | pub fn emit(&self, value: &Value) -> Result<()> { |
| 58 | let value = match &self.json { |
| 59 | Some(fields) if !fields.is_empty() => select(value, fields)?, |
| 60 | _ => value.clone(), |
| 61 | }; |
| 62 | let mut out = std::io::stdout().lock(); |
| 63 | if let Some(filter) = &self.jq { |
| 64 | for result in crate::jq::run(filter, &value)? { |
| 65 | writeln!(out, "{}", crate::jq::line(&result))?; |
| 66 | } |
| 67 | return Ok(()); |
| 68 | } |
| 69 | let value = sorted(&value); |
| 70 | let text = if self.tty { |
| 71 | serde_json::to_string_pretty(&value) |
| 72 | } else { |
| 73 | serde_json::to_string(&value) |
| 74 | } |
| 75 | .expect("json"); |
| 76 | writeln!(out, "{text}")?; |
| 77 | Ok(()) |
| 78 | } |
| 79 | |
| 80 | /// Prints one record of a stream as `--json`/`--jq` ask: compact, on |
| 81 | /// one line, terminal or not, so each line can be read as it comes. |
| 82 | pub fn emit_line(&self, value: &Value) -> Result<()> { |
| 83 | if self.jq.is_some() { |
| 84 | return self.emit(value); |
| 85 | } |
| 86 | writeln!(std::io::stdout().lock(), "{}", json_line(self, value)?)?; |
| 87 | Ok(()) |
| 88 | } |
| 89 | |
| 90 | pub fn paint(&self, code: &str, text: &str) -> String { |
| 91 | if self.color && !text.is_empty() { |
| 92 | format!("\x1b[{code}m{text}\x1b[0m") |
| 93 | } else { |
| 94 | text.to_owned() |
| 95 | } |
| 96 | } |
| 97 | pub fn bold(&self, t: &str) -> String { |
| 98 | self.paint("1", t) |
| 99 | } |
| 100 | pub fn dim(&self, t: &str) -> String { |
| 101 | self.paint("2", t) |
| 102 | } |
| 103 | pub fn green(&self, t: &str) -> String { |
| 104 | self.paint("32", t) |
| 105 | } |
| 106 | pub fn red(&self, t: &str) -> String { |
| 107 | self.paint("31", t) |
| 108 | } |
| 109 | pub fn yellow(&self, t: &str) -> String { |
| 110 | self.paint("33", t) |
| 111 | } |
| 112 | pub fn magenta(&self, t: &str) -> String { |
| 113 | self.paint("35", t) |
| 114 | } |
| 115 | pub fn cyan(&self, t: &str) -> String { |
| 116 | self.paint("36", t) |
| 117 | } |
| 118 | |
| 119 | /// A state word in the color it is usually shown in. |
| 120 | pub fn state(&self, state: &str) -> String { |
| 121 | match state { |
| 122 | "open" | "success" | "passed" | "approved" | "active" | "completed" | "ready" => { |
| 123 | self.green(state) |
| 124 | } |
| 125 | "merged" => self.magenta(state), |
| 126 | "closed" | "failure" | "failed" | "error" | "cancelled" | "timed_out" | "denied" |
| 127 | | "request_changes" => self.red(state), |
| 128 | "draft" | "skipped" | "neutral" | "inactive" | "not_planned" => self.dim(state), |
| 129 | "" => String::new(), |
| 130 | _ => self.yellow(state), |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | /// Prints rows: aligned with a header on a terminal, tab-separated |
| 135 | /// without one when piped. |
| 136 | pub fn table(&self, header: &[&str], rows: &[Vec<Cell>]) -> Result<()> { |
| 137 | let mut out = std::io::stdout().lock(); |
| 138 | if !self.tty { |
| 139 | for row in rows { |
| 140 | let line: Vec<&str> = row.iter().map(|c| c.plain.as_str()).collect(); |
| 141 | writeln!(out, "{}", line.join("\t"))?; |
| 142 | } |
| 143 | return Ok(()); |
| 144 | } |
| 145 | let columns = header.len(); |
| 146 | let mut widths: Vec<usize> = header.iter().map(|h| h.chars().count()).collect(); |
| 147 | for row in rows { |
| 148 | for (i, cell) in row.iter().enumerate().take(columns) { |
| 149 | widths[i] = widths[i].max(cell.plain.chars().count()); |
| 150 | } |
| 151 | } |
| 152 | // Squeeze the widest column to fit the terminal. |
| 153 | let gaps = 2 * columns.saturating_sub(1); |
| 154 | let total: usize = widths.iter().sum::<usize>() + gaps; |
| 155 | if total > self.width |
| 156 | && let Some((widest, _)) = widths.iter().enumerate().max_by_key(|(_, w)| **w) |
| 157 | { |
| 158 | let others: usize = widths.iter().sum::<usize>() - widths[widest] + gaps; |
| 159 | widths[widest] = self.width.saturating_sub(others).max(8); |
| 160 | } |
| 161 | let heads: Vec<Cell> = header |
| 162 | .iter() |
| 163 | .map(|h| Cell::styled(h.to_uppercase(), self.dim(&h.to_uppercase()))) |
| 164 | .collect(); |
| 165 | for row in std::iter::once(&heads).chain(rows.iter()) { |
| 166 | let mut line = String::new(); |
| 167 | for (i, cell) in row.iter().enumerate().take(columns) { |
| 168 | let last = i + 1 == columns; |
| 169 | line.push_str(&cell.fit(widths[i], !last)); |
| 170 | if !last { |
| 171 | line.push_str(" "); |
| 172 | } |
| 173 | } |
| 174 | writeln!(out, "{}", line.trim_end())?; |
| 175 | } |
| 176 | Ok(()) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | /// A table cell: its text, and how it looks on a terminal. |
| 181 | #[derive(Clone, Debug)] |
| 182 | pub struct Cell { |
| 183 | pub plain: String, |
| 184 | pub shown: String, |
| 185 | } |
| 186 | |
| 187 | impl Cell { |
| 188 | pub fn new(text: impl Into<String>) -> Cell { |
| 189 | let plain = text.into(); |
| 190 | Cell { |
| 191 | shown: plain.clone(), |
| 192 | plain, |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | pub fn styled(plain: impl Into<String>, shown: String) -> Cell { |
| 197 | Cell { |
| 198 | plain: plain.into(), |
| 199 | shown, |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | fn fit(&self, width: usize, pad: bool) -> String { |
| 204 | let len = self.plain.chars().count(); |
| 205 | if len > width { |
| 206 | let cut: String = self.plain.chars().take(width.saturating_sub(1)).collect(); |
| 207 | return format!("{cut}…"); |
| 208 | } |
| 209 | if pad { |
| 210 | format!("{}{}", self.shown, " ".repeat(width - len)) |
| 211 | } else { |
| 212 | self.shown.clone() |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | /// `value` with every object's keys in sorted order. Printed JSON must not |
| 218 | /// depend on serde_json's `preserve_order`, which other crates in the |
| 219 | /// workspace turn on for a shared build. |
| 220 | pub fn sorted(value: &Value) -> Value { |
| 221 | match value { |
| 222 | Value::Object(map) => { |
| 223 | let mut keys: Vec<&String> = map.keys().collect(); |
| 224 | keys.sort(); |
| 225 | Value::Object(keys.into_iter().map(|k| (k.clone(), sorted(&map[k]))).collect()) |
| 226 | } |
| 227 | Value::Array(list) => Value::Array(list.iter().map(sorted).collect()), |
| 228 | other => other.clone(), |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | /// `value` as one compact line of JSON, with only the `--json` fields asked for. |
| 233 | pub fn json_line(out: &Output, value: &Value) -> Result<String> { |
| 234 | let value = match &out.json { |
| 235 | Some(fields) if !fields.is_empty() => select(value, fields)?, |
| 236 | _ => value.clone(), |
| 237 | }; |
| 238 | Ok(serde_json::to_string(&sorted(&value)).expect("json")) |
| 239 | } |
| 240 | |
| 241 | /// Keeps only `fields` of each object in `value`. |
| 242 | pub fn select(value: &Value, fields: &[String]) -> Result<Value> { |
| 243 | let objects: Vec<&Map<String, Value>> = match value { |
| 244 | Value::Array(items) => items.iter().filter_map(Value::as_object).collect(), |
| 245 | Value::Object(map) => vec![map], |
| 246 | _ => return Ok(value.clone()), |
| 247 | }; |
| 248 | if let Some(first) = objects.first() { |
| 249 | let unknown: Vec<&String> = fields |
| 250 | .iter() |
| 251 | .filter(|f| !objects.iter().any(|o| o.contains_key(f.as_str()))) |
| 252 | .collect(); |
| 253 | if !unknown.is_empty() { |
| 254 | let mut available: Vec<&String> = first.keys().collect(); |
| 255 | available.sort(); |
| 256 | let available: Vec<&str> = available.iter().map(|s| s.as_str()).collect(); |
| 257 | return Err(Error::usage(format!( |
| 258 | "Unknown JSON field `{}`. Available: {}", |
| 259 | unknown[0], |
| 260 | available.join(", ") |
| 261 | ))); |
| 262 | } |
| 263 | } |
| 264 | let pick = |map: &Map<String, Value>| { |
| 265 | let mut out = Map::new(); |
| 266 | for f in fields { |
| 267 | out.insert(f.clone(), map.get(f).cloned().unwrap_or(Value::Null)); |
| 268 | } |
| 269 | Value::Object(out) |
| 270 | }; |
| 271 | Ok(match value { |
| 272 | Value::Array(items) => Value::Array( |
| 273 | items |
| 274 | .iter() |
| 275 | .map(|v| v.as_object().map(pick).unwrap_or_else(|| v.clone())) |
| 276 | .collect(), |
| 277 | ), |
| 278 | Value::Object(map) => pick(map), |
| 279 | other => other.clone(), |
| 280 | }) |
| 281 | } |
| 282 | |
| 283 | fn enable_ansi() -> bool { |
| 284 | #[cfg(windows)] |
| 285 | { |
| 286 | anstyle_query::windows::enable_ansi_colors().unwrap_or(false) |
| 287 | } |
| 288 | #[cfg(not(windows))] |
| 289 | { |
| 290 | true |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | /// Seconds since 1970 for an RFC 3339 time like `2026-10-01T18:04:11.482Z`. |
| 295 | pub fn parse_time(text: &str) -> Option<i64> { |
| 296 | let text = text.trim(); |
| 297 | if text.len() < 19 { |
| 298 | return None; |
| 299 | } |
| 300 | let num = |range: std::ops::Range<usize>| text.get(range)?.parse::<i64>().ok(); |
| 301 | let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); |
| 302 | let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); |
| 303 | // Days from civil (Howard Hinnant's algorithm). |
| 304 | let y2 = if mo <= 2 { y - 1 } else { y }; |
| 305 | let era = y2.div_euclid(400); |
| 306 | let yoe = y2 - era * 400; |
| 307 | let mp = (mo + 9) % 12; |
| 308 | let doy = (153 * mp + 2) / 5 + d - 1; |
| 309 | let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; |
| 310 | let days = era * 146_097 + doe - 719_468; |
| 311 | let mut secs = days * 86_400 + h * 3600 + mi * 60 + s; |
| 312 | // An offset like +02:00 at the end. |
| 313 | let tail = &text[19..]; |
| 314 | let tail = tail.trim_start_matches(|c: char| c == '.' || c.is_ascii_digit()); |
| 315 | if let Some(sign) = tail.chars().next().filter(|c| *c == '+' || *c == '-') { |
| 316 | let oh: i64 = tail.get(1..3)?.parse().ok()?; |
| 317 | let om: i64 = tail.get(4..6).and_then(|m| m.parse().ok()).unwrap_or(0); |
| 318 | let offset = oh * 3600 + om * 60; |
| 319 | secs -= if sign == '+' { offset } else { -offset }; |
| 320 | } |
| 321 | Some(secs) |
| 322 | } |
| 323 | |
| 324 | pub fn now() -> i64 { |
| 325 | std::time::SystemTime::now() |
| 326 | .duration_since(std::time::UNIX_EPOCH) |
| 327 | .map(|d| d.as_secs() as i64) |
| 328 | .unwrap_or_default() |
| 329 | } |
| 330 | |
| 331 | /// `3m ago`, `2h ago`, `5d ago`, or the date for older ones. |
| 332 | pub fn ago(text: &str) -> String { |
| 333 | let Some(then) = parse_time(text) else { |
| 334 | return text.to_owned(); |
| 335 | }; |
| 336 | let secs = (now() - then).max(0); |
| 337 | match secs { |
| 338 | 0..60 => "just now".to_owned(), |
| 339 | 60..3600 => format!("{}m ago", secs / 60), |
| 340 | 3600..86_400 => format!("{}h ago", secs / 3600), |
| 341 | 86_400..2_592_000 => format!("{}d ago", secs / 86_400), |
| 342 | _ => text.get(0..10).unwrap_or(text).to_owned(), |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | /// `1m32s` for a span of seconds. |
| 347 | pub fn duration(secs: i64) -> String { |
| 348 | let secs = secs.max(0); |
| 349 | match secs { |
| 350 | 0..60 => format!("{secs}s"), |
| 351 | 60..3600 => format!("{}m{}s", secs / 60, secs % 60), |
| 352 | _ => format!("{}h{}m", secs / 3600, (secs % 3600) / 60), |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | /// How long between two times, when both are known. |
| 357 | pub fn elapsed(start: &Value, end: &Value) -> String { |
| 358 | match ( |
| 359 | start.as_str().and_then(parse_time), |
| 360 | end.as_str().and_then(parse_time), |
| 361 | ) { |
| 362 | (Some(a), Some(b)) => duration(b - a), |
| 363 | (Some(a), None) => duration(now() - a), |
| 364 | _ => String::new(), |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | /// A string field, or "". |
| 369 | pub fn s<'a>(value: &'a Value, key: &str) -> &'a str { |
| 370 | value[key].as_str().unwrap_or("") |
| 371 | } |
| 372 | |
| 373 | #[cfg(test)] |
| 374 | mod tests { |
| 375 | use super::*; |
| 376 | use serde_json::json; |
| 377 | |
| 378 | #[test] |
| 379 | fn fields_are_selected() { |
| 380 | let list = json!([{ "number": 1, "title": "a", "body": "x" }, { "number": 2, "title": "b", "body": "y" }]); |
| 381 | let picked = select(&list, &["number".into(), "title".into()]).unwrap(); |
| 382 | assert_eq!( |
| 383 | picked, |
| 384 | json!([{ "number": 1, "title": "a" }, { "number": 2, "title": "b" }]) |
| 385 | ); |
| 386 | let one = select(&json!({ "a": 1, "b": 2 }), &["b".into()]).unwrap(); |
| 387 | assert_eq!(one, json!({ "b": 2 })); |
| 388 | let err = select(&list, &["nope".into()]).unwrap_err(); |
| 389 | assert_eq!(err.kind, crate::error::Kind::Usage); |
| 390 | assert!( |
| 391 | err.message.contains("Available: body, number, title"), |
| 392 | "{}", |
| 393 | err.message |
| 394 | ); |
| 395 | // Nothing to check against: an empty list stays empty. |
| 396 | assert_eq!(select(&json!([]), &["nope".into()]).unwrap(), json!([])); |
| 397 | } |
| 398 | |
| 399 | #[test] |
| 400 | fn json_is_compact_and_ordered() { |
| 401 | let value: Value = serde_json::from_str(r#"{"title":"a","number":1}"#).unwrap(); |
| 402 | assert_eq!( |
| 403 | serde_json::to_string(&sorted(&value)).unwrap(), |
| 404 | r#"{"number":1,"title":"a"}"# |
| 405 | ); |
| 406 | } |
| 407 | |
| 408 | #[test] |
| 409 | fn times_parse() { |
| 410 | assert_eq!(parse_time("1970-01-01T00:00:00Z"), Some(0)); |
| 411 | assert_eq!(parse_time("2026-10-01T18:04:11.482Z"), Some(1_790_877_851)); |
| 412 | assert_eq!(parse_time("2026-10-01T20:04:11+02:00"), Some(1_790_877_851)); |
| 413 | assert_eq!(parse_time("yesterday"), None); |
| 414 | assert_eq!(duration(92), "1m32s"); |
| 415 | assert_eq!(duration(3700), "1h1m"); |
| 416 | } |
| 417 | |
| 418 | #[test] |
| 419 | fn cells_fit_their_column() { |
| 420 | let cell = Cell::new("a long title"); |
| 421 | assert_eq!(cell.fit(6, true), "a lon…"); |
| 422 | assert_eq!(cell.fit(14, true), "a long title "); |
| 423 | assert_eq!(cell.fit(14, false), "a long title"); |
| 424 | } |
| 425 | |
| 426 | #[test] |
| 427 | fn plain_output_has_no_color() { |
| 428 | let out = Output { |
| 429 | color: false, |
| 430 | ..Output::default() |
| 431 | }; |
| 432 | assert_eq!(out.state("open"), "open"); |
| 433 | let out = Output { |
| 434 | color: true, |
| 435 | ..Output::default() |
| 436 | }; |
| 437 | assert_eq!(out.state("open"), "\x1b[32mopen\x1b[0m"); |
| 438 | } |
| 439 | } |