Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

439 linesCodeBlameRaw
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
9use serde_json::{Map, Value};
10use std::io::{IsTerminal, Write};
11
12use crate::error::{Error, Result};
13
14#[derive(Clone, Debug, Default)]
15pub 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
24impl 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)]
182pub struct Cell {
183 pub plain: String,
184 pub shown: String,
185}
186
187impl 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.
220pub 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.
233pub 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`.
242pub 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
283fn 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`.
295pub 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
324pub 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.
332pub 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.
347pub 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.
357pub 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 "".
369pub fn s<'a>(value: &'a Value, key: &str) -> &'a str {
370 value[key].as_str().unwrap_or("")
371}
372
373#[cfg(test)]
374mod 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}