Skip to main content

normalize_native_rules/
high_fan_out.rs

1//! `high-fan-out` native rule — flags files that import from too many other files.
2//!
3//! High fan-out is a structural design smell: a file coupled to many others becomes
4//! a change magnet and makes the system harder to reason about in isolation.
5//!
6//! Requires the structural index (`normalize structure rebuild`).
7//!
8//! # Configuration
9//!
10//! ```toml
11//! [rules.rule."high-fan-out"]
12//! threshold = 15   # default: 20
13//! ```
14
15use normalize_output::diagnostics::{DiagnosticsReport, Issue, Severity, ToolFailure};
16use std::path::Path;
17
18/// Build a `DiagnosticsReport` for the `high-fan-out` rule.
19///
20/// Opens the structural index under `root`, queries fan-out counts per file, and
21/// emits a warning for each file that imports from strictly more than `threshold`
22/// distinct resolved files.
23pub async fn build_high_fan_out_report(root: &Path, threshold: usize) -> DiagnosticsReport {
24    let mut report = DiagnosticsReport::new();
25
26    let db_path = crate::check_refs::normalize_dir_for_root(root).join("index.sqlite");
27    let idx = match normalize_facts::FileIndex::open(&db_path, root).await {
28        Ok(idx) => idx,
29        Err(e) => {
30            report.tool_errors.push(ToolFailure {
31                tool: "high-fan-out".into(),
32                message: format!(
33                    "failed to open index at {}: {}. Run `normalize structure rebuild` first.",
34                    db_path.display(),
35                    e
36                ),
37            });
38            return report;
39        }
40    };
41
42    let fan_out = match idx.import_fan_out_by_file().await {
43        Ok(v) => v,
44        Err(e) => {
45            report.tool_errors.push(ToolFailure {
46                tool: "high-fan-out".into(),
47                message: format!("failed to query imports table: {e}"),
48            });
49            return report;
50        }
51    };
52
53    for (file, count) in &fan_out {
54        if *count > threshold {
55            report.issues.push(Issue {
56                file: file.clone(),
57                line: Some(1),
58                column: None,
59                end_line: None,
60                end_column: None,
61                rule_id: "high-fan-out".into(),
62                message: format!(
63                    "file imports from {count} modules (threshold: {threshold})"
64                ),
65                severity: Severity::Warning,
66                source: "high-fan-out".into(),
67                related: vec![],
68                suggestion: Some(
69                    "consider splitting responsibilities or introducing an abstraction layer to reduce coupling".into(),
70                ),
71            });
72        }
73    }
74
75    report.files_checked = fan_out.len();
76    report.sources_run.push("high-fan-out".into());
77    report
78}