1use std::path::Path;
12
13pub use fallow_output::{OWNER_GROUP_CAP, OwnerGroupFact, OwnershipFacts, OwnershipSliceFact};
14use rustc_hash::{FxHashMap, FxHashSet};
15
16use fallow_config::ResolvedConfig;
17use fallow_engine::codeowners::{CodeOwners, UNOWNED_LABEL};
18use fallow_engine::module_graph::PartitionOrderPaths;
19
20pub fn load_codeowners(root: &Path, config: &ResolvedConfig) -> Result<Option<CodeOwners>, String> {
32 match config.codeowners.as_deref() {
33 Some(path) => CodeOwners::load(root, Some(path))
34 .map(Some)
35 .map_err(|error| format!("codeowners path `{path}`: {error}")),
36 None => Ok(CodeOwners::discover(root).ok()),
37 }
38}
39
40#[must_use]
47pub fn compute_ownership_facts(
48 codeowners: &CodeOwners,
49 changed: &[String],
50 affected: &[String],
51 partition: Option<&PartitionOrderPaths>,
52) -> OwnershipFacts {
53 let owner_of = |path: &str| -> String {
54 codeowners
55 .owner_of(Path::new(path))
56 .unwrap_or(UNOWNED_LABEL)
57 .to_string()
58 };
59
60 let mut counts: FxHashMap<String, (usize, usize)> = FxHashMap::default();
61 for path in changed {
62 counts.entry(owner_of(path)).or_default().0 += 1;
63 }
64 for path in affected {
65 counts.entry(owner_of(path)).or_default().1 += 1;
66 }
67
68 let unowned_direct_count = counts.get(UNOWNED_LABEL).map_or(0, |&(direct, _)| direct);
69 let transitive_only_count = counts.values().filter(|&&(direct, _)| direct == 0).count();
70 let group_count = counts.len();
71
72 let mut groups: Vec<OwnerGroupFact> = counts
73 .into_iter()
74 .map(|(owner, (direct_count, affected_count))| OwnerGroupFact {
75 owner,
76 direct_count,
77 affected_count,
78 })
79 .collect();
80 groups.sort_by(|a, b| {
81 b.direct_count
82 .cmp(&a.direct_count)
83 .then_with(|| b.affected_count.cmp(&a.affected_count))
84 .then_with(|| a.owner.cmp(&b.owner))
85 });
86 let groups_omitted = groups.len().saturating_sub(OWNER_GROUP_CAP);
87 groups.truncate(OWNER_GROUP_CAP);
88
89 OwnershipFacts {
90 group_count,
91 transitive_only_count,
92 unowned_direct_count,
93 groups,
94 groups_omitted,
95 slices: partition.map_or_else(Vec::new, |partition| slice_owners(partition, &owner_of)),
96 }
97}
98
99fn slice_owners(
110 partition: &PartitionOrderPaths,
111 owner_of: &dyn Fn(&str) -> String,
112) -> Vec<OwnershipSliceFact> {
113 if partition.independent_slices.len() < 2 {
114 return Vec::new();
115 }
116 let files_by_dir: FxHashMap<&str, &[String]> = partition
117 .units
118 .iter()
119 .map(|unit| (unit.module_dir.as_str(), unit.files.as_slice()))
120 .collect();
121 partition
122 .independent_slices
123 .iter()
124 .map(|module_dirs| {
125 let owners: FxHashSet<String> = module_dirs
126 .iter()
127 .filter_map(|dir| files_by_dir.get(dir.as_str()))
128 .flat_map(|files| files.iter())
129 .map(|file| owner_of(file))
130 .collect();
131 let mut owners: Vec<String> = owners.into_iter().collect();
132 owners.sort_unstable();
133 debug_assert!(
134 !owners.is_empty(),
135 "slice {module_dirs:?} has no unit with changed files"
136 );
137 OwnershipSliceFact {
138 module_dirs: module_dirs.clone(),
139 separable: owners.len() == 1,
140 owners,
141 }
142 })
143 .collect()
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use fallow_engine::module_graph::ReviewUnitPaths;
150
151 fn paths(items: &[&str]) -> Vec<String> {
152 items.iter().map(|item| (*item).to_string()).collect()
153 }
154
155 fn owners(content: &str) -> CodeOwners {
156 CodeOwners::parse(content).expect("valid CODEOWNERS")
157 }
158
159 fn group(owner: &str, direct_count: usize, affected_count: usize) -> OwnerGroupFact {
160 OwnerGroupFact {
161 owner: owner.to_string(),
162 direct_count,
163 affected_count,
164 }
165 }
166
167 fn partition(units: &[(&str, &[&str])], slices: &[&[&str]]) -> PartitionOrderPaths {
168 PartitionOrderPaths {
169 units: units
170 .iter()
171 .map(|(dir, files)| ReviewUnitPaths {
172 module_dir: (*dir).to_string(),
173 files: paths(files),
174 })
175 .collect(),
176 order: Vec::new(),
177 independent_slices: slices.iter().map(|slice| paths(slice)).collect(),
178 }
179 }
180
181 #[test]
182 fn all_unowned_files_form_one_unowned_group() {
183 let facts = compute_ownership_facts(
184 &owners("docs/ @docs\n"),
185 &paths(&["src/a.ts", "src/b.ts"]),
186 &paths(&["src/c.ts"]),
187 None,
188 );
189 assert_eq!(facts.group_count, 1);
190 assert_eq!(facts.unowned_direct_count, 2);
191 assert_eq!(facts.transitive_only_count, 0);
192 assert_eq!(facts.groups, vec![group(UNOWNED_LABEL, 2, 1)]);
193 }
194
195 #[test]
196 fn one_owner_owns_the_change_and_its_closure() {
197 let facts = compute_ownership_facts(
198 &owners("* @org/web\n"),
199 &paths(&["src/a.ts"]),
200 &paths(&["src/b.ts", "src/c.ts"]),
201 None,
202 );
203 assert_eq!(facts.group_count, 1);
204 assert_eq!(facts.unowned_direct_count, 0);
205 assert_eq!(facts.groups, vec![group("@org/web", 1, 2)]);
206 }
207
208 #[test]
209 fn a_group_reached_only_through_the_closure_is_transitive_only() {
210 let facts = compute_ownership_facts(
211 &owners("src/web/ @org/web\nsrc/tokens/ @org/design\nsrc/api/ @org/api\n"),
212 &paths(&["src/web/a.ts", "src/web/b.ts", "src/tokens/t.ts"]),
213 &paths(&["src/api/x.ts", "src/api/y.ts", "src/web/c.ts"]),
214 None,
215 );
216 assert_eq!(facts.group_count, 3);
217 assert_eq!(facts.transitive_only_count, 1);
218 assert_eq!(
219 facts.groups,
220 vec![
221 group("@org/web", 2, 1),
222 group("@org/design", 1, 0),
223 group("@org/api", 0, 2),
224 ]
225 );
226 }
227
228 #[test]
229 fn a_gitlab_negation_makes_the_file_unowned() {
230 let facts = compute_ownership_facts(
231 &owners("src/ @org/web\n!src/generated/\n"),
232 &paths(&["src/a.ts", "src/generated/types.ts"]),
233 &[],
234 None,
235 );
236 assert_eq!(facts.unowned_direct_count, 1);
237 assert_eq!(
238 facts.groups,
239 vec![group(UNOWNED_LABEL, 1, 0), group("@org/web", 1, 0)]
240 );
241 }
242
243 #[test]
244 fn groups_beyond_the_cap_are_counted_not_dropped_silently() {
245 let rules = (0..OWNER_GROUP_CAP + 3).fold(String::new(), |mut acc, i| {
246 use std::fmt::Write as _;
247 let _ = writeln!(acc, "pkg{i:02}/ @team{i:02}");
248 acc
249 });
250 let changed: Vec<String> = (0..OWNER_GROUP_CAP + 3)
251 .map(|i| format!("pkg{i:02}/index.ts"))
252 .collect();
253 let facts = compute_ownership_facts(&owners(&rules), &changed, &[], None);
254 assert_eq!(facts.group_count, OWNER_GROUP_CAP + 3);
255 assert_eq!(facts.groups.len(), OWNER_GROUP_CAP);
256 assert_eq!(facts.groups_omitted, 3);
257 }
258
259 #[test]
260 fn ties_sort_by_owner_so_the_order_is_deterministic() {
261 let facts = compute_ownership_facts(
262 &owners("c/ @c\na/ @a\nb/ @b\n"),
263 &paths(&["c/x.ts", "b/x.ts", "a/x.ts"]),
264 &[],
265 None,
266 );
267 let order: Vec<&str> = facts.groups.iter().map(|g| g.owner.as_str()).collect();
268 assert_eq!(order, vec!["@a", "@b", "@c"]);
269 }
270
271 #[test]
272 fn slices_align_with_the_partition_and_flag_one_owner_as_separable() {
273 let partition = partition(
274 &[
275 ("src/app", &["src/app/main.ts"]),
276 ("src/core", &["src/core/lib.ts"]),
277 ("src/tools", &["src/tools/cli.ts"]),
278 ],
279 &[&["src/app", "src/core"], &["src/tools"]],
280 );
281 let facts = compute_ownership_facts(
282 &owners("src/app/ @team/app\nsrc/core/ @team/core\nsrc/tools/ @team/tools\n"),
283 &paths(&["src/app/main.ts", "src/core/lib.ts", "src/tools/cli.ts"]),
284 &[],
285 Some(&partition),
286 );
287 assert_eq!(
288 facts.slices,
289 vec![
290 OwnershipSliceFact {
291 module_dirs: paths(&["src/app", "src/core"]),
292 owners: paths(&["@team/app", "@team/core"]),
293 separable: false,
294 },
295 OwnershipSliceFact {
296 module_dirs: paths(&["src/tools"]),
297 owners: paths(&["@team/tools"]),
298 separable: true,
299 },
300 ]
301 );
302 }
303
304 #[test]
305 fn slice_owners_come_from_files_so_a_rule_can_split_a_directory() {
306 let partition = partition(
307 &[
308 ("src/a", &["src/a/x.ts", "src/a/y.ts"]),
309 ("src/b", &["src/b/z.ts"]),
310 ],
311 &[&["src/a"], &["src/b"]],
312 );
313 let facts = compute_ownership_facts(
314 &owners("src/ @team/all\nsrc/a/y.ts @team/y\n"),
315 &paths(&["src/a/x.ts", "src/a/y.ts", "src/b/z.ts"]),
316 &[],
317 Some(&partition),
318 );
319 assert_eq!(facts.slices[0].owners, paths(&["@team/all", "@team/y"]));
320 assert!(!facts.slices[0].separable);
321 assert!(facts.slices[1].separable);
322 }
323
324 #[test]
325 fn an_unowned_slice_counts_the_unowned_label_as_its_owner() {
326 let partition = partition(
327 &[("src/a", &["src/a/x.ts"]), ("src/b", &["src/b/z.ts"])],
328 &[&["src/a"], &["src/b"]],
329 );
330 let facts = compute_ownership_facts(
331 &owners("src/a/ @team/a\n"),
332 &paths(&["src/a/x.ts", "src/b/z.ts"]),
333 &[],
334 Some(&partition),
335 );
336 assert_eq!(facts.slices[1].owners, paths(&[UNOWNED_LABEL]));
337 assert!(facts.slices[1].separable);
338 }
339
340 #[test]
341 fn a_single_slice_emits_no_slice_owners() {
342 let partition = partition(&[("src", &["src/a.ts"])], &[&["src"]]);
343 let facts = compute_ownership_facts(
344 &owners("* @org/web\n"),
345 &paths(&["src/a.ts"]),
346 &[],
347 Some(&partition),
348 );
349 assert!(facts.slices.is_empty());
350 }
351}