git_atomic/core/
matcher.rs1use crate::config::Config;
2use crate::core::ConfigError;
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use std::path::Path;
5
6pub struct ComponentMatcher {
11 glob_set: GlobSet,
12 glob_owners: Vec<(usize, String)>,
14 component_names: Vec<String>,
16}
17
18impl ComponentMatcher {
19 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 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 pub fn group_files<'a>(
61 &self,
62 paths: &'a [&Path],
63 ) -> (Vec<(&str, Vec<&'a Path>)>, Vec<&'a Path>) {
64 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 assert_eq!(m.match_file(Path::new("src/foo.rs")), Some("specific"));
131 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 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}