oxicode_agent/tools/
path_utils.rs1use std::path::{Path, PathBuf};
4
5use unicode_normalization::UnicodeNormalization;
6
7pub 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
26pub 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
37pub 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 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 let nfd_variant = try_nfd_variant(&resolved);
59 if nfd_variant != resolved && nfd_variant.exists() {
60 return nfd_variant;
61 }
62
63 let curly_variant = try_curly_quote_variant(&resolved);
65 if curly_variant != resolved && curly_variant.exists() {
66 return curly_variant;
67 }
68
69 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
78fn normalize_at_prefix(path: &str) -> &str {
80 path.strip_prefix('@').unwrap_or(path)
81}
82
83fn 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
92fn is_unicode_space(ch: char) -> bool {
94 matches!(
95 ch,
96 '\u{00A0}' | '\u{2000}'
98 ..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' )
103}
104
105fn 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
118fn 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
126fn 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 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 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}