1use 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
27pub 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#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum Status {
42 Ours,
44 Missing,
46 Foreign,
49 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#[derive(Debug, Clone)]
66pub struct Finding {
67 pub name: String,
68 pub status: Status,
69 pub path: Option<PathBuf>,
71 pub version: Option<String>,
73 pub formula: Option<String>,
76 pub shadowed: Vec<PathBuf>,
78 pub fix: Option<String>,
80}
81
82pub fn diagnose(family: &Family) -> Vec<Finding> {
84 let path = std::env::var_os("PATH").unwrap_or_default();
85 diagnose_path(family, &path)
86}
87
88pub 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 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
208const PROBE_POLL: Duration = Duration::from_millis(10);
211const PROBE_POLLS: u32 = 500;
212
213const PROBE_KEEP: usize = 64 * 1024;
217
218fn 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 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 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
265fn 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
304pub 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
313pub fn run(family: &Family) -> Outcome {
316 let path = std::env::var_os("PATH").unwrap_or_default();
317 run_path(family, &path)
318}
319
320pub 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}