Skip to main content

choir_cli/
doctor.rs

1//! `choir doctor` — one command that answers "why did that fail?".
2//!
3//! Every other command in this binary assumes its surroundings: that
4//! `git` is on the path, that `curl` can reach a node, that the auth
5//! file is readable and not world-readable, that the node it is about
6//! to talk to is actually up. When one of those is false the failure
7//! surfaces wherever it happens to be noticed — a subprocess exit
8//! status, a `curl` error, a 401 — and the reader has to work backwards
9//! from a symptom to a cause.
10//!
11//! This module checks all of them up front and reports each one with the
12//! command that fixes it. It is the only place in the crate that treats
13//! a missing dependency as a *finding* rather than an error: nothing
14//! here aborts on the first failure, because "git is missing" and "the
15//! node is unreachable" are independently useful and a reader who has
16//! both wants both in one pass.
17//!
18//! # Examples
19//!
20//! ```
21//! use choir_cli::doctor::{Check, Status};
22//!
23//! let checks = vec![
24//!     Check::pass("git", "/usr/bin/git"),
25//!     Check::fail("mergiraf", "not on PATH").with_fix("brew install mergiraf"),
26//! ];
27//! assert_eq!(Status::worst(&checks), Status::Fail);
28//! assert_eq!(choir_cli::doctor::exit_code(&checks), 1);
29//! ```
30
31use crate::style::Style;
32use std::path::PathBuf;
33
34/// How a single check came out.
35///
36/// Three states rather than two because the difference matters to the
37/// exit code: an absent `mergiraf` narrows the merge ladder to line
38/// merge and first-class conflicts, which is a *worse* node and a
39/// working one. Failing the command for it would teach operators to
40/// ignore the command.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum Status {
43    /// Present and usable.
44    Pass,
45    /// Usable, but something is degraded or unset.
46    Warn,
47    /// Something that is required is absent or broken.
48    Fail,
49}
50
51impl Status {
52    /// The most severe status in a set, or [`Status::Pass`] for none.
53    ///
54    /// This is what decides the exit code, so it is ordering over the
55    /// enum rather than a hand-written match: adding a status variant
56    /// between the existing ones must not silently keep the old answer.
57    pub fn worst(checks: &[Check]) -> Status {
58        checks
59            .iter()
60            .map(|c| c.status)
61            .max()
62            .unwrap_or(Status::Pass)
63    }
64
65    /// The fixed-width label used in the report, without colour.
66    pub fn label(self) -> &'static str {
67        match self {
68            Status::Pass => "ok  ",
69            Status::Warn => "warn",
70            Status::Fail => "FAIL",
71        }
72    }
73
74    fn paint(self, style: Style) -> String {
75        match self {
76            Status::Pass => style.green(self.label()),
77            Status::Warn => style.cyan(self.label()),
78            Status::Fail => style.red(self.label()),
79        }
80    }
81}
82
83/// One thing that was checked, and what to do when it is wrong.
84#[derive(Debug, Clone)]
85pub struct Check {
86    /// What was checked, e.g. `git` or `node reachable`.
87    pub name: String,
88    /// How it came out.
89    pub status: Status,
90    /// What was found: a path, a version, an error, an http status.
91    pub detail: String,
92    /// The command that fixes it, when there is one to name.
93    ///
94    /// `None` for a passing check and for a failure whose fix depends on
95    /// something this process cannot know. A wrong fix is worse than no
96    /// fix: it gets run.
97    pub fix: Option<String>,
98}
99
100impl Check {
101    /// A passing check.
102    pub fn pass(name: &str, detail: impl Into<String>) -> Check {
103        Check {
104            name: name.into(),
105            status: Status::Pass,
106            detail: detail.into(),
107            fix: None,
108        }
109    }
110
111    /// A degraded-but-working check.
112    pub fn warn(name: &str, detail: impl Into<String>) -> Check {
113        Check {
114            name: name.into(),
115            status: Status::Warn,
116            detail: detail.into(),
117            fix: None,
118        }
119    }
120
121    /// A check that failed.
122    pub fn fail(name: &str, detail: impl Into<String>) -> Check {
123        Check {
124            name: name.into(),
125            status: Status::Fail,
126            detail: detail.into(),
127            fix: None,
128        }
129    }
130
131    /// Attaches the command that fixes this check.
132    pub fn with_fix(mut self, fix: impl Into<String>) -> Check {
133        self.fix = Some(fix.into());
134        self
135    }
136}
137
138/// The exit code for a set of checks: 0 when nothing failed, 1 when
139/// something did.
140///
141/// A warning does not fail the command. This follows the binary's own
142/// convention — 0 accepted, 1 rejected, 2 usage — so `choir doctor &&
143/// choir propose …` means what it looks like it means.
144pub fn exit_code(checks: &[Check]) -> i32 {
145    match Status::worst(checks) {
146        Status::Fail => 1,
147        _ => 0,
148    }
149}
150
151/// Finds an executable on `PATH`, the way exec does.
152///
153/// Reading `PATH` is not configuration — it is the environment
154/// describing the machine, the same category as the `PATH`/`HOME`
155/// pass-through `choir-queue` already relies on. Walking it here rather
156/// than shelling out to `which` costs no subprocess and gives the same
157/// answer the failing `Command::new` would have got.
158pub fn on_path(tool: &str) -> Option<PathBuf> {
159    let path = std::env::var_os("PATH")?;
160    std::env::split_paths(&path)
161        .map(|dir| dir.join(tool))
162        .find(|candidate| is_executable(candidate))
163}
164
165#[cfg(unix)]
166fn is_executable(path: &std::path::Path) -> bool {
167    use std::os::unix::fs::PermissionsExt;
168    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
169}
170
171#[cfg(not(unix))]
172fn is_executable(path: &std::path::Path) -> bool {
173    path.is_file()
174}
175
176/// Runs `<tool> <args>` and returns its first line of output.
177///
178/// Both streams are read because the version banners disagree about
179/// which one they belong on, and a tool that prints its version to
180/// stderr is not thereby broken.
181fn first_line(tool: &std::path::Path, args: &[&str]) -> Option<String> {
182    let out = std::process::Command::new(tool).args(args).output().ok()?;
183    let text = if out.stdout.is_empty() {
184        out.stderr
185    } else {
186        out.stdout
187    };
188    String::from_utf8_lossy(&text)
189        .lines()
190        .next()
191        .map(str::to_string)
192}
193
194/// One external binary this workspace shells out to.
195struct Tool {
196    name: &'static str,
197    /// How to ask it for its version, or empty when it has no clean way
198    /// to be asked. `ssh-keygen` is the empty case: every spelling of
199    /// `--version` is an unknown flag it answers with usage.
200    version: &'static [&'static str],
201    /// What stops working without it. Empty means it is required.
202    without: &'static str,
203    fix: &'static str,
204}
205
206/// The tools every command here assumes, and what each is for.
207///
208/// Required and optional in one table rather than two, because the
209/// difference is one field and two tables drift.
210const TOOLS: &[Tool] = &[
211    Tool {
212        name: "git",
213        version: &["--version"],
214        without: "",
215        fix: "install git (xcode-select --install, or your package manager)",
216    },
217    Tool {
218        name: "curl",
219        version: &["--version"],
220        without: "",
221        fix: "install curl",
222    },
223    Tool {
224        name: "openssl",
225        version: &["version"],
226        without: "",
227        fix: "brew install openssl@3 (macOS) or apt install openssl libssl-dev (Debian)",
228    },
229    Tool {
230        name: "ssh-keygen",
231        version: &[],
232        without: "",
233        fix: "install openssh-client",
234    },
235    Tool {
236        name: "mergiraf",
237        version: &["--version"],
238        without: "structured merge; line merge and first-class conflicts still work",
239        fix: "brew install mergiraf",
240    },
241    Tool {
242        name: "mdbook",
243        version: &["--version"],
244        without: "`choir docs` cannot build the book",
245        fix: "cargo install mdbook --locked",
246    },
247];
248
249/// Checks every external binary in [`TOOLS`].
250fn tool_checks() -> Vec<Check> {
251    TOOLS
252        .iter()
253        .map(|tool| match on_path(tool.name) {
254            Some(path) => {
255                let detail = match tool.version.is_empty() {
256                    true => path.display().to_string(),
257                    false => first_line(&path, tool.version)
258                        .unwrap_or_else(|| path.display().to_string()),
259                };
260                Check::pass(tool.name, detail)
261            }
262            None if tool.without.is_empty() => {
263                Check::fail(tool.name, "not on PATH").with_fix(tool.fix)
264            }
265            None => Check::warn(
266                tool.name,
267                format!("not on PATH — without it, {}", tool.without),
268            )
269            .with_fix(tool.fix),
270        })
271        .collect()
272}
273
274/// Checks the auth file: that it exists, and that nobody else can read
275/// it.
276///
277/// The mode check is not decoration. This file carries a bearer
278/// credential for a node, and a group- or world-readable one is a
279/// credential every account on the machine holds. It is reported as a
280/// failure rather than a warning for that reason, even though every
281/// command would keep working.
282fn auth_check(path: Option<&str>) -> Check {
283    let Some(path) = path else {
284        return Check::warn("auth file", "none given, and no default found").with_fix(
285            "choir join '<link>' to be issued one, or name yours: choir --auth-file <path> …",
286        );
287    };
288    let Ok(meta) = std::fs::metadata(path) else {
289        return Check::fail("auth file", format!("{path} cannot be read"))
290            .with_fix("choir join '<link>'");
291    };
292    #[cfg(unix)]
293    {
294        use std::os::unix::fs::PermissionsExt;
295        let mode = meta.permissions().mode() & 0o777;
296        if mode & 0o077 != 0 {
297            return Check::fail("auth file", format!("{path} is mode {mode:04o}"))
298                .with_fix(format!("chmod 600 {path}"));
299        }
300        Check::pass("auth file", format!("{path} ({mode:04o})"))
301    }
302    #[cfg(not(unix))]
303    {
304        let _ = meta;
305        Check::pass("auth file", path.to_string())
306    }
307}
308
309/// Checks that a node answers, using the same credential the other
310/// commands would.
311///
312/// `/healthz` rather than `/api/view`, because the question here is "is
313/// anything serving" and `/api/view` conflates that with "may I read
314/// it". The node authenticates `/healthz` like everything else, so this
315/// goes through [`HttpClient`] rather than a bare `curl`: a doctor that
316/// reached the node differently from the commands it is diagnosing
317/// would be checking a path nobody runs, and would report every
318/// correctly authenticated node as a 401.
319///
320/// A 401 *with* a credential is therefore a real finding — the token is
321/// wrong or the node does not know it — and is reported as one.
322///
323/// [`HttpClient`]: crate::mcp::HttpClient
324fn node_check(api: Option<&str>, auth: Option<&str>, curl: bool) -> Check {
325    let Some(api) = api else {
326        return Check::warn("node", "no node configured").with_fix(
327            "choir join '<link>' writes one to ~/.choir/config; or write `node = <url>` \
328             to .choir/config here, or pass the URL",
329        );
330    };
331    if !curl {
332        return Check::fail("node", format!("{api}: cannot check without curl"));
333    }
334    let client = match crate::mcp::HttpClient::new(api, auth.map(std::path::Path::new), None) {
335        Ok(client) => client,
336        Err(error) => return Check::fail("node", format!("{api}: {error}")),
337    };
338    match client.get("/healthz") {
339        Ok((200, _)) => Check::pass("node", format!("{api} is healthy")),
340        // The node serves 503 here when its own durability check has
341        // failed. That is the node telling the truth about itself, and
342        // it is the one answer this command must not soften.
343        Ok((503, body)) => Check::fail("node", format!("{api} reports unhealthy: {body}"))
344            .with_fix("check the node's log; a failed durable append exits it 75"),
345        Ok((401, _)) if auth.is_none() => {
346            Check::warn("node", format!("{api} is up; needs a credential"))
347                .with_fix("choir --auth-file <path> doctor")
348        }
349        Ok((401, _)) => Check::fail("node", format!("{api} refused the credential"))
350            .with_fix("check --auth-file names a token this node knows"),
351        Ok((code, _)) => Check::warn("node", format!("{api} answered {code} on /healthz")),
352        Err(error) => Check::fail("node", format!("{api}: {error}")),
353    }
354}
355
356/// Runs every check.
357///
358/// `api` and `auth` are passed in rather than discovered here so the
359/// caller's own resolution — the `.choir/config` walk, the `--auth-file`
360/// flag — is the one being reported on. A doctor that resolves its
361/// inputs differently from the commands it is diagnosing is checking a
362/// configuration nobody runs.
363pub fn run(api: Option<&str>, auth: Option<&str>) -> Vec<Check> {
364    let mut checks = vec![self_check()];
365    checks.extend(tool_checks());
366    let curl = checks
367        .iter()
368        .any(|c| c.name == "curl" && c.status == Status::Pass);
369    checks.push(daemon_check());
370    checks.push(auth_check(auth));
371    checks.push(node_check(api, auth, curl));
372    checks
373}
374
375/// What this `choir` is: its version, the commit it was built from, and
376/// the file it is running as.
377///
378/// First in the report because every other line describes the machine
379/// and this one describes the thing doing the reporting. The common
380/// support question after there are two ways to obtain the binary is
381/// "which one am I running" — an old copy in `~/.cargo/bin` shadowing a
382/// new one in a build tree answers every other check identically.
383///
384/// The stamp source is carried through rather than summarised: `env`
385/// means something set `CHOIR_GIT_HEAD` at build time, which is the
386/// release workflow and the on-box installer; `git` means a build in a
387/// checkout; `unavailable` means neither, and the commit reads
388/// `unknown`. See `crates/choir-node/build.rs`.
389///
390/// Deliberately not claimed: *which* installer put it there. The shell
391/// installer writes no receipt (the self-updater that would consume one
392/// is off, on purpose), so a binary in `$CARGO_HOME/bin` could equally
393/// have come from `cargo install` or `cargo binstall`. Naming one of
394/// them would be a guess printed as a fact, in the one command whose
395/// whole job is to stop people guessing.
396fn self_check() -> Check {
397    let Ok(exe) = std::env::current_exe() else {
398        // Not a failure: the binary plainly ran. It just cannot say
399        // which file it is, which is a curiosity rather than a fault.
400        return Check::warn(
401            "choir",
402            format!(
403                "{} {} (this process cannot name its own path)",
404                env!("CARGO_PKG_VERSION"),
405                choir_node::build_line()
406            ),
407        );
408    };
409    let where_ = if crate::supervise::in_build_directory(&exe) {
410        "a build directory"
411    } else if in_cargo_home(&exe) {
412        "an install directory"
413    } else {
414        "not an install or build directory"
415    };
416    Check::pass(
417        "choir",
418        format!(
419            "{} {}, {} ({where_})",
420            env!("CARGO_PKG_VERSION"),
421            choir_node::build_line(),
422            exe.display()
423        ),
424    )
425}
426
427/// Whether a path sits in the `bin` directory both installers write to.
428///
429/// `CARGO_HOME` before `HOME/.cargo`, in that order, because that is the
430/// order the shell installer resolves it in and this has to agree with
431/// the thing it is describing.
432fn in_cargo_home(exe: &std::path::Path) -> bool {
433    let bin = match std::env::var_os("CARGO_HOME") {
434        Some(home) => PathBuf::from(home).join("bin"),
435        None => match std::env::var_os("HOME") {
436            Some(home) => PathBuf::from(home).join(".cargo").join("bin"),
437            None => return false,
438        },
439    };
440    exe.parent() == Some(bin.as_path())
441}
442
443/// The six facts that describe a machine *hosting* a node, as opposed
444/// to one talking to somebody else's.
445///
446/// Appended to [`run`] rather than folded into it, because they are only
447/// findings on a machine that has a node: reporting "linger is off" to a
448/// laptop that only ever clones would be six rows of noise on a report
449/// whose value is that every row means something.
450///
451/// The last of them is a request to this node's *public* name, made from
452/// the node itself. That is a hairpin — the packet may never leave the
453/// box — and it still answers the two questions that go wrong most
454/// often: whether the name resolves, and whether the certificate
455/// presented matches it. A firewall closed to the outside is the case it
456/// cannot see, and the firewall row is what covers that.
457#[must_use]
458pub fn host(state: &std::path::Path, auth: Option<&str>) -> Vec<Check> {
459    let layout = crate::serve::Layout::new(state, port_of_state(state));
460    let tls = layout.tls();
461    let mut checks = vec![Check::pass(
462        "bind",
463        match tls {
464            Some(_) => format!("{}:{} (reachable from outside)", layout.bind(), layout.port),
465            None => format!("127.0.0.1:{} (loopback only)", layout.port),
466        },
467    )];
468
469    checks.push(match &tls {
470        Some((cert, _)) => Check::pass("tls", format!("on, {}", cert.display())),
471        // A pass, not a warning. A loopback node without a certificate
472        // is correct rather than degraded — invariant 9 is what makes it
473        // so — and a report that cries "degraded" at every laptop is a
474        // report people stop reading.
475        // A pass, not a warning. A loopback node without a certificate
476        // is correct rather than degraded — invariant 9 is what makes it
477        // so — and a report that cries "degraded" at every laptop is a
478        // report people stop reading. The pointer goes in the detail
479        // rather than in a fix, because a fix on a passing row is a fix
480        // for a problem the row just said there isn't.
481        None => Check::pass(
482            "tls",
483            "off — not needed on loopback; `choir host --domain <name>` publishes it",
484        ),
485    });
486
487    checks.push(match &tls {
488        None => Check::pass("certificate", "none needed for a loopback node"),
489        Some((cert, _)) => match crate::tls::expiry(cert) {
490            Err(error) => Check::fail("certificate", error)
491                .with_fix("sudo choir node tls <domain> --user $(id -un)"),
492            Ok(when) => {
493                // `openssl -checkend` rather than parsing that date and
494                // doing the arithmetic here: the question is "is it
495                // still good", openssl answers it with an exit code, and
496                // a date parser written for one report is a date parser
497                // that is wrong in one time zone.
498                if !checkend(cert, 0) {
499                    Check::fail("certificate", format!("EXPIRED — was valid until {when}"))
500                        .with_fix("sudo certbot renew --force-renewal && choir node restart")
501                } else if !checkend(cert, 21 * 24 * 3600) {
502                    Check::warn("certificate", format!("expires within 21 days — {when}"))
503                        .with_fix("sudo certbot renew --dry-run   (checks the renewal path)")
504                } else {
505                    Check::pass("certificate", format!("valid until {when}"))
506                }
507            }
508        },
509    });
510
511    let user = crate::host::username();
512    checks.push(match crate::host::linger(&user) {
513        None => Check::pass("linger", "not a systemd --user machine"),
514        Some(true) => Check::pass("linger", format!("on for {user}")),
515        Some(false) => Check::fail(
516            "linger",
517            format!("off — the node dies when {user} logs out"),
518        )
519        .with_fix(format!("sudo loginctl enable-linger {user}")),
520    });
521
522    checks.push(unit_check());
523
524    checks.push(match layout.public() {
525        None => Check::pass("public url", "none; this node is not published"),
526        Some(url) => {
527            match crate::mcp::HttpClient::new(&url, auth.map(std::path::Path::new), None) {
528                Err(error) => Check::fail("public url", format!("{url}: {error}")),
529                Ok(client) => match client.get("/healthz") {
530                    Ok((200 | 401, _)) => Check::pass("public url", format!("{url} answers")),
531                    Ok((code, _)) => Check::warn("public url", format!("{url} answered {code}")),
532                    Err(error) => Check::fail("public url", format!("{url}: {error}")).with_fix(
533                        "check DNS points here, and that the port is open in the firewall",
534                    ),
535                },
536            }
537        }
538    });
539
540    checks
541}
542
543/// Whether a certificate is still valid `seconds` from now.
544fn checkend(cert: &std::path::Path, seconds: u64) -> bool {
545    std::process::Command::new("openssl")
546        .args(["x509", "-noout", "-checkend", &seconds.to_string(), "-in"])
547        .arg(cert)
548        .output()
549        .is_ok_and(|out| out.status.success())
550}
551
552/// Whether the service manager has this node loaded and running.
553fn unit_check() -> Check {
554    let Some(supervisor) = crate::supervise::Supervisor::detect() else {
555        return Check::warn(
556            "unit",
557            format!("no service manager on {}", std::env::consts::OS),
558        )
559        .with_fix("run it in the foreground: choir node serve");
560    };
561    let (program, args) = match supervisor {
562        crate::supervise::Supervisor::Launchd => (
563            "launchctl",
564            vec![
565                "print".to_string(),
566                format!("gui/{}/{}", crate::tls::uid(), crate::supervise::LABEL),
567            ],
568        ),
569        crate::supervise::Supervisor::Systemd => (
570            "systemctl",
571            vec![
572                "--user".to_string(),
573                "is-active".to_string(),
574                "choir-node.service".to_string(),
575            ],
576        ),
577    };
578    match std::process::Command::new(program).args(&args).output() {
579        Ok(out) if out.status.success() => Check::pass("unit", "loaded and running"),
580        // A warning, not a failure. An unsupervised node is a real
581        // state and a supported one — `choir node serve` in a terminal,
582        // and `choir host --foreground` in a container, where the
583        // runtime is the supervisor and there is no unit to find. That
584        // the node is *answering* is the `node` row's question, and it
585        // is the one that fails when nothing does.
586        Ok(_) => Check::warn("unit", "not loaded — this node is not supervised")
587            .with_fix("choir node install, to survive a logout and a reboot"),
588        Err(error) => Check::warn("unit", format!("could not ask {program}: {error}")),
589    }
590}
591
592/// The port a state directory's node was installed on.
593///
594/// Read back out of the public URL when there is one, so a published
595/// node reports the port it actually serves rather than the default.
596fn port_of_state(state: &std::path::Path) -> u16 {
597    let layout = crate::serve::Layout::new(state, 8417);
598    layout
599        .public()
600        .as_deref()
601        .and_then(crate::serve::port_of)
602        .unwrap_or(8417)
603}
604
605/// Whether `choir node serve` has a daemon to exec.
606///
607/// A warning rather than a failure: a machine that only ever talks to
608/// somebody else's node needs no `choir-node` at all, and failing there
609/// would tell a perfectly healthy client install that it is broken. It
610/// is here because `serve` and `install` both depend on it, and finding
611/// out at install time — from a service manager that then retries every
612/// two seconds — is the worst place to find out.
613fn daemon_check() -> Check {
614    match crate::serve::find_daemon() {
615        Ok(path) => {
616            let where_ = path.display().to_string();
617            if crate::supervise::in_build_directory(&path) {
618                // Not a failure either: running from a build tree is
619                // exactly right in a checkout. It is only a unit
620                // pointing at one that breaks, and `node install`
621                // refuses that on its own.
622                Check::warn("choir-node", format!("{where_} (a build directory)")).with_fix(
623                    "fine for a checkout; `choir node install` refuses it, so install \
624                     the pair before supervising one",
625                )
626            } else {
627                Check::pass("choir-node", where_)
628            }
629        }
630        Err(_) => Check::warn("choir-node", "not beside `choir` or on PATH")
631            .with_fix("only needed to run a node yourself: cargo build --release -p choir-node"),
632    }
633}
634
635/// The line above the checks: what this machine is set up as.
636///
637/// Separate from [`report`] rather than folded into it because the role
638/// is read off paths and `report` is handed findings. Keeping the two
639/// apart is what lets the caller's own resolution — the `.choir/config`
640/// walk, the `--auth-file` flag — be the thing reported on, which is the
641/// same reason [`run`] takes its inputs rather than discovering them.
642#[must_use]
643pub fn heading(role: crate::join::Role, style: Style) -> String {
644    format!("\n  {}\n\n", style.bold(role.line()))
645}
646
647/// Renders the checks as the report the command prints.
648///
649/// Fixes are gathered under the table rather than shown per row: the
650/// table answers "what is wrong" at a glance, and a `fix` column would
651/// wrap every long command and destroy that. Ordering is the check
652/// order, not severity — the reader is looking for a name they
653/// recognise.
654pub fn report(checks: &[Check], style: Style) -> String {
655    let width = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
656    let mut out = String::new();
657    for check in checks {
658        let name = format!("{:width$}", check.name);
659        out.push_str(&format!(
660            "  {}  {}  {}\n",
661            check.status.paint(style),
662            style.dim(&name),
663            check.detail
664        ));
665    }
666    let fixes: Vec<&Check> = checks.iter().filter(|c| c.fix.is_some()).collect();
667    if !fixes.is_empty() {
668        out.push('\n');
669        for check in fixes {
670            let fix = check.fix.as_deref().unwrap_or_default();
671            out.push_str(&format!(
672                "  {} {}\n",
673                style.dim(&format!("{}:", check.name)),
674                fix
675            ));
676        }
677    }
678    out.push('\n');
679    out.push_str(&match Status::worst(checks) {
680        Status::Fail => format!("  {}\n", style.red("something required is missing")),
681        Status::Warn => format!("  {}\n", style.cyan("usable; some things are degraded")),
682        Status::Pass => format!("  {}\n", style.green("everything checks out")),
683    });
684    out
685}