use std::collections::HashMap; use std::path::Path; use crate::DomainError; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ModuleName(String); /// How a module name was assigned to a source file. #[derive(Debug, Clone)] pub enum ModuleAssignment { /// Matched an explicit user-configured mapping in `archlens.toml`. Explicit(ModuleName), /// Derived from file path structure by heuristic (e.g. `crates//...`). Inferred(ModuleName), /// No mapping matched and the heuristic could not determine a module. Unresolved(&'static str), } impl ModuleAssignment { pub fn module_name(&self) -> Option<&ModuleName> { match self { Self::Explicit(m) | Self::Inferred(m) => Some(m), Self::Unresolved(_) => None, } } pub fn into_module_name(self) -> Option { match self { Self::Explicit(m) | Self::Inferred(m) => Some(m), Self::Unresolved(_) => None, } } pub fn reason(&self) -> Option<&'static str> { match self { Self::Unresolved(r) => Some(r), _ => None, } } } impl ModuleName { pub fn new(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { return Err(DomainError::EmptyValue("ModuleName")); } Ok(Self(trimmed.to_string())) } /// Assign a module to a file path, returning a typed assignment that /// distinguishes explicit mappings, heuristic inference, and failure. pub fn assign( file_path: &str, root: &Path, module_mappings: &HashMap, ) -> ModuleAssignment { let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); let root_str = canonical_root.to_str().unwrap_or(""); let relative = file_path .strip_prefix(root_str) .unwrap_or(file_path) .trim_start_matches('/'); // 1. Explicit mapping for (pattern, module_name) in module_mappings { if relative.starts_with(pattern.as_str()) && let Ok(m) = Self::new(module_name) { return ModuleAssignment::Explicit(m); } } // 2. Heuristic inference from path structure let parts: Vec<&str> = relative.split('/').collect(); if parts.len() <= 1 { return ModuleAssignment::Unresolved("path has no directory component"); } let module_dir = if (parts[0] == "crates" || parts[0] == "src") && parts.len() > 2 { parts[1] } else if parts[0] != "src" && parts.len() > 1 { parts[0] } else { return ModuleAssignment::Unresolved("path under src/ with no further structure"); }; match Self::new(&Self::capitalize(module_dir)) { Ok(m) => ModuleAssignment::Inferred(m), Err(_) => ModuleAssignment::Unresolved("inferred directory name was empty"), } } /// Convenience wrapper — returns None for Unresolved. /// Prefer `assign()` when you want to distinguish strategies. pub fn from_path( file_path: &str, root: &Path, module_mappings: &HashMap, ) -> Option { Self::assign(file_path, root, module_mappings).into_module_name() } pub fn from_directory_group(member_path: &str) -> Option { let parts: Vec<&str> = member_path.split('/').collect(); if parts.len() < 3 { return None; } let group = parts[parts.len() - 2]; Self::new(&Self::capitalize(group)).ok() } pub fn capitalize(s: &str) -> String { s.split('-') .map(|seg| { if seg.is_empty() { String::new() } else { format!("{}{}", seg[..1].to_uppercase(), &seg[1..]) } }) .collect::>() .join("-") } pub fn as_str(&self) -> &str { &self.0 } }