use std::path::{Path, PathBuf};
pub use fallow_api::ResultGroup;
use fallow_config::WorkspaceInfo;
use fallow_types::results::AnalysisResults;
use super::relative_path;
use crate::codeowners::{self, CodeOwners, NO_SECTION_LABEL, UNOWNED_LABEL};
pub enum OwnershipResolver {
Owner(CodeOwners),
Directory,
Package(PackageResolver),
Section(CodeOwners),
}
pub struct PackageResolver {
workspaces: Vec<(PathBuf, String)>,
}
const ROOT_PACKAGE_LABEL: &str = "(root)";
impl PackageResolver {
pub fn new(project_root: &Path, workspaces: &[WorkspaceInfo]) -> Self {
let mut ws: Vec<(PathBuf, String)> = workspaces
.iter()
.map(|w| {
let rel = w.root.strip_prefix(project_root).unwrap_or(&w.root);
(rel.to_path_buf(), w.name.clone())
})
.collect();
ws.sort_by_key(|b| std::cmp::Reverse(b.0.as_os_str().len()));
Self { workspaces: ws }
}
fn resolve(&self, rel_path: &Path) -> &str {
self.workspaces
.iter()
.find(|(root, _)| rel_path.starts_with(root))
.map_or(ROOT_PACKAGE_LABEL, |(_, name)| name.as_str())
}
}
impl OwnershipResolver {
pub fn resolve(&self, rel_path: &Path) -> String {
match self {
Self::Owner(co) => co.owner_of(rel_path).unwrap_or(UNOWNED_LABEL).to_string(),
Self::Directory => codeowners::directory_group(rel_path).to_string(),
Self::Package(pr) => pr.resolve(rel_path).to_string(),
Self::Section(co) => match co.section_of(rel_path) {
Some(Some(name)) => name.to_string(),
Some(None) => NO_SECTION_LABEL.to_string(),
None => UNOWNED_LABEL.to_string(),
},
}
}
pub fn resolve_with_rule(&self, rel_path: &Path) -> (String, Option<String>) {
match self {
Self::Owner(co) => {
if let Some((owner, rule)) = co.owner_and_rule_of(rel_path) {
(owner.to_string(), Some(rule.to_string()))
} else {
(UNOWNED_LABEL.to_string(), None)
}
}
Self::Directory => (codeowners::directory_group(rel_path).to_string(), None),
Self::Package(pr) => (pr.resolve(rel_path).to_string(), None),
Self::Section(co) => {
if let Some((section, _owners, rule)) = co.section_owners_and_rule_of(rel_path) {
let key = section.map_or_else(|| NO_SECTION_LABEL.to_string(), str::to_string);
(key, Some(rule.to_string()))
} else {
(UNOWNED_LABEL.to_string(), None)
}
}
}
}
pub fn mode_label(&self) -> &'static str {
match self {
Self::Owner(_) => "owner",
Self::Directory => "directory",
Self::Package(_) => "package",
Self::Section(_) => "section",
}
}
pub fn section_owners_of(&self, rel_path: &Path) -> Option<&[String]> {
if let Self::Section(co) = self
&& let Some((_, owners)) = co.section_and_owners_of(rel_path)
{
Some(owners)
} else {
None
}
}
}
pub fn group_analysis_results(
results: &AnalysisResults,
root: &Path,
resolver: &OwnershipResolver,
) -> Vec<ResultGroup> {
let is_section_mode = matches!(resolver, OwnershipResolver::Section(_));
fallow_api::group_analysis_results_with(
results,
|path| {
let rel = relative_path(path, root);
resolver.resolve(rel)
},
|path| {
let rel = relative_path(path, root);
resolver.section_owners_of(rel).map(<[String]>::to_vec)
},
is_section_mode,
)
}
pub fn resolve_owner(path: &Path, root: &Path, resolver: &OwnershipResolver) -> String {
resolver.resolve(relative_path(path, root))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use fallow_types::output_dead_code::*;
use fallow_types::results::*;
use super::*;
use crate::codeowners::CodeOwners;
fn root() -> PathBuf {
PathBuf::from("/root")
}
fn unused_file(path: &str) -> UnusedFileFinding {
UnusedFileFinding::with_actions(UnusedFile {
path: PathBuf::from(path),
})
}
fn unlisted_dep(name: &str, sites: Vec<ImportSite>) -> UnlistedDependencyFinding {
UnlistedDependencyFinding::with_actions(UnlistedDependency {
package_name: name.to_string(),
imported_from: sites,
})
}
fn import_site(path: &str) -> ImportSite {
ImportSite {
path: PathBuf::from(path),
line: 1,
col: 0,
}
}
#[test]
fn groups_unused_files_by_directory() {
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/src/a.ts"));
results.unused_files.push(unused_file("/root/lib/b.ts"));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].key, "lib");
assert_eq!(groups[1].key, "src");
}
#[test]
fn groups_dependencies_with_empty_locations_as_unowned() {
let mut results = AnalysisResults::default();
results
.unlisted_dependencies
.push(unlisted_dep("react", vec![]));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups[0].key, UNOWNED_LABEL);
assert_eq!(groups[0].results.unlisted_dependencies.len(), 1);
}
#[test]
fn groups_dependencies_by_first_import_site() {
let mut results = AnalysisResults::default();
results.unlisted_dependencies.push(unlisted_dep(
"react",
vec![import_site("/root/app/page.ts")],
));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups[0].key, "app");
}
#[test]
fn owner_grouping_uses_codeowners() {
let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
let resolver = OwnershipResolver::Owner(co);
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/src/a.ts"));
results
.unused_files
.push(unused_file("/root/docs/readme.md"));
let groups = group_analysis_results(&results, &root(), &resolver);
assert_eq!(groups[0].key, "@frontend");
assert_eq!(groups[1].key, UNOWNED_LABEL);
}
#[test]
fn unowned_group_is_pinned_last() {
let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
let resolver = OwnershipResolver::Owner(co);
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/aaa/a.ts"));
results.unused_files.push(unused_file("/root/src/a.ts"));
let groups = group_analysis_results(&results, &root(), &resolver);
assert_eq!(groups.last().unwrap().key, UNOWNED_LABEL);
}
#[test]
fn sorts_by_issue_count_then_key() {
let mut results = AnalysisResults::default();
results.unused_files.push(unused_file("/root/src/a.ts"));
results.unused_files.push(unused_file("/root/app/a.ts"));
results.unused_files.push(unused_file("/root/app/b.ts"));
let groups = group_analysis_results(&results, &root(), &OwnershipResolver::Directory);
assert_eq!(groups[0].key, "app");
assert_eq!(groups[1].key, "src");
}
#[test]
fn resolve_owner_returns_directory() {
let owner = resolve_owner(
Path::new("/root/src/file.ts"),
&root(),
&OwnershipResolver::Directory,
);
assert_eq!(owner, "src");
}
#[test]
fn resolve_owner_returns_codeowner() {
let co = CodeOwners::parse("/src/ @frontend\n").unwrap();
let resolver = OwnershipResolver::Owner(co);
let owner = resolve_owner(Path::new("/root/src/file.ts"), &root(), &resolver);
assert_eq!(owner, "@frontend");
}
#[test]
fn mode_label_matches_group_by_flag() {
assert_eq!(OwnershipResolver::Directory.mode_label(), "directory");
let pr = PackageResolver {
workspaces: vec![(PathBuf::from("packages/a"), "a".to_string())],
};
assert_eq!(OwnershipResolver::Package(pr).mode_label(), "package");
let co = CodeOwners::parse("[Docs]\n/docs/ @docs\n").unwrap();
assert_eq!(OwnershipResolver::Section(co).mode_label(), "section");
}
}