Skip to main content

fallow_cli/report/
grouping.rs

1//! Grouping infrastructure for `--group-by owner|directory|package`.
2//!
3//! Partitions `AnalysisResults` into labeled groups by ownership (CODEOWNERS),
4//! by first directory component, or by workspace package.
5
6use 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
15/// Ownership resolver for `--group-by`.
16///
17/// Owns the `CodeOwners` data when grouping by owner, avoiding lifetime
18/// complexity in the report context.
19pub enum OwnershipResolver {
20    /// Group by CODEOWNERS file (first owner, last matching rule).
21    Owner(CodeOwners),
22    /// Group by first directory component.
23    Directory,
24    /// Group by workspace package (monorepo).
25    Package(PackageResolver),
26    /// Group by GitLab CODEOWNERS section name (`[Section]` headers).
27    Section(CodeOwners),
28}
29
30/// Resolves file paths to workspace package names via longest-prefix matching.
31///
32/// Stores workspace roots as paths relative to the project root so that
33/// resolution works with the relative paths passed to `OwnershipResolver::resolve`.
34pub struct PackageResolver {
35    /// `(relative_root, package_name)` sorted by path length descending.
36    workspaces: Vec<(PathBuf, String)>,
37}
38
39const ROOT_PACKAGE_LABEL: &str = "(root)";
40
41impl PackageResolver {
42    /// Build a resolver from discovered workspace info.
43    ///
44    /// Workspace roots are stored relative to `project_root` and sorted by path
45    /// length descending so the first match is always the most specific prefix.
46    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    /// Find the workspace package that owns `rel_path`, or `"(root)"` if none match.
59    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    /// Resolve the group key for a file path (relative to project root).
69    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    /// Resolve the group key and matching rule for a path.
83    ///
84    /// Returns `(owner, Some(pattern))` for Owner mode,
85    /// `(directory, None)` for Directory/Package mode,
86    /// `(section, Some(pattern))` for Section mode (pattern is the raw
87    /// CODEOWNERS pattern from the last matching rule).
88    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    /// Label for the grouping mode (used in JSON `grouped_by` field).
111    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    /// Look up the section default owners for a group key.
121    ///
122    /// Returns `Some(&[...])` only in Section mode when `rel_path` resolves
123    /// to a rule inside a named section. Used to emit the `owners` metadata
124    /// array in grouped JSON output.
125    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
136/// Partition analysis results into groups by ownership or directory.
137///
138/// Each issue is assigned to a group by extracting its primary file path
139/// and resolving the group key via the `OwnershipResolver`.
140/// Returns groups sorted alphabetically by key, with `(unowned)` last.
141pub 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
161/// Resolve the group key for a single path (for per-result tagging in SARIF/CodeClimate).
162pub 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}