Skip to main content

kiss/
stats.rs

1use crate::graph::DependencyGraph;
2use crate::parsing::ParsedFile;
3use crate::py_metrics::{compute_class_metrics, compute_file_metrics, compute_function_metrics};
4use crate::rust_fn_metrics::{compute_rust_file_metrics, compute_rust_function_metrics};
5use crate::rust_parsing::ParsedRustFile;
6use rayon::prelude::*;
7use syn::{ImplItem, Item};
8use tree_sitter::Node;
9
10#[derive(Debug, Default)]
11pub struct MetricStats {
12    pub statements_per_function: Vec<usize>,
13    pub arguments_per_function: Vec<usize>,
14    pub arguments_positional: Vec<usize>,
15    pub arguments_keyword_only: Vec<usize>,
16    pub max_indentation: Vec<usize>,
17    pub nested_function_depth: Vec<usize>,
18    pub returns_per_function: Vec<usize>,
19    pub return_values_per_function: Vec<usize>,
20    pub branches_per_function: Vec<usize>,
21    pub local_variables_per_function: Vec<usize>,
22    pub statements_per_try_block: Vec<usize>,
23    pub boolean_parameters: Vec<usize>,
24    pub annotations_per_function: Vec<usize>,
25    pub calls_per_function: Vec<usize>,
26    pub methods_per_class: Vec<usize>,
27    pub statements_per_file: Vec<usize>,
28    pub lines_per_file: Vec<usize>,
29    pub functions_per_file: Vec<usize>,
30    pub interface_types_per_file: Vec<usize>,
31    pub concrete_types_per_file: Vec<usize>,
32    pub imported_names_per_file: Vec<usize>,
33    pub fan_in: Vec<usize>,
34    pub fan_out: Vec<usize>,
35    pub cycle_size: Vec<usize>,
36    pub transitive_dependencies: Vec<usize>,
37    pub dependency_depth: Vec<usize>,
38}
39
40impl MetricStats {
41    pub fn collect(parsed_files: &[&ParsedFile]) -> Self {
42        // This is the hot path for `kiss stats`: per-file tree walks and per-function metric
43        // extraction. Parallelize at the file level and merge the per-thread aggregates.
44        parsed_files
45            .par_iter()
46            .map(|parsed| {
47                let mut stats = Self::default();
48                let fm = compute_file_metrics(parsed);
49                stats.statements_per_file.push(fm.statements);
50                stats.lines_per_file.push(parsed.source.lines().count());
51                stats.functions_per_file.push(fm.functions);
52                stats.interface_types_per_file.push(fm.interface_types);
53                stats.concrete_types_per_file.push(fm.concrete_types);
54                stats.imported_names_per_file.push(fm.imports);
55                collect_from_node(parsed.tree.root_node(), &parsed.source, &mut stats, false);
56                stats
57            })
58            .reduce(Self::default, |mut a, b| {
59                a.merge(b);
60                a
61            })
62    }
63
64    pub fn merge(&mut self, o: Self) {
65        macro_rules! ext { ($($f:ident),*) => { $(self.$f.extend(o.$f);)* }; }
66        ext!(
67            statements_per_function,
68            arguments_per_function,
69            arguments_positional,
70            arguments_keyword_only,
71            max_indentation,
72            nested_function_depth,
73            returns_per_function,
74            return_values_per_function,
75            branches_per_function,
76            local_variables_per_function,
77            statements_per_try_block,
78            boolean_parameters,
79            annotations_per_function,
80            calls_per_function,
81            methods_per_class,
82            statements_per_file,
83            lines_per_file,
84            functions_per_file,
85            interface_types_per_file,
86            concrete_types_per_file,
87            imported_names_per_file,
88            fan_in,
89            fan_out,
90            cycle_size,
91            transitive_dependencies,
92            dependency_depth
93        );
94    }
95
96    pub fn collect_graph_metrics(&mut self, graph: &DependencyGraph) {
97        use std::collections::HashMap;
98
99        let cycles = graph.find_cycles().cycles;
100        let mut cycle_size_by_module: HashMap<&str, usize> = HashMap::new();
101        for cycle in &cycles {
102            let size = cycle.len();
103            for m in cycle {
104                cycle_size_by_module.insert(m.as_str(), size);
105            }
106        }
107
108        // Only include internal modules (those with a known path). External imports create nodes
109        // but should not skew per-module distributions in `stats`.
110        for name in graph.paths.keys() {
111            let m = graph.module_metrics(name);
112            self.fan_in.push(m.fan_in);
113            self.fan_out.push(m.fan_out);
114            self.transitive_dependencies.push(m.transitive_dependencies);
115            self.dependency_depth.push(m.dependency_depth);
116            self.cycle_size
117                .push(*cycle_size_by_module.get(name.as_str()).unwrap_or(&0));
118        }
119    }
120
121    pub fn max_depth(&self) -> usize {
122        self.dependency_depth.iter().copied().max().unwrap_or(0)
123    }
124
125    pub fn collect_rust(parsed_files: &[&ParsedRustFile]) -> Self {
126        let mut stats = Self::default();
127        for parsed in parsed_files {
128            let fm = compute_rust_file_metrics(parsed);
129            stats.statements_per_file.push(fm.statements);
130            stats.lines_per_file.push(parsed.source.lines().count());
131            stats.functions_per_file.push(fm.functions);
132            stats.interface_types_per_file.push(fm.interface_types);
133            stats.concrete_types_per_file.push(fm.concrete_types);
134            stats.imported_names_per_file.push(fm.imports);
135            collect_rust_from_items(&parsed.ast.items, &mut stats);
136        }
137        stats
138    }
139}
140
141// inside_class tracks context for method counting; passed through recursion to nested scopes
142#[allow(clippy::only_used_in_recursion)]
143fn collect_from_node(node: Node, source: &str, stats: &mut MetricStats, inside_class: bool) {
144    match node.kind() {
145        "function_definition" | "async_function_definition" => {
146            push_py_fn_metrics(stats, &compute_function_metrics(node, source));
147            let mut c = node.walk();
148            for child in node.children(&mut c) {
149                collect_from_node(child, source, stats, false);
150            }
151        }
152        "class_definition" => {
153            let m = compute_class_metrics(node);
154            stats.methods_per_class.push(m.methods);
155            let mut c = node.walk();
156            for child in node.children(&mut c) {
157                collect_from_node(child, source, stats, true);
158            }
159        }
160        _ => {
161            let mut c = node.walk();
162            for child in node.children(&mut c) {
163                collect_from_node(child, source, stats, inside_class);
164            }
165        }
166    }
167}
168
169fn push_py_fn_metrics(stats: &mut MetricStats, m: &crate::py_metrics::FunctionMetrics) {
170    stats.statements_per_function.push(m.statements);
171    stats.arguments_per_function.push(m.arguments);
172    stats.arguments_positional.push(m.arguments_positional);
173    stats.arguments_keyword_only.push(m.arguments_keyword_only);
174    stats.max_indentation.push(m.max_indentation);
175    stats.nested_function_depth.push(m.nested_function_depth);
176    stats.returns_per_function.push(m.returns);
177    stats.return_values_per_function.push(m.max_return_values);
178    stats.branches_per_function.push(m.branches);
179    stats.local_variables_per_function.push(m.local_variables);
180    stats
181        .statements_per_try_block
182        .push(m.max_try_block_statements);
183    stats.boolean_parameters.push(m.boolean_parameters);
184    stats.annotations_per_function.push(m.decorators);
185    stats.calls_per_function.push(m.calls);
186}
187
188fn push_rust_fn_metrics(stats: &mut MetricStats, m: &crate::rust_counts::RustFunctionMetrics) {
189    stats.statements_per_function.push(m.statements);
190    stats.arguments_per_function.push(m.arguments);
191    // N/A: Rust has no positional/keyword distinction; don't push to avoid skewing distributions
192    stats.max_indentation.push(m.max_indentation);
193    stats.nested_function_depth.push(m.nested_function_depth);
194    stats.returns_per_function.push(m.returns);
195    // N/A: Rust doesn't have multiple-return-value tuples in the same sense as Python
196    stats.branches_per_function.push(m.branches);
197    stats.local_variables_per_function.push(m.local_variables);
198    // N/A: try-block size is Python-only; don't push to avoid skewing distributions
199    stats.boolean_parameters.push(m.bool_parameters);
200    stats.annotations_per_function.push(m.attributes);
201    stats.calls_per_function.push(m.calls);
202}
203
204fn collect_rust_from_items(items: &[Item], stats: &mut MetricStats) {
205    for item in items {
206        match item {
207            Item::Fn(f) => push_rust_fn_metrics(
208                stats,
209                &compute_rust_function_metrics(&f.sig.inputs, &f.block, f.attrs.len()),
210            ),
211            Item::Impl(i) => {
212                let mcnt = i
213                    .items
214                    .iter()
215                    .filter(|ii| matches!(ii, ImplItem::Fn(_)))
216                    .count();
217                stats.methods_per_class.push(mcnt);
218                for ii in &i.items {
219                    if let ImplItem::Fn(m) = ii {
220                        push_rust_fn_metrics(
221                            stats,
222                            &compute_rust_function_metrics(&m.sig.inputs, &m.block, m.attrs.len()),
223                        );
224                    }
225                }
226            }
227            Item::Mod(m) => {
228                if let Some((_, items)) = &m.content {
229                    collect_rust_from_items(items, stats);
230                }
231            }
232            _ => {}
233        }
234    }
235}
236
237#[allow(
238    clippy::cast_precision_loss,
239    clippy::cast_possible_truncation,
240    clippy::cast_sign_loss
241)]
242pub fn percentile(sorted: &[usize], p: f64) -> usize {
243    if sorted.is_empty() {
244        return 0;
245    }
246    let len = sorted.len();
247    let idx_f = (len.saturating_sub(1) as f64) * p / 100.0;
248    let idx = idx_f.round().max(0.0) as usize;
249    sorted[idx.min(len - 1)]
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum MetricScope {
254    Function,
255    Type,
256    File,
257    Module,
258}
259
260#[derive(Debug, Clone, Copy)]
261pub struct MetricDef {
262    pub metric_id: &'static str,
263    pub scope: MetricScope,
264}
265
266/// Central registry of all metrics with stable IDs
267pub const METRICS: &[MetricDef] = &[
268    MetricDef {
269        metric_id: "statements_per_function",
270        scope: MetricScope::Function,
271    },
272    MetricDef {
273        metric_id: "arguments_per_function",
274        scope: MetricScope::Function,
275    },
276    MetricDef {
277        metric_id: "positional_args",
278        scope: MetricScope::Function,
279    },
280    MetricDef {
281        metric_id: "keyword_only_args",
282        scope: MetricScope::Function,
283    },
284    MetricDef {
285        metric_id: "max_indentation_depth",
286        scope: MetricScope::Function,
287    },
288    MetricDef {
289        metric_id: "nested_function_depth",
290        scope: MetricScope::Function,
291    },
292    MetricDef {
293        metric_id: "returns_per_function",
294        scope: MetricScope::Function,
295    },
296    MetricDef {
297        metric_id: "return_values_per_function",
298        scope: MetricScope::Function,
299    },
300    MetricDef {
301        metric_id: "branches_per_function",
302        scope: MetricScope::Function,
303    },
304    MetricDef {
305        metric_id: "local_variables_per_function",
306        scope: MetricScope::Function,
307    },
308    MetricDef {
309        metric_id: "statements_per_try_block",
310        scope: MetricScope::Function,
311    },
312    MetricDef {
313        metric_id: "boolean_parameters",
314        scope: MetricScope::Function,
315    },
316    MetricDef {
317        metric_id: "annotations_per_function",
318        scope: MetricScope::Function,
319    },
320    MetricDef {
321        metric_id: "calls_per_function",
322        scope: MetricScope::Function,
323    },
324    MetricDef {
325        metric_id: "methods_per_class",
326        scope: MetricScope::Type,
327    },
328    MetricDef {
329        metric_id: "statements_per_file",
330        scope: MetricScope::File,
331    },
332    MetricDef {
333        metric_id: "lines_per_file",
334        scope: MetricScope::File,
335    },
336    MetricDef {
337        metric_id: "functions_per_file",
338        scope: MetricScope::File,
339    },
340    MetricDef {
341        metric_id: "interface_types_per_file",
342        scope: MetricScope::File,
343    },
344    MetricDef {
345        metric_id: "concrete_types_per_file",
346        scope: MetricScope::File,
347    },
348    MetricDef {
349        metric_id: "imported_names_per_file",
350        scope: MetricScope::File,
351    },
352    MetricDef {
353        metric_id: "fan_in",
354        scope: MetricScope::Module,
355    },
356    MetricDef {
357        metric_id: "fan_out",
358        scope: MetricScope::Module,
359    },
360    MetricDef {
361        metric_id: "cycle_size",
362        scope: MetricScope::Module,
363    },
364    MetricDef {
365        metric_id: "transitive_dependencies",
366        scope: MetricScope::Module,
367    },
368    MetricDef {
369        metric_id: "dependency_depth",
370        scope: MetricScope::Module,
371    },
372];
373
374pub fn get_metric_def(metric_id: &str) -> Option<&'static MetricDef> {
375    METRICS.iter().find(|m| m.metric_id == metric_id)
376}
377
378#[derive(Debug)]
379pub struct PercentileSummary {
380    pub metric_id: &'static str,
381    pub count: usize,
382    pub p50: usize,
383    pub p90: usize,
384    pub p95: usize,
385    pub p99: usize,
386    pub max: usize,
387}
388
389impl PercentileSummary {
390    pub fn from_values(metric_id: &'static str, values: &[usize]) -> Self {
391        if values.is_empty() {
392            return Self {
393                metric_id,
394                count: 0,
395                p50: 0,
396                p90: 0,
397                p95: 0,
398                p99: 0,
399                max: 0,
400            };
401        }
402        let mut sorted = values.to_vec();
403        sorted.sort_unstable();
404        Self {
405            metric_id,
406            count: sorted.len(),
407            p50: percentile(&sorted, 50.0),
408            p90: percentile(&sorted, 90.0),
409            p95: percentile(&sorted, 95.0),
410            p99: percentile(&sorted, 99.0),
411            max: *sorted.last().unwrap_or(&0),
412        }
413    }
414}
415
416fn metric_values<'a>(stats: &'a MetricStats, metric_id: &str) -> Option<&'a [usize]> {
417    Some(match metric_id {
418        "statements_per_function" => &stats.statements_per_function,
419        "arguments_per_function" => &stats.arguments_per_function,
420        "positional_args" => &stats.arguments_positional,
421        "keyword_only_args" => &stats.arguments_keyword_only,
422        "max_indentation_depth" => &stats.max_indentation,
423        "nested_function_depth" => &stats.nested_function_depth,
424        "returns_per_function" => &stats.returns_per_function,
425        "return_values_per_function" => &stats.return_values_per_function,
426        "branches_per_function" => &stats.branches_per_function,
427        "local_variables_per_function" => &stats.local_variables_per_function,
428        "statements_per_try_block" => &stats.statements_per_try_block,
429        "boolean_parameters" => &stats.boolean_parameters,
430        "annotations_per_function" => &stats.annotations_per_function,
431        "calls_per_function" => &stats.calls_per_function,
432        "methods_per_class" => &stats.methods_per_class,
433        "statements_per_file" => &stats.statements_per_file,
434        "lines_per_file" => &stats.lines_per_file,
435        "functions_per_file" => &stats.functions_per_file,
436        "interface_types_per_file" => &stats.interface_types_per_file,
437        "concrete_types_per_file" => &stats.concrete_types_per_file,
438        "imported_names_per_file" => &stats.imported_names_per_file,
439        "fan_in" => &stats.fan_in,
440        "fan_out" => &stats.fan_out,
441        "cycle_size" => &stats.cycle_size,
442        "transitive_dependencies" => &stats.transitive_dependencies,
443        "dependency_depth" => &stats.dependency_depth,
444        _ => return None,
445    })
446}
447
448pub fn compute_summaries(stats: &MetricStats) -> Vec<PercentileSummary> {
449    METRICS
450        .iter()
451        .filter_map(|m| {
452            let values = metric_values(stats, m.metric_id)?;
453            if values.is_empty() {
454                None
455            } else {
456                Some(PercentileSummary::from_values(m.metric_id, values))
457            }
458        })
459        .collect()
460}
461
462pub fn format_stats_table(summaries: &[PercentileSummary]) -> String {
463    use std::fmt::Write;
464    let header = format!(
465        "{:<28} {:>5} {:>5} {:>5} {:>5} {:>5} {:>5}",
466        "metric_id", "N", "p50", "p90", "p95", "p99", "max"
467    );
468    let mut out = header;
469    out.push('\n');
470    out.push_str(&"-".repeat(out.trim_end_matches('\n').len()));
471    out.push('\n');
472    for s in summaries.iter().filter(|s| s.count > 0) {
473        let _ = writeln!(
474            out,
475            "{:<28} {:>5} {:>5} {:>5} {:>5} {:>5} {:>5}",
476            s.metric_id, s.count, s.p50, s.p90, s.p95, s.p99, s.max
477        );
478    }
479    out
480}
481
482/// Map `metric_id` to config key (some metrics use different config key names)
483fn config_key_for(metric_id: &str) -> Option<&'static str> {
484    Some(match metric_id {
485        "statements_per_function" => "statements_per_function",
486        "arguments_per_function" => "arguments_per_function",
487        "positional_args" => "arguments_positional",
488        "keyword_only_args" => "arguments_keyword_only",
489        "max_indentation_depth" => "max_indentation_depth",
490        "nested_function_depth" => "nested_function_depth",
491        "returns_per_function" => "returns_per_function",
492        "return_values_per_function" => "return_values_per_function",
493        "branches_per_function" => "branches_per_function",
494        "local_variables_per_function" => "local_variables_per_function",
495        "statements_per_try_block" => "statements_per_try_block",
496        "boolean_parameters" => "boolean_parameters",
497        "annotations_per_function" => "annotations_per_function",
498        "calls_per_function" => "calls_per_function",
499        "methods_per_class" => "methods_per_class",
500        "statements_per_file" => "statements_per_file",
501        "functions_per_file" => "functions_per_file",
502        "interface_types_per_file" => "interface_types_per_file",
503        "concrete_types_per_file" => "concrete_types_per_file",
504        "imported_names_per_file" => "imported_names_per_file",
505        "fan_in" => "fan_in",
506        "fan_out" => "fan_out",
507        "cycle_size" => "cycle_size",
508        "transitive_dependencies" => "transitive_dependencies",
509        "dependency_depth" => "dependency_depth",
510        _ => return None,
511    })
512}
513
514pub fn generate_config_toml(summaries: &[PercentileSummary]) -> String {
515    use std::fmt::Write;
516    let mut out = String::from(
517        "# Generated by kiss mimic\n# Thresholds based on 99th percentile\n\n[thresholds]\n",
518    );
519    for s in summaries {
520        if let Some(k) = config_key_for(s.metric_id) {
521            let _ = writeln!(out, "{k} = {}", s.p99);
522        }
523    }
524    out
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use crate::parsing::{create_parser, parse_file};
531    use crate::rust_parsing::parse_rust_file;
532    use std::io::Write;
533
534    #[test]
535    fn test_stats_helpers() {
536        assert_eq!(percentile(&[], 50.0), 0);
537        assert_eq!(percentile(&[42], 50.0), 42);
538        let s = PercentileSummary::from_values("test_id", &[]);
539        assert_eq!(s.count, 0);
540        let vals: Vec<usize> = (1..=100).collect();
541        assert_eq!(PercentileSummary::from_values("test_id", &vals).max, 100);
542        let mut a = MetricStats::default();
543        a.statements_per_function.push(5);
544        let mut b = MetricStats::default();
545        b.statements_per_function.push(10);
546        a.merge(b);
547        assert_eq!(a.statements_per_function.len(), 2);
548        let s2 = MetricStats {
549            statements_per_function: vec![1, 2, 3],
550            ..Default::default()
551        };
552        assert!(!compute_summaries(&s2).is_empty());
553        let toml = generate_config_toml(&[PercentileSummary {
554            metric_id: "statements_per_function",
555            count: 10,
556            p50: 5,
557            p90: 9,
558            p95: 10,
559            p99: 15,
560            max: 20,
561        }]);
562        assert!(toml.contains("statements_per_function = 15"));
563        assert_eq!(
564            config_key_for("statements_per_function"),
565            Some("statements_per_function")
566        );
567        assert!(
568            format_stats_table(&[PercentileSummary {
569                metric_id: "test_id",
570                count: 10,
571                p50: 5,
572                p90: 8,
573                p95: 9,
574                p99: 10,
575                max: 12
576            }])
577            .contains("test_id")
578        );
579    }
580
581    #[test]
582    fn test_metric_registry() {
583        assert!(get_metric_def("statements_per_function").is_some());
584        assert!(get_metric_def("fan_in").is_some());
585        assert!(get_metric_def("nonexistent").is_none());
586        assert_eq!(
587            get_metric_def("statements_per_function").unwrap().scope,
588            MetricScope::Function
589        );
590        assert_eq!(get_metric_def("fan_in").unwrap().scope, MetricScope::Module);
591        assert!(METRICS.len() > 20); // Verify we have a reasonable number of metrics
592        // Test MetricDef struct fields
593        let def = MetricDef {
594            metric_id: "test",
595            scope: MetricScope::Function,
596        };
597        assert_eq!(def.metric_id, "test");
598    }
599
600    #[test]
601    fn test_push_py_fn_metrics() {
602        let mut stats = MetricStats::default();
603        let m = crate::py_metrics::FunctionMetrics {
604            statements: 3,
605            arguments: 2,
606            ..Default::default()
607        };
608        push_py_fn_metrics(&mut stats, &m);
609        assert_eq!(stats.statements_per_function, vec![3]);
610        assert_eq!(stats.arguments_per_function, vec![2]);
611    }
612
613    #[test]
614    fn test_collection() {
615        let mut stats = MetricStats::default();
616        let mut tmp_rs = tempfile::NamedTempFile::with_suffix(".rs").unwrap();
617        write!(tmp_rs, "fn foo() {{ let x = 1; }}").unwrap();
618        let parsed_rs = parse_rust_file(tmp_rs.path()).unwrap();
619        assert!(
620            !MetricStats::collect_rust(&[&parsed_rs])
621                .statements_per_file
622                .is_empty()
623        );
624        let mut tmp_py = tempfile::NamedTempFile::with_suffix(".py").unwrap();
625        write!(tmp_py, "def foo():\n    x = 1").unwrap();
626        let parsed_py = parse_file(&mut create_parser().unwrap(), tmp_py.path()).unwrap();
627        let mut stats2 = MetricStats::default();
628        collect_from_node(
629            parsed_py.tree.root_node(),
630            &parsed_py.source,
631            &mut stats2,
632            false,
633        );
634        assert!(!stats2.statements_per_function.is_empty());
635        let m = crate::rust_counts::RustFunctionMetrics {
636            statements: 5,
637            arguments: 2,
638            max_indentation: 1,
639            nested_function_depth: 0,
640            returns: 1,
641            branches: 0,
642            local_variables: 2,
643            bool_parameters: 0,
644            attributes: 0,
645            calls: 3,
646        };
647        push_rust_fn_metrics(&mut stats, &m);
648        let ast: syn::File = syn::parse_str("fn bar() { let y = 2; }").unwrap();
649        collect_rust_from_items(&ast.items, &mut stats);
650    }
651
652    #[test]
653    fn test_graph_metrics() {
654        let mut stats = MetricStats::default();
655        let mut graph = crate::graph::DependencyGraph::new();
656        graph.add_dependency("a", "b");
657        graph.add_dependency("b", "c");
658        graph.paths.insert("a".into(), std::path::PathBuf::from("a.py"));
659        graph.paths.insert("b".into(), std::path::PathBuf::from("b.py"));
660        graph.paths.insert("c".into(), std::path::PathBuf::from("c.py"));
661        stats.collect_graph_metrics(&graph);
662        assert!(!stats.fan_in.is_empty());
663        assert!(!stats.fan_out.is_empty());
664        assert!(!stats.transitive_dependencies.is_empty());
665        assert!(!stats.dependency_depth.is_empty());
666        assert!(stats.max_depth() > 0);
667    }
668
669    #[test]
670    fn test_graph_metrics_exclude_external_nodes_from_distributions() {
671        // Regression: `stats` distributions should only include internal modules (those with paths).
672        let mut stats = MetricStats::default();
673        let mut graph = crate::graph::DependencyGraph::new();
674
675        // Internal module a imports an external node "os".
676        graph.get_or_create_node("a");
677        graph.paths
678            .insert("a".into(), std::path::PathBuf::from("a.py"));
679        graph.add_dependency("a", "os");
680
681        stats.collect_graph_metrics(&graph);
682        assert_eq!(stats.fan_out.len(), 1, "fan_out should only include internal modules");
683        assert_eq!(stats.fan_out[0], 1, "a should have one outgoing edge (to external os)");
684    }
685
686    #[test]
687    fn test_cycle_size_is_per_module_distribution() {
688        // Regression guard: module-scoped metrics should collect one value per module.
689        // `cycle_size` should behave like fan_in/fan_out: for modules in a cycle, record the size
690        // of their cycle (SCC); for modules not in any cycle, record 0.
691        let mut stats = MetricStats::default();
692        let mut graph = crate::graph::DependencyGraph::new();
693
694        // a <-> b forms a 2-module cycle; c is acyclic.
695        graph.add_dependency("a", "b");
696        graph.add_dependency("b", "a");
697        graph.get_or_create_node("c");
698        graph.paths.insert("a".into(), std::path::PathBuf::from("a.py"));
699        graph.paths.insert("b".into(), std::path::PathBuf::from("b.py"));
700        graph.paths.insert("c".into(), std::path::PathBuf::from("c.py"));
701
702        stats.collect_graph_metrics(&graph);
703
704        assert_eq!(
705            stats.cycle_size.len(),
706            graph.paths.len(),
707            "Expected cycle_size to have one entry per internal module (got {:?} for {} modules)",
708            stats.cycle_size,
709            graph.paths.len()
710        );
711
712        let mut got = stats.cycle_size.clone();
713        got.sort_unstable();
714        assert_eq!(
715            got,
716            vec![0, 2, 2],
717            "Expected modules in the cycle to record 2, and the acyclic module to record 0"
718        );
719    }
720
721    // === Bug-hunting tests ===
722
723    #[test]
724    fn test_generate_config_toml_includes_boolean_parameters() {
725        // generate_config_toml should include newer metrics like boolean_parameters
726        let summaries = vec![PercentileSummary {
727            metric_id: "boolean_parameters",
728            count: 10,
729            p50: 0,
730            p90: 1,
731            p95: 1,
732            p99: 2,
733            max: 3,
734        }];
735        let toml = generate_config_toml(&summaries);
736        assert!(
737            toml.contains("boolean_parameters"),
738            "Generated config should include boolean_parameters threshold"
739        );
740    }
741
742    #[test]
743    fn test_metric_values() {
744        let mut stats = MetricStats::default();
745        stats.statements_per_function.push(10);
746        stats.arguments_per_function.push(3);
747        stats.fan_in.push(2);
748        assert_eq!(
749            super::metric_values(&stats, "statements_per_function"),
750            Some(&[10][..])
751        );
752        assert_eq!(
753            super::metric_values(&stats, "arguments_per_function"),
754            Some(&[3][..])
755        );
756        assert_eq!(super::metric_values(&stats, "fan_in"), Some(&[2][..]));
757        assert_eq!(super::metric_values(&stats, "unknown_metric"), None);
758    }
759}