Skip to main content

lean_ctx/core/
path_suggest.rs

1// SPDX-License-Identifier: Apache-2.0
2//! "Did you mean …?" for a path that does not exist.
3//!
4//! A missing file is the most frequent tool failure in the field, and the
5//! usual causes are mechanical: a typo in the file name, a wrong extension, or
6//! a path written relative to the wrong directory (`src/x.rs` for
7//! `rust/src/x.rs`). Naming the existing file turns the agent's next call into
8//! the right one instead of a `ctx_tree` round trip.
9//!
10//! Bounded on purpose: one directory listing for the name check, and a capped,
11//! gitignore-respecting walk below the nearest existing ancestor only when that
12//! ancestor is a safe scan root (never `~`, `/` or a TCC-protected folder).
13
14use std::path::{Path, PathBuf};
15
16/// Entries read from the parent directory for the name check.
17const MAX_SIBLINGS: usize = 2_000;
18/// Directory entries visited while looking for the same file elsewhere.
19const MAX_WALK_ENTRIES: usize = 20_000;
20const MAX_WALK_DEPTH: usize = 12;
21const MAX_SUGGESTIONS: usize = 3;
22
23/// Existing paths the agent most likely meant by the missing `path`, best
24/// first. Empty when nothing is close enough to suggest with confidence.
25pub(crate) fn suggestions(path: &Path) -> Vec<PathBuf> {
26    if path.exists() {
27        return Vec::new();
28    }
29    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
30        return Vec::new();
31    };
32    if let Some(parent) = path.parent().filter(|p| p.is_dir()) {
33        return similar_sibling(parent, name).into_iter().collect();
34    }
35    nearest_existing_ancestor(path)
36        .map(|base| same_tail_below(&base, path))
37        .unwrap_or_default()
38}
39
40/// The one file `path` unambiguously names, for a read that may follow it
41/// without asking: the same name differing only in case in the same
42/// directory, or the whole relative path found under exactly one other
43/// directory (`src/core/x.rs` for `rust/src/core/x.rs`). Edit-distance and
44/// other-extension guesses (`main.ts` → `main.rs`) are a different file and
45/// stay suggestions only.
46pub(crate) fn unambiguous_match(path: &Path) -> Option<PathBuf> {
47    if path.exists() {
48        return None;
49    }
50    let name = path.file_name()?.to_str()?;
51    if let Some(parent) = path.parent().filter(|p| p.is_dir()) {
52        let lowered = name.to_lowercase();
53        let mut same_name = std::fs::read_dir(parent)
54            .ok()?
55            .filter_map(Result::ok)
56            .take(MAX_SIBLINGS)
57            .filter_map(|e| e.file_name().into_string().ok())
58            .filter(|n| n.to_lowercase() == lowered);
59        let only = same_name.next()?;
60        return same_name.next().is_none().then(|| parent.join(only));
61    }
62    let base = nearest_existing_ancestor(path)?;
63    let (ranked, tail_len) = ranked_tail_matches(&base, path);
64    match ranked.as_slice() {
65        [(shared, only)] if *shared == tail_len => Some(only.clone()),
66        [(shared, best), (next, _), ..] if *shared == tail_len && next < shared => {
67            Some(best.clone())
68        }
69        _ => None,
70    }
71}
72
73/// `unambiguous_match`, plus one more mechanical slip: a path built from a
74/// search result's directory-relative hit on the wrong prefix
75/// (`src/app/signal/test_x.py` after a search under `tests/` listed
76/// `signal/test_x.py`). The file is then looked up under the whole
77/// `project_root`; it is taken only when exactly one file there shares the
78/// longest trailing part with `path`, and that part names at least its
79/// directory and file (`signal/test_x.py`) — a bare file-name match stays a
80/// suggestion.
81pub fn unambiguous_match_in(path: &Path, project_root: &Path) -> Option<PathBuf> {
82    if let Some(found) = unambiguous_match(path) {
83        return Some(found);
84    }
85    if path.exists() || !path.starts_with(project_root) || !project_root.is_dir() {
86        return None;
87    }
88    let (ranked, _) = ranked_tail_matches(project_root, path);
89    match ranked.as_slice() {
90        [(shared, only)] if *shared >= 2 => Some(only.clone()),
91        [(shared, best), (next, _), ..] if *shared >= 2 && next < shared => Some(best.clone()),
92        _ => None,
93    }
94}
95
96/// The suggestions as a one-line suffix for an error message, or "".
97pub fn hint(path: &Path) -> String {
98    let found = suggestions(path);
99    match found.as_slice() {
100        [] => String::new(),
101        [one] => format!(" — did you mean {}?", one.display()),
102        many => format!(
103            " — did you mean one of: {}?",
104            many.iter()
105                .map(|p| p.display().to_string())
106                .collect::<Vec<_>>()
107                .join(", ")
108        ),
109    }
110}
111
112/// A sibling whose name is within a small edit distance of `name`, or the same
113/// stem with another extension (`main.ts` for `main.js`).
114fn similar_sibling(parent: &Path, name: &str) -> Option<PathBuf> {
115    let names: Vec<String> = std::fs::read_dir(parent)
116        .ok()?
117        .filter_map(Result::ok)
118        .take(MAX_SIBLINGS)
119        .filter_map(|e| e.file_name().into_string().ok())
120        .collect();
121    let lowered = name.to_lowercase();
122    if let Some(exact_case) = names.iter().find(|n| n.to_lowercase() == lowered) {
123        return Some(parent.join(exact_case));
124    }
125    if let Some(close) = crate::core::levenshtein::closest(name, names.iter().map(String::as_str)) {
126        return Some(parent.join(close));
127    }
128    let stem = Path::new(name).file_stem()?.to_str()?;
129    names
130        .iter()
131        .find(|n| Path::new(n).file_stem().and_then(|s| s.to_str()) == Some(stem))
132        .map(|n| parent.join(n))
133}
134
135fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
136    path.ancestors()
137        .skip(1)
138        .take(32)
139        .find(|a| a.is_dir())
140        .map(Path::to_path_buf)
141}
142
143/// Files below `base` whose path ends with the missing part of `path` — the
144/// same file reached from a different directory. Falls back to the file name
145/// alone when no longer tail matches.
146fn same_tail_below(base: &Path, path: &Path) -> Vec<PathBuf> {
147    ranked_tail_matches(base, path)
148        .0
149        .into_iter()
150        .take(MAX_SUGGESTIONS)
151        .map(|(_, p)| p)
152        .collect()
153}
154
155/// Every file below `base` named like `path`, best first, with how many
156/// trailing components it shares with the missing part of `path`; plus that
157/// missing part's length (a full match shares all of it).
158fn ranked_tail_matches(base: &Path, path: &Path) -> (Vec<(usize, PathBuf)>, usize) {
159    if !crate::core::graph_index::is_safe_scan_root_public(&base.to_string_lossy()) {
160        return (Vec::new(), 0);
161    }
162    let Ok(missing) = path.strip_prefix(base) else {
163        return (Vec::new(), 0);
164    };
165    let tail: Vec<_> = missing.components().collect();
166    let Some(name) = tail.last().map(|c| c.as_os_str().to_owned()) else {
167        return (Vec::new(), 0);
168    };
169
170    // Matches ranked by how many trailing components they share with `path`.
171    let mut ranked: Vec<(usize, PathBuf)> = Vec::new();
172    let walker = ignore::WalkBuilder::new(base)
173        .max_depth(Some(MAX_WALK_DEPTH))
174        .hidden(true)
175        .build();
176    for entry in walker.filter_map(Result::ok).take(MAX_WALK_ENTRIES) {
177        let candidate = entry.path();
178        if candidate.file_name() != Some(name.as_os_str()) {
179            continue;
180        }
181        let shared = candidate
182            .components()
183            .rev()
184            .zip(tail.iter().rev())
185            .take_while(|(a, b)| a == *b)
186            .count();
187        ranked.push((shared, candidate.to_path_buf()));
188    }
189    ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
190    (ranked, tail.len())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn project() -> tempfile::TempDir {
198        let dir = tempfile::tempdir().unwrap();
199        let root = dir.path();
200        std::fs::create_dir_all(root.join(".git")).unwrap();
201        std::fs::create_dir_all(root.join("rust/src/core")).unwrap();
202        std::fs::write(root.join("rust/src/core/config.rs"), "").unwrap();
203        std::fs::write(root.join("rust/src/main.rs"), "").unwrap();
204        dir
205    }
206
207    #[test]
208    fn typo_in_file_name_suggests_the_sibling() {
209        let dir = project();
210        let wanted = dir.path().join("rust/src/core/confg.rs");
211        assert_eq!(
212            suggestions(&wanted),
213            vec![dir.path().join("rust/src/core/config.rs")]
214        );
215    }
216
217    #[test]
218    fn wrong_extension_suggests_the_same_stem() {
219        let dir = project();
220        let wanted = dir.path().join("rust/src/main.ts");
221        assert_eq!(
222            suggestions(&wanted),
223            vec![dir.path().join("rust/src/main.rs")]
224        );
225    }
226
227    #[test]
228    fn wrong_directory_finds_the_file_by_its_tail() {
229        let dir = project();
230        let wanted = dir.path().join("src/core/config.rs");
231        let found = suggestions(&wanted);
232        assert_eq!(
233            found.first(),
234            Some(&dir.path().join("rust/src/core/config.rs"))
235        );
236        assert!(hint(&wanted).contains("did you mean"), "{}", hint(&wanted));
237    }
238
239    #[test]
240    fn unambiguous_match_follows_case_and_wrong_prefix_only() {
241        let dir = project();
242        let root = dir.path();
243        // Same name, other case, same directory — only observable on a
244        // case-sensitive file system (APFS/NTFS defaults open it directly).
245        let other_case = root.join("rust/src/core/Config.rs");
246        if !other_case.exists() {
247            assert_eq!(
248                unambiguous_match(&other_case),
249                Some(root.join("rust/src/core/config.rs"))
250            );
251        }
252        // Whole relative path found under exactly one other directory.
253        assert_eq!(
254            unambiguous_match(&root.join("src/core/config.rs")),
255            Some(root.join("rust/src/core/config.rs"))
256        );
257        // A typo or another extension names a different file: no substitute.
258        assert_eq!(
259            unambiguous_match(&root.join("rust/src/core/confg.rs")),
260            None
261        );
262        assert_eq!(unambiguous_match(&root.join("rust/src/main.ts")), None);
263        // Existing paths are never substituted.
264        assert_eq!(unambiguous_match(&root.join("rust/src/main.rs")), None);
265    }
266
267    #[test]
268    fn project_wide_match_repairs_a_search_relative_path_on_the_wrong_prefix() {
269        let dir = project();
270        let root = dir.path();
271        std::fs::create_dir_all(root.join("tests/core")).unwrap();
272        std::fs::write(root.join("tests/core/test_config.rs"), "").unwrap();
273        // `rust/src/core` exists, so only the project-wide lookup finds it.
274        let wanted = root.join("rust/src/core/test_config.rs");
275        assert_eq!(unambiguous_match(&wanted), None);
276        assert_eq!(
277            unambiguous_match_in(&wanted, root),
278            Some(root.join("tests/core/test_config.rs"))
279        );
280        // A bare file-name match is a different file: suggestion only.
281        assert_eq!(
282            unambiguous_match_in(&root.join("rust/src/other/test_config.rs"), root),
283            None
284        );
285        // Two files sharing the same tail: no substitute.
286        std::fs::create_dir_all(root.join("more/core")).unwrap();
287        std::fs::write(root.join("more/core/test_config.rs"), "").unwrap();
288        assert_eq!(unambiguous_match_in(&wanted, root), None);
289    }
290
291    #[test]
292    fn unambiguous_match_refuses_two_equally_good_candidates() {
293        let dir = project();
294        let root = dir.path();
295        std::fs::create_dir_all(root.join("other/src/core")).unwrap();
296        std::fs::write(root.join("other/src/core/config.rs"), "").unwrap();
297        assert_eq!(unambiguous_match(&root.join("src/core/config.rs")), None);
298        assert_eq!(suggestions(&root.join("src/core/config.rs")).len(), 2);
299    }
300
301    #[test]
302    fn nothing_close_suggests_nothing() {
303        let dir = project();
304        {
305            let found = &suggestions(&dir.path().join("rust/src/zzzzzzzz.py"));
306            assert!(found.is_empty(), "{found:?}");
307        }
308        assert_eq!(hint(&dir.path().join("nope/void.md")), "");
309    }
310}