Grade and colour what doctor reports
Findings now carry a level: ok, warn or FAIL. The distinction between the middle two is the part worth getting right — warn is something the agent runs without and that the user can fix from the line printed beneath it, FAIL is something it cannot work around. A diagnostic that grades those the wrong way round is worse than an ungraded one, so each check states which it is. The clearest case is storage: the same failure on the state directory is FAIL and on the cache directory is warn, because one is mandatory and the other is disposable. That asymmetry is central to the design and the report now shows it. The control plane check also became real — it binds a UDP socket rather than asserting that it could. Colour is redundant by construction. Every row carries its grade as a word, so the report reads identically when the escapes are gone: piped to a file, on a dumb terminal, under NO_COLOR, or to someone who cannot distinguish the colours. anstream decides whether they survive, which also gets virtual terminal processing right on Windows; it and anstyle were already in the tree through clap. What is reported and how it looks are separated, so the rendering is tested without a terminal: that a plain render contains no escapes, that a styled one says the same thing once they are stripped, that columns line up across sections whose labels differ in length, and that the summary names the worst thing found. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+2
@@ -3656,6 +3656,8 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
|||||||
name = "tsunagi"
|
name = "tsunagi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
"boringtun",
|
"boringtun",
|
||||||
"bytes",
|
"bytes",
|
||||||
"caps",
|
"caps",
|
||||||
|
|||||||
+6
-1
@@ -13,7 +13,7 @@ categories = ["network-programming"]
|
|||||||
[features]
|
[features]
|
||||||
default = ["cli"]
|
default = ["cli"]
|
||||||
# The `tsunagi` command line binary. Library users can opt out.
|
# The `tsunagi` command line binary. Library users can opt out.
|
||||||
cli = ["dep:clap", "dep:tracing-subscriber", "tokio/signal", "tun-device"]
|
cli = ["dep:clap", "dep:anstream", "dep:anstyle", "dep:tracing-subscriber", "tokio/signal", "tun-device"]
|
||||||
# A real TUN device, so the WireGuard plugin can carry actual IP traffic.
|
# A real TUN device, so the WireGuard plugin can carry actual IP traffic.
|
||||||
# Needs CAP_NET_ADMIN at run time; without it the plugin still runs and its
|
# Needs CAP_NET_ADMIN at run time; without it the plugin still runs and its
|
||||||
# in-memory device can be used for tests.
|
# in-memory device can be used for tests.
|
||||||
@@ -26,6 +26,11 @@ required-features = ["cli"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5", features = ["derive", "env"], optional = true }
|
clap = { version = "4.5", features = ["derive", "env"], optional = true }
|
||||||
|
# Already in the tree through clap. `anstream` strips the escapes when stdout
|
||||||
|
# is not a terminal and turns on virtual terminal processing on Windows, so
|
||||||
|
# colour is never written where it would show up as rubbish.
|
||||||
|
anstream = { version = "1.0", optional = true }
|
||||||
|
anstyle = { version = "1.0", optional = true }
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||||
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
|
iroh = { version = "1.2", default-features = false, features = ["tls-ring"] }
|
||||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||||
|
|||||||
@@ -169,7 +169,12 @@ allocation changes. Everything else, including every byte from the network,
|
|||||||
is handled with it lowered. `+ep` works too; the agent lowers it on the way
|
is handled with it lowered. `+ep` works too; the agent lowers it on the way
|
||||||
in.
|
in.
|
||||||
|
|
||||||
`tsunagi doctor` says which of these applies on the host it runs on.
|
`tsunagi doctor` says which of these applies on the host it runs on. It
|
||||||
|
grades each finding: **ok** for what works, **warn** for what the agent runs
|
||||||
|
without and you can fix from the line it prints, **FAIL** for what it cannot
|
||||||
|
work around. The words carry the grade as well as the colour, so the report
|
||||||
|
reads the same piped to a file or on a terminal without colour, and it honours
|
||||||
|
`NO_COLOR`.
|
||||||
|
|
||||||
### It cleans up after itself
|
### It cleans up after itself
|
||||||
|
|
||||||
|
|||||||
+460
-47
@@ -346,85 +346,498 @@ async fn show_id(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reports what this machine can and cannot do, and how badly it matters.
|
||||||
|
///
|
||||||
|
/// Three levels, and the distinction between the middle two is deliberate:
|
||||||
|
/// *degraded* is something the agent runs without and that the user can fix
|
||||||
|
/// from a stated one-liner, *broken* is something it cannot work around.
|
||||||
|
/// Getting those the wrong way round makes a diagnostic tool useless, so
|
||||||
|
/// each check below says which it is and why.
|
||||||
async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use report::{Health, Report, Row, Section};
|
||||||
|
|
||||||
let paths = paths.resolve()?;
|
let paths = paths.resolve()?;
|
||||||
println!("tsunagi doctor\n");
|
let mut doctor = Report::new();
|
||||||
|
|
||||||
println!("state directory {}", paths.state_dir.display());
|
// Storage. The asymmetry here is the point: state is mandatory and cache
|
||||||
println!("cache directory {}", paths.cache_dir.display());
|
// is disposable, so the same failure means different things.
|
||||||
match std::fs::create_dir_all(&paths.state_dir) {
|
let mut storage = Section::new("storage");
|
||||||
Ok(()) => println!(" writable yes"),
|
storage.push(match std::fs::create_dir_all(&paths.state_dir) {
|
||||||
Err(err) => println!(" writable NO ({err})"),
|
Ok(()) => Row::new(
|
||||||
}
|
Health::Good,
|
||||||
|
"state directory",
|
||||||
|
format!("{} (writable)", paths.state_dir.display()),
|
||||||
|
),
|
||||||
|
Err(err) => Row::new(
|
||||||
|
Health::Broken,
|
||||||
|
"state directory",
|
||||||
|
format!("{}: {err}", paths.state_dir.display()),
|
||||||
|
)
|
||||||
|
.with_note("mandatory: the agent will not start without it"),
|
||||||
|
});
|
||||||
|
storage.push(match std::fs::create_dir_all(&paths.cache_dir) {
|
||||||
|
Ok(()) => Row::new(
|
||||||
|
Health::Good,
|
||||||
|
"cache directory",
|
||||||
|
format!("{} (writable)", paths.cache_dir.display()),
|
||||||
|
),
|
||||||
|
Err(err) => Row::new(
|
||||||
|
Health::Degraded,
|
||||||
|
"cache directory",
|
||||||
|
format!("{}: {err}", paths.cache_dir.display()),
|
||||||
|
)
|
||||||
|
.with_note("disposable: the agent runs, rediscovering what it cached"),
|
||||||
|
});
|
||||||
|
doctor.push(storage);
|
||||||
|
|
||||||
println!("\ncontrol plane");
|
// Control plane. Binding a socket is a real check rather than a claim.
|
||||||
println!(" needs outbound UDP; no privileges");
|
let mut control = Section::new("control plane");
|
||||||
println!(" status always available");
|
control.push(
|
||||||
|
match std::net::UdpSocket::bind((std::net::Ipv6Addr::UNSPECIFIED, 0))
|
||||||
|
.or_else(|_| std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0)))
|
||||||
|
{
|
||||||
|
Ok(_) => Row::new(Health::Good, "udp socket", "can bind; no privileges needed"),
|
||||||
|
Err(err) => Row::new(Health::Broken, "udp socket", format!("cannot bind: {err}"))
|
||||||
|
.with_note("nothing will reach any peer"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
doctor.push(control);
|
||||||
|
|
||||||
println!("\ndata plane (WireGuard)");
|
let mut data = Section::new("data plane (WireGuard)");
|
||||||
println!(" implementation userspace (boringtun); no kernel module needed");
|
data.push(Row::new(
|
||||||
|
Health::Good,
|
||||||
|
"implementation",
|
||||||
|
"userspace (boringtun); no kernel module needed",
|
||||||
|
));
|
||||||
#[cfg(feature = "tun-device")]
|
#[cfg(feature = "tun-device")]
|
||||||
{
|
{
|
||||||
let tun_path = std::path::Path::new("/dev/net/tun");
|
|
||||||
if cfg!(target_os = "linux") {
|
if cfg!(target_os = "linux") {
|
||||||
if tun_path.exists() {
|
let tun_path = std::path::Path::new("/dev/net/tun");
|
||||||
|
data.push(if !tun_path.exists() {
|
||||||
|
Row::new(Health::Broken, "/dev/net/tun", "missing")
|
||||||
|
.with_note("load the `tun` module; without it there can be no interface")
|
||||||
|
} else {
|
||||||
match std::fs::OpenOptions::new()
|
match std::fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(tun_path)
|
.open(tun_path)
|
||||||
{
|
{
|
||||||
Ok(_) => println!(" /dev/net/tun openable"),
|
Ok(_) => Row::new(Health::Good, "/dev/net/tun", "openable"),
|
||||||
Err(err) => println!(" /dev/net/tun present but not openable ({err})"),
|
Err(err) => Row::new(
|
||||||
|
Health::Broken,
|
||||||
|
"/dev/net/tun",
|
||||||
|
format!("not openable: {err}"),
|
||||||
|
)
|
||||||
|
.with_note("the device node must be readable and writable by this user"),
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
println!(" /dev/net/tun missing (load the `tun` module)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
println!(" interfaces supported on this build");
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "tun-device"))]
|
|
||||||
println!(" interfaces not built in (enable the `tun-device` feature)");
|
|
||||||
|
|
||||||
#[cfg(feature = "tun-device")]
|
|
||||||
{
|
|
||||||
use tsunagi::dataplane::wireguard::{Privilege, probe_net_admin};
|
use tsunagi::dataplane::wireguard::{Privilege, probe_net_admin};
|
||||||
match probe_net_admin() {
|
match probe_net_admin() {
|
||||||
Privilege::Available => {
|
Privilege::Available => {
|
||||||
println!(" privileges CAP_NET_ADMIN held");
|
data.push(Row::new(Health::Good, "privileges", "CAP_NET_ADMIN held"));
|
||||||
println!(
|
data.push(Row::new(
|
||||||
" interface managed by the agent: created on start, \
|
Health::Good,
|
||||||
removed on exit"
|
"interface",
|
||||||
);
|
"managed by the agent: created on start, removed on exit",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Privilege::Missing(reason) => {
|
Privilege::Missing(_) => {
|
||||||
println!(" privileges no CAP_NET_ADMIN ({reason})");
|
// The note is the command and nothing else: a paragraph of
|
||||||
println!(" interface cannot be created; run with `--no-tun` meanwhile");
|
// explanation belongs in the runtime error, not in a column
|
||||||
println!(
|
// the eye is meant to scan.
|
||||||
" to grant it {}",
|
data.push(
|
||||||
Privilege::how_to_grant(&program_path())
|
Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held")
|
||||||
|
.with_note(format!("sudo setcap cap_net_admin+p {}", program_path())),
|
||||||
);
|
);
|
||||||
|
data.push(Row::new(
|
||||||
|
Health::Degraded,
|
||||||
|
"interface",
|
||||||
|
"cannot be created; run with `--no-tun` meanwhile",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Privilege::Unsupported => {
|
Privilege::Unsupported => {
|
||||||
println!(
|
data.push(Row::new(
|
||||||
" privileges managing interfaces is not implemented on {} yet",
|
Health::Degraded,
|
||||||
std::env::consts::OS
|
"privileges",
|
||||||
);
|
format!(
|
||||||
println!(" interface cannot be created; run with `--no-tun`");
|
"managing interfaces is not implemented on {} yet",
|
||||||
|
std::env::consts::OS
|
||||||
|
),
|
||||||
|
));
|
||||||
|
data.push(Row::new(
|
||||||
|
Health::Degraded,
|
||||||
|
"interface",
|
||||||
|
"cannot be created; run with `--no-tun`",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(not(feature = "tun-device"))]
|
||||||
|
data.push(
|
||||||
|
Row::new(
|
||||||
|
Health::Degraded,
|
||||||
|
"interface",
|
||||||
|
"not built in; the tunnels run but cannot reach the OS",
|
||||||
|
)
|
||||||
|
.with_note("rebuild with the `tun-device` feature, or run with `--no-tun`"),
|
||||||
|
);
|
||||||
|
doctor.push(data);
|
||||||
|
|
||||||
println!("\nlocal addresses");
|
let mut addresses = Section::new("local addresses");
|
||||||
let state = netwatch_addresses().await;
|
let found = netwatch_addresses().await;
|
||||||
if state.is_empty() {
|
if found.is_empty() {
|
||||||
println!(" none found");
|
addresses.push(
|
||||||
|
Row::new(Health::Degraded, "interfaces", "none found")
|
||||||
|
.with_note("best effort; the agent may still find a way out"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for addr in state {
|
for addr in found {
|
||||||
println!(" {addr}");
|
// Loopback alone reaches nobody, but on a host that also has a real
|
||||||
|
// address it is unremarkable, so it is labelled rather than flagged.
|
||||||
|
let kind = match (addr.is_loopback(), addr.is_ipv4()) {
|
||||||
|
(true, _) => "loopback",
|
||||||
|
(false, true) => "ipv4",
|
||||||
|
(false, false) => "ipv6",
|
||||||
|
};
|
||||||
|
addresses.push(Row::new(Health::Good, kind, addr.to_string()));
|
||||||
}
|
}
|
||||||
|
doctor.push(addresses);
|
||||||
|
|
||||||
|
// `anstream` decides whether the escapes survive: they are stripped when
|
||||||
|
// stdout is not a terminal, when NO_COLOR is set, and on a Windows console
|
||||||
|
// that cannot render them.
|
||||||
|
use std::io::Write;
|
||||||
|
let mut out = anstream::stdout().lock();
|
||||||
|
writeln!(out, "tsunagi doctor\n")?;
|
||||||
|
write!(out, "{}", doctor.render(true))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shape of what `tsunagi doctor` reports.
|
||||||
|
///
|
||||||
|
/// Findings are built first and rendered second, so what is reported is
|
||||||
|
/// decided separately from how it looks and can be tested without a
|
||||||
|
/// terminal. Colour is deliberately *redundant*: every row carries a word as
|
||||||
|
/// well, so the report reads the same when the escapes are stripped — piped
|
||||||
|
/// to a file, on a dumb terminal, or by someone who cannot distinguish the
|
||||||
|
/// colours.
|
||||||
|
mod report {
|
||||||
|
use anstyle::{AnsiColor, Color, Style};
|
||||||
|
|
||||||
|
/// How healthy one finding is.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Health {
|
||||||
|
/// Works, nothing to do.
|
||||||
|
Good,
|
||||||
|
/// The agent runs, but something it could do it cannot, and there is
|
||||||
|
/// a remedy. A missing capability with a one-line fix lands here.
|
||||||
|
Degraded,
|
||||||
|
/// Something the agent needs is unavailable and the function it
|
||||||
|
/// serves will not work at all.
|
||||||
|
Broken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Health {
|
||||||
|
/// The word printed in the margin. Four characters, so rows line up.
|
||||||
|
fn word(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Health::Good => "ok ",
|
||||||
|
Health::Degraded => "warn",
|
||||||
|
Health::Broken => "FAIL",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn style(self) -> Style {
|
||||||
|
let colour = match self {
|
||||||
|
Health::Good => AnsiColor::Green,
|
||||||
|
Health::Degraded => AnsiColor::Yellow,
|
||||||
|
Health::Broken => AnsiColor::Red,
|
||||||
|
};
|
||||||
|
Style::new().fg_color(Some(Color::Ansi(colour)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One finding.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Row {
|
||||||
|
health: Health,
|
||||||
|
label: String,
|
||||||
|
detail: String,
|
||||||
|
/// What to do about it, when there is something to do.
|
||||||
|
note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Row {
|
||||||
|
/// A finding with no remedy attached.
|
||||||
|
pub fn new(health: Health, label: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
health,
|
||||||
|
label: label.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the remedy shown under the row.
|
||||||
|
pub fn with_note(mut self, note: impl Into<String>) -> Self {
|
||||||
|
self.note = Some(note.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group of findings under a heading.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Section {
|
||||||
|
title: String,
|
||||||
|
rows: Vec<Row>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Section {
|
||||||
|
/// An empty section.
|
||||||
|
pub fn new(title: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
rows: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a finding.
|
||||||
|
pub fn push(&mut self, row: Row) {
|
||||||
|
self.rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything `doctor` found.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Report {
|
||||||
|
sections: Vec<Section>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Report {
|
||||||
|
/// An empty report.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a section, dropping it if it has no findings.
|
||||||
|
pub fn push(&mut self, section: Section) {
|
||||||
|
if !section.rows.is_empty() {
|
||||||
|
self.sections.push(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(&self, health: Health) -> usize {
|
||||||
|
self.sections
|
||||||
|
.iter()
|
||||||
|
.flat_map(|section| §ion.rows)
|
||||||
|
.filter(|row| row.health == health)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The worst thing in the report.
|
||||||
|
pub fn worst(&self) -> Health {
|
||||||
|
if self.count(Health::Broken) > 0 {
|
||||||
|
Health::Broken
|
||||||
|
} else if self.count(Health::Degraded) > 0 {
|
||||||
|
Health::Degraded
|
||||||
|
} else {
|
||||||
|
Health::Good
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The closing line.
|
||||||
|
fn summary(&self) -> String {
|
||||||
|
fn checks(count: usize) -> String {
|
||||||
|
if count == 1 {
|
||||||
|
"1 check".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{count} checks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (degraded, broken) = (self.count(Health::Degraded), self.count(Health::Broken));
|
||||||
|
match (degraded, broken) {
|
||||||
|
(0, 0) => "everything checked out".to_string(),
|
||||||
|
(0, broken) => format!("{} broken", checks(broken)),
|
||||||
|
(degraded, 0) => format!("{} degraded", checks(degraded)),
|
||||||
|
(degraded, broken) => {
|
||||||
|
format!("{} degraded, {} broken", checks(degraded), checks(broken))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the report.
|
||||||
|
///
|
||||||
|
/// `styled` false leaves out every escape sequence, which is what a
|
||||||
|
/// test asserts against and what a redirected stdout gets.
|
||||||
|
pub fn render(&self, styled: bool) -> String {
|
||||||
|
let width = self
|
||||||
|
.sections
|
||||||
|
.iter()
|
||||||
|
.flat_map(|section| §ion.rows)
|
||||||
|
.map(|row| row.label.chars().count())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let paint = |style: Style, text: &str| {
|
||||||
|
if styled {
|
||||||
|
format!("{style}{text}{style:#}")
|
||||||
|
} else {
|
||||||
|
text.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let bold = Style::new().bold();
|
||||||
|
let dim = Style::new().dimmed();
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
for section in &self.sections {
|
||||||
|
out.push_str(&paint(bold, §ion.title));
|
||||||
|
out.push('\n');
|
||||||
|
for row in §ion.rows {
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {} {:width$} {}\n",
|
||||||
|
paint(row.health.style(), row.health.word()),
|
||||||
|
row.label,
|
||||||
|
row.detail,
|
||||||
|
width = width
|
||||||
|
));
|
||||||
|
if let Some(note) = &row.note {
|
||||||
|
// Indented under the row it belongs to, and dimmed so
|
||||||
|
// the findings stay the thing the eye lands on.
|
||||||
|
out.push_str(&format!(
|
||||||
|
" {:4} {:width$} {}\n",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
paint(dim, note),
|
||||||
|
width = width
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let worst = self.worst();
|
||||||
|
out.push_str(&paint(worst.style(), &self.summary()));
|
||||||
|
out.push('\n');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample() -> Report {
|
||||||
|
let mut report = Report::new();
|
||||||
|
let mut storage = Section::new("storage");
|
||||||
|
storage.push(Row::new(Health::Good, "state", "/var/lib/tsunagi"));
|
||||||
|
storage.push(
|
||||||
|
Row::new(Health::Degraded, "cache directory", "not writable")
|
||||||
|
.with_note("disposable; the agent runs without it"),
|
||||||
|
);
|
||||||
|
report.push(storage);
|
||||||
|
let mut plane = Section::new("data plane");
|
||||||
|
plane.push(Row::new(Health::Broken, "/dev/net/tun", "missing"));
|
||||||
|
report.push(plane);
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops every CSI sequence, so a styled render can be compared with
|
||||||
|
/// a plain one.
|
||||||
|
fn strip(text: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = text.chars();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\u{1b}' {
|
||||||
|
for ch in chars.by_ref() {
|
||||||
|
if ch == 'm' {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unstyled_report_carries_no_escape_sequences() {
|
||||||
|
// Colour must never be the only signal: this is what lands in a
|
||||||
|
// file, a pipe, or a terminal that cannot do colour.
|
||||||
|
let text = sample().render(false);
|
||||||
|
assert!(!text.contains('\u{1b}'), "{text:?}");
|
||||||
|
assert!(text.contains("ok "));
|
||||||
|
assert!(text.contains("warn"));
|
||||||
|
assert!(text.contains("FAIL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_styled_report_says_the_same_thing_with_escapes_added() {
|
||||||
|
let styled = sample().render(true);
|
||||||
|
assert!(styled.contains('\u{1b}'));
|
||||||
|
assert_eq!(strip(&styled), sample().render(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_detail_column_starts_at_the_same_offset_on_every_row() {
|
||||||
|
// Labels differ in length across sections, so the padding has to
|
||||||
|
// be computed over the whole report rather than per section.
|
||||||
|
let mut report = Report::new();
|
||||||
|
let mut short = Section::new("short labels");
|
||||||
|
short.push(Row::new(Health::Good, "a", "detail-one"));
|
||||||
|
report.push(short);
|
||||||
|
let mut long = Section::new("long labels");
|
||||||
|
long.push(Row::new(
|
||||||
|
Health::Broken,
|
||||||
|
"a-much-longer-label",
|
||||||
|
"detail-two",
|
||||||
|
));
|
||||||
|
report.push(long);
|
||||||
|
|
||||||
|
let text = report.render(false);
|
||||||
|
let offsets: Vec<usize> = ["detail-one", "detail-two"]
|
||||||
|
.iter()
|
||||||
|
.map(|detail| {
|
||||||
|
let line = text
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.contains(detail))
|
||||||
|
.unwrap_or_else(|| panic!("no row for {detail} in:\n{text}"));
|
||||||
|
line.find(detail).unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(offsets[0], offsets[1], "misaligned:\n{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_summary_names_the_worst_thing_found() {
|
||||||
|
assert_eq!(sample().worst(), Health::Broken);
|
||||||
|
assert!(
|
||||||
|
sample()
|
||||||
|
.render(false)
|
||||||
|
.contains("1 check degraded, 1 check broken")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut clean = Report::new();
|
||||||
|
let mut section = Section::new("storage");
|
||||||
|
section.push(Row::new(Health::Good, "state", "fine"));
|
||||||
|
clean.push(section);
|
||||||
|
assert_eq!(clean.worst(), Health::Good);
|
||||||
|
assert!(clean.render(false).contains("everything checked out"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_section_is_left_out_rather_than_printed_bare() {
|
||||||
|
let mut report = Report::new();
|
||||||
|
report.push(Section::new("nothing here"));
|
||||||
|
assert!(!report.render(false).contains("nothing here"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// This program's path, for an instruction the user can paste.
|
/// This program's path, for an instruction the user can paste.
|
||||||
fn program_path() -> String {
|
fn program_path() -> String {
|
||||||
std::env::current_exe()
|
std::env::current_exe()
|
||||||
|
|||||||
Reference in New Issue
Block a user