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
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}