Skip to main content

fallow_engine/health/
branching.rs

1//! Per-file branching totals for the audit's base-versus-head comparison.
2//!
3//! Deliberately independent of the health findings path. That path drops every
4//! unit below the configured threshold and skips suppressed units entirely, so
5//! aggregating there would let one `fallow-ignore-next-line complexity` comment
6//! remove a unit's branches from the total. These totals are threshold-blind and
7//! suppression-blind by construction.
8
9use fallow_types::discover::DiscoveredFile;
10use fallow_types::extract::{FileBranching, ModuleInfo};
11use rustc_hash::FxHashMap;
12use std::path::PathBuf;
13
14/// Branching totals per absolute file path.
15///
16/// Empty when the run did not request complexity, because `ModuleInfo`
17/// carries no units in that case and an all-zero map would be
18/// indistinguishable from a project with no branching.
19pub type BranchingByFile = FxHashMap<PathBuf, FileBranching>;
20
21/// Aggregate every analyzed module into per-file branching totals.
22///
23/// Files whose module holds no units are omitted rather than recorded as zero:
24/// a file with no functions contributes nothing to either term, and omitting it
25/// keeps the payload proportional to the code that has units.
26///
27/// A file whose only unit is the synthetic `<module>` unit is included. Its
28/// module scope is the only place it branches, and that branching is exactly
29/// what these totals exist to conserve.
30#[must_use]
31pub fn branching_by_file(files: &[DiscoveredFile], modules: &[ModuleInfo]) -> BranchingByFile {
32    let mut by_file = BranchingByFile::default();
33    for module in modules {
34        if module.complexity.is_empty() {
35            continue;
36        }
37        let Some(file) = files.get(module.file_id.0 as usize) else {
38            continue;
39        };
40        let totals = FileBranching::from_units(&module.complexity);
41        if totals.functions == 0 {
42            continue;
43        }
44        by_file.insert(file.path.clone(), totals);
45    }
46    by_file
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use fallow_types::discover::FileId;
53    use fallow_types::extract::{
54        ComplexityContribution, ComplexityContributionKind, ComplexityMetric, FunctionComplexity,
55    };
56
57    fn file(id: u32, path: &str) -> DiscoveredFile {
58        DiscoveredFile {
59            id: FileId(id),
60            path: PathBuf::from(path),
61            size_bytes: 0,
62        }
63    }
64
65    fn unit(name: &str, cyclomatic: u16, branches: u16) -> FunctionComplexity {
66        FunctionComplexity {
67            name: name.to_string(),
68            is_private_member: false,
69            context_named: None,
70            line: 1,
71            col: 0,
72            cyclomatic,
73            cognitive: 0,
74            line_count: 10,
75            param_count: 0,
76            react_hook_count: 0,
77            react_jsx_max_depth: 0,
78            react_prop_count: 0,
79            source_hash: None,
80            contributions: std::iter::repeat_n(
81                ComplexityContribution {
82                    line: 1,
83                    col: 0,
84                    metric: ComplexityMetric::Cyclomatic,
85                    kind: ComplexityContributionKind::If,
86                    weight: 1,
87                    nesting: 0,
88                    literal_membership: false,
89                },
90                usize::from(branches),
91            )
92            .collect(),
93        }
94    }
95
96    fn module(file_id: u32, units: Vec<FunctionComplexity>) -> ModuleInfo {
97        ModuleInfo {
98            complexity: units,
99            ..ModuleInfo::empty(FileId(file_id))
100        }
101    }
102
103    #[test]
104    fn aggregates_each_module_under_its_path() {
105        let files = vec![file(0, "/p/a.ts"), file(1, "/p/b.ts")];
106        let modules = vec![
107            module(0, vec![unit("one", 3, 2), unit("two", 2, 1)]),
108            module(1, vec![unit("three", 1, 0)]),
109        ];
110
111        let by_file = branching_by_file(&files, &modules);
112
113        let a = by_file[&PathBuf::from("/p/a.ts")];
114        assert_eq!(a.branch_points, 3);
115        assert_eq!(a.functions, 2);
116        assert_eq!(a.peak_cyclomatic, 3);
117        assert_eq!(a.implied_cyclomatic(), 5, "2 functions carrying 3 branches");
118
119        let b = by_file[&PathBuf::from("/p/b.ts")];
120        assert_eq!(b.branch_points, 0);
121        assert_eq!(b.functions, 1);
122    }
123
124    #[test]
125    fn omits_modules_with_no_units() {
126        let files = vec![file(0, "/p/a.ts")];
127        let modules = vec![module(0, Vec::new())];
128        assert!(branching_by_file(&files, &modules).is_empty());
129    }
130
131    #[test]
132    fn omits_modules_whose_only_units_are_synthetic() {
133        let files = vec![file(0, "/p/a.vue")];
134        let modules = vec![module(0, vec![unit("<template>", 4, 3)])];
135        assert!(
136            branching_by_file(&files, &modules).is_empty(),
137            "a synthetic template unit is excluded, so the file has nothing to report"
138        );
139    }
140
141    #[test]
142    fn reports_a_module_whose_only_unit_is_module_scope() {
143        let files = vec![file(0, "/p/config.ts")];
144        let modules = vec![module(0, vec![unit("<module>", 3, 2)])];
145
146        let totals = branching_by_file(&files, &modules)[&PathBuf::from("/p/config.ts")];
147
148        assert_eq!(totals.branch_points, 2, "module-scope branching is counted");
149        assert_eq!(totals.functions, 1);
150        assert!(
151            !totals.has_synthetic_units,
152            "the module unit is not a template unit and excludes nothing"
153        );
154    }
155
156    #[test]
157    fn skips_a_module_whose_file_id_is_out_of_range() {
158        let files = vec![file(0, "/p/a.ts")];
159        let modules = vec![module(7, vec![unit("one", 2, 1)])];
160        assert!(branching_by_file(&files, &modules).is_empty());
161    }
162}