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