client_core/container/
path_utils.rs1use crate::container::environment::{HostOs, PathFormat};
6use std::path::{Path, PathBuf};
7use tracing::debug;
8
9#[derive(Debug, thiserror::Error)]
11pub enum PathUtilsError {
12 #[error("Path processing error: {0}")]
13 InvalidPath(String),
14}
15
16#[derive(Debug, Clone)]
18pub struct PathProcessor {
19 pub host_os: HostOs,
20 pub path_format: PathFormat,
21}
22
23impl PathProcessor {
24 pub fn new(host_os: HostOs, path_format: PathFormat) -> Self {
26 Self {
27 host_os,
28 path_format,
29 }
30 }
31
32 pub fn normalize_path(&self, input_path: &str) -> Result<String, PathUtilsError> {
35 let path = input_path.trim();
36
37 if path.is_empty() {
38 return Err(PathUtilsError::InvalidPath(
39 "Path cannot be empty".to_string(),
40 ));
41 }
42
43 debug!(
44 "🔍 Normalizing path: '{}' (current environment: {:?})",
45 path, self.path_format
46 );
47
48 let cleaned_path = self.clean_path(path);
50
51 let formatted_path = match self.path_format {
53 PathFormat::Wsl2 => self.to_wsl2_format(&cleaned_path),
54 PathFormat::Windows => self.to_windows_format(&cleaned_path),
55 PathFormat::Posix => self.to_posix_format(&cleaned_path),
56 };
57
58 debug!("✅ Path normalization complete: '{}'", formatted_path);
59 Ok(formatted_path)
60 }
61
62 pub fn is_bind_mount_path(&self, path: &str) -> bool {
64 let path = path.trim();
65
66 if path.is_empty() {
67 return false;
68 }
69
70 if path.starts_with('/') && !path.starts_with("//") {
72 return true;
73 }
74
75 if path.len() >= 3
77 && path.chars().nth(1).unwrap_or_default() == ':'
78 && (path.chars().nth(2).unwrap_or_default() == '\\'
79 || path.chars().nth(2).unwrap_or_default() == '/')
80 {
81 return true;
82 }
83
84 if path.starts_with("/mnt/") || path.starts_with("/c/") || path.starts_with("/d/") {
86 return true;
87 }
88
89 if path.contains('/') || path.contains('\\') {
91 return true;
92 }
93
94 false
95 }
96
97 pub fn to_absolute_path(&self, path: &str, work_dir: &Path) -> Result<PathBuf, PathUtilsError> {
99 let normalized_path = self.normalize_path(path)?;
100 let path_buf = PathBuf::from(&normalized_path);
101
102 if path_buf.is_absolute() {
103 Ok(path_buf)
104 } else {
105 let absolute = work_dir.join(path_buf);
107 Ok(absolute)
108 }
109 }
110
111 fn clean_path(&self, path: &str) -> String {
113 let mut components: Vec<std::path::Component> = Vec::new();
114 let mut has_root = false;
115
116 for component in Path::new(path).components() {
117 match component {
118 std::path::Component::CurDir => {
119 continue;
121 }
122 std::path::Component::RootDir => {
123 has_root = true;
125 }
126 std::path::Component::ParentDir => {
127 if let Some(last) = components.last()
129 && *last != std::path::Component::RootDir
130 {
131 components.pop();
132 }
133 }
134 _ => {
135 components.push(component);
136 }
137 }
138 }
139
140 let separator = std::path::MAIN_SEPARATOR_STR;
141 let cleaned = if has_root {
142 let prefix = if separator == "/" { "/" } else { "" };
143 format!(
144 "{}{}",
145 prefix,
146 components
147 .iter()
148 .map(|c| c.as_os_str().to_string_lossy())
149 .collect::<Vec<_>>()
150 .join(separator)
151 )
152 } else {
153 components
154 .iter()
155 .map(|c| c.as_os_str().to_string_lossy())
156 .collect::<Vec<_>>()
157 .join(separator)
158 };
159
160 if cleaned.is_empty() {
162 ".".to_string()
163 } else {
164 cleaned
165 }
166 }
167
168 fn to_wsl2_format(&self, path: &str) -> String {
170 let path = path.replace('\\', "/");
171
172 if path.len() >= 3
174 && path.chars().nth(1).unwrap_or_default() == ':'
175 && (path.chars().nth(2).unwrap_or_default() == '/'
176 || path.chars().nth(2).unwrap_or_default() == '\\')
177 {
178 let drive_letter = path
179 .chars()
180 .nth(0)
181 .unwrap_or_default()
182 .to_lowercase()
183 .next()
184 .unwrap_or_default();
185 let rest = &path[2..];
187 return format!("/mnt/{}{}", drive_letter, rest);
188 }
189
190 if path.starts_with("/mnt/") || path.starts_with("/c/") || path.starts_with("/d/") {
192 return path;
193 }
194
195 path
197 }
198
199 fn to_windows_format(&self, path: &str) -> String {
201 if let Some(rest) = path
203 .strip_prefix("/mnt/")
204 .or_else(|| path.strip_prefix("\\mnt\\"))
205 && !rest.is_empty()
206 {
207 let drive_letter = rest
208 .chars()
209 .next()
210 .unwrap_or_default()
211 .to_uppercase()
212 .next()
213 .unwrap_or_default();
214 let rest = rest[1..].trim_start_matches(['/', '\\']).replace('/', "\\");
216 return format!("{}:\\{}", drive_letter, rest);
217 }
218
219 if let Some(rest) = path
220 .strip_prefix("/c/")
221 .or_else(|| path.strip_prefix("\\c\\"))
222 {
223 let rest = rest.trim_start_matches(['/', '\\']).replace('/', "\\");
224 return format!("C:\\{}", rest);
225 }
226
227 if let Some(rest) = path
228 .strip_prefix("/d/")
229 .or_else(|| path.strip_prefix("\\d\\"))
230 {
231 let rest = rest.trim_start_matches(['/', '\\']).replace('/', "\\");
232 return format!("D:\\{}", rest);
233 }
234
235 path.replace('/', "\\")
237 }
238
239 fn to_posix_format(&self, path: &str) -> String {
241 let path = path.replace('\\', "/");
242
243 if path.len() >= 3
245 && path.chars().nth(1).unwrap_or_default() == ':'
246 && (path.chars().nth(2).unwrap_or_default() == '\\'
247 || path.chars().nth(2).unwrap_or_default() == '/')
248 {
249 let drive_letter = path
250 .chars()
251 .nth(0)
252 .unwrap_or_default()
253 .to_lowercase()
254 .next()
255 .unwrap_or_default();
256 let rest = &path[3..];
257 return format!("/mnt/{}{}", drive_letter, rest);
258 }
259
260 if path.starts_with("/mnt/") || path.starts_with("/c/") || path.starts_with("/d/") {
262 return path;
263 }
264
265 path
266 }
267
268 pub fn convert_separators(&self, path: &str) -> String {
270 match self.path_format {
271 PathFormat::Wsl2 | PathFormat::Posix => path.replace('\\', "/"),
272 PathFormat::Windows => path.replace('/', "\\"),
273 }
274 }
275
276 pub fn needs_special_handling(&self, path: &str) -> bool {
278 self.is_bind_mount_path(path)
280 && (self.path_format == PathFormat::Wsl2 || self.path_format == PathFormat::Windows)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn test_normalize_path_wsl2() {
290 let processor = PathProcessor::new(HostOs::WindowsWsl2, PathFormat::Wsl2);
291
292 assert_eq!(
294 processor.normalize_path(r"C:\Users\test\data").unwrap(),
295 "/mnt/c/Users/test/data"
296 );
297
298 assert_eq!(processor.normalize_path("./data").unwrap(), "data");
300
301 assert_eq!(
303 processor.normalize_path("/mnt/c/Users/test").unwrap(),
304 "/mnt/c/Users/test"
305 );
306 }
307
308 #[test]
309 fn test_normalize_path_windows() {
310 let processor = PathProcessor::new(HostOs::WindowsNative, PathFormat::Windows);
311
312 assert_eq!(
314 processor.normalize_path("/mnt/c/Users/test").unwrap(),
315 r"C:\Users\test"
316 );
317
318 assert_eq!(
320 processor.normalize_path("C:/Users/test").unwrap(),
321 r"C:\Users\test"
322 );
323 }
324
325 #[test]
326 fn test_is_bind_mount_path_wsl2() {
327 let processor = PathProcessor::new(HostOs::WindowsWsl2, PathFormat::Wsl2);
328
329 assert!(processor.is_bind_mount_path("/mnt/c/Users/test/data"));
330 assert!(processor.is_bind_mount_path("/c/Users/test/data"));
331 assert!(processor.is_bind_mount_path("/data/mysql"));
332 assert!(processor.is_bind_mount_path("./data"));
333 assert!(processor.is_bind_mount_path("../data"));
334 assert!(processor.is_bind_mount_path(r"C:\data"));
335
336 assert!(!processor.is_bind_mount_path("volume_name"));
338 assert!(!processor.is_bind_mount_path(""));
339 }
340
341 #[test]
342 fn test_to_absolute_path() {
343 let processor = PathProcessor::new(HostOs::LinuxNative, PathFormat::Posix);
344 let work_dir = PathBuf::from("/workspace");
345
346 assert_eq!(
348 processor.to_absolute_path("/data", &work_dir).unwrap(),
349 PathBuf::from("/data")
350 );
351
352 assert_eq!(
354 processor.to_absolute_path("./data", &work_dir).unwrap(),
355 PathBuf::from("/workspace/data")
356 );
357 }
358
359 #[test]
360 fn test_convert_separators() {
361 let processor_wsl2 = PathProcessor::new(HostOs::WindowsWsl2, PathFormat::Wsl2);
363 assert_eq!(
364 processor_wsl2.convert_separators(r"C:\Users\test"),
365 "C:/Users/test"
366 );
367
368 let processor_windows = PathProcessor::new(HostOs::WindowsNative, PathFormat::Windows);
369 assert_eq!(
370 processor_windows.convert_separators("/mnt/c/Users/test"),
371 "\\mnt\\c\\Users\\test"
372 );
373 }
374
375 #[test]
376 fn test_clean_path() {
377 let processor = PathProcessor::new(HostOs::LinuxNative, PathFormat::Posix);
378
379 assert_eq!(processor.clean_path("./data"), "data");
380 assert_eq!(processor.clean_path("data/./test"), "data/test");
381 assert_eq!(processor.clean_path("data/../test"), "test");
382 assert_eq!(processor.clean_path("./"), ".");
384 }
385
386 #[test]
387 fn test_needs_special_handling() {
388 let processor_wsl2 = PathProcessor::new(HostOs::WindowsWsl2, PathFormat::Wsl2);
389 assert!(processor_wsl2.needs_special_handling("/mnt/c/data"));
390
391 let processor_posix = PathProcessor::new(HostOs::LinuxNative, PathFormat::Posix);
392 assert!(!processor_posix.needs_special_handling("/data"));
393 }
394}