use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use syn::visit::{self, Visit};
use syn::{Item, ItemUse};
use crate::finding::{
EvidenceClass, Finding, FindingGraph, FindingId, Location, OneBasedLine, Origin, Severity,
};
use crate::ingest::{CrateInfo, EntryPointKind, SourceFile, SourceKind, Workspace};
pub const UNLINKED_FILE_RULE: &str = "unlinked-file";
pub const UNLINKED_FILE_RULE_REVISION: u32 = 1;
pub const ORPHAN_MODULE_RULE: &str = "orphan-module";
pub const ORPHAN_MODULE_RULE_REVISION: u32 = 1;
#[derive(Debug)]
pub enum ModuleGraphError {
Io(PathBuf, std::io::Error),
Parse(PathBuf, syn::Error),
Graph(crate::finding::GraphError),
}
impl std::fmt::Display for ModuleGraphError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(path, err) => write!(f, "{}: failed to read file: {err}", path.display()),
Self::Parse(path, err) => write!(f, "{}: failed to parse: {err}", path.display()),
Self::Graph(err) => write!(f, "graph error: {err}"),
}
}
}
impl std::error::Error for ModuleGraphError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(_, err) => Some(err),
Self::Parse(_, err) => Some(err),
Self::Graph(err) => Some(err),
}
}
}
#[derive(Debug, Clone)]
struct ModuleNode {
module_path: String,
file: PathBuf,
declared_at_file: PathBuf,
declared_at_line: usize,
}
#[derive(Debug, Default)]
struct CrateModuleTree {
file_module_path: HashMap<PathBuf, String>,
nodes: Vec<ModuleNode>,
root_labels: Vec<String>,
}
fn entry_point_label(entry: &crate::ingest::EntryPoint) -> String {
match entry.kind {
EntryPointKind::Lib => "lib".to_string(),
EntryPointKind::Bin => format!("bin:{}", entry.name),
EntryPointKind::Example => format!("example:{}", entry.name),
EntryPointKind::Test => format!("test:{}", entry.name),
EntryPointKind::Bench => format!("bench:{}", entry.name),
EntryPointKind::BuildScript => "build-script".to_string(),
}
}
fn build_crate_module_tree(krate: &CrateInfo) -> (CrateModuleTree, Vec<ModuleGraphError>) {
let mut tree = CrateModuleTree::default();
let mut errors = Vec::new();
for entry in &krate.entry_points {
tree.root_labels.push(entry_point_label(entry));
if tree.file_module_path.contains_key(&entry.path) {
continue;
}
tree.file_module_path
.insert(entry.path.clone(), String::new());
visit_mod_file(&entry.path, String::new(), &mut tree, &mut errors);
}
(tree, errors)
}
fn join_module_path(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}::{name}")
}
}
fn module_path_segments(module_path: &str) -> Vec<String> {
if module_path.is_empty() {
Vec::new()
} else {
module_path.split("::").map(str::to_string).collect()
}
}
fn visit_mod_file(
file: &Path,
module_path: String,
tree: &mut CrateModuleTree,
errors: &mut Vec<ModuleGraphError>,
) {
let ast = match crate::functions::read_and_parse_source(
file,
|err| ModuleGraphError::Io(file.to_path_buf(), err),
|err| ModuleGraphError::Parse(file.to_path_buf(), err),
) {
Ok((_, ast)) => ast,
Err(err) => {
errors.push(err);
return;
}
};
visit_items(&ast.items, file, &module_path, tree, errors);
}
fn visit_items(
items: &[Item],
file: &Path,
module_path: &str,
tree: &mut CrateModuleTree,
errors: &mut Vec<ModuleGraphError>,
) {
for item in items {
let Item::Mod(item_mod) = item else { continue };
let child_module_path = join_module_path(module_path, &item_mod.ident.to_string());
if let Some((_, content_items)) = &item_mod.content {
visit_items(content_items, file, &child_module_path, tree, errors);
continue;
}
let Some(child_file) = resolve_mod_item_file(file, item_mod) else {
continue; };
if tree.file_module_path.contains_key(&child_file) {
continue; }
let line = item_mod.ident.span().start().line;
tree.nodes.push(ModuleNode {
module_path: child_module_path.clone(),
file: child_file.clone(),
declared_at_file: file.to_path_buf(),
declared_at_line: line,
});
tree.file_module_path
.insert(child_file.clone(), child_module_path.clone());
visit_mod_file(&child_file, child_module_path, tree, errors);
}
}
fn resolve_mod_file(
declaring_file: &Path,
attrs: &[syn::Attribute],
name: &str,
) -> Option<PathBuf> {
if let Some(path_value) = path_attr_value(attrs) {
let base = declaring_file.parent().unwrap_or_else(|| Path::new(""));
let candidate = base.join(path_value);
return candidate.is_file().then_some(candidate);
}
let own_dir = module_own_dir(declaring_file);
let as_file = own_dir.join(format!("{name}.rs"));
if as_file.is_file() {
return Some(as_file);
}
let as_mod_dir = own_dir.join(name).join("mod.rs");
if as_mod_dir.is_file() {
return Some(as_mod_dir);
}
None
}
fn resolve_mod_item_file(declaring_file: &Path, item_mod: &syn::ItemMod) -> Option<PathBuf> {
resolve_mod_file(declaring_file, &item_mod.attrs, &item_mod.ident.to_string())
}
fn path_attr_value(attrs: &[syn::Attribute]) -> Option<String> {
attrs.iter().find_map(|attr| {
if !attr.path().is_ident("path") {
return None;
}
let syn::Meta::NameValue(name_value) = &attr.meta else {
return None;
};
let syn::Expr::Lit(expr_lit) = &name_value.value else {
return None;
};
let syn::Lit::Str(lit_str) = &expr_lit.lit else {
return None;
};
Some(lit_str.value())
})
}
fn module_own_dir(file: &Path) -> PathBuf {
let parent = file.parent().unwrap_or_else(|| Path::new(""));
match file.file_name().and_then(|name| name.to_str()) {
Some("lib.rs" | "main.rs" | "mod.rs") => parent.to_path_buf(),
_ => parent.join(file.file_stem().unwrap_or_default()),
}
}
fn module_file_set<'a>(tree: &'a CrateModuleTree, module_path: &str) -> HashSet<&'a Path> {
let prefix = format!("{module_path}::");
tree.file_module_path
.iter()
.filter(|(_, path)| path.as_str() == module_path || path.starts_with(&prefix))
.map(|(file, _)| file.as_path())
.collect()
}
fn module_path_references(hit_path: &str, node_path: &str) -> bool {
hit_path == node_path || hit_path.starts_with(&format!("{node_path}::"))
}
#[derive(Debug, Default)]
pub struct WorkspaceModuleGraph {
pub findings: Vec<Finding>,
pub excluded_generated: usize,
pub errors: Vec<ModuleGraphError>,
}
pub fn analyze_workspace(workspace: &Workspace, include_generated: bool) -> WorkspaceModuleGraph {
let mut errors = Vec::new();
let trees: HashMap<&str, CrateModuleTree> = workspace
.crates
.iter()
.map(|krate| {
let (tree, tree_errors) = build_crate_module_tree(krate);
errors.extend(tree_errors);
(krate.name.as_str(), tree)
})
.collect();
let mut excluded_generated = 0;
let mut findings = unlinked_file_findings(
workspace,
&trees,
include_generated,
&mut excluded_generated,
&mut errors,
);
findings.extend(orphan_module_findings(
workspace,
&trees,
include_generated,
&mut excluded_generated,
));
WorkspaceModuleGraph {
findings,
excluded_generated,
errors,
}
}
fn crates_with_trees<'a>(
workspace: &'a Workspace,
trees: &'a HashMap<&str, CrateModuleTree>,
) -> impl Iterator<Item = (&'a CrateInfo, &'a CrateModuleTree)> {
workspace
.crates
.iter()
.map(move |krate| (krate, &trees[krate.name.as_str()]))
}
fn skip_generated(
kind: SourceKind,
include_generated: bool,
excluded_generated: &mut usize,
) -> bool {
if kind == SourceKind::Generated && !include_generated {
*excluded_generated += 1;
true
} else {
false
}
}
fn unlinked_file_findings(
workspace: &Workspace,
trees: &HashMap<&str, CrateModuleTree>,
include_generated: bool,
excluded_generated: &mut usize,
errors: &mut Vec<ModuleGraphError>,
) -> Vec<Finding> {
let mut unreached_by_crate: HashMap<&str, Vec<&SourceFile>> = HashMap::new();
for (krate, tree) in crates_with_trees(workspace, trees) {
let mut unreached = Vec::new();
for file in &krate.source_files {
if tree.file_module_path.contains_key(&file.path) {
continue;
}
if skip_generated(file.kind, include_generated, excluded_generated) {
continue;
}
unreached.push(file);
}
unreached_by_crate.insert(krate.name.as_str(), unreached);
}
let mut graph = FindingGraph::new();
let mut id_by_path: HashMap<&Path, FindingId> = HashMap::new();
for (krate, tree) in crates_with_trees(workspace, trees) {
for file in &unreached_by_crate[krate.name.as_str()] {
let finding = unlinked_file_finding(krate, file, tree);
let id = finding.id.clone();
graph
.add_finding(finding)
.expect("unlinked-file ids are unique per source file path");
id_by_path.insert(file.path.as_path(), id);
}
}
for krate in &workspace.crates {
for file in &unreached_by_crate[krate.name.as_str()] {
let Some(cause_id) = id_by_path.get(file.path.as_path()).cloned() else {
continue;
};
let Ok((_, ast)) = crate::functions::read_and_parse_source(&file.path, |_| (), |_| ())
else {
continue;
};
for item in &ast.items {
let Item::Mod(item_mod) = item else { continue };
if item_mod.content.is_some() {
continue;
}
let Some(child_file) = resolve_mod_item_file(&file.path, item_mod) else {
continue;
};
if let Some(effect_id) = id_by_path.get(child_file.as_path())
&& let Err(err) = graph.add_edge(&cause_id, effect_id)
{
errors.push(ModuleGraphError::Graph(err));
}
}
}
}
graph.into_findings()
}
fn unlinked_file_finding(krate: &CrateInfo, file: &SourceFile, tree: &CrateModuleTree) -> Finding {
let mut roots_searched = tree.root_labels.clone();
roots_searched.sort();
roots_searched.dedup();
Finding::new(
format!("{UNLINKED_FILE_RULE}:{}", file.path.display()),
UNLINKED_FILE_RULE,
Severity::Warn,
Location {
file: file.path.clone(),
line: OneBasedLine::FIRST,
item_path: format!("{}: {}", krate.name, file.path.display()),
},
EvidenceClass::BoundedSemantic,
Origin::Code,
Some(serde_json::json!({
"crate": krate.name,
"roots_searched": roots_searched,
"reason": "not reached by resolving `mod` declarations (including #[path] \
overrides) from any of this crate's own Cargo target roots",
})),
)
}
struct ReferenceHit {
referencing_file: PathBuf,
owning_crate: String,
module_path: String,
}
struct ReferenceCollector<'a> {
own_crate: &'a str,
current_module: Option<&'a str>,
crate_by_identifier: &'a HashMap<String, String>,
local_mod_names: &'a HashSet<String>,
hits: Vec<(String, String)>,
}
impl ReferenceCollector<'_> {
fn resolve(&self, mut segments: Vec<String>) -> Option<(String, String)> {
if segments.is_empty() {
return None;
}
let head = segments.remove(0);
if head == "crate" {
return Some((self.own_crate.to_string(), segments.join("::")));
}
if let Some(owner) = self.crate_by_identifier.get(&head) {
return Some((owner.clone(), segments.join("::")));
}
if head == "super" {
let mut parts = module_path_segments(self.current_module?);
parts.pop()?;
crate::rules::boundaries::pop_leading_supers(&mut parts, &mut segments)?;
parts.extend(segments);
return Some((self.own_crate.to_string(), parts.join("::")));
}
if self.local_mod_names.contains(&head) {
let mut parts = module_path_segments(self.current_module?);
parts.push(head);
parts.extend(segments);
return Some((self.own_crate.to_string(), parts.join("::")));
}
None
}
}
impl<'ast> Visit<'ast> for ReferenceCollector<'_> {
fn visit_item_use(&mut self, node: &'ast ItemUse) {
let mut leaves = Vec::new();
crate::rules::boundaries::use_tree_leaf_segments(&node.tree, &mut Vec::new(), &mut leaves);
for leaf in leaves {
if let Some(hit) = self.resolve(leaf) {
self.hits.push(hit);
}
}
}
fn visit_path(&mut self, node: &'ast syn::Path) {
if let Some(hit) = self.resolve(crate::rules::boundaries::path_segments(node)) {
self.hits.push(hit);
}
visit::visit_path(self, node);
}
}
fn module_has_recognized_entry_point(krate: &CrateInfo, file_set: &HashSet<&Path>) -> bool {
krate
.source_files
.iter()
.filter(|file| file_set.contains(file.path.as_path()))
.any(|file| {
let Ok((_, ast)) = crate::functions::read_and_parse_source(&file.path, |_| (), |_| ())
else {
return false;
};
file_has_recognized_entry_point(&ast)
})
}
fn attr_looks_like_test(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "test")
})
}
fn file_has_recognized_entry_point(ast: &syn::File) -> bool {
let mut found = false;
crate::functions::walk_functions(ast, |site| {
if found {
return;
}
if site.qualified_name == "main" || attr_looks_like_test(site.attrs) {
found = true;
}
});
found
}
fn orphan_module_findings(
workspace: &Workspace,
trees: &HashMap<&str, CrateModuleTree>,
include_generated: bool,
excluded_generated: &mut usize,
) -> Vec<Finding> {
let crate_by_identifier: HashMap<String, String> = workspace
.crates
.iter()
.map(|krate| (krate.name.replace('-', "_"), krate.name.clone()))
.collect();
let mut hits: Vec<ReferenceHit> = Vec::new();
let mut files_scanned = 0usize;
for (krate, tree) in crates_with_trees(workspace, trees) {
for file in &krate.source_files {
let Ok((_, ast)) = crate::functions::read_and_parse_source(&file.path, |_| (), |_| ())
else {
continue;
};
files_scanned += 1;
let current_module = tree.file_module_path.get(&file.path).cloned();
let local_mod_names: HashSet<String> = ast
.items
.iter()
.filter_map(|item| match item {
Item::Mod(item_mod) => Some(item_mod.ident.to_string()),
_ => None,
})
.collect();
let mut collector = ReferenceCollector {
own_crate: krate.name.as_str(),
current_module: current_module.as_deref(),
crate_by_identifier: &crate_by_identifier,
local_mod_names: &local_mod_names,
hits: Vec::new(),
};
collector.visit_file(&ast);
for (owning_crate, module_path) in collector.hits {
hits.push(ReferenceHit {
referencing_file: file.path.clone(),
owning_crate,
module_path,
});
}
}
}
let mut findings = Vec::new();
for (krate, tree) in crates_with_trees(workspace, trees) {
for node in &tree.nodes {
let node_kind = krate
.source_files
.iter()
.find(|file| file.path == node.file)
.map_or(SourceKind::Authored, |file| file.kind);
if skip_generated(node_kind, include_generated, excluded_generated) {
continue;
}
let file_set = module_file_set(tree, &node.module_path);
if module_has_recognized_entry_point(krate, &file_set) {
continue;
}
let referenced = hits.iter().any(|hit| {
hit.owning_crate == krate.name
&& module_path_references(&hit.module_path, &node.module_path)
&& !file_set.contains(hit.referencing_file.as_path())
});
if !referenced {
findings.push(orphan_module_finding(krate, node, files_scanned));
}
}
}
findings
}
fn orphan_module_finding(krate: &CrateInfo, node: &ModuleNode, files_scanned: usize) -> Finding {
Finding::new(
format!("{ORPHAN_MODULE_RULE}:{}:{}", krate.name, node.module_path),
ORPHAN_MODULE_RULE,
Severity::Warn,
Location {
file: node.declared_at_file.clone(),
line: OneBasedLine::new(node.declared_at_line).unwrap_or(OneBasedLine::FIRST),
item_path: format!("{}::{}", krate.name, node.module_path),
},
EvidenceClass::BoundedSemantic,
Origin::Code,
Some(serde_json::json!({
"crate": krate.name,
"module_path": node.module_path,
"searched_files": files_scanned,
"reason": "no reference found anywhere in the examined workspace matching \
`crate::<module_path>` or `<crate-name>::<module_path>` from outside the \
module's own files",
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
fn write_single_crate_manifest(dir: &TempDir, name: &str) {
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
)
.unwrap();
}
fn rule_findings<'a>(findings: &'a [Finding], rule: &str) -> Vec<&'a Finding> {
findings.iter().filter(|f| f.rule == rule).collect()
}
#[test]
fn an_unreferenced_source_file_is_unlinked() {
let dir = TempDir::new("module-graph-unlinked");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(dir.join("src/orphan.rs"), "pub fn never_wired() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
let hits = rule_findings(&report.findings, UNLINKED_FILE_RULE);
assert_eq!(hits.len(), 1);
assert!(hits[0].location.file.ends_with("orphan.rs"));
assert_eq!(hits[0].evidence_class, EvidenceClass::BoundedSemantic);
}
#[test]
fn unlinked_file_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(UNLINKED_FILE_RULE)
.expect("unlinked-file has a registry entry")
.example
.expect("unlinked-file has a curated example")
.before;
let dir = TempDir::new("module-graph-unlinked-file-registry-example");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(dir.join("src/legacy_config.rs"), example).unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert_eq!(rule_findings(&report.findings, UNLINKED_FILE_RULE).len(), 1);
}
#[test]
fn a_file_linked_via_a_conventional_mod_declaration_is_not_unlinked() {
let dir = TempDir::new("module-graph-linked-conventional");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "mod foo;\n").unwrap();
std::fs::write(dir.join("src/foo.rs"), "pub fn hello() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert!(rule_findings(&report.findings, UNLINKED_FILE_RULE).is_empty());
}
#[test]
fn a_file_linked_via_a_path_attribute_is_not_unlinked() {
let dir = TempDir::new("module-graph-linked-path-attr");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("src/lib.rs"),
"#[path = \"unusual_name.rs\"]\nmod foo;\n",
)
.unwrap();
std::fs::write(dir.join("src/unusual_name.rs"), "pub fn hello() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert!(rule_findings(&report.findings, UNLINKED_FILE_RULE).is_empty());
}
#[test]
fn build_rs_is_never_flagged_as_unlinked() {
let dir = TempDir::new("module-graph-build-rs");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(dir.join("build.rs"), "fn main() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert!(rule_findings(&report.findings, UNLINKED_FILE_RULE).is_empty());
}
#[test]
fn an_integration_test_target_is_its_own_root_not_unlinked() {
let dir = TempDir::new("module-graph-test-target");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::create_dir_all(dir.join("tests")).unwrap();
std::fs::write(dir.join("tests/it.rs"), "#[test]\nfn works() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert!(rule_findings(&report.findings, UNLINKED_FILE_RULE).is_empty());
}
#[test]
fn a_generated_unlinked_file_is_excluded_by_default() {
let dir = TempDir::new("module-graph-generated-excluded");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(
dir.join("src/schema.rs"),
"// @generated. DO NOT EDIT.\npub struct Schema;\n",
)
.unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let default_report = analyze_workspace(&workspace, false);
assert!(rule_findings(&default_report.findings, UNLINKED_FILE_RULE).is_empty());
assert_eq!(default_report.excluded_generated, 1);
let included_report = analyze_workspace(&workspace, true);
assert_eq!(
rule_findings(&included_report.findings, UNLINKED_FILE_RULE).len(),
1
);
}
#[test]
fn a_missing_root_groups_its_own_cascade_of_unreached_children() {
let dir = TempDir::new("module-graph-cascade");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(dir.join("src/foo.rs"), "mod bar;\n").unwrap();
std::fs::create_dir_all(dir.join("src/foo")).unwrap();
std::fs::write(dir.join("src/foo/bar.rs"), "pub fn hello() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
let hits = rule_findings(&report.findings, UNLINKED_FILE_RULE);
assert_eq!(hits.len(), 2);
let root = hits
.iter()
.find(|f| f.location.file.ends_with("foo.rs"))
.unwrap();
let child = hits
.iter()
.find(|f| f.location.file.ends_with("bar.rs"))
.unwrap();
assert!(root.caused_by().is_empty());
assert_eq!(root.causes().to_vec(), vec![child.id.clone()]);
assert_eq!(child.caused_by().to_vec(), vec![root.id.clone()]);
let roots = crate::finding::root_findings(&report.findings);
assert_eq!(roots.len(), 1);
assert!(roots[0].location.file.ends_with("foo.rs"));
}
#[test]
fn a_module_with_no_external_reference_is_orphaned() {
let dir = TempDir::new("module-graph-orphan");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "mod foo;\n").unwrap();
std::fs::write(
dir.join("src/foo.rs"),
"pub fn bar() {}\n\npub fn calls_self() {\n bar();\n}\n",
)
.unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
let hits = rule_findings(&report.findings, ORPHAN_MODULE_RULE);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].location.item_path, "fixture::foo");
assert_eq!(hits[0].evidence_class, EvidenceClass::BoundedSemantic);
}
#[test]
fn orphan_module_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(ORPHAN_MODULE_RULE)
.expect("orphan-module has a registry entry")
.example
.expect("orphan-module has a curated example")
.before;
let dir = TempDir::new("module-graph-orphan-module-registry-example");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "mod text_utils;\n").unwrap();
std::fs::write(dir.join("src/text_utils.rs"), example).unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert_eq!(rule_findings(&report.findings, ORPHAN_MODULE_RULE).len(), 1);
}
#[test]
fn a_module_referenced_via_an_absolute_crate_path_is_not_orphaned() {
let dir = TempDir::new("module-graph-referenced-absolute");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("src/lib.rs"),
"mod foo;\nmod baz;\n\npub fn bar() -> i32 {\n 0\n}\n",
)
.unwrap();
std::fs::write(dir.join("src/foo.rs"), "pub fn bar() -> i32 {\n 1\n}\n").unwrap();
std::fs::write(
dir.join("src/baz.rs"),
"pub fn calls_foo() -> i32 {\n crate::foo::bar()\n}\n",
)
.unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
let hits: Vec<&str> = rule_findings(&report.findings, ORPHAN_MODULE_RULE)
.iter()
.map(|f| f.location.item_path.as_str())
.collect();
assert!(!hits.contains(&"fixture::foo"));
}
#[test]
fn a_module_with_a_test_function_is_not_orphaned_even_when_unreferenced() {
let dir = TempDir::new("module-graph-test-exception");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "mod tests;\n").unwrap();
std::fs::write(
dir.join("src/tests.rs"),
"#[test]\nfn it_works() {\n assert!(true);\n}\n",
)
.unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert!(rule_findings(&report.findings, ORPHAN_MODULE_RULE).is_empty());
}
#[test]
fn a_same_file_reexport_of_a_declared_submodule_counts_as_a_reference() {
let dir = TempDir::new("module-graph-bare-reexport");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "mod foo;\npub use foo::Bar;\n").unwrap();
std::fs::write(dir.join("src/foo.rs"), "pub struct Bar;\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert!(rule_findings(&report.findings, ORPHAN_MODULE_RULE).is_empty());
}
#[test]
fn a_module_referenced_from_another_workspace_crate_by_crate_name_is_not_orphaned() {
let dir = TempDir::new("module-graph-cross-crate");
std::fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"lib_crate\", \"consumer\"]\nresolver = \"2\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("lib_crate/src")).unwrap();
std::fs::write(
dir.join("lib_crate/Cargo.toml"),
"[package]\nname = \"lib_crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(dir.join("lib_crate/src/lib.rs"), "mod foo;\n").unwrap();
std::fs::write(
dir.join("lib_crate/src/foo.rs"),
"pub fn bar() -> i32 {\n 1\n}\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("consumer/src")).unwrap();
std::fs::write(
dir.join("consumer/Cargo.toml"),
"[package]\nname = \"consumer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nlib_crate = { path = \"../lib_crate\" }\n",
)
.unwrap();
std::fs::write(
dir.join("consumer/src/lib.rs"),
"pub fn use_it() -> i32 {\n lib_crate::foo::bar()\n}\n",
)
.unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
let hits: Vec<&str> = rule_findings(&report.findings, ORPHAN_MODULE_RULE)
.iter()
.map(|f| f.location.item_path.as_str())
.collect();
assert!(!hits.contains(&"lib_crate::foo"));
}
#[test]
fn a_mod_rs_file_resolves_via_the_directory_convention() {
let dir = TempDir::new("module-graph-mod-rs");
write_single_crate_manifest(&dir, "fixture");
std::fs::create_dir_all(dir.join("src/foo")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "mod foo;\n").unwrap();
std::fs::write(dir.join("src/foo/mod.rs"), "pub fn hello() {}\n").unwrap();
let workspace = crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap();
let report = analyze_workspace(&workspace, true);
assert!(rule_findings(&report.findings, UNLINKED_FILE_RULE).is_empty());
}
}