Skip to main content

anchor_cli/debugger/
path_label.rs

1//! Classify a resolved source path into a human-readable label for the
2//! source pane title.
3//!
4//! Categories tried in order:
5//!
6//! 1. **Stdlib** — path lies under one of the platform-tools rewrite
7//!    targets (e.g. `~/.cache/solana/v1.52/.../rust/library/`). The crate
8//!    name is the first segment after `library/` (`core`, `alloc`, `std`).
9//! 2. **Cargo registry crate** — path under
10//!    `$CARGO_HOME/registry/src/<index>/<name>-<version>/`.
11//! 3. **Cargo git checkout** — path under
12//!    `$CARGO_HOME/git/checkouts/<repo>-<hash>/<rev>/...`.
13//! 4. **Workspace member** — path under the workspace root. Walks up
14//!    looking for the nearest `Cargo.toml` to surface the package name.
15//! 5. **Unknown** — show the full path verbatim.
16//!
17//! Falls through to "unknown" rather than panicking on weird inputs.
18
19use std::path::{Component, Path, PathBuf};
20
21/// What we render in the source pane title.
22#[derive(Clone, Debug)]
23pub struct PathLabel {
24    /// Short category label, e.g. `"stdlib · core"`, `"pinocchio v0.11.1"`,
25    /// `"debugger-testing"`.
26    pub label: String,
27    /// Path relative to the chosen anchor (workspace root, crate root,
28    /// stdlib library root). Falls back to the absolute path when no
29    /// anchor matched.
30    pub path_display: String,
31}
32
33/// Classify a resolved source path for the source pane title.
34///
35/// `cwd` is the directory the debugger was invoked from — paths under it
36/// are displayed relative to it (e.g. `src/lib.rs`). `src_roots` are
37/// tried next for workspace-relative display. `path_rewrites` handle
38/// stdlib CI paths.
39pub fn classify(
40    path: &Path,
41    src_roots: &[PathBuf],
42    path_rewrites: &[(PathBuf, PathBuf)],
43    cwd: Option<&Path>,
44) -> PathLabel {
45    // Highest priority: paths under the user's CWD are shown relative to
46    // it with the enclosing package name as the label.
47    if let Some(cwd) = cwd {
48        if let Some(label) = classify_cwd(path, cwd) {
49            return label;
50        }
51    }
52    if let Some(label) = classify_stdlib(path, path_rewrites) {
53        return label;
54    }
55    if let Some(label) = classify_cargo_registry(path) {
56        return label;
57    }
58    if let Some(label) = classify_cargo_git(path) {
59        return label;
60    }
61    if let Some(label) = classify_workspace(path, src_roots) {
62        return label;
63    }
64    PathLabel {
65        label: "(unknown)".to_owned(),
66        path_display: path.display().to_string(),
67    }
68}
69
70fn classify_cwd(path: &Path, cwd: &Path) -> Option<PathLabel> {
71    let rel = path.strip_prefix(cwd).ok()?;
72    let pkg = enclosing_package_name(cwd, rel);
73    let label = pkg
74        .or_else(|| cwd.file_name().and_then(|n| n.to_str()).map(str::to_owned))
75        .unwrap_or_else(|| "workspace".to_owned());
76    Some(PathLabel {
77        label,
78        path_display: rel.display().to_string(),
79    })
80}
81
82fn classify_stdlib(path: &Path, rewrites: &[(PathBuf, PathBuf)]) -> Option<PathLabel> {
83    for (_, replacement) in rewrites {
84        if let Ok(rel) = path.strip_prefix(replacement) {
85            // rel is e.g. `core/src/array/equality.rs`. The first segment
86            // is the crate name; the rest is the in-crate path.
87            let mut comps = rel.components();
88            let crate_name = comps.next()?.as_os_str().to_str()?.to_owned();
89            let after_crate: PathBuf = comps.collect();
90            return Some(PathLabel {
91                label: format!("stdlib · {crate_name}"),
92                path_display: after_crate.display().to_string(),
93            });
94        }
95    }
96    None
97}
98
99fn classify_cargo_registry(path: &Path) -> Option<PathLabel> {
100    let cargo_home = cargo_home()?;
101    let registry = cargo_home.join("registry").join("src");
102    let rel = path.strip_prefix(&registry).ok()?;
103    // `rel` = `<index-hash-dir>/<name>-<version>/<rest>`
104    let mut comps = rel.components();
105    let _index = comps.next()?;
106    let pkg_dir = comps.next()?.as_os_str().to_str()?;
107    let rest: PathBuf = comps.collect();
108    let (name, version) = split_name_version(pkg_dir);
109    let label = match version {
110        Some(v) => format!("{name} v{v}"),
111        None => name.to_owned(),
112    };
113    Some(PathLabel {
114        label,
115        path_display: rest.display().to_string(),
116    })
117}
118
119fn classify_cargo_git(path: &Path) -> Option<PathLabel> {
120    let cargo_home = cargo_home()?;
121    let checkouts = cargo_home.join("git").join("checkouts");
122    let rel = path.strip_prefix(&checkouts).ok()?;
123    // `rel` = `<repo>-<hash>/<rev-prefix>/<rest>`
124    let mut comps = rel.components();
125    let repo_dir = comps.next()?.as_os_str().to_str()?;
126    let _rev = comps.next();
127    let rest: PathBuf = comps.collect();
128    // Strip the trailing `-<hash>` to get a readable repo name. cargo's
129    // hash is 16 hex chars — when present, drop it; otherwise keep the
130    // dir name verbatim.
131    let repo = repo_dir
132        .rsplit_once('-')
133        .filter(|(_, hash)| hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
134        .map(|(name, _)| name)
135        .unwrap_or(repo_dir);
136    Some(PathLabel {
137        label: format!("{repo} (git)"),
138        path_display: rest.display().to_string(),
139    })
140}
141
142fn classify_workspace(path: &Path, src_roots: &[PathBuf]) -> Option<PathLabel> {
143    for root in src_roots {
144        let rel = match path.strip_prefix(root) {
145            Ok(r) => r,
146            Err(_) => continue,
147        };
148        let pkg = enclosing_package_name(root, rel);
149        let label = pkg
150            .or_else(|| root.file_name().and_then(|n| n.to_str()).map(str::to_owned))
151            .unwrap_or_else(|| "workspace".to_owned());
152        return Some(PathLabel {
153            label,
154            path_display: rel.display().to_string(),
155        });
156    }
157    None
158}
159
160/// Walk up from `<workspace>/<rel>` toward `workspace` looking for a
161/// `Cargo.toml`, returning the `[package] name` it declares.
162fn enclosing_package_name(workspace: &Path, rel: &Path) -> Option<String> {
163    let mut current = workspace.join(rel);
164    while current.pop() {
165        if !current.starts_with(workspace) {
166            return None;
167        }
168        let manifest = current.join("Cargo.toml");
169        if manifest.is_file() {
170            return read_package_name(&manifest);
171        }
172    }
173    None
174}
175
176/// Pull `[package] name = "..."` out of a Cargo.toml without a full TOML
177/// parse — the stricter parse would just ignore everything else anyway,
178/// and we don't want to drag in `cargo_toml` for a one-line lookup.
179fn read_package_name(manifest: &Path) -> Option<String> {
180    let contents = std::fs::read_to_string(manifest).ok()?;
181    let mut in_package = false;
182    for line in contents.lines() {
183        let trimmed = line.trim();
184        if trimmed.starts_with('[') {
185            in_package = trimmed == "[package]";
186            continue;
187        }
188        if !in_package {
189            continue;
190        }
191        // `name = "foo"` or `name="foo"` — be lenient about whitespace.
192        let Some(rest) = trimmed.strip_prefix("name") else {
193            continue;
194        };
195        let after_eq = rest.trim_start().strip_prefix('=')?.trim_start();
196        let stripped = after_eq.strip_prefix('"')?;
197        let end = stripped.find('"')?;
198        return Some(stripped[..end].to_owned());
199    }
200    None
201}
202
203/// Split `"<name>-<version>"` (a cargo registry dir name) into its parts.
204/// Returns `(name, None)` if no parseable version suffix is present.
205///
206/// "Parseable" means the suffix after the last `-` starts with a digit —
207/// good enough to tell `tokio-1.35.0` from `idl-1.0` (also a valid name+ver)
208/// vs hyphenated-only names. We never panic on weird inputs.
209fn split_name_version(dir_name: &str) -> (&str, Option<&str>) {
210    if let Some((name, version)) = dir_name.rsplit_once('-') {
211        if version.starts_with(|c: char| c.is_ascii_digit()) {
212            return (name, Some(version));
213        }
214    }
215    (dir_name, None)
216}
217
218fn cargo_home() -> Option<PathBuf> {
219    if let Some(p) = std::env::var_os("CARGO_HOME") {
220        return Some(PathBuf::from(p));
221    }
222    dirs::home_dir().map(|h| h.join(".cargo"))
223}
224
225/// Best-effort canonicalization. Returns `path` verbatim on failure so the
226/// caller doesn't have to unwrap. Used to defang `/tmp` ↔ `/private/tmp`
227/// drift on macOS before prefix matching.
228#[allow(dead_code)]
229pub fn canonicalize(path: &Path) -> PathBuf {
230    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
231}
232
233/// Helper to detect that a single path component is "src" — used by
234/// callers that want to prettify in-crate display further. Kept here so
235/// the policy lives next to the other path heuristics.
236#[allow(dead_code)]
237pub fn is_src_dir(c: Component<'_>) -> bool {
238    c.as_os_str() == "src"
239}