Skip to main content

dbg_cli/
deps.rs

1//! Shared dependency-checking infrastructure.
2//!
3//! Used by both `dbg` and `gdbg` to verify tool availability.
4
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8fn is_executable_file(path: &Path) -> bool {
9    let Ok(metadata) = path.metadata() else {
10        return false;
11    };
12    if !metadata.is_file() {
13        return false;
14    }
15    #[cfg(unix)]
16    {
17        use std::os::unix::fs::PermissionsExt;
18        metadata.permissions().mode() & 0o111 != 0
19    }
20    #[cfg(not(unix))]
21    {
22        true
23    }
24}
25
26/// How to verify a dependency is installed.
27pub enum DependencyCheck {
28    /// Check that a binary exists on PATH (optionally with minimum version).
29    Binary {
30        name: &'static str,
31        /// Alternative names to try (e.g., "lldb-20", "lldb-18", "lldb").
32        alternatives: &'static [&'static str],
33        /// Command + args to get version string, e.g., ("lldb-20", &["--version"]).
34        /// If None, just checks existence.
35        version_cmd: Option<(&'static str, &'static [&'static str])>,
36    },
37    /// Run an arbitrary command; exit code 0 means installed.
38    Command {
39        program: &'static str,
40        args: &'static [&'static str],
41    },
42}
43
44/// A single dependency with its check and install instructions.
45pub struct Dependency {
46    pub name: &'static str,
47    pub check: DependencyCheck,
48    pub install: &'static str,
49}
50
51/// Result of checking a single dependency.
52pub struct DepStatus {
53    pub name: &'static str,
54    pub ok: bool,
55    /// The resolved path or version if found.
56    pub detail: String,
57    /// Install instructions if not found.
58    pub install: &'static str,
59    /// Optional warning (tool found but degraded).
60    pub warning: Option<String>,
61}
62
63/// Check a single dependency.
64pub fn check_dep(dep: Dependency) -> DepStatus {
65    match &dep.check {
66        DependencyCheck::Binary {
67            alternatives,
68            version_cmd,
69            ..
70        } => {
71            for name in *alternatives {
72                let found_path =
73                    which::which(name)
74                        .map(|p| p.display().to_string())
75                        .or_else(|_| {
76                            for dir in extra_tool_dirs() {
77                                let path = dir.join(name);
78                                if is_executable_file(&path) {
79                                    return Ok(path.display().to_string());
80                                }
81                            }
82                            Err(())
83                        });
84                if let Ok(path) = found_path {
85                    // Binary exists on disk. If a version_cmd is
86                    // provided, run it to verify the toolchain
87                    // actually works (catches broken installs like a
88                    // Homebrew GHC that can't find libc).
89                    if let Some((probe_bin, probe_args)) = version_cmd {
90                        let probe_path = if probe_bin == name {
91                            path.clone()
92                        } else {
93                            find_bin(probe_bin)
94                        };
95                        let runnable = Command::new(&probe_path)
96                            .args(*probe_args)
97                            .stdout(std::process::Stdio::null())
98                            .stderr(std::process::Stdio::null())
99                            .status()
100                            .is_ok_and(|s| s.success());
101                        if !runnable {
102                            return DepStatus {
103                                name: dep.name,
104                                ok: false,
105                                detail: format!(
106                                    "{path} (found but broken — `{probe_path}` failed to run)"
107                                ),
108                                install: dep.install,
109                                warning: None,
110                            };
111                        }
112                    }
113                    return DepStatus {
114                        name: dep.name,
115                        ok: true,
116                        detail: path,
117                        install: dep.install,
118                        warning: None,
119                    };
120                }
121            }
122            DepStatus {
123                name: dep.name,
124                ok: false,
125                detail: "not found".into(),
126                install: dep.install,
127                warning: None,
128            }
129        }
130        DependencyCheck::Command { program, args } => {
131            let ok = Command::new(program)
132                .args(*args)
133                .stdout(std::process::Stdio::null())
134                .stderr(std::process::Stdio::null())
135                .status()
136                .is_ok_and(|s| s.success());
137            DepStatus {
138                name: dep.name,
139                ok,
140                detail: if ok { "ok".into() } else { "failed".into() },
141                install: dep.install,
142                warning: None,
143            }
144        }
145    }
146}
147
148/// Resolve a binary name to its full path, checking PATH and extra tool dirs.
149/// Returns the full path if found, or the original name as fallback.
150pub fn find_bin(name: &str) -> String {
151    if let Ok(path) = which::which(name) {
152        return path.display().to_string();
153    }
154    for dir in extra_tool_dirs() {
155        let path = dir.join(name);
156        if is_executable_file(&path) {
157            return path.display().to_string();
158        }
159    }
160    name.to_string()
161}
162
163#[cfg(all(test, unix))]
164mod tests {
165    use super::*;
166    use std::os::unix::fs::PermissionsExt;
167
168    #[test]
169    fn executable_discovery_rejects_regular_files_without_execute_permission() {
170        let tmp = tempfile::tempdir().unwrap();
171        let path = tmp.path().join("tool");
172        std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
173        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
174        assert!(!is_executable_file(&path));
175        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
176        assert!(is_executable_file(&path));
177    }
178}
179
180// ---------------------------------------------------------------------------
181// Bundled-toolkit finder
182// ---------------------------------------------------------------------------
183//
184// Some NVIDIA toolkits (Nsight Systems, Nsight Compute, the CUDA toolkit
185// itself) ship helper binaries in an install-local subdirectory that is
186// *not* on $PATH.  Example: NVIDIA Nsight Systems places the `nsys`
187// CLI in `<prefix>/target-linux-x64/nsys` but its `QdstrmImporter`
188// helper in the sibling `<prefix>/host-linux-x64/QdstrmImporter`.
189//
190// Finding those helpers is awkward because `<prefix>` varies:
191//   * `/usr/lib/nsight-systems`                    (Debian/Ubuntu apt)
192//   * `/usr/lib/x86_64-linux-gnu/nsight-systems`   (apt multiarch layout)
193//   * `/opt/nvidia/nsight-systems/<ver>`           (tarball / standalone)
194//   * `/usr/local/cuda-<ver>/nsight-systems-<ver>` (CUDA toolkit)
195//
196// `find_bundled_tool` takes a declarative description of where a toolkit
197// can live and resolves a named helper binary.
198
199/// A directory to probe for a bundled toolkit.
200pub struct ToolkitRoot {
201    /// Absolute path to probe.
202    pub path: &'static str,
203    /// How many levels to descend below `path` looking for the toolkit's
204    /// `bin_subdir`.  `0` means `path` itself IS the toolkit root, so the
205    /// tool is looked up at `<path>/<bin_subdir>/<tool>`.
206    pub max_depth: usize,
207    /// If non-empty, only descend into subdirectories whose names start
208    /// with one of these prefixes.  Used to prune wide roots like
209    /// `/usr/local` where only `cuda*` and `nsight-systems*` are relevant.
210    pub dir_filter: &'static [&'static str],
211}
212
213/// Anchor a toolkit lookup to a binary that IS on `$PATH`.  When set,
214/// `find_bundled_tool` canonicalizes the anchor binary and walks up the
215/// directory tree looking for a sibling `<bin_subdir>/<tool>`.
216pub struct ToolkitAnchor {
217    /// Name of the binary (e.g. `"nsys"`).
218    pub bin: &'static str,
219    /// How many parent levels to walk above the resolved anchor before
220    /// giving up.  Typical nsys-style layouts require 1 (grandparent).
221    pub walk_up: usize,
222}
223
224/// Declarative description of a toolkit that bundles helpers in a
225/// known subdirectory (e.g. `host-linux-x64/`).
226pub struct BundledToolkit {
227    /// Human-readable name, used for diagnostics.
228    pub name: &'static str,
229    /// Subdirectory within each install prefix that holds the helpers.
230    pub bin_subdir: &'static str,
231    /// Static roots to probe, ordered by preference.
232    pub roots: &'static [ToolkitRoot],
233    /// Optional `$PATH` anchor for non-standard installs.
234    pub anchor: Option<ToolkitAnchor>,
235}
236
237/// Locate a helper binary inside a bundled toolkit.  Returns the full
238/// path to the binary if found, otherwise `None`.
239///
240/// Resolution order:
241///   1. Each `ToolkitRoot` in declaration order (with bounded descent).
242///   2. The `ToolkitAnchor`, if set: `which <bin>` → canonicalize → walk up.
243pub fn find_bundled_tool(toolkit: &BundledToolkit, tool: &str) -> Option<PathBuf> {
244    for root in toolkit.roots {
245        if let Some(p) = probe_root(
246            Path::new(root.path),
247            root.max_depth,
248            root.dir_filter,
249            toolkit.bin_subdir,
250            tool,
251        ) {
252            return Some(p);
253        }
254    }
255    if let Some(anchor) = &toolkit.anchor
256        && let Some(p) = probe_anchor(anchor, toolkit.bin_subdir, tool)
257    {
258        return Some(p);
259    }
260    None
261}
262
263/// Recursive bounded-depth probe of a single toolkit root.
264fn probe_root(
265    root: &Path,
266    max_depth: usize,
267    dir_filter: &[&str],
268    bin_subdir: &str,
269    tool: &str,
270) -> Option<PathBuf> {
271    let candidate = root.join(bin_subdir).join(tool);
272    if candidate.is_file() {
273        return Some(candidate);
274    }
275    if max_depth == 0 {
276        return None;
277    }
278    let entries = std::fs::read_dir(root).ok()?;
279    for entry in entries.flatten() {
280        let path = entry.path();
281        if !path.is_dir() {
282            continue;
283        }
284        if !dir_filter.is_empty() {
285            let name = entry.file_name();
286            let name = name.to_string_lossy();
287            if !dir_filter.iter().any(|p| name.starts_with(p)) {
288                continue;
289            }
290        }
291        if let Some(found) = probe_root(&path, max_depth - 1, dir_filter, bin_subdir, tool) {
292            return Some(found);
293        }
294    }
295    None
296}
297
298/// Resolve a tool like `dotnet` to its installation root.  Canonicalizes
299/// `anchor_bin` from `$PATH` (following Homebrew-style shims), walks up
300/// to `walk_up` levels looking for a `preferred_sibling` directory, and
301/// falls back to the directory that directly contains the anchor binary.
302///
303/// If `sibling_marker` is `Some`, only `preferred_sibling` directories
304/// that contain the given relative path (e.g. `"shared"`) are accepted —
305/// used to gate on a shape-specific file that proves this is the right
306/// root, not just a directory that happens to be named the same.
307pub fn find_tool_root(
308    anchor_bin: &str,
309    preferred_sibling: Option<&str>,
310    sibling_marker: Option<&str>,
311    walk_up: usize,
312) -> Option<PathBuf> {
313    let path = which::which(anchor_bin).ok()?;
314    let real = std::fs::canonicalize(&path).ok()?;
315
316    if let Some(sibling) = preferred_sibling {
317        let mut cur: &Path = real.as_path();
318        for _ in 0..=walk_up {
319            let candidate = cur.join(sibling);
320            if candidate.is_dir() {
321                let accepted = match sibling_marker {
322                    Some(m) => candidate.join(m).exists(),
323                    None => true,
324                };
325                if accepted {
326                    return Some(candidate);
327                }
328            }
329            match cur.parent() {
330                Some(p) => cur = p,
331                None => break,
332            }
333        }
334    }
335
336    // Fallback: directory containing the canonicalized anchor binary.
337    real.parent().map(|p| p.to_path_buf())
338}
339
340/// Walk up from a `$PATH`-resolvable anchor binary, probing at each level
341/// for `<cur>/<bin_subdir>/<tool>`.
342fn probe_anchor(anchor: &ToolkitAnchor, bin_subdir: &str, tool: &str) -> Option<PathBuf> {
343    let path = which::which(anchor.bin).ok()?;
344    let real = std::fs::canonicalize(&path).ok()?;
345    let mut cur: &Path = real.as_path();
346    for _ in 0..=anchor.walk_up {
347        let candidate = cur.join(bin_subdir).join(tool);
348        if candidate.is_file() {
349            return Some(candidate);
350        }
351        match cur.parent() {
352            Some(parent) => cur = parent,
353            None => return None,
354        }
355    }
356    None
357}
358
359/// Extra directories to search for tool binaries not on PATH.
360pub fn extra_tool_dirs() -> Vec<std::path::PathBuf> {
361    let mut dirs = Vec::new();
362    if let Ok(home) = std::env::var("HOME") {
363        let home = std::path::PathBuf::from(&home);
364        dirs.push(home.join(".dotnet/tools"));
365        dirs.push(home.join(".ghcup/bin"));
366        dirs.push(home.join(".cargo/bin"));
367        dirs.push(home.join(".local/bin"));
368    }
369    dirs
370}
371
372/// Format check results for display.
373pub fn format_results(results: &[(&str, Vec<DepStatus>)]) -> String {
374    let mut out = String::new();
375    for (name, statuses) in results {
376        out.push_str(&format!("{name}:\n"));
377        for s in statuses {
378            let icon = if s.ok { "ok" } else { "MISSING" };
379            out.push_str(&format!("  {}: {} ({})\n", s.name, icon, s.detail));
380            if !s.ok {
381                out.push_str(&format!("    install: {}\n", s.install));
382            }
383            if let Some(warn) = &s.warning {
384                out.push_str(&format!("    WARNING: {warn}\n"));
385            }
386        }
387    }
388    out
389}