Skip to main content

grapha_core/
module.rs

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}