opendev_tools_core/path.rs
1//! Canonical path resolution for LLM-produced tool parameters.
2//!
3//! LLMs frequently return incorrect paths — relative paths, redundant basename
4//! prefixes (e.g., `myproject/src/main.rs` when cwd is already `myproject`),
5//! `./` prefixes, `$HOME` paths, etc. This module provides the single source
6//! of truth for resolving such paths.
7
8use std::path::{Component, Path, PathBuf};
9
10/// Expand tilde (`~`) and `$HOME` prefixes in a path string.
11///
12/// - `~/foo` -> `/home/user/foo`
13/// - `$HOME/foo` -> `/home/user/foo`
14/// - `~` -> `/home/user`
15/// - Other paths are returned as-is.
16pub fn expand_home(path: &str) -> String {
17 if path == "~" {
18 return dirs::home_dir()
19 .map(|h| h.to_string_lossy().to_string())
20 .unwrap_or_else(|| path.to_string());
21 }
22 if let Some(rest) = path.strip_prefix("~/")
23 && let Some(home) = dirs::home_dir()
24 {
25 return format!("{}/{}", home.display(), rest);
26 }
27 if let Some(rest) = path.strip_prefix("$HOME/")
28 && let Some(home) = dirs::home_dir()
29 {
30 return format!("{}/{}", home.display(), rest);
31 }
32 if path == "$HOME" {
33 return dirs::home_dir()
34 .map(|h| h.to_string_lossy().to_string())
35 .unwrap_or_else(|| path.to_string());
36 }
37 path.to_string()
38}
39
40/// Strip leading `.` and `./` components from a path, returning the
41/// meaningful portion. E.g., `./myproject/src` -> `myproject/src`.
42pub fn strip_curdir(path: &Path) -> PathBuf {
43 path.components()
44 .filter(|c| !matches!(c, Component::CurDir))
45 .collect()
46}
47
48/// Normalize a path by collapsing `.` and `..` components without touching the filesystem.
49///
50/// Unlike `canonicalize()`, this works on paths that don't exist yet.
51pub fn normalize_path(path: &Path) -> PathBuf {
52 let mut components = Vec::new();
53
54 for component in path.components() {
55 match component {
56 Component::CurDir => {} // skip `.`
57 Component::ParentDir => {
58 // Pop the last component if it's a normal component.
59 if let Some(last) = components.last()
60 && !matches!(last, Component::RootDir | Component::Prefix(_))
61 {
62 components.pop();
63 continue;
64 }
65 components.push(component);
66 }
67 _ => components.push(component),
68 }
69 }
70
71 components.iter().collect()
72}
73
74/// Strip hallucinated Docker-style prefixes like `/workspace/` or `/testbed/`.
75///
76/// If the path starts with a known fake prefix AND the resulting absolute path
77/// doesn't exist, rewrites it to be relative to the working directory.
78/// If the original absolute path does exist (e.g., there really is a `/workspace/` dir),
79/// it is left unchanged.
80fn strip_hallucinated_prefix(path_str: &str, working_dir: &Path) -> String {
81 for prefix in HALLUCINATED_PREFIXES {
82 if let Some(rest) = path_str.strip_prefix(prefix) {
83 let original = Path::new(path_str);
84 // Only rewrite if the original doesn't exist but the working_dir version does
85 // (or the working_dir version's parent exists for new file creation).
86 if !original.exists() {
87 let candidate = working_dir.join(rest);
88 if candidate.exists() || candidate.parent().map(|p| p.is_dir()).unwrap_or(false) {
89 return candidate.to_string_lossy().to_string();
90 }
91 // Even if candidate doesn't exist, still rewrite — `/workspace/` is almost
92 // certainly wrong on a real system.
93 return candidate.to_string_lossy().to_string();
94 }
95 }
96 }
97 // Also handle bare `/workspace` or `/testbed` (without trailing slash or subpath)
98 let bare_prefixes = ["/workspace", "/testbed"];
99 for prefix in &bare_prefixes {
100 if path_str == *prefix && !Path::new(prefix).exists() {
101 return working_dir.to_string_lossy().to_string();
102 }
103 }
104 path_str.to_string()
105}
106
107/// Well-known fake prefixes that LLMs hallucinate from Docker training data.
108/// When we see these as absolute path prefixes and the real path doesn't exist,
109/// we strip them and resolve relative to the actual working directory.
110const HALLUCINATED_PREFIXES: &[&str] = &["/workspace/", "/testbed/"];
111
112/// Resolve a user-provided file path against the working directory.
113///
114/// Handles common LLM mistakes:
115/// - `./src/main.rs` -> strips `./` prefix
116/// - `~/file.rs` / `$HOME/file.rs` -> expands home directory
117/// - `myproject/main.rs` when cwd is `/home/user/myproject` -> `/home/user/myproject/main.rs`
118/// (detects and strips redundant basename prefix)
119/// - Absolute paths with doubled project name
120/// - `/workspace/foo` or `/testbed/foo` -> `{working_dir}/foo` (LLM hallucination from Docker)
121pub fn resolve_file_path(user_path: &str, working_dir: &Path) -> PathBuf {
122 let expanded = expand_home(user_path);
123 // Rewrite hallucinated Docker prefixes to working_dir-relative paths
124 let expanded = strip_hallucinated_prefix(&expanded, working_dir);
125 let path = strip_curdir(Path::new(&expanded));
126 let path = normalize_path(&path);
127 let path = path.as_path();
128 if path.is_absolute() {
129 if path.exists() {
130 return path.to_path_buf();
131 }
132 // Check if the path has a redundant component matching the working dir basename.
133 // e.g., /home/user/myproject/myproject/src/main.rs -> /home/user/myproject/src/main.rs
134 if let Ok(rel) = path.strip_prefix(working_dir)
135 && let Some(first) = rel.components().next()
136 {
137 let first_name = first.as_os_str();
138 if working_dir
139 .file_name()
140 .map(|n| n == first_name)
141 .unwrap_or(false)
142 {
143 let fixed = working_dir.join(rel.strip_prefix(first_name).unwrap_or(rel));
144 // Accept if the file exists OR its parent directory exists
145 // (supports new file creation with redundant prefix)
146 if fixed.exists() || fixed.parent().map(|p| p.is_dir()).unwrap_or(false) {
147 return fixed;
148 }
149 }
150 }
151 path.to_path_buf()
152 } else {
153 let joined = normalize_path(&working_dir.join(path));
154 if joined.exists() {
155 return joined;
156 }
157 // Check if first component matches working dir basename (redundant prefix)
158 let mut components = path.components();
159 if let Some(first) = components.next() {
160 let first_name = first.as_os_str();
161 if working_dir
162 .file_name()
163 .map(|n| n == first_name)
164 .unwrap_or(false)
165 {
166 let rest: PathBuf = components.collect();
167 if !rest.as_os_str().is_empty() {
168 let fixed = normalize_path(&working_dir.join(&rest));
169 if fixed.exists() || fixed.parent().map(|p| p.is_dir()).unwrap_or(false) {
170 return fixed;
171 }
172 }
173 }
174 }
175 joined
176 }
177}
178
179/// Resolve a user-provided directory path against the working directory.
180///
181/// Same as [`resolve_file_path`] but optimized for directory paths. If a relative
182/// path doesn't exist when joined with working_dir, checks if stripping a redundant
183/// leading directory component (matching the working dir's basename) helps.
184pub fn resolve_dir_path(user_path: &str, working_dir: &Path) -> PathBuf {
185 let expanded = expand_home(user_path);
186 // Rewrite hallucinated Docker prefixes to working_dir-relative paths
187 let expanded = strip_hallucinated_prefix(&expanded, working_dir);
188 let path = strip_curdir(Path::new(&expanded));
189 let path = normalize_path(&path);
190 let path = path.as_path();
191 if path.is_absolute() {
192 if path.is_dir() {
193 return path.to_path_buf();
194 }
195 // Check if the path has a redundant component matching the working dir basename.
196 if let Ok(rel) = path.strip_prefix(working_dir)
197 && let Some(first) = rel.components().next()
198 {
199 let first_name = first.as_os_str();
200 if working_dir
201 .file_name()
202 .map(|n| n == first_name)
203 .unwrap_or(false)
204 {
205 let fixed = working_dir.join(rel.strip_prefix(first_name).unwrap_or(rel));
206 if fixed.is_dir() || fixed.parent().map(|p| p.is_dir()).unwrap_or(false) {
207 return fixed;
208 }
209 }
210 }
211 // Absolute path doesn't exist as a directory — check if it matches
212 // the working directory or is a parent prefix of it.
213 if working_dir.starts_with(path) || working_dir == path {
214 working_dir.to_path_buf()
215 } else {
216 path.to_path_buf()
217 }
218 } else {
219 let joined = normalize_path(&working_dir.join(path));
220 if joined.is_dir() {
221 return joined;
222 }
223 // Check if first component matches working dir basename (redundant prefix)
224 let mut components = path.components();
225 if let Some(first) = components.next() {
226 let first_name = first.as_os_str();
227 if working_dir
228 .file_name()
229 .map(|n| n == first_name)
230 .unwrap_or(false)
231 {
232 let rest: PathBuf = components.collect();
233 if rest.as_os_str().is_empty() {
234 // Single component matching basename — fall back to cwd
235 return working_dir.to_path_buf();
236 }
237 let fixed = working_dir.join(&rest);
238 if fixed.is_dir() || fixed.parent().map(|p| p.is_dir()).unwrap_or(false) {
239 return fixed;
240 }
241 }
242 }
243 joined
244 }
245}
246
247#[cfg(test)]
248#[path = "path_tests.rs"]
249mod tests;