use std::io::IsTerminal;
use alp_core::{DoctorCheck, DoctorStatus, DoctorSummary};
use owo_colors::OwoColorize;
use crate::cli::GlobalArgs;
#[derive(Debug, Clone, Copy)]
pub struct Theme {
color: bool,
}
impl Theme {
pub fn from_args(g: &GlobalArgs) -> Self {
let color = !g.no_color
&& !g.ci
&& std::env::var_os("NO_COLOR").is_none()
&& std::io::stderr().is_terminal();
Theme { color }
}
fn bold(&self, s: &str) -> String {
if self.color {
s.bold().to_string()
} else {
s.to_string()
}
}
fn dim(&self, s: &str) -> String {
if self.color {
s.dimmed().to_string()
} else {
s.to_string()
}
}
fn cyan(&self, s: &str) -> String {
if self.color {
s.cyan().to_string()
} else {
s.to_string()
}
}
fn glyph(&self, status: DoctorStatus) -> String {
if !self.color {
return match status {
DoctorStatus::Pass => "[+]".to_string(),
DoctorStatus::Warn => "[!]".to_string(),
DoctorStatus::Fail => "[x]".to_string(),
};
}
match status {
DoctorStatus::Pass => "✓".green().bold().to_string(),
DoctorStatus::Warn => "!".yellow().bold().to_string(),
DoctorStatus::Fail => "✗".red().bold().to_string(),
}
}
fn summary_line(&self, summary: &DoctorSummary) -> String {
let passed = format!("{} passed", summary.pass);
let passed = if self.color && summary.pass > 0 {
passed.green().to_string()
} else {
self.dim(&passed)
};
let warnings = format!("{} {}", summary.warn, plural(summary.warn, "warning"));
let warnings = if self.color && summary.warn > 0 {
warnings.yellow().to_string()
} else {
self.dim(&warnings)
};
let failed = format!("{} failed", summary.fail);
let failed = if self.color && summary.fail > 0 {
failed.red().to_string()
} else {
self.dim(&failed)
};
let sep = self.dim("·");
format!("{passed} {sep} {warnings} {sep} {failed}")
}
pub fn error_lines(&self, message: &str) -> Vec<String> {
let glyph = self.glyph(DoctorStatus::Fail);
vec![
String::new(),
format!(" {glyph} {}", self.bold(message)),
String::new(),
]
}
}
pub fn render_report(
g: &GlobalArgs,
title: &str,
subtitle: &str,
checks: &[DoctorCheck],
summary: &DoctorSummary,
next_steps: &[String],
) -> Vec<String> {
let theme = Theme::from_args(g);
let (quiet, verbose) = (g.quiet, g.verbose);
let mut lines = vec![String::new()];
let heading = if subtitle.is_empty() {
theme.bold(title)
} else {
format!("{} {}", theme.bold(title), theme.dim(subtitle))
};
lines.push(format!(" {heading}"));
lines.push(String::new());
if !quiet && !checks.is_empty() {
let width = checks
.iter()
.map(|c| c.name.chars().count())
.max()
.unwrap_or(0);
for check in checks {
let name = format!("{:<width$}", check.name, width = width);
let name = theme.bold(&name);
let detail = if check.status == DoctorStatus::Pass {
theme.dim(&check.detail)
} else {
check.detail.clone()
};
lines.push(format!(
" {} {name} {detail}",
theme.glyph(check.status)
));
}
lines.push(String::new());
}
lines.push(format!(" {}", theme.summary_line(summary)));
if verbose && !next_steps.is_empty() {
lines.push(String::new());
lines.push(format!(" {}", theme.bold("Next steps")));
for step in next_steps {
lines.push(format!(" {} {step}", theme.cyan("→")));
}
}
lines
}
fn plural(n: u32, word: &str) -> String {
if n == 1 {
word.to_string()
} else {
format!("{word}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Format;
fn args(quiet: bool, verbose: bool) -> GlobalArgs {
GlobalArgs {
project: None,
board_yaml: None,
sdk_root: None,
target: None,
all: false,
format: Format::Text,
verbose,
quiet,
no_color: true,
non_interactive: false,
ci: false,
}
}
fn check(name: &str, status: DoctorStatus, detail: &str) -> DoctorCheck {
DoctorCheck {
name: name.to_string(),
status,
detail: detail.to_string(),
fix: None,
}
}
#[test]
fn plain_markers_are_equal_width_and_aligned() {
let checks = vec![
check("west", DoctorStatus::Pass, "ok"),
check("vendorToolchain", DoctorStatus::Warn, "needs toolchain"),
];
let summary = DoctorSummary {
pass: 1,
warn: 1,
fail: 0,
};
let lines = render_report(&args(false, false), "t", "sub", &checks, &summary, &[]);
let pass_line = lines.iter().find(|l| l.contains("west")).unwrap();
let warn_line = lines
.iter()
.find(|l| l.contains("vendorToolchain"))
.unwrap();
assert!(pass_line.contains("[+]"));
assert!(warn_line.contains("[!]"));
assert_eq!(
pass_line.find("west").unwrap(),
warn_line.find("vendorToolchain").unwrap()
);
assert!(lines.iter().all(|l| !l.contains('\u{1b}')), "no ANSI");
}
#[test]
fn summary_pluralizes_and_never_styles_when_plain() {
let theme = Theme { color: false };
assert_eq!(
theme.summary_line(&DoctorSummary {
pass: 1,
warn: 1,
fail: 1,
}),
"1 passed · 1 warning · 1 failed"
);
assert_eq!(
theme.summary_line(&DoctorSummary {
pass: 4,
warn: 0,
fail: 2,
}),
"4 passed · 0 warnings · 2 failed"
);
}
#[test]
fn error_lines_are_blank_padded_and_plain() {
let lines = Theme { color: false }.error_lines("boom");
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "");
assert!(lines[1].contains("[x]") && lines[1].contains("boom"));
assert_eq!(lines[2], "");
}
#[test]
fn quiet_hides_checks_but_keeps_summary() {
let checks = vec![check("west", DoctorStatus::Pass, "ok")];
let summary = DoctorSummary {
pass: 1,
warn: 0,
fail: 0,
};
let lines = render_report(&args(true, false), "t", "sub", &checks, &summary, &[]);
assert!(lines.iter().all(|l| !l.contains("west")));
assert!(lines.iter().any(|l| l.contains("1 passed")));
}
#[test]
fn next_steps_only_when_verbose() {
let summary = DoctorSummary {
pass: 0,
warn: 1,
fail: 0,
};
let steps = vec!["install west".to_string()];
let quiet = render_report(&args(false, false), "t", "sub", &[], &summary, &steps);
assert!(quiet.iter().all(|l| !l.contains("install west")));
let loud = render_report(&args(false, true), "t", "sub", &[], &summary, &steps);
assert!(loud.iter().any(|l| l.contains("install west")));
}
}