use crate::container::environment::{HostOs, PathFormat};
use std::path::{Path, PathBuf};
use tracing::debug;
#[derive(Debug, thiserror::Error)]
pub enum PathUtilsError {
#[error("Path processing error: {0}")]
InvalidPath(String),
}
#[derive(Debug, Clone)]
pub struct PathProcessor {
pub host_os: HostOs,
pub path_format: PathFormat,
}
impl PathProcessor {
pub fn new(host_os: HostOs, path_format: PathFormat) -> Self {
Self {
host_os,
path_format,
}
}
pub fn normalize_path(&self, input_path: &str) -> Result<String, PathUtilsError> {
let path = input_path.trim();
if path.is_empty() {
return Err(PathUtilsError::InvalidPath(
"Path cannot be empty".to_string(),
));
}
debug!(
"🔍 Normalizing path: '{}' (current environment: {:?})",
path, self.path_format
);
let cleaned_path = self.clean_path(path);
let formatted_path = match self.path_format {
PathFormat::Wsl2 => self.to_wsl2_format(&cleaned_path),
PathFormat::Windows => self.to_windows_format(&cleaned_path),
PathFormat::Posix => self.to_posix_format(&cleaned_path),
};
debug!("✅ Path normalization complete: '{}'", formatted_path);
Ok(formatted_path)
}
pub fn is_bind_mount_path(&self, path: &str) -> bool {
let path = path.trim();
if path.is_empty() {
return false;
}
if path.starts_with('/') && !path.starts_with("//") {
return true;
}
if path.len() >= 3
&& path.chars().nth(1).unwrap_or_default() == ':'
&& (path.chars().nth(2).unwrap_or_default() == '\\'
|| path.chars().nth(2).unwrap_or_default() == '/')
{
return true;
}
if path.starts_with("/mnt/") || path.starts_with("/c/") || path.starts_with("/d/") {
return true;
}
if path.contains('/') || path.contains('\\') {
return true;
}
false
}
pub fn to_absolute_path(&self, path: &str, work_dir: &Path) -> Result<PathBuf, PathUtilsError> {
let normalized_path = self.normalize_path(path)?;
let path_buf = PathBuf::from(&normalized_path);
if path_buf.is_absolute() {
Ok(path_buf)
} else {
let absolute = work_dir.join(path_buf);
Ok(absolute)
}
}
fn clean_path(&self, path: &str) -> String {
let mut components: Vec<std::path::Component> = Vec::new();
let mut has_root = false;
for component in Path::new(path).components() {
match component {
std::path::Component::CurDir => {
continue;
}
std::path::Component::RootDir => {
has_root = true;
}
std::path::Component::ParentDir => {
if let Some(last) = components.last()
&& *last != std::path::Component::RootDir
{
components.pop();
}
}
_ => {
components.push(component);
}
}
}
let separator = std::path::MAIN_SEPARATOR_STR;
let cleaned = if has_root {
let prefix = if separator == "/" { "/" } else { "" };
format!(
"{}{}",
prefix,
components
.iter()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join(separator)
)
} else {
components
.iter()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join(separator)
};
if cleaned.is_empty() {
".".to_string()
} else {
cleaned
}
}
fn to_wsl2_format(&self, path: &str) -> String {
let path = path.replace('\\', "/");
if path.len() >= 3
&& path.chars().nth(1).unwrap_or_default() == ':'
&& (path.chars().nth(2).unwrap_or_default() == '/'
|| path.chars().nth(2).unwrap_or_default() == '\\')
{
let drive_letter = path
.chars()
.nth(0)
.unwrap_or_default()
.to_lowercase()
.next()
.unwrap_or_default();
let rest = &path[2..];
return format!("/mnt/{}{}", drive_letter, rest);
}
if path.starts_with("/mnt/") || path.starts_with("/c/") || path.starts_with("/d/") {
return path;
}
path
}
fn to_windows_format(&self, path: &str) -> String {
if let Some(rest) = path
.strip_prefix("/mnt/")
.or_else(|| path.strip_prefix("\\mnt\\"))
&& !rest.is_empty()
{
let drive_letter = rest
.chars()
.next()
.unwrap_or_default()
.to_uppercase()
.next()
.unwrap_or_default();
let rest = rest[1..].trim_start_matches(['/', '\\']).replace('/', "\\");
return format!("{}:\\{}", drive_letter, rest);
}
if let Some(rest) = path
.strip_prefix("/c/")
.or_else(|| path.strip_prefix("\\c\\"))
{
let rest = rest.trim_start_matches(['/', '\\']).replace('/', "\\");
return format!("C:\\{}", rest);
}
if let Some(rest) = path
.strip_prefix("/d/")
.or_else(|| path.strip_prefix("\\d\\"))
{
let rest = rest.trim_start_matches(['/', '\\']).replace('/', "\\");
return format!("D:\\{}", rest);
}
path.replace('/', "\\")
}
fn to_posix_format(&self, path: &str) -> String {
let path = path.replace('\\', "/");
if path.len() >= 3
&& path.chars().nth(1).unwrap_or_default() == ':'
&& (path.chars().nth(2).unwrap_or_default() == '\\'
|| path.chars().nth(2).unwrap_or_default() == '/')
{
let drive_letter = path
.chars()
.nth(0)
.unwrap_or_default()
.to_lowercase()
.next()
.unwrap_or_default();
let rest = &path[3..];
return format!("/mnt/{}{}", drive_letter, rest);
}
if path.starts_with("/mnt/") || path.starts_with("/c/") || path.starts_with("/d/") {
return path;
}
path
}
pub fn convert_separators(&self, path: &str) -> String {
match self.path_format {
PathFormat::Wsl2 | PathFormat::Posix => path.replace('\\', "/"),
PathFormat::Windows => path.replace('/', "\\"),
}
}
pub fn needs_special_handling(&self, path: &str) -> bool {
self.is_bind_mount_path(path)
&& (self.path_format == PathFormat::Wsl2 || self.path_format == PathFormat::Windows)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path_wsl2() {
let processor = PathProcessor::new(HostOs::WindowsWsl2, PathFormat::Wsl2);
assert_eq!(
processor.normalize_path(r"C:\Users\test\data").unwrap(),
"/mnt/c/Users/test/data"
);
assert_eq!(processor.normalize_path("./data").unwrap(), "data");
assert_eq!(
processor.normalize_path("/mnt/c/Users/test").unwrap(),
"/mnt/c/Users/test"
);
}
#[test]
fn test_normalize_path_windows() {
let processor = PathProcessor::new(HostOs::WindowsNative, PathFormat::Windows);
assert_eq!(
processor.normalize_path("/mnt/c/Users/test").unwrap(),
r"C:\Users\test"
);
assert_eq!(
processor.normalize_path("C:/Users/test").unwrap(),
r"C:\Users\test"
);
}
#[test]
fn test_is_bind_mount_path_wsl2() {
let processor = PathProcessor::new(HostOs::WindowsWsl2, PathFormat::Wsl2);
assert!(processor.is_bind_mount_path("/mnt/c/Users/test/data"));
assert!(processor.is_bind_mount_path("/c/Users/test/data"));
assert!(processor.is_bind_mount_path("/data/mysql"));
assert!(processor.is_bind_mount_path("./data"));
assert!(processor.is_bind_mount_path("../data"));
assert!(processor.is_bind_mount_path(r"C:\data"));
assert!(!processor.is_bind_mount_path("volume_name"));
assert!(!processor.is_bind_mount_path(""));
}
#[test]
fn test_to_absolute_path() {
let processor = PathProcessor::new(HostOs::LinuxNative, PathFormat::Posix);
let work_dir = PathBuf::from("/workspace");
assert_eq!(
processor.to_absolute_path("/data", &work_dir).unwrap(),
PathBuf::from("/data")
);
assert_eq!(
processor.to_absolute_path("./data", &work_dir).unwrap(),
PathBuf::from("/workspace/data")
);
}
#[test]
fn test_convert_separators() {
let processor_wsl2 = PathProcessor::new(HostOs::WindowsWsl2, PathFormat::Wsl2);
assert_eq!(
processor_wsl2.convert_separators(r"C:\Users\test"),
"C:/Users/test"
);
let processor_windows = PathProcessor::new(HostOs::WindowsNative, PathFormat::Windows);
assert_eq!(
processor_windows.convert_separators("/mnt/c/Users/test"),
"\\mnt\\c\\Users\\test"
);
}
#[test]
fn test_clean_path() {
let processor = PathProcessor::new(HostOs::LinuxNative, PathFormat::Posix);
assert_eq!(processor.clean_path("./data"), "data");
assert_eq!(processor.clean_path("data/./test"), "data/test");
assert_eq!(processor.clean_path("data/../test"), "test");
assert_eq!(processor.clean_path("./"), ".");
}
#[test]
fn test_needs_special_handling() {
let processor_wsl2 = PathProcessor::new(HostOs::WindowsWsl2, PathFormat::Wsl2);
assert!(processor_wsl2.needs_special_handling("/mnt/c/data"));
let processor_posix = PathProcessor::new(HostOs::LinuxNative, PathFormat::Posix);
assert!(!processor_posix.needs_special_handling("/data"));
}
}