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