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
30 }
31
32 pub fn module_for_file(&self, file: &Path) -> Option<String> {
33 let canonical_file = normalize_path(file);
34 let mut best_match: Option<(&str, usize)> = None;
35
36 for (name, dirs) in &self.modules {
37 for dir in dirs {
38 let canonical_dir = normalize_path(dir);
39
40 if let Ok(suffix) = canonical_file.strip_prefix(&canonical_dir) {
41 let depth = suffix.components().count();
42 match best_match {
43 Some((_, best_depth)) if depth < best_depth => {
44 best_match = Some((name, depth));
45 }
46 None => {
47 best_match = Some((name, depth));
48 }
49 _ => {}
50 }
51 }
52
53 if best_match.is_none()
54 && file.is_relative()
55 && let Some(dir_name) = canonical_dir.file_name().and_then(|name| name.to_str())
56 {
57 let file_str = file.to_string_lossy();
58 if file_str.starts_with(dir_name)
59 || file_str.starts_with(&format!("{dir_name}/"))
60 {
61 best_match = Some((name, usize::MAX));
62 }
63 }
64 }
65 }
66
67 best_match.map(|(name, _)| name.to_string())
68 }
69}
70
71fn normalize_path(path: &Path) -> PathBuf {
72 if let Ok(canonical) = path.canonicalize() {
73 return canonical;
74 }
75
76 let mut components = Vec::new();
77 for component in path.components() {
78 match component {
79 std::path::Component::ParentDir => {
80 components.pop();
81 }
82 std::path::Component::CurDir => {}
83 other => components.push(other),
84 }
85 }
86 components.iter().collect()
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn merges_module_fragments() {
95 let mut left = ModuleMap::new();
96 left.modules
97 .insert("Alpha".to_string(), vec![PathBuf::from("/tmp/alpha/src")]);
98 let mut right = ModuleMap::new();
99 right
100 .modules
101 .insert("Beta".to_string(), vec![PathBuf::from("/tmp/beta/src")]);
102
103 left.merge(right);
104
105 assert_eq!(left.modules.len(), 2);
106 assert!(left.modules.contains_key("Alpha"));
107 assert!(left.modules.contains_key("Beta"));
108 }
109
110 #[test]
111 fn module_for_file_prefers_deepest_match() {
112 let mut map = ModuleMap::new();
113 map.modules.insert(
114 "Root".to_string(),
115 vec![PathBuf::from("/workspace/project/src")],
116 );
117 map.modules.insert(
118 "Feature".to_string(),
119 vec![PathBuf::from("/workspace/project/src/feature")],
120 );
121
122 let resolved = map.module_for_file(Path::new("/workspace/project/src/feature/file.rs"));
123 assert_eq!(resolved.as_deref(), Some("Feature"));
124 }
125
126 #[test]
127 fn fallback_uses_root_name() {
128 let map = ModuleMap::new().with_fallback(Path::new("/workspace/grapha"));
129 assert_eq!(map.modules.len(), 1);
130 assert!(map.modules.contains_key("grapha"));
131 }
132}