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 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 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#[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 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 stats.branches_per_function.push(m.branches);
197 stats.local_variables_per_function.push(m.local_variables);
198 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
266pub 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
482fn 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); 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 let mut stats = MetricStats::default();
673 let mut graph = crate::graph::DependencyGraph::new();
674
675 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 let mut stats = MetricStats::default();
692 let mut graph = crate::graph::DependencyGraph::new();
693
694 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 #[test]
724 fn test_generate_config_toml_includes_boolean_parameters() {
725 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}