//! A report made of checks: the four outcomes, the row, and the two shapes a //! row is printed in. //! //! atgc has two reports that answer by *reporting* rather than by acting, and //! they ask opposite questions. [`crate::cmd::doctor`]'s `local` asks whether //! this machine, this account and this checkout are set up — every row is //! about you, and a bad one has a remedy you can run. Its `remote` asks //! whether the services are answering — no row is about you, and a bad one //! has nothing to run at all. //! //! Different questions, one shape of answer, and the shape is the part worth //! writing once. A row is a name, one of four outcomes, a sentence, and //! sometimes a command; the report is those rows padded into columns, with a //! status derived from the worst of them. Both halves of that were one //! report's private business until there was a second, and a second copy of //! `Status` would have been a second vocabulary for `--json` to promise — //! which is the failure [`crate::term::style`] was extracted to stop, one //! module over. //! //! The two reports therefore line up on the same columns whether you run one //! or both, which is why [`NAME_WIDTH`] is a constant rather than the widest //! name in the report at hand. use std::borrow::Cow; use crate::exit::Exit; // --------------------------------------------------------------------------- // What a check comes out as // --------------------------------------------------------------------------- /// How one check came out. /// /// Four outcomes rather than a boolean, because two of the four are the ones /// that keep a report usable. `Na` is what makes running `doctor local` /// outside a checkout, or logged out, a report instead of a refusal; `Warn` is /// what /// keeps a lagging index or an unconfigured git identity from being dressed /// up as a broken install. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Status { /// Nothing to do about this one. Ok, /// Nothing to check: the question does not apply here. Outside a /// checkout, logged out, or a repo nothing could name. Na, /// Something is off, and the thing it affects still works today. Warn, /// Broken, carrying the status atgc would have exited with had you run /// the command that needs it — see [`exit_status`]. Bad(Exit), } impl Status { /// The one word both views print. A closed vocabulary, so a caller /// matching on `--json` never has to parse the sentence beside it — the /// same reason [`crate::clients::atproto::oauth::sessions::SessionState::label`] exists. pub fn label(self) -> &'static str { match self { Status::Ok => "ok", Status::Na => "n/a", Status::Warn => "warn", Status::Bad(_) => "error", } } /// The exit status this outcome argues for, if it argues for one. pub fn exit(self) -> Option { match self { Status::Bad(exit) => Some(exit), _ => None, } } } /// One row of a report. #[derive(Debug)] pub struct Check { /// The label in the left column, and the `name` in `--json`. /// /// `Cow` rather than `&'static str` for exactly one row: `doctor local`'s /// remote row is named for the remote it asked about, which `--remote` /// makes a runtime string. Every other row in both reports is a literal /// and costs nothing to borrow. pub name: Cow<'static, str>, pub status: Status, /// The finding, as a sentence. May span lines; the renderer indents the /// continuation under the first, the way [`crate::term::say`] does. pub detail: String, /// The command that fixes it, when one command does. Kept apart from /// `detail` so a caller can act on it without reading prose — and left /// `None` rather than guessed, because advice that does not help is worse /// than none. A report about somebody else's services leaves it `None` /// throughout: there is no command here that mends a host that is down. pub remedy: Option, } impl Check { pub fn new( name: impl Into>, status: Status, detail: impl Into, ) -> Self { Check { name: name.into(), status, detail: detail.into(), remedy: None, } } pub fn ok(name: impl Into>, detail: impl Into) -> Self { Check::new(name, Status::Ok, detail) } pub fn na(name: impl Into>, detail: impl Into) -> Self { Check::new(name, Status::Na, detail) } pub fn warn(name: impl Into>, detail: impl Into) -> Self { Check::new(name, Status::Warn, detail) } pub fn bad(name: impl Into>, exit: Exit, detail: impl Into) -> Self { Check::new(name, Status::Bad(exit), detail) } /// The command that fixes this finding. pub fn fix(mut self, remedy: impl Into) -> Self { self.remedy = Some(remedy.into()); self } } /// The status a whole report exits with. /// /// `Ok` when nothing is broken — a warning is not, or `doctor local` would /// exit non-zero in every checkout that has never been near Tangled. Otherwise the /// status of the **first** broken check, which is not arbitrary: a report /// orders its rows by what depends on what, so the first one broken is the one /// whose remedy comes first. A session that is missing makes the scope gap /// beneath it unanswerable, and a knot nothing can reach makes the index /// question beneath *that* moot. /// /// No check invents a code. Each one carries the status the operation it /// stands for would have exited with: no session is /// [`NoSession`](Exit::NoSession) because `atgc auth login` is the fix, a /// scope gap and an unregistered key are both [`Denied`](Exit::Denied) /// because the PDS and the knot respectively will refuse the write, and every /// network failure is whatever [`crate::exit::classify`] reads off the error /// — which for a host that never answered is [`Unreachable`](Exit::Unreachable) /// with nobody having to say so. pub fn exit_status(checks: &[Check]) -> Exit { checks .iter() .find_map(|check| check.status.exit()) .unwrap_or(Exit::Ok) } /// The one line on stderr beside a report that found something. /// /// It names the rows rather than repeating them: the detail is already on /// stdout, and what this has to add is which of them the exit status came /// from — with more than one broken, the status is the first's and saying so /// is the difference between "atgc is broken" and "fix these in this order". /// /// `subject` is the command speaking, because the sentence is read on stderr /// with no report beside it as often as not — in a pipeline, in CI, in a /// scrollback with three commands above it. /// /// `tail` is what the first row being chosen *means*, and only one of the two /// reports has an answer. `doctor local` orders its rows by what depends on /// what, so the first broken one is the one to fix first and saying so is the /// whole /// value of the line. A report about four independent services has no such /// claim to make and passes `None` rather than inventing one — advice that /// does not help being worse than none, the same rule [`Check::remedy`] /// follows. pub fn summary(checks: &[Check], subject: &str, tail: Option<&str>) -> String { let broken: Vec<&str> = checks .iter() .filter(|check| check.status.exit().is_some()) .map(|check| check.name.as_ref()) .collect(); match broken.split_first() { Some((first, [])) => format!("{subject} found a problem: {first}"), Some((first, rest)) => { let tail = tail.map(|t| format!(", {t}")).unwrap_or_default(); format!( "{subject} found {} problems: {first}, {}\nthe exit status is {first}'s{tail}", broken.len(), rest.join(", ") ) } None => format!("{subject} found a problem"), } } // --------------------------------------------------------------------------- // The report, as a person reads it // --------------------------------------------------------------------------- /// Columns before the status word. /// /// A constant and not the widest name in the report being rendered: atgc's /// reports are read one after the other as often as alone, and two commands /// printing the same shape at two left margins would look like two formats. /// Twelve is `git identity`, the longest label either of them has. pub const NAME_WIDTH: usize = 12; /// Columns the status word occupies. `warn` is the longest of the four. pub const STATUS_WIDTH: usize = 5; /// A whole report as text. /// /// Pure, and separated from the gathering for the reason /// [`crate::docs::testing`] gives: /// the network half of a report command is four lines of plumbing per check, /// and the half worth pinning is what a set of findings turns into. pub fn render(checks: &[Check]) -> String { let indent = " ".repeat(2 + NAME_WIDTH + 2 + STATUS_WIDTH + 2); let mut out = String::new(); for check in checks { // The remedy is the last line of the row rather than a column of its // own: it is a command to be copied, and a command wrapped into a // narrow column is a command that has to be retyped. let lines = check .detail .lines() .map(str::to_string) .chain(check.remedy.iter().map(|fix| format!("run: {fix}"))) .collect::>(); for (i, line) in lines.iter().enumerate() { if i == 0 { out.push_str(&format!( " {} {} {line}\n", crate::term::column::pad_to(&check.name, NAME_WIDTH), crate::term::column::pad_to(check.status.label(), STATUS_WIDTH), )); } else { out.push_str(&format!("{indent}{line}\n")); } } } out } // --------------------------------------------------------------------------- // The report, as a script reads it // --------------------------------------------------------------------------- /// One check, as `--json` prints it. #[derive(serde::Serialize, Debug, PartialEq)] pub struct CheckJson { /// The row's label — `doctor local`'s `config dir`, `session`, `scopes`, /// `ssh key`, `git identity`, `change-id hook`, `origin`; `doctor /// remote`'s `appview`, `bobbin`, `index`, `knot`, `pds`. Stable per /// command; the sentence beside it is not. /// /// One exception, and `--remote` is the whole of it: the last row of /// `doctor local` is named for the git remote it resolved, which is /// `origin` unless you named another. A row asserting `origin` about a /// remote called something else would be the lie this field exists to /// avoid. pub name: Cow<'static, str>, /// `ok`, `warn`, `error` or `n/a` — the closed vocabulary a caller /// matches on. pub status: &'static str, /// The finding as a sentence, newlines and all. Prose, deliberately: what /// is wrong with a session is not a fixed set of cases, and the fields a /// caller can act on are `status` and `remedy`. pub detail: String, /// The one command that fixes this, or `null` where no single command /// does — which for a report about somebody else's services is every row. pub remedy: Option, } impl CheckJson { /// The `--json` view of a row, which every reporting command needs and /// none of them should spell for itself. pub fn of(check: &Check) -> Self { CheckJson { name: check.name.clone(), status: check.status.label(), detail: check.detail.clone(), remedy: check.remedy.clone(), } } }