Skip to main content

cccc_core/
report.rs

1//! Result types and language-agnostic aggregation (summary statistics and
2//! cross-file rankings). Output *rendering* lives in the CLI, not here.
3
4use serde::Serialize;
5
6/// Complexity metrics for a single function-like unit (function, method, arrow, accessor).
7///
8/// Each unit is measured independently: nesting resets to 0 at the function
9/// boundary and nested functions are reported as `children` rather than being
10/// folded into the parent's own score. See [`crate::engine`] for the exact rules.
11#[derive(Debug, Clone, Serialize)]
12pub struct FunctionReport {
13    pub name: String,
14    /// "function" | "method" | "arrow" | "getter" | "setter" | "constructor"
15    pub kind: String,
16    /// 1-based line where the function starts.
17    pub line: u32,
18    pub cognitive: u32,
19    pub cyclomatic: u32,
20    #[serde(skip_serializing_if = "Vec::is_empty")]
21    pub children: Vec<FunctionReport>,
22}
23
24/// Aggregated metrics for a single source file.
25#[derive(Debug, Clone, Serialize)]
26pub struct FileReport {
27    pub path: String,
28    /// File total = module-level code + every function (all nesting depths).
29    pub cognitive: u32,
30    pub cyclomatic: u32,
31    pub functions: Vec<FunctionReport>,
32    #[serde(skip_serializing_if = "Vec::is_empty")]
33    pub parse_errors: Vec<String>,
34}
35
36/// Distribution of one metric over the population of all functions.
37///
38/// Complexity is right-skewed, so the percentiles (not a mean/stddev) carry the
39/// signal: `median` is the typical function, `p90`/`p95`/`max` describe the tail
40/// where refactoring candidates live.
41#[derive(Debug, Clone, Serialize)]
42pub struct MetricSummary {
43    pub sum: u32,
44    pub max: u32,
45    pub median: u32,
46    pub p90: u32,
47    pub p95: u32,
48}
49
50/// Project-wide rollup across every function in every file.
51#[derive(Debug, Clone, Serialize)]
52pub struct Summary {
53    pub file_count: usize,
54    pub function_count: usize,
55    pub cognitive: MetricSummary,
56    pub cyclomatic: MetricSummary,
57}
58
59/// Top-level output: per-file reports plus a whole-project summary.
60#[derive(Debug, Clone, Serialize)]
61pub struct Report {
62    pub files: Vec<FileReport>,
63    pub summary: Summary,
64}
65
66/// The complexity metric a ranking is ordered by.
67#[derive(Debug, Clone, Copy)]
68pub enum Metric {
69    Cognitive,
70    Cyclomatic,
71}
72
73impl Metric {
74    fn as_str(self) -> &'static str {
75        match self {
76            Metric::Cognitive => "cognitive",
77            Metric::Cyclomatic => "cyclomatic",
78        }
79    }
80}
81
82/// One function in a flat cross-file ranking. Carries `path`/`line` so each row
83/// is locatable on its own (the per-file nesting is flattened away).
84#[derive(Debug, Clone, Serialize)]
85pub struct TopEntry {
86    pub path: String,
87    pub name: String,
88    pub kind: String,
89    pub line: u32,
90    pub cognitive: u32,
91    pub cyclomatic: u32,
92}
93
94/// Top-level output for `--top-*`: a flat ranking plus the whole-project summary.
95#[derive(Debug, Clone, Serialize)]
96pub struct TopReport {
97    /// The metric the ranking is sorted by ("cognitive" | "cyclomatic").
98    pub metric: String,
99    pub top: Vec<TopEntry>,
100    pub summary: Summary,
101}
102
103/// Visit every function in a report tree (parents before children, all depths).
104pub fn for_each_function(fns: &[FunctionReport], f: &mut impl FnMut(&FunctionReport)) {
105    for func in fns {
106        f(func);
107        for_each_function(&func.children, f);
108    }
109}
110
111/// Build a flat ranking of the `n` most complex functions across all files,
112/// ordered by `metric` descending. Ties break by path then line for stable,
113/// reproducible output. Counts every function at every nesting depth.
114pub fn compute_top(reports: &[FileReport], metric: Metric, n: usize) -> Vec<TopEntry> {
115    let mut entries = Vec::new();
116    for r in reports {
117        for_each_function(&r.functions, &mut |f| {
118            entries.push(TopEntry {
119                path: r.path.clone(),
120                name: f.name.clone(),
121                kind: f.kind.clone(),
122                line: f.line,
123                cognitive: f.cognitive,
124                cyclomatic: f.cyclomatic,
125            });
126        });
127    }
128    entries.sort_by(|a, b| {
129        let (av, bv) = match metric {
130            Metric::Cognitive => (a.cognitive, b.cognitive),
131            Metric::Cyclomatic => (a.cyclomatic, b.cyclomatic),
132        };
133        bv.cmp(&av)
134            .then_with(|| a.path.cmp(&b.path))
135            .then(a.line.cmp(&b.line))
136    });
137    entries.truncate(n);
138    entries
139}
140
141/// Assemble a `TopReport` from the per-file reports and a precomputed summary.
142pub fn build_top_report(
143    reports: &[FileReport],
144    summary: Summary,
145    metric: Metric,
146    n: usize,
147) -> TopReport {
148    TopReport {
149        metric: metric.as_str().to_string(),
150        top: compute_top(reports, metric, n),
151        summary,
152    }
153}
154
155/// Nearest-rank percentile on an ascending-sorted slice. `p` is in `[0, 100]`.
156/// Returns 0 for an empty slice.
157fn percentile(sorted: &[u32], p: f64) -> u32 {
158    if sorted.is_empty() {
159        return 0;
160    }
161    let n = sorted.len();
162    let rank = ((p / 100.0) * n as f64).ceil() as usize;
163    let idx = rank.saturating_sub(1).min(n - 1);
164    sorted[idx]
165}
166
167fn metric_summary(mut values: Vec<u32>) -> MetricSummary {
168    values.sort_unstable();
169    MetricSummary {
170        sum: values.iter().sum(),
171        max: values.last().copied().unwrap_or(0),
172        median: percentile(&values, 50.0),
173        p90: percentile(&values, 90.0),
174        p95: percentile(&values, 95.0),
175    }
176}
177
178/// Build the whole-project summary. The population is every function at every
179/// nesting depth across all files (module-level totals are excluded). Call this
180/// before any display-only filtering so the distribution reflects all code.
181pub fn compute_summary(reports: &[FileReport]) -> Summary {
182    let mut cog = Vec::new();
183    let mut cyc = Vec::new();
184    for r in reports {
185        for_each_function(&r.functions, &mut |f| {
186            cog.push(f.cognitive);
187            cyc.push(f.cyclomatic);
188        });
189    }
190    Summary {
191        file_count: reports.len(),
192        function_count: cog.len(),
193        cognitive: metric_summary(cog),
194        cyclomatic: metric_summary(cyc),
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn percentile_nearest_rank() {
204        let v: Vec<u32> = (1..=10).collect();
205        assert_eq!(percentile(&v, 50.0), 5);
206        assert_eq!(percentile(&v, 90.0), 9);
207        assert_eq!(percentile(&v, 95.0), 10);
208        assert_eq!(percentile(&v, 100.0), 10);
209    }
210
211    #[test]
212    fn percentile_empty_is_zero() {
213        assert_eq!(percentile(&[], 50.0), 0);
214    }
215}