1use std::path::{Path, PathBuf};
7
8pub use fallow_api::ResultGroup;
9use fallow_config::WorkspaceInfo;
10use fallow_types::results::AnalysisResults;
11
12use super::relative_path;
13use crate::codeowners::{self, CodeOwners, NO_SECTION_LABEL, UNOWNED_LABEL};
14
15pub enum OwnershipResolver {
20 Owner(CodeOwners),
22 Directory,
24 Package(PackageResolver),
26 Section(CodeOwners),
28}
29
30pub struct PackageResolver {
35 workspaces: Vec<(PathBuf, String)>,
37}
38
39const ROOT_PACKAGE_LABEL: &str = "(root)";
40
41impl PackageResolver {
42 pub fn new(project_root: &Path, workspaces: &[WorkspaceInfo]) -> Self {
47 let mut ws: Vec<(PathBuf, String)> = workspaces
48 .iter()
49 .map(|w| {
50 let rel = w.root.strip_prefix(project_root).unwrap_or(&w.root);
51 (rel.to_path_buf(), w.name.clone())
52 })
53 .collect();
54 ws.sort_by_key(|b| std::cmp::Reverse(b.0.as_os_str().len()));
55 Self { workspaces: ws }
56 }
57
58 fn resolve(&self, rel_path: &Path) -> &str {
60 self.workspaces
61 .iter()
62 .find(|(root, _)| rel_path.starts_with(root))
63 .map_or(ROOT_PACKAGE_LABEL, |(_, name)| name.as_str())
64 }
65}
66
67impl OwnershipResolver {
68 pub fn resolve(&self, rel_path: &Path) -> String {
70 match self {
71 Self::Owner(co) => co.owner_of(rel_path).unwrap_or(UNOWNED_LABEL).to_string(),
72 Self::Directory => codeowners::directory_group(rel_path).to_string(),
73 Self::Package(pr) => pr.resolve(rel_path).to_string(),
74 Self::Section(co) => match co.section_of(rel_path) {
75 Some(Some(name)) => name.to_string(),
76 Some(None) => NO_SECTION_LABEL.to_string(),
77 None => UNOWNED_LABEL.to_string(),
78 },
79 }
80 }
81
82 pub fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>) {
89 match self {
90 Self::Owner(co) => {
91 if let Some((owner, rule)) = co.owner_and_rule_of(rel_path) {
92 (owner.to_string(), Some(rule.to_string()))
93 } else {
94 (UNOWNED_LABEL.to_string(), None)
95 }
96 }
97 Self::Directory => (codeowners::directory_group(rel_path).to_string(), None),
98 Self::Package(pr) => (pr.resolve(rel_path).to_string(), None),
99 Self::Section(co) => {
100 if let Some((section, _owners, rule)) = co.section_owners_and_rule_of(rel_path) {
101 let key = section.map_or_else(|| NO_SECTION_LABEL.to_string(), str::to_string);
102 (key, Some(rule.to_string()))
103 } else {
104 (UNOWNED_LABEL.to_string(), None)
105 }
106 }
107 }
108 }
109
110 pub fn mode_label(&self) -> &'static str {
112 match self {
113 Self::Owner(_) => "owner",
114 Self::Directory => "directory",
115 Self::Package(_) => "package",
116 Self::Section(_) => "section",
117 }
118 }
119
120 pub fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]> {
126 if let Self::Section(co) = self
127 && let Some((_, owners)) = co.section_and_owners_of(rel_path)
128 {
129 Some(owners)
130 } else {
131 None
132 }
133 }
134}
135
136pub fn group_analysis_results(
142 results: &AnalysisResults,
143 root: &Path,
144 resolver: &OwnershipResolver,
145) -> Vec<ResultGroup> {
146 let is_section_mode = matches!(resolver, OwnershipResolver::Section(_));
147 fallow_api::group_analysis_results_with(
148 results,
149 |path| {
150 let rel = relative_path(path, root);
151 resolver.resolve(rel)
152 },
153 |path| {
154 let rel = relative_path(path, root);
155 resolver.section_owners_of(rel).map(<[String]>::to_vec)
156 },
157 is_section_mode,
158 )
159}
160
161pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
163 resolver.resolve(relative_path(path, root))
164}
165
166#[cfg(test)]
167mod tests {
168 use std::path::{Path, PathBuf};
169
170 use fallow_types::output_dead_code::*;
171 use fallow_types::results::*;
172
173 use super::*;
174 use crate::codeowners::CodeOwners;
175
176 fn root() -> PathBuf {
177 PathBuf::from("/root")
178 }
179
180 fn unused_file(path: &str) -> UnusedFileFinding {
181 UnusedFileFinding::with_actions(UnusedFile {
182 path: PathBuf::from(path),
183 })
184 }
185
186 fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependencyFinding {
187 UnlistedDependencyFinding::with_actions(UnlistedDependency {
188 package_name: name.to_string(),
189 imported_from: sites,
190 })
191 }
192
193 fn import_site(path: &str) -> ImportSite {
194 ImportSite {
195 path: PathBuf::from(path),
196 line: 1,
197 col: 0,
198 }
199 }
200
201 #[test]
202 fn groups_unused_files_by_directory() {
203 let mut results = AnalysisResults::default();
204 results.unused_files.push(unused_file("/root/src/a.ts"));
205 results.unused_files.push(unused_file("/root/lib/b.ts"));
206
207 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
208 assert_eq!(groups.len(), 2);
209 assert_eq!(groups[0].key, "lib");
210 assert_eq!(groups[1].key, "src");
211 }
212
213 #[test]
214 fn groups_dependencies_with_empty_locations_as_unowned() {
215 let mut results = AnalysisResults::default();
216 results
217 .unlisted_dependencies
218 .push(unlisted_dep("react", vec![]));
219
220 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
221 assert_eq!(groups[0].key, UNOWNED_LABEL);
222 assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
223 }
224
225 #[test]
226 fn groups_dependencies_by_first_import_site() {
227 let mut results = AnalysisResults::default();
228 results.unlisted_dependencies.push(unlisted_dep(
229 "react",
230 vec![import_site("/root/app/page.ts")],
231 ));
232
233 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
234 assert_eq!(groups[0].key, "app");
235 }
236
237 #[test]
238 fn owner_grouping_uses_codeowners() {
239 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
240 let resolver = OwnershipResolver::Owner(co);
241 let mut results = AnalysisResults::default();
242 results.unused_files.push(unused_file("/root/src/a.ts"));
243 results
244 .unused_files
245 .push(unused_file("/root/docs/readme.md"));
246
247 let groups = group_analysis_results(&results, &root(), &resolver);
248 assert_eq!(groups[0].key, "@frontend");
249 assert_eq!(groups[1].key, UNOWNED_LABEL);
250 }
251
252 #[test]
253 fn unowned_group_is_pinned_last() {
254 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
255 let resolver = OwnershipResolver::Owner(co);
256 let mut results = AnalysisResults::default();
257 results.unused_files.push(unused_file("/root/aaa/a.ts"));
258 results.unused_files.push(unused_file("/root/src/a.ts"));
259
260 let groups = group_analysis_results(&results, &root(), &resolver);
261 assert_eq!(groups.last().unwrap().key, UNOWNED_LABEL);
262 }
263
264 #[test]
265 fn sorts_by_issue_count_then_key() {
266 let mut results = AnalysisResults::default();
267 results.unused_files.push(unused_file("/root/src/a.ts"));
268 results.unused_files.push(unused_file("/root/app/a.ts"));
269 results.unused_files.push(unused_file("/root/app/b.ts"));
270
271 let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
272 assert_eq!(groups[0].key, "app");
273 assert_eq!(groups[1].key, "src");
274 }
275
276 #[test]
277 fn resolve_owner_returns_directory() {
278 let owner = resolve_owner(
279 Path::new("/root/src/file.ts"),
280 &root(),
281 &OwnershipResolver::Directory,
282 );
283 assert_eq!(owner, "src");
284 }
285
286 #[test]
287 fn resolve_owner_returns_codeowner() {
288 let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
289 let resolver = OwnershipResolver::Owner(co);
290 let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
291 assert_eq!(owner, "@frontend");
292 }
293
294 #[test]
295 fn mode_label_matches_group_by_flag() {
296 assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
297 let pr = PackageResolver {
298 workspaces: vec![(PathBuf::from("packages/a"), "a".to_string())],
299 };
300 assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
301 let co = CodeOwners::parse("[Docs]\n/docs/ @docs\n").unwrap();
302 assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
303 }
304}