//! Path mapping from file paths to ConceptPath segments. use std::path::Path; use crate::config::AphoriaConfig; use crate::types::Language; /// Maps file paths to ConceptPath segments. pub struct PathMapper { /// Project name. project_name: String, } impl PathMapper { /// Create a new path mapper for a project. pub fn new(root: &Path, config: &AphoriaConfig) -> Self { let project_name = config.project.name.clone().or_else(|| detect_project_name(root)).unwrap_or_else( || root.file_name().and_then(|s| s.to_str()).unwrap_or("unknown").to_string(), ); Self { project_name } } /// Convert a relative file path to ConceptPath segments. /// /// Language-specific stripping rules remove boilerplate directories. pub fn to_segments(&self, relative_path: &str, language: Language) -> Vec { let mut segments = Vec::new(); // Add language prefix let lang_prefix = match language { Language::Rust | Language::CargoManifest => "rust", Language::Go | Language::GoMod => "go", Language::Python | Language::PythonManifest => "python", Language::TypeScript => "typescript", Language::JavaScript | Language::NpmManifest => "javascript", Language::C => "c", Language::Cpp => "cpp", Language::Java => "java", Language::Php => "php", Language::Ruby => "ruby", Language::CSharp => "csharp", Language::Ini | Language::Properties => "config", Language::Yaml | Language::Toml | Language::Json | Language::Dotenv | Language::Terraform => "config", Language::Docker => "docker", Language::Unknown => "unknown", }; segments.push(lang_prefix.to_string()); // Add project name segments.push(self.project_name.clone()); // Process path components let path = Path::new(relative_path); let components: Vec<&str> = path.components().filter_map(|c| c.as_os_str().to_str()).collect(); // Apply language-specific stripping let stripped = strip_boilerplate(&components, language); // Remove file extension from last component if let Some((last, rest)) = stripped.split_last() { for component in rest { segments.push((*component).to_string()); } // Strip extension let stem = Path::new(last).file_stem().and_then(|s| s.to_str()).unwrap_or(last); segments.push(stem.to_string()); } segments } } /// Strip boilerplate directories based on language conventions. /// /// Removes common structural directories that don't add semantic meaning: /// - Rust: `src/`, `crates/` /// - Go: `cmd/`, `internal/`, `pkg/` /// - Python: `src/`, `lib/` /// - JS/TS: `src/`, `lib/` fn strip_boilerplate<'a>(components: &'a [&'a str], language: Language) -> Vec<&'a str> { let skip_dirs: &[&str] = match language { Language::Rust | Language::CargoManifest => &["src", "crates"], Language::Go | Language::GoMod => &["cmd", "internal", "pkg"], Language::Python | Language::PythonManifest => &["src", "lib"], Language::TypeScript | Language::JavaScript | Language::NpmManifest => &["src", "lib"], _ => &[], }; components.iter().filter(|c| !skip_dirs.contains(c)).copied().collect() } /// Detect project name from manifest files. fn detect_project_name(root: &Path) -> Option { // Try Cargo.toml if let Ok(content) = std::fs::read_to_string(root.join("Cargo.toml")) { if let Ok(parsed) = content.parse::() { if let Some(package) = parsed.get("package").and_then(|p| p.as_table()) { if let Some(name) = package.get("name").and_then(|n| n.as_str()) { return Some(name.to_string()); } } } } // Try go.mod if let Ok(content) = std::fs::read_to_string(root.join("go.mod")) { for line in content.lines() { if line.starts_with("module ") { let module = line.trim_start_matches("module ").trim(); // Extract last segment of module path return Some(module.rsplit('/').next().unwrap_or(module).to_string()); } } } // Try package.json if let Ok(content) = std::fs::read_to_string(root.join("package.json")) { if let Ok(parsed) = serde_json::from_str::(&content) { if let Some(name) = parsed.get("name").and_then(|n| n.as_str()) { return Some(name.to_string()); } } } None } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_rust_path_mapping() { let dir = TempDir::new().expect("create temp dir"); let config = AphoriaConfig { project: crate::config::ProjectConfig { name: Some("citadeldb".to_string()), language: None, }, ..Default::default() }; let mapper = PathMapper::new(dir.path(), &config); let segments = mapper.to_segments("crates/citadeldb/src/auth/jwt.rs", Language::Rust); assert_eq!(segments, vec!["rust", "citadeldb", "citadeldb", "auth", "jwt"]); } #[test] fn test_go_path_mapping() { let dir = TempDir::new().expect("create temp dir"); let config = AphoriaConfig { project: crate::config::ProjectConfig { name: Some("myapp".to_string()), language: None, }, ..Default::default() }; let mapper = PathMapper::new(dir.path(), &config); let segments = mapper.to_segments("internal/auth/jwt/validator.go", Language::Go); assert_eq!(segments, vec!["go", "myapp", "auth", "jwt", "validator"]); } #[test] fn test_config_path_mapping() { let dir = TempDir::new().expect("create temp dir"); let config = AphoriaConfig { project: crate::config::ProjectConfig { name: Some("myapp".to_string()), language: None, }, ..Default::default() }; let mapper = PathMapper::new(dir.path(), &config); let segments = mapper.to_segments("config/production.yaml", Language::Yaml); assert_eq!(segments, vec!["config", "myapp", "config", "production"]); } #[test] fn test_strip_boilerplate() { let components = vec!["src", "auth", "jwt.rs"]; let result = strip_boilerplate(&components, Language::Rust); assert_eq!(result, vec!["auth", "jwt.rs"]); // Multiple boilerplate dirs (crates/xxx/src/) let components = vec!["crates", "mylib", "src", "auth", "jwt.rs"]; let result = strip_boilerplate(&components, Language::Rust); assert_eq!(result, vec!["mylib", "auth", "jwt.rs"]); let components = vec!["internal", "auth", "jwt", "validator.go"]; let result = strip_boilerplate(&components, Language::Go); assert_eq!(result, vec!["auth", "jwt", "validator.go"]); } }