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