Skip to main content

oxicode_agent/tools/
path_utils.rs

1/// Path resolution utilities
2/// Provides path normalization, home expansion, and macOS-specific path handling.
3use std::path::{Path, PathBuf};
4
5use unicode_normalization::UnicodeNormalization;
6
7/// Expand a path that may start with `~` to the full home directory path.
8/// Also strips a leading `@` prefix (used by some tool interfaces).
9pub fn expand_path(path: &str) -> PathBuf {
10    let normalized = normalize_at_prefix(path);
11    let normalized = normalize_unicode_spaces(normalized);
12
13    if normalized == "~" {
14        return dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
15    }
16
17    if let Some(rest) = normalized.strip_prefix("~/")
18        && let Some(home) = dirs::home_dir()
19    {
20        return home.join(rest);
21    }
22
23    PathBuf::from(normalized)
24}
25
26/// Resolve a path relative to the given working directory.
27/// Handles `~` expansion and absolute paths.
28pub fn resolve_to_cwd(path: &str, cwd: &Path) -> PathBuf {
29    let expanded = expand_path(path);
30    if expanded.is_absolute() {
31        expanded
32    } else {
33        cwd.join(expanded)
34    }
35}
36
37/// Resolve a path for reading, trying macOS-specific variants if the initial
38/// path doesn't exist.
39///
40/// macOS may store filenames in:
41/// - NFD (decomposed) Unicode form
42/// - With narrow no-break spaces before AM/PM in screenshots
43/// - With curly quotes (U+2019) instead of straight apostrophes
44pub fn resolve_read_path(path: &str, cwd: &Path) -> PathBuf {
45    let resolved = resolve_to_cwd(path, cwd);
46
47    if resolved.exists() {
48        return resolved;
49    }
50
51    // Try macOS AM/PM variant (narrow no-break space before AM/PM)
52    let am_pm_variant = try_macos_screenshot_path(&resolved);
53    if am_pm_variant != resolved && am_pm_variant.exists() {
54        return am_pm_variant;
55    }
56
57    // Try NFD variant (macOS stores filenames in NFD form)
58    let nfd_variant = try_nfd_variant(&resolved);
59    if nfd_variant != resolved && nfd_variant.exists() {
60        return nfd_variant;
61    }
62
63    // Try curly quote variant (macOS uses U+2019 in screenshot names)
64    let curly_variant = try_curly_quote_variant(&resolved);
65    if curly_variant != resolved && curly_variant.exists() {
66        return curly_variant;
67    }
68
69    // Try combined NFD + curly quote
70    let nfd_curly_variant = try_curly_quote_variant(&nfd_variant);
71    if nfd_curly_variant != resolved && nfd_curly_variant.exists() {
72        return nfd_curly_variant;
73    }
74
75    resolved
76}
77
78/// Strip leading `@` prefix from a path string.
79fn normalize_at_prefix(path: &str) -> &str {
80    path.strip_prefix('@').unwrap_or(path)
81}
82
83/// Normalize Unicode spaces (non-breaking spaces, etc.) to regular spaces.
84fn normalize_unicode_spaces(s: &str) -> String {
85    let mut result = String::with_capacity(s.len());
86    for ch in s.chars() {
87        result.push(if is_unicode_space(ch) { ' ' } else { ch });
88    }
89    result
90}
91
92/// Check if a character is a Unicode space that should be normalized.
93fn is_unicode_space(ch: char) -> bool {
94    matches!(
95        ch,
96        '\u{00A0}'      // No-Break Space (NBSP)
97        | '\u{2000}'
98            ..='\u{200A}' // Various spaces (En Quad through Hair Space)
99        | '\u{202F}'     // Narrow No-Break Space
100        | '\u{205F}'     // Medium Mathematical Space
101        | '\u{3000}' // Ideographic Space
102    )
103}
104
105/// Try replacing regular space + AM/PM with narrow no-break space + AM/PM
106/// (macOS screenshot naming convention).
107fn try_macos_screenshot_path(path: &Path) -> PathBuf {
108    let path_str = path.to_string_lossy();
109    let replaced = path_str
110        .replace(" AM.", "\u{202F}AM.")
111        .replace(" PM.", "\u{202F}PM.");
112    let replaced = replaced
113        .replace(" am.", "\u{202F}AM.")
114        .replace(" pm.", "\u{202F}PM.");
115    PathBuf::from(replaced)
116}
117
118/// Try NFD (decomposed) Unicode form of the path.
119/// macOS stores filenames in NFD form, but users may provide NFC input.
120fn try_nfd_variant(path: &Path) -> PathBuf {
121    let path_str = path.to_string_lossy();
122    let nfd = path_str.nfd().collect::<String>();
123    PathBuf::from(nfd)
124}
125
126/// Try replacing straight apostrophes with curly quotes (U+2019).
127/// macOS uses U+2019 in screenshot names like "Capture d'écran".
128fn try_curly_quote_variant(path: &Path) -> PathBuf {
129    let path_str = path.to_string_lossy();
130    let replaced = path_str.replace('\'', "\u{2019}");
131    PathBuf::from(replaced)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_expand_path_home() {
140        let home = dirs::home_dir().unwrap();
141        let expanded = expand_path("~/foo.txt");
142        assert_eq!(expanded, home.join("foo.txt"));
143    }
144
145    #[test]
146    fn test_expand_path_home_only() {
147        let home = dirs::home_dir().unwrap();
148        let expanded = expand_path("~");
149        assert_eq!(expanded, home);
150    }
151
152    #[test]
153    fn test_expand_path_absolute() {
154        let expanded = expand_path("/tmp/foo.txt");
155        assert_eq!(expanded, PathBuf::from("/tmp/foo.txt"));
156    }
157
158    #[test]
159    fn test_expand_path_relative() {
160        let expanded = expand_path("foo.txt");
161        assert_eq!(expanded, PathBuf::from("foo.txt"));
162    }
163
164    #[test]
165    fn test_expand_path_at_prefix() {
166        let expanded = expand_path("@/tmp/foo.txt");
167        assert_eq!(expanded, PathBuf::from("/tmp/foo.txt"));
168    }
169
170    #[test]
171    fn test_expand_path_unicode_spaces() {
172        // Non-breaking space (U+00A0) should be normalized
173        let expanded = expand_path("hello\u{00A0}world");
174        assert_eq!(expanded, PathBuf::from("hello world"));
175    }
176
177    #[test]
178    fn test_resolve_to_cwd_absolute() {
179        let cwd = Path::new("/home/user/project");
180        let resolved = resolve_to_cwd("/tmp/foo.txt", cwd);
181        assert_eq!(resolved, PathBuf::from("/tmp/foo.txt"));
182    }
183
184    #[test]
185    fn test_resolve_to_cwd_relative() {
186        let cwd = Path::new("/home/user/project");
187        let resolved = resolve_to_cwd("src/main.rs", cwd);
188        assert_eq!(resolved, PathBuf::from("/home/user/project/src/main.rs"));
189    }
190
191    #[test]
192    fn test_resolve_to_cwd_home() {
193        let home = dirs::home_dir().unwrap();
194        let cwd = Path::new("/home/user/project");
195        let resolved = resolve_to_cwd("~/foo.txt", cwd);
196        assert_eq!(resolved, home.join("foo.txt"));
197    }
198
199    #[test]
200    fn test_resolve_read_path_existing() {
201        // The current directory should always exist
202        let cwd = std::env::current_dir().unwrap();
203        let resolved = resolve_read_path(".", &cwd);
204        assert!(resolved.exists());
205    }
206
207    #[test]
208    fn test_resolve_read_path_nonexistent() {
209        let cwd = Path::new("/tmp");
210        let resolved = resolve_read_path("nonexistent_file_xyz.txt", cwd);
211        assert!(!resolved.exists());
212        assert_eq!(resolved, PathBuf::from("/tmp/nonexistent_file_xyz.txt"));
213    }
214
215    #[test]
216    fn test_normalize_unicode_spaces() {
217        assert_eq!(
218            normalize_unicode_spaces("hello\u{00A0}world"),
219            "hello world"
220        );
221        assert_eq!(
222            normalize_unicode_spaces("hello\u{202F}world"),
223            "hello world"
224        );
225        assert_eq!(normalize_unicode_spaces("hello world"), "hello world");
226    }
227
228    #[test]
229    fn test_is_unicode_space() {
230        assert!(is_unicode_space('\u{00A0}'));
231        assert!(is_unicode_space('\u{202F}'));
232        assert!(is_unicode_space('\u{3000}'));
233        assert!(!is_unicode_space(' '));
234        assert!(!is_unicode_space('a'));
235    }
236}