1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Default, PartialEq)]
5pub struct ModuleMap {
6 pub modules: HashMap<String, Vec<PathBuf>>,
7}
8
9impl ModuleMap {
10 pub fn new() -> Self {
11 Self::default()
12 }
13
14 pub fn merge(&mut self, other: ModuleMap) {
15 for (name, dirs) in other.modules {
16 self.modules.entry(name).or_default().extend(dirs);
17 }
18 }
19
20 pub fn with_fallback(mut self, root: &Path) -> Self {
21 if self.modules.is_empty() {
22 let name = root
23 .file_name()
24 .and_then(|name| name.to_str())
25 .unwrap_or("root")
26 .to_string();
27 self.modules.insert(name, vec![root.to_path_buf()]);
28 }
29 self.canonicalize_dirs();
30 self
31 }
32
33 fn canonicalize_dirs(&mut self) {
34 for dirs in self.modules.values_mut() {
35 for dir in dirs.iter_mut() {
36 if let Ok(canonical) = dir.canonicalize() {
37 *dir = canonical;
38 }
39 }
40 }
41 }
42
43 pub fn module_for_file(&self, file: &Path) -> Option<String> {
44 let canonical_file = normalize_path(file);
45 let mut best_match: Option<(&str, usize)> = None;
46
47 for (name, dirs) in &self.modules {
48 for dir in dirs {
49 if let Ok(suffix) = canonical_file.strip_prefix(dir) {
50 let depth = suffix.components().count();
51 match best_match {
52 Some((_, best_depth)) if depth < best_depth => {
53 best_match = Some((name, depth));
54 }
55 None => {
56 best_match = Some((name, depth));
57 }
58 _ => {}
59 }
60 }
61
62 if best_match.is_none()
63 && file.is_relative()
64 && let Some(dir_name) = dir.file_name().and_then(|name| name.to_str())
65 {
66 let file_str = file.to_string_lossy();
67 if file_str.starts_with(dir_name)
68 || file_str.starts_with(&format!("{dir_name}/"))
69 {
70 best_match = Some((name, usize::MAX));
71 }
72 }
73 }
74 }
75
76 best_match.map(|(name, _)| name.to_string())
77 }
78}
79
80fn normalize_path(path: &Path) -> PathBuf {
81 if let Ok(canonical) = path.canonicalize() {
82 return canonical;
83 }
84
85 let mut components = Vec::new();
86 for component in path.components() {
87 match component {
88 std::path::Component::ParentDir => {
89 components.pop();
90 }
91 std::path::Component::CurDir => {}
92 other => components.push(other),
93 }
94 }
95 components.iter().collect()
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn merges_module_fragments() {
104 let mut left = ModuleMap::new();
105 left.modules
106 .insert("Alpha".to_string(), vec![PathBuf::from("/tmp/alpha/src")]);
107 let mut right = ModuleMap::new();
108 right
109 .modules
110 .insert("Beta".to_string(), vec![PathBuf::from("/tmp/beta/src")]);
111
112 left.merge(right);
113
114 assert_eq!(left.modules.len(), 2);
115 assert!(left.modules.contains_key("Alpha"));
116 assert!(left.modules.contains_key("Beta"));
117 }
118
119 #[test]
120 fn module_for_file_prefers_deepest_match() {
121 let mut map = ModuleMap::new();
122 map.modules.insert(
123 "Root".to_string(),
124 vec![PathBuf::from("/workspace/project/src")],
125 );
126 map.modules.insert(
127 "Feature".to_string(),
128 vec![PathBuf::from("/workspace/project/src/feature")],
129 );
130
131 let resolved = map.module_for_file(Path::new("/workspace/project/src/feature/file.rs"));
132 assert_eq!(resolved.as_deref(), Some("Feature"));
133 }
134
135 #[test]
136 fn fallback_uses_root_name() {
137 let map = ModuleMap::new().with_fallback(Path::new("/workspace/grapha"));
138 assert_eq!(map.modules.len(), 1);
139 assert!(map.modules.contains_key("grapha"));
140 }
141}