Skip to main content

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;