use crate::config::{Config, PathStyle, PathsConfig};
use crate::error::ToolError;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub type PathResult<T> = Result<T, ToolError>;
#[derive(Debug, Clone)]
pub struct PathMapper {
root: String,
mappings: HashMap<String, String>,
map_windows_drives: bool,
style: PathStyle,
}
#[allow(clippy::result_large_err)]
impl PathMapper {
pub fn from_config(config: &PathsConfig, full_config: Option<&Config>) -> PathResult<Self> {
let root = Self::resolve_root(&config.root)?;
let mut mappings = HashMap::new();
for (prefix, value) in &config.mappings {
if !prefix.chars().all(|c: char| c.is_ascii_lowercase()) {
return Err(ToolError::prefix_not_lowercase(prefix));
}
let resolved = Self::resolve_mapping_value(value, &root, full_config)?;
mappings.insert(prefix.clone(), resolved);
}
Ok(Self {
root,
mappings,
map_windows_drives: config.map_windows_drives,
style: config.style,
})
}
pub fn new() -> PathResult<Self> {
Self::from_config(&PathsConfig::default(), None)
}
fn resolve_root(root: &str) -> PathResult<String> {
let root_path = if root == "." || root.is_empty() {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
} else {
let path = Path::new(root);
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
};
let normalized = normalize_path_components(&root_path);
Ok(path_to_forward_slashes(&normalized))
}
fn resolve_mapping_value(
value: &str,
root: &str,
full_config: Option<&Config>,
) -> PathResult<String> {
if value == "." {
return Ok(root.to_string());
}
if let Some(env_var) = value.strip_prefix('$') {
if let Some(config_path) = env_var.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
return Self::resolve_config_ref(config_path, root, full_config);
}
return match std::env::var(env_var) {
Ok(val) => {
let path = Path::new(&val);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
let normalized = normalize_path_components(&absolute);
Ok(path_to_forward_slashes(&normalized))
}
Err(_) => Err(ToolError::invalid_path(
value,
&format!("Environment variable {} not set", env_var),
)),
};
}
let path = Path::new(value);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
PathBuf::from(root).join(path)
};
let normalized = normalize_path_components(&absolute);
Ok(path_to_forward_slashes(&normalized))
}
fn resolve_config_ref(
config_path: &str,
root: &str,
full_config: Option<&Config>,
) -> PathResult<String> {
let config = full_config.ok_or_else(|| {
ToolError::invalid_path(
config_path,
"Config reference requires full config, but none provided",
)
})?;
let parts: Vec<&str> = config_path.split('.').collect();
if parts.len() != 2 {
return Err(ToolError::invalid_path(
config_path,
"Config reference must be in format 'section.field'",
));
}
let value = match (parts[0], parts[1]) {
("server", "media_dir") => config.server.media_dir.to_string_lossy().to_string(),
("server", "db_path") => config.server.db_path.to_string_lossy().to_string(),
("server", "skills_dir") => config.server.skills_dir.to_string_lossy().to_string(),
("server", "log_dir") => config.server.log_dir.to_string_lossy().to_string(),
_ => {
return Err(ToolError::invalid_path(
config_path,
&format!("Unknown config path: {}", config_path),
));
}
};
let path = Path::new(&value);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
PathBuf::from(root).join(path)
};
let normalized = normalize_path_components(&absolute);
Ok(path_to_forward_slashes(&normalized))
}
pub fn normalize(&self, path: &str) -> PathResult<String> {
let (resolved_base, remainder) = self.resolve_prefix(path)?;
let full_path = if let Some(base) = resolved_base {
if remainder.is_empty() {
base
} else {
format!("{}/{}", base.trim_end_matches('/'), remainder)
}
} else {
if Path::new(remainder).is_absolute() {
remainder.to_string()
} else {
format!("{}/{}", self.root.trim_end_matches('/'), remainder)
}
};
let path_buf = PathBuf::from(&full_path);
let normalized = normalize_path_components(&path_buf);
let canonical = path_to_forward_slashes(&normalized);
self.check_sandbox(&canonical)?;
Ok(canonical)
}
pub fn normalize_all(&self, paths: Vec<String>) -> PathResult<Vec<String>> {
paths.into_iter().map(|p| self.normalize(&p)).collect()
}
fn resolve_prefix<'a>(&self, path: &'a str) -> PathResult<(Option<String>, &'a str)> {
if let Some(colon_pos) = path.find(':') {
let prefix = &path[..colon_pos];
let remainder = &path[colon_pos + 1..].trim_start_matches('/');
if prefix.is_empty() {
return Err(ToolError::invalid_path(path, "Empty prefix before colon"));
}
if prefix.chars().any(|c: char| c.is_ascii_uppercase()) {
return Err(ToolError::prefix_not_lowercase(prefix));
}
if !prefix.chars().all(|c: char| c.is_ascii_lowercase()) {
return Err(ToolError::invalid_path(
path,
&format!("Prefix '{}' contains non-letter characters", prefix),
));
}
if prefix.len() == 1 {
if let Some(base) = self.mappings.get(prefix) {
return Ok((Some(base.clone()), remainder));
}
if self.map_windows_drives {
let drive = prefix.to_ascii_uppercase();
let drive_path = format!("{}:/", drive);
return Ok((Some(drive_path), remainder));
}
return Err(ToolError::unknown_prefix(prefix));
}
if let Some(base) = self.mappings.get(prefix) {
return Ok((Some(base.clone()), remainder));
}
return Err(ToolError::unknown_prefix(prefix));
}
if path.len() >= 2 {
let first_char = path.chars().next().unwrap();
let second_char = path.chars().nth(1).unwrap();
if first_char.is_ascii_alphabetic() && second_char == ':' {
return Ok((None, path));
}
}
Ok((None, path))
}
fn check_sandbox(&self, canonical: &str) -> PathResult<()> {
let canonical_normalized = canonical.to_lowercase();
let root_normalized = self.root.to_lowercase();
if !canonical_normalized.starts_with(&root_normalized) {
return Err(ToolError::sandbox_escape(canonical, &self.root));
}
if canonical_normalized.len() > root_normalized.len() {
let next_char = canonical_normalized.chars().nth(root_normalized.len());
if next_char != Some('/') && next_char.is_some() {
return Err(ToolError::sandbox_escape(canonical, &self.root));
}
}
Ok(())
}
pub fn to_display(&self, canonical: &str) -> String {
match self.style {
PathStyle::Relative => {
let root_with_slash = if self.root.ends_with('/') {
self.root.clone()
} else {
format!("{}/", self.root)
};
if let Some(relative) = canonical.strip_prefix(&root_with_slash) {
relative.to_string()
} else if canonical == self.root {
".".to_string()
} else {
canonical.to_string()
}
}
PathStyle::ProjectPrefixed => {
let root_with_slash = if self.root.ends_with('/') {
self.root.clone()
} else {
format!("{}/", self.root)
};
if let Some(relative) = canonical.strip_prefix(&root_with_slash) {
format!("${{project}}/{}", relative)
} else if canonical == self.root {
"${project}".to_string()
} else {
canonical.to_string()
}
}
}
}
pub fn to_filesystem_path(&self, canonical: &str) -> PathBuf {
PathBuf::from(canonical)
}
pub fn from_filesystem_path(&self, fs_path: &Path) -> PathResult<String> {
let absolute = if fs_path.is_absolute() {
fs_path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(fs_path)
};
let normalized = normalize_path_components(&absolute);
let canonical = path_to_forward_slashes(&normalized);
self.check_sandbox(&canonical)?;
Ok(canonical)
}
pub fn root(&self) -> &str {
&self.root
}
pub fn style(&self) -> PathStyle {
self.style
}
pub fn has_prefix(&self, prefix: &str) -> bool {
self.mappings.contains_key(prefix)
}
pub fn prefixes(&self) -> Vec<&str> {
self.mappings.keys().map(|s| s.as_str()).collect()
}
}
impl Default for PathMapper {
fn default() -> Self {
Self::new().expect("Failed to create default PathMapper")
}
}
fn normalize_path_components(path: &Path) -> PathBuf {
use std::path::Component;
let mut components = Vec::new();
for component in path.components() {
match component {
Component::Prefix(p) => {
components.push(Component::Prefix(p));
}
Component::RootDir => {
components.push(Component::RootDir);
}
Component::CurDir => {
}
Component::ParentDir => {
if let Some(Component::Normal(_)) = components.last() {
components.pop();
} else {
components.push(Component::ParentDir);
}
}
Component::Normal(name) => {
components.push(Component::Normal(name));
}
}
}
components.iter().collect()
}
fn path_to_forward_slashes(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_path_mapper() {
let mapper = PathMapper::new().unwrap();
assert!(!mapper.root().is_empty());
}
#[test]
fn test_normalize_relative_path() {
let mapper = PathMapper::new().unwrap();
let result = mapper.normalize("src/main.rs").unwrap();
assert!(result.contains("src/main.rs"));
assert!(result.starts_with(&*mapper.root()));
}
#[test]
fn test_normalize_with_dot_components() {
let mapper = PathMapper::new().unwrap();
let result = mapper.normalize("./src/../src/main.rs").unwrap();
assert!(result.ends_with("/src/main.rs"));
}
#[test]
fn test_sandbox_escape_blocked() {
let mapper = PathMapper::new().unwrap();
let result = mapper.normalize("../../../etc/passwd");
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.code, crate::error::ErrorCode::InvalidPath);
}
}
#[test]
fn test_prefix_must_be_lowercase() {
let mapper = PathMapper::new().unwrap();
let result = mapper.normalize("HOME:projects/foo");
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.code, crate::error::ErrorCode::InvalidPrefix);
}
}
#[test]
fn test_unknown_prefix_rejected() {
let mapper = PathMapper::new().unwrap();
let result = mapper.normalize("unknown:path/to/file");
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.code, crate::error::ErrorCode::InvalidPrefix);
}
}
#[test]
fn test_display_relative_style() {
let mapper = PathMapper::new().unwrap();
let canonical = mapper.normalize("src/main.rs").unwrap();
let display = mapper.to_display(&canonical);
assert_eq!(display, "src/main.rs");
}
#[test]
fn test_round_trip_filesystem_path() {
let mapper = PathMapper::new().unwrap();
let original = "src/main.rs";
let canonical = mapper.normalize(original).unwrap();
let fs_path = mapper.to_filesystem_path(&canonical);
let back = mapper.from_filesystem_path(&fs_path).unwrap();
assert_eq!(canonical, back);
}
#[test]
fn test_normalize_all() {
let mapper = PathMapper::new().unwrap();
let paths = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()];
let results = mapper.normalize_all(paths).unwrap();
assert_eq!(results.len(), 2);
assert!(results[0].ends_with("/src/main.rs"));
assert!(results[1].ends_with("/src/lib.rs"));
}
#[test]
fn test_config_with_mappings() {
let mut config = PathsConfig::default();
config.mappings.insert("test".to_string(), ".".to_string());
let mapper = PathMapper::from_config(&config, None).unwrap();
assert!(mapper.has_prefix("test"));
}
#[test]
fn test_normalize_path_components() {
let path = Path::new("/foo/bar/../baz/./qux");
let normalized = normalize_path_components(path);
let result = path_to_forward_slashes(&normalized);
assert_eq!(result, "/foo/baz/qux");
}
#[test]
fn test_path_to_forward_slashes() {
let path = Path::new("foo\\bar\\baz");
let result = path_to_forward_slashes(path);
assert_eq!(result, "foo/bar/baz");
}
#[test]
fn test_uppercase_prefix_in_config_rejected() {
let mut config = PathsConfig::default();
config.mappings.insert("Home".to_string(), ".".to_string());
let result = PathMapper::from_config(&config, None);
assert!(result.is_err());
}
#[cfg(windows)]
#[test]
fn test_windows_drive_mapping() {
let mut config = PathsConfig::default();
config.map_windows_drives = true;
let mapper = PathMapper::from_config(&config, None).unwrap();
assert!(mapper.map_windows_drives);
}
}