use std::fmt::Write as _;
pub struct Style {
pub bold: &'static str,
pub dim: &'static str,
pub red: &'static str,
pub green: &'static str,
pub yellow: &'static str,
pub cyan: &'static str,
pub reset: &'static str,
}
impl Style {
pub const PLAIN: Style = Style {
bold: "",
dim: "",
red: "",
green: "",
yellow: "",
cyan: "",
reset: "",
};
pub const COLOUR: Style = Style {
bold: "\u{1b}[1m",
dim: "\u{1b}[2m",
red: "\u{1b}[0;31m",
green: "\u{1b}[0;32m",
yellow: "\u{1b}[1;33m",
cyan: "\u{1b}[0;36m",
reset: "\u{1b}[0m",
};
pub fn detect() -> &'static Style {
let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
if is_tty {
&Style::COLOUR
} else {
&Style::PLAIN
}
}
}
const COL1: usize = 26;
const COL2: usize = 10;
const COL3: usize = 44;
pub enum Row {
Entry {
label: String,
status: String,
detail: String,
},
Rule,
}
impl Row {
pub fn new(label: impl Into<String>, status: impl Into<String>, detail: impl Into<String>) -> Self {
Row::Entry {
label: label.into(),
status: status.into(),
detail: detail.into(),
}
}
}
fn status_colour<'a>(status: &str, style: &'a Style) -> &'a str {
match status.to_ascii_uppercase().as_str() {
"OK" | "VALID" | "PRESENT" | "SIGNED" | "FRESH" | "FOUND" | "YES" | "MATCH" | "CLEAN"
| "UP-TO-DATE" => style.green,
"MISSING" | "INVALID" | "CHANGED" | "NO" | "FAIL" | "UNSIGNED" | "STALE" | "ABSENT"
| "ERROR" | "NONE" => style.red,
"WARN" | "UNKNOWN" | "SKIP" | "PARTIAL" | "OUTDATED" => style.yellow,
_ => style.dim,
}
}
pub fn show(title: &str, rows: &[Row], verdict_line: Option<(&str, Verdict)>) {
if crate::output::is_jsonl() {
crate::output::emit(crate::output::Record::Table {
title: title.to_string(),
columns: vec![
"Component".to_string(),
"Status".to_string(),
"Detail".to_string(),
],
rows: rows
.iter()
.filter_map(|row| match row {
Row::Rule => None,
Row::Entry {
label,
status,
detail,
} => Some(vec![label.clone(), status.clone(), detail.clone()]),
})
.collect(),
verdict: verdict_line.map(|(message, level)| crate::output::record::Verdict {
message: message.to_string(),
level: level.as_level(),
}),
});
return;
}
let style = Style::detect();
crate::out!("{}", analysis_table(title, rows, style));
if let Some((message, level)) = verdict_line {
crate::outln!("{}", verdict(message, level, style));
}
}
pub fn show_diagnostic(title: &str, rows: &[Row], verdict_line: Option<(&str, Verdict)>) {
if crate::output::is_jsonl() {
show(title, rows, verdict_line);
return;
}
let style = if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
&Style::COLOUR
} else {
&Style::PLAIN
};
crate::errln!("{}", analysis_table(title, rows, style).trim_end_matches('\n'));
if let Some((message, level)) = verdict_line {
crate::errln!("{}", verdict(message, level, style));
}
}
pub fn analysis_table(title: &str, rows: &[Row], style: &Style) -> String {
let width = COL1 + COL2 + COL3 + 6;
let rule: String = "─".repeat(width);
let mut out = String::new();
let _ = write!(out, "\n{}{} {title}{}\n", style.bold, style.cyan, style.reset);
let _ = write!(out, " {}{rule}{}\n", style.dim, style.reset);
let _ = write!(
out,
" {}{:<COL1$} {:<COL2$} {:<COL3$}{}\n",
style.bold, "Component", "Status", "Detail", style.reset
);
let _ = write!(out, " {}{rule}{}\n", style.dim, style.reset);
for row in rows {
match row {
Row::Rule => {
let _ = write!(out, " {}{rule}{}\n", style.dim, style.reset);
}
Row::Entry {
label,
status,
detail,
} => {
let colour = status_colour(status, style);
let label: String = label.chars().take(COL1).collect();
let detail: String = detail.chars().take(COL3).collect();
let _ = write!(
out,
" {label:<COL1$} {colour}{status:<COL2$}{} {}{detail:<COL3$}{}\n",
style.reset, style.dim, style.reset
);
}
}
}
let _ = write!(out, " {}{rule}{}\n", style.dim, style.reset);
out
}
pub fn analysis_json(title: &str, rows: &[Row], verdict: Option<(&str, Verdict)>) -> serde_json::Value {
serde_json::json!({
"title": title,
"rows": rows
.iter()
.filter_map(|row| match row {
Row::Rule => None,
Row::Entry { label, status, detail } => Some(serde_json::json!({
"label": label,
"status": status,
"detail": detail,
})),
})
.collect::<Vec<_>>(),
"verdict": verdict.map(|(message, level)| serde_json::json!({
"message": message,
"level": level.as_str(),
})),
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Ok,
Warn,
Fail,
}
impl Verdict {
pub fn as_level(self) -> crate::output::Level {
match self {
Verdict::Ok => crate::output::Level::Info,
Verdict::Warn => crate::output::Level::Warn,
Verdict::Fail => crate::output::Level::Error,
}
}
pub fn as_str(self) -> &'static str {
match self {
Verdict::Ok => "ok",
Verdict::Warn => "warn",
Verdict::Fail => "fail",
}
}
}
pub fn verdict(message: &str, level: Verdict, style: &Style) -> String {
let colour = match level {
Verdict::Ok => style.green,
Verdict::Warn => style.yellow,
Verdict::Fail => style.red,
};
format!(" {}{colour}→ {message}{}\n\n", style.bold, style.reset)
}
pub fn age(timestamp: Option<u64>, now: u64) -> String {
let Some(timestamp) = timestamp else {
return "unknown".to_string();
};
let delta = now.saturating_sub(timestamp);
if delta < 60 {
format!("{delta}s ago")
} else if delta < 3_600 {
format!("{}m ago", delta / 60)
} else if delta < 86_400 {
format!("{}h ago", delta / 3_600)
} else {
format!("{}d ago", delta / 86_400)
}
}
pub fn tool(name: &str) -> (String, String) {
match which(name) {
Some(path) => ("FOUND".to_string(), path),
None => ("MISSING".to_string(), "not in PATH".to_string()),
}
}
pub fn which(name: &str) -> Option<String> {
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
for candidate in crate::platform::command_file_names(name) {
let candidate = dir.join(candidate);
if crate::platform::is_executable(&candidate) {
return Some(candidate.display().to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test8830_the_table_has_one_shape() {
let rows = vec![
Row::new("Binary", "PRESENT", "/opt/thing"),
Row::Rule,
Row::new("jq", "MISSING", "not in PATH"),
];
let text = analysis_table("Analysis", &rows, &Style::PLAIN);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 9, "{text}");
let width = COL1 + COL2 + COL3 + 6;
for index in [2, 4, 6, 8] {
assert_eq!(
lines[index].trim().chars().count(),
width,
"line {index} is not a full-width rule: {:?}",
lines[index]
);
}
for index in [3, 5, 7] {
assert!(
lines[index].starts_with(" "),
"line {index} is not indented: {:?}",
lines[index]
);
}
assert!(lines[5].contains("Binary"));
assert!(lines[5].contains("PRESENT"));
}
#[test]
fn test8831_a_long_detail_does_not_reshape_the_table() {
let long = "x".repeat(COL3 * 2);
let text = analysis_table("A", &[Row::new("L", "OK", &long)], &Style::PLAIN);
let entry = text.lines().nth(5).expect("an entry line");
assert_eq!(
entry.chars().count(),
2 + COL1 + 2 + COL2 + 2 + COL3,
"the entry line is not the fixed width: {entry:?}"
);
assert_eq!(text.lines().count(), 7, "the detail wrapped onto another line");
}
#[test]
fn test8832_an_unknown_status_is_not_reassuring() {
assert_eq!(status_colour("OK", &Style::COLOUR), Style::COLOUR.green);
assert_eq!(status_colour("ok", &Style::COLOUR), Style::COLOUR.green);
assert_eq!(status_colour("MISSING", &Style::COLOUR), Style::COLOUR.red);
assert_eq!(status_colour("WARN", &Style::COLOUR), Style::COLOUR.yellow);
assert_eq!(status_colour("OKAY", &Style::COLOUR), Style::COLOUR.dim);
assert_eq!(status_colour("", &Style::COLOUR), Style::COLOUR.dim);
}
#[test]
fn test8833_plain_output_has_no_escapes() {
let text = analysis_table("A", &[Row::new("L", "OK", "d")], &Style::PLAIN)
+ &verdict("go", Verdict::Ok, &Style::PLAIN);
assert!(!text.contains('\u{1b}'), "{text:?}");
let coloured = analysis_table("A", &[Row::new("L", "OK", "d")], &Style::COLOUR);
assert!(coloured.contains('\u{1b}'));
}
#[test]
fn test8834_age_uses_the_largest_unit_that_fits() {
assert_eq!(age(Some(100), 100), "0s ago");
assert_eq!(age(Some(41), 100), "59s ago");
assert_eq!(age(Some(40), 100), "1m ago");
assert_eq!(age(Some(0), 3_599), "59m ago");
assert_eq!(age(Some(0), 3_600), "1h ago");
assert_eq!(age(Some(0), 86_399), "23h ago");
assert_eq!(age(Some(0), 86_400), "1d ago");
assert_eq!(age(None, 100), "unknown");
assert_eq!(age(Some(200), 100), "0s ago");
}
}