Skip to main content

git_atomic/core/
matcher.rs

1use crate::config::Config;
2use crate::core::ConfigError;
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use std::path::Path;
5
6/// Maps file paths to components using first-match-wins (ADR-003).
7///
8/// Each glob is tagged with its component index (position in the Vec).
9/// When `GlobSet` returns multiple matches, the lowest index wins.
10pub struct ComponentMatcher {
11    glob_set: GlobSet,
12    /// For each glob in the set: (component_index, component_name).
13    glob_owners: Vec<(usize, String)>,
14    /// Component names in config order.
15    component_names: Vec<String>,
16}
17
18impl ComponentMatcher {
19    /// Build a matcher from the loaded config.
20    pub fn from_config(config: &Config) -> Result<Self, ConfigError> {
21        let mut builder = GlobSetBuilder::new();
22        let mut glob_owners = Vec::new();
23        let mut component_names = Vec::new();
24
25        for (idx, component) in config.components.iter().enumerate() {
26            component_names.push(component.name.clone());
27            for pattern in &component.globs {
28                let glob = Glob::new(pattern).map_err(|e| ConfigError::InvalidGlob {
29                    component: component.name.clone(),
30                    pattern: pattern.clone(),
31                    reason: e.to_string(),
32                })?;
33                builder.add(glob);
34                glob_owners.push((idx, component.name.clone()));
35            }
36        }
37
38        let glob_set = builder.build().map_err(|e| ConfigError::Invalid {
39            reason: format!("failed to build glob set: {e}"),
40        })?;
41
42        Ok(Self {
43            glob_set,
44            glob_owners,
45            component_names,
46        })
47    }
48
49    /// Return the component name for a file path, or `None` if unmatched.
50    pub fn match_file(&self, path: &Path) -> Option<&str> {
51        let matches = self.glob_set.matches(path);
52        matches
53            .iter()
54            .map(|&i| &self.glob_owners[i])
55            .min_by_key(|(idx, _)| *idx)
56            .map(|(_, name)| name.as_str())
57    }
58
59    /// Group a set of file paths by component. Returns `(grouped, unmatched)`.
60    pub fn group_files<'a>(
61        &self,
62        paths: &'a [&Path],
63    ) -> (Vec<(&str, Vec<&'a Path>)>, Vec<&'a Path>) {
64        // One vec per component index.
65        let mut buckets: Vec<Vec<&Path>> = vec![vec![]; self.component_names.len()];
66        let mut unmatched = Vec::new();
67
68        for &path in paths {
69            match self.match_file(path) {
70                Some(name) => {
71                    let idx = self.component_names.iter().position(|n| n == name).unwrap();
72                    buckets[idx].push(path);
73                }
74                None => unmatched.push(path),
75            }
76        }
77
78        let grouped = self
79            .component_names
80            .iter()
81            .zip(buckets)
82            .filter(|(_, files)| !files.is_empty())
83            .map(|(name, files)| (name.as_str(), files))
84            .collect();
85
86        (grouped, unmatched)
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::config::{Component, Config, Settings};
94
95    fn make_config(components: Vec<(&str, Vec<&str>)>) -> Config {
96        Config {
97            settings: Settings::default(),
98            components: components
99                .into_iter()
100                .map(|(name, globs)| Component {
101                    name: name.to_string(),
102                    globs: globs.into_iter().map(String::from).collect(),
103                    commit_type: None,
104                    branch: None,
105                })
106                .collect(),
107        }
108    }
109
110    #[test]
111    fn exact_match() {
112        let cfg = make_config(vec![("ui", vec!["src/ui/**"]), ("api", vec!["src/api/**"])]);
113        let m = ComponentMatcher::from_config(&cfg).unwrap();
114        assert_eq!(m.match_file(Path::new("src/ui/button.rs")), Some("ui"));
115        assert_eq!(m.match_file(Path::new("src/api/handler.rs")), Some("api"));
116    }
117
118    #[test]
119    fn no_match() {
120        let cfg = make_config(vec![("ui", vec!["src/ui/**"])]);
121        let m = ComponentMatcher::from_config(&cfg).unwrap();
122        assert_eq!(m.match_file(Path::new("README.md")), None);
123    }
124
125    #[test]
126    fn first_match_wins() {
127        let cfg = make_config(vec![("specific", vec!["src/**"]), ("catchall", vec!["**"])]);
128        let m = ComponentMatcher::from_config(&cfg).unwrap();
129        // src/foo matches both, but "specific" is first.
130        assert_eq!(m.match_file(Path::new("src/foo.rs")), Some("specific"));
131        // README only matches catchall.
132        assert_eq!(m.match_file(Path::new("README.md")), Some("catchall"));
133    }
134
135    #[test]
136    fn group_files_works() {
137        let cfg = make_config(vec![("ui", vec!["src/ui/**"]), ("api", vec!["src/api/**"])]);
138        let m = ComponentMatcher::from_config(&cfg).unwrap();
139
140        let paths: Vec<&Path> = vec![
141            Path::new("src/ui/a.rs"),
142            Path::new("src/api/b.rs"),
143            Path::new("README.md"),
144        ];
145        let (grouped, unmatched) = m.group_files(&paths);
146
147        assert_eq!(grouped.len(), 2);
148        assert_eq!(grouped[0].0, "ui");
149        assert_eq!(grouped[1].0, "api");
150        assert_eq!(unmatched, vec![Path::new("README.md")]);
151    }
152
153    #[test]
154    fn catch_all_last() {
155        let cfg = make_config(vec![("core", vec!["src/core/**"]), ("_other", vec!["**"])]);
156        let m = ComponentMatcher::from_config(&cfg).unwrap();
157        assert_eq!(m.match_file(Path::new("src/core/lib.rs")), Some("core"));
158        assert_eq!(m.match_file(Path::new("docs/readme.md")), Some("_other"));
159    }
160
161    #[test]
162    fn overlapping_globs_first_component_wins() {
163        let cfg = make_config(vec![("alpha", vec!["src/**"]), ("beta", vec!["src/**"])]);
164        let m = ComponentMatcher::from_config(&cfg).unwrap();
165        // Both claim src/**, but alpha is first in config order
166        assert_eq!(m.match_file(Path::new("src/lib.rs")), Some("alpha"));
167        assert_eq!(
168            m.match_file(Path::new("src/deep/nested/file.rs")),
169            Some("alpha")
170        );
171    }
172
173    #[test]
174    fn empty_glob_list_matches_nothing() {
175        let cfg = make_config(vec![("empty", vec![]), ("real", vec!["src/**"])]);
176        let m = ComponentMatcher::from_config(&cfg).unwrap();
177        assert_eq!(m.match_file(Path::new("src/main.rs")), Some("real"));
178        assert_eq!(m.match_file(Path::new("anything.txt")), None);
179    }
180
181    #[test]
182    fn deeply_nested_paths_match() {
183        let cfg = make_config(vec![("deep", vec!["a/**"])]);
184        let m = ComponentMatcher::from_config(&cfg).unwrap();
185        assert_eq!(
186            m.match_file(Path::new("a/b/c/d/e/f/g/h/i/j/file.rs")),
187            Some("deep")
188        );
189    }
190}