Skip to main content

fs_core/cli/
doctor.rs

1//! `<repo> doctor`: is every name this repository installs, as PATH
2//! resolves it, our program?
3//!
4//! A dotted name is a shared namespace. `mkfs.<fs>` may also be the
5//! reference tools' program, a distribution's `/usr/sbin/mkfs.<fs>`, or an
6//! older install of ours, and
7//! whichever PATH finds first is the one a user runs. When that is not
8//! ours the symptom is a tool that "behaves unexpectedly" — so this says,
9//! per name, what wins, whose it is, and the exact fix.
10//!
11//! "Ours" is decided by asking: each program found is run with
12//! `--version` and must answer `<name> (<crate>) <version>` with this
13//! binary's crate AND version. A same-crate program at another version is
14//! a second install, stale, and reported as such.
15
16use std::ffi::OsStr;
17use std::path::{Path, PathBuf};
18use std::process::{Command as Process, Stdio};
19use std::time::Duration;
20
21use clap::Command as Cmd;
22
23use super::family::Family;
24use super::output::{Json, Outcome};
25use super::version;
26
27/// The clap command for `doctor`.
28pub fn command(family: &Family) -> Cmd {
29    Cmd::new("doctor")
30        .about("Check that every tool PATH resolves is this program, and say how to fix one that is not")
31        .args(super::format_args())
32        .after_help(format!(
33            "Exits 0 when every name resolves to this program, 1 otherwise.\n\n\
34             Examples:\n  {repo} doctor\n  {repo} doctor --text",
35            repo = family.repo
36        ))
37}
38
39/// What one name resolves to.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Status {
42    /// This program, at this version.
43    Ours,
44    /// Nothing on PATH answers to the name.
45    Missing,
46    /// Something else wins: another package's program, or a file that
47    /// does not answer `--version` in our shape.
48    Foreign,
49    /// Ours, but another version: a second install is ahead on PATH.
50    Stale,
51}
52
53impl Status {
54    fn as_str(&self) -> &'static str {
55        match self {
56            Status::Ours => "ours",
57            Status::Missing => "missing",
58            Status::Foreign => "foreign",
59            Status::Stale => "stale",
60        }
61    }
62}
63
64/// One name's diagnosis.
65#[derive(Debug, Clone)]
66pub struct Finding {
67    pub name: String,
68    pub status: Status,
69    /// The program PATH runs for the name.
70    pub path: Option<PathBuf>,
71    /// The first line of its `--version`, as it answered.
72    pub version: Option<String>,
73    /// The Homebrew formula the winning program belongs to, when it lives
74    /// in a Cellar.
75    pub formula: Option<String>,
76    /// Every later program of the same name on PATH, in PATH order.
77    pub shadowed: Vec<PathBuf>,
78    /// What to do, when there is something to do.
79    pub fix: Option<String>,
80}
81
82/// Diagnose every name, against the process's PATH.
83pub fn diagnose(family: &Family) -> Vec<Finding> {
84    let path = std::env::var_os("PATH").unwrap_or_default();
85    diagnose_path(family, &path)
86}
87
88/// Diagnose every name against an explicit PATH value.
89pub fn diagnose_path(family: &Family, path: &OsStr) -> Vec<Finding> {
90    let dirs: Vec<PathBuf> = std::env::split_paths(path).collect();
91    family
92        .tools
93        .iter()
94        .map(|tool| diagnose_one(family, tool.name, &dirs))
95        .collect()
96}
97
98fn diagnose_one(family: &Family, name: &str, dirs: &[PathBuf]) -> Finding {
99    let found: Vec<PathBuf> = dirs
100        .iter()
101        .map(|dir| dir.join(name))
102        .filter(|candidate| is_executable(candidate))
103        .collect();
104    let Some(winner) = found.first().cloned() else {
105        return Finding {
106            name: name.to_string(),
107            status: Status::Missing,
108            path: None,
109            version: None,
110            formula: None,
111            shadowed: Vec::new(),
112            fix: Some(format!(
113                "{name} is not on PATH. Install it: {}",
114                family.install_hints.join(", or ")
115            )),
116        };
117    };
118    let answer = ask_version(&winner);
119    let identity = answer.as_deref().and_then(version::parse);
120    let status = match &identity {
121        Some(id) if id.crate_name == family.crate_name && id.tool == name => {
122            if id.version == family.version {
123                Status::Ours
124            } else {
125                Status::Stale
126            }
127        }
128        _ => Status::Foreign,
129    };
130    let formula = cellar_formula(&winner);
131    // Only asked when the winner is not ours: the later programs are never
132    // run otherwise (a CI runner may have stubbed them to fail loudly).
133    let ours_later = found[1..]
134        .iter()
135        .filter(|_| status != Status::Ours)
136        .find(|p| {
137            ask_version(p)
138                .as_deref()
139                .and_then(version::parse)
140                .is_some_and(|id| {
141                    id.crate_name == family.crate_name
142                        && id.tool == name
143                        && id.version == family.version
144                })
145        })
146        .cloned();
147    let fix = match status {
148        Status::Ours => None,
149        Status::Missing => unreachable!("handled above"),
150        Status::Foreign | Status::Stale => Some(fix_for(
151            family,
152            name,
153            &winner,
154            &status,
155            identity.as_ref().map(|id| id.version.as_str()),
156            formula.as_deref(),
157            ours_later.as_deref(),
158        )),
159    };
160    Finding {
161        name: name.to_string(),
162        status,
163        path: Some(winner),
164        version: answer.map(|a| a.lines().next().unwrap_or("").trim().to_string()),
165        formula,
166        shadowed: found[1..].to_vec(),
167        fix,
168    }
169}
170
171fn fix_for(
172    family: &Family,
173    name: &str,
174    winner: &Path,
175    status: &Status,
176    their_version: Option<&str>,
177    formula: Option<&str>,
178    ours_later: Option<&Path>,
179) -> String {
180    let winner_dir = winner.parent().unwrap_or(Path::new("")).display();
181    let mut fixes = Vec::new();
182    if let Some(formula) = formula {
183        fixes.push(format!("`brew unlink {formula}`"));
184    }
185    match ours_later {
186        Some(ours) => fixes.push(format!(
187            "put {} before {winner_dir} on PATH",
188            ours.parent().unwrap_or(Path::new("")).display()
189        )),
190        None => fixes.push(format!(
191            "install ours ({}) in a directory before {winner_dir} on PATH",
192            family.install_hints.join(", or ")
193        )),
194    }
195    let what = match status {
196        Status::Stale => format!(
197            "{} is {} {}, not {}",
198            winner.display(),
199            family.crate_name,
200            their_version.unwrap_or("?"),
201            family.version
202        ),
203        _ => format!("{} is not {}'s {name}", winner.display(), family.crate_name),
204    };
205    format!("{what}: {}", fixes.join(", or "))
206}
207
208/// How long `--version` may take, as a number of polls this far apart:
209/// five seconds.
210const PROBE_POLL: Duration = Duration::from_millis(10);
211const PROBE_POLLS: u32 = 500;
212
213/// How much of a `--version` answer is kept: the identity is its first
214/// line. The rest is read and dropped, so a long answer cannot fill the
215/// pipe and stall the program.
216const PROBE_KEEP: usize = 64 * 1024;
217
218/// Run `program --version` with no input, and give up after a few
219/// seconds: a program that is not ours may wait for a terminal.
220///
221/// Its stdout is READ WHILE IT RUNS, on a thread. Read only after it
222/// exits, a program answering more than a pipe holds blocks on the full
223/// pipe, runs out the time and is taken for someone else's.
224fn ask_version(program: &Path) -> Option<String> {
225    let mut child = spawn_version(program)?;
226    let mut stdout = child.stdout.take()?;
227    let reader = std::thread::spawn(move || {
228        use std::io::Read;
229        let mut kept = Vec::new();
230        let mut chunk = [0u8; 8192];
231        loop {
232            match stdout.read(&mut chunk) {
233                Ok(0) | Err(_) => break,
234                Ok(n) => {
235                    let room = PROBE_KEEP.saturating_sub(kept.len());
236                    kept.extend_from_slice(&chunk[..n.min(room)]);
237                }
238            }
239        }
240        kept
241    });
242    // Polled rather than timed: a count of short sleeps needs no clock.
243    let mut polls_left = PROBE_POLLS;
244    let exited = loop {
245        match child.try_wait() {
246            Ok(Some(_)) => break true,
247            Ok(None) if polls_left > 0 => {
248                polls_left -= 1;
249                std::thread::sleep(PROBE_POLL);
250            }
251            _ => break false,
252        }
253    };
254    if !exited {
255        // Killing it closes the pipe, which ends the reader.
256        let _ = child.kill();
257        let _ = child.wait();
258        let _ = reader.join();
259        return None;
260    }
261    let kept = reader.join().ok()?;
262    Some(String::from_utf8_lossy(&kept).into_owned())
263}
264
265/// Start `program --version`, asking again while the file is busy.
266///
267/// Linux refuses to run a file some process still holds open for writing
268/// (ETXTBSY): an install still copying the binary into place, or any
269/// program that forked while it had the file open. That is momentary, and
270/// the program is whatever it is the moment the writer lets go, so a busy
271/// file is asked again, for up to five seconds, rather than
272/// taken for somebody else's.
273fn spawn_version(program: &Path) -> Option<std::process::Child> {
274    let mut polls_left = PROBE_POLLS;
275    loop {
276        match Process::new(program)
277            .arg("--version")
278            .stdin(Stdio::null())
279            .stdout(Stdio::piped())
280            .stderr(Stdio::null())
281            .spawn()
282        {
283            Ok(child) => return Some(child),
284            Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && polls_left > 0 => {
285                polls_left -= 1;
286                std::thread::sleep(PROBE_POLL);
287            }
288            Err(_) => return None,
289        }
290    }
291}
292
293#[cfg(unix)]
294fn is_executable(path: &Path) -> bool {
295    use std::os::unix::fs::PermissionsExt;
296    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
297}
298
299#[cfg(not(unix))]
300fn is_executable(path: &Path) -> bool {
301    std::fs::metadata(path).is_ok_and(|m| m.is_file())
302}
303
304/// The Homebrew formula a program belongs to: the directory after
305/// `Cellar` in its resolved path (`.../Cellar/<formula>/<version>/bin/...`).
306pub fn cellar_formula(program: &Path) -> Option<String> {
307    let resolved = std::fs::canonicalize(program).ok()?;
308    let mut components = resolved.components().map(|c| c.as_os_str());
309    components.find(|c| *c == "Cellar")?;
310    components.next().map(|f| f.to_string_lossy().into_owned())
311}
312
313/// Run the diagnosis and turn it into a result: exit 0 when every name is
314/// ours, 1 otherwise.
315pub fn run(family: &Family) -> Outcome {
316    let path = std::env::var_os("PATH").unwrap_or_default();
317    run_path(family, &path)
318}
319
320/// [`run`] against an explicit PATH value.
321pub fn run_path(family: &Family, path: &OsStr) -> Outcome {
322    let findings = diagnose_path(family, path);
323    let ok = findings.iter().all(|f| f.status == Status::Ours);
324    let tools: Vec<Json> = findings
325        .iter()
326        .map(|f| {
327            Json::object([
328                ("name", Json::from(f.name.as_str())),
329                ("status", Json::from(f.status.as_str())),
330                (
331                    "path",
332                    Json::from(f.path.as_ref().map(|p| p.display().to_string())),
333                ),
334                ("version", Json::from(f.version.clone())),
335                ("formula", Json::from(f.formula.clone())),
336                (
337                    "shadowed",
338                    Json::from(
339                        f.shadowed
340                            .iter()
341                            .map(|p| p.display().to_string())
342                            .collect::<Vec<_>>(),
343                    ),
344                ),
345                ("fix", Json::from(f.fix.clone())),
346            ])
347        })
348        .collect();
349    let report = Json::object([
350        ("ok", Json::from(ok)),
351        ("crate", Json::from(family.crate_name)),
352        ("version", Json::from(family.version)),
353        ("tools", Json::Arr(tools)),
354    ]);
355    let mut text = Vec::new();
356    for f in &findings {
357        let at = f
358            .path
359            .as_ref()
360            .map(|p| p.display().to_string())
361            .unwrap_or_else(|| "not on PATH".to_string());
362        text.push(format!("{}: {} ({at})", f.name, f.status.as_str()));
363        for later in &f.shadowed {
364            text.push(format!("  also on PATH, not run: {}", later.display()));
365        }
366        if let Some(fix) = &f.fix {
367            text.push(format!("  fix: {fix}"));
368        }
369    }
370    text.push(if ok {
371        format!("every tool is {} {}", family.crate_name, family.version)
372    } else {
373        "some tools on PATH are not this program; see the fixes above".to_string()
374    });
375    Outcome::report(report)
376        .with_text(text.join("\n"))
377        .with_code(u8::from(!ok))
378}