lean_ctx/core/code_health/
delta.rs1use super::cognitive::cognitive_per_function;
10use std::collections::BTreeMap;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct CognitiveDelta {
15 pub name: String,
16 pub before: u32,
17 pub after: u32,
18}
19
20impl CognitiveDelta {
21 pub fn increase(&self) -> i64 {
23 i64::from(self.after) - i64::from(self.before)
24 }
25
26 pub fn crosses_threshold(&self, threshold: u32) -> bool {
29 self.before <= threshold && self.after > threshold
30 }
31}
32
33pub fn cognitive_delta(old: &str, new: &str, ext: &str) -> Vec<CognitiveDelta> {
40 let before = map_by_name(old, ext);
41 let after = map_by_name(new, ext);
42
43 let mut out: Vec<CognitiveDelta> = after
44 .iter()
45 .filter_map(|(name, &after_cc)| {
46 let before_cc = before.get(name).copied().unwrap_or(0);
47 (before_cc != after_cc).then(|| CognitiveDelta {
48 name: name.clone(),
49 before: before_cc,
50 after: after_cc,
51 })
52 })
53 .collect();
54 out.sort_by(|a, b| a.name.cmp(&b.name));
55 out
56}
57
58pub fn worst_regression(deltas: &[CognitiveDelta], threshold: u32) -> Option<&CognitiveDelta> {
61 deltas
62 .iter()
63 .filter(|d| d.after > d.before && d.after > threshold)
64 .max_by_key(|d| {
65 (
66 d.after - d.before,
67 d.after,
68 std::cmp::Reverse(d.name.clone()),
69 )
70 })
71}
72
73pub fn format_gate_notice(delta: &CognitiveDelta, threshold: u32) -> String {
75 format!(
76 "[CODE HEALTH] fn {}: cognitive {}->{} (+{}, >{}) — consider extracting helpers",
77 delta.name,
78 delta.before,
79 delta.after,
80 delta.after.saturating_sub(delta.before),
81 threshold
82 )
83}
84
85fn map_by_name(source: &str, ext: &str) -> BTreeMap<String, u32> {
86 let mut map: BTreeMap<String, u32> = BTreeMap::new();
87 if let Some(fns) = cognitive_per_function(source, ext) {
88 for f in fns {
89 let entry = map.entry(f.name).or_insert(0);
90 *entry = (*entry).max(f.cognitive);
91 }
92 }
93 map
94}
95
96#[cfg(all(test, feature = "tree-sitter"))]
97pub mod tests {
98 use super::*;
99
100 #[test]
101 fn reports_increase_for_edited_function() {
102 let old = "fn f(a: bool) { if a {} }";
103 let new = "fn f(a: bool, b: bool) { if a { if b {} } }";
104 let deltas = cognitive_delta(old, new, "rs");
105 assert_eq!(deltas.len(), 1);
106 assert_eq!(deltas[0].name, "f");
107 assert_eq!(deltas[0].before, 1);
108 assert_eq!(deltas[0].after, 3);
109 assert_eq!(deltas[0].increase(), 2);
110 }
111
112 #[test]
113 fn ignores_unchanged_functions() {
114 let src = "fn stable(a: bool) { if a {} }";
115 {
116 let found = &cognitive_delta(src, src, "rs");
117 assert!(found.is_empty(), "{found:?}");
118 }
119 }
120
121 #[test]
122 fn new_function_starts_from_zero() {
123 let old = "fn a() {}";
124 let new = "fn a() {}\nfn b(x: bool) { if x { if x {} } }";
125 let deltas = cognitive_delta(old, new, "rs");
126 let b = deltas.iter().find(|d| d.name == "b").unwrap();
127 assert_eq!(b.before, 0);
128 assert_eq!(b.after, 3);
129 }
130
131 #[test]
132 fn worst_regression_picks_threshold_crosser() {
133 let deltas = vec![
134 CognitiveDelta {
135 name: "small".into(),
136 before: 2,
137 after: 5,
138 },
139 CognitiveDelta {
140 name: "big".into(),
141 before: 10,
142 after: 20,
143 },
144 ];
145 let worst = worst_regression(&deltas, 15).unwrap();
146 assert_eq!(worst.name, "big");
147 assert!(deltas[1].crosses_threshold(15));
148 assert!(!deltas[0].crosses_threshold(15));
149 }
150
151 #[test]
152 fn notice_is_deterministic() {
153 let d = CognitiveDelta {
154 name: "foo".into(),
155 before: 8,
156 after: 16,
157 };
158 let n1 = format_gate_notice(&d, 15);
159 let n2 = format_gate_notice(&d, 15);
160 assert_eq!(n1, n2);
161 assert!(n1.contains("cognitive 8->16 (+8, >15)"));
162 }
163}