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}