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"))]
97mod 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 assert!(cognitive_delta(src, src, "rs").is_empty());
116 }
117
118 #[test]
119 fn new_function_starts_from_zero() {
120 let old = "fn a() {}";
121 let new = "fn a() {}\nfn b(x: bool) { if x { if x {} } }";
122 let deltas = cognitive_delta(old, new, "rs");
123 let b = deltas.iter().find(|d| d.name == "b").unwrap();
124 assert_eq!(b.before, 0);
125 assert_eq!(b.after, 3);
126 }
127
128 #[test]
129 fn worst_regression_picks_threshold_crosser() {
130 let deltas = vec![
131 CognitiveDelta {
132 name: "small".into(),
133 before: 2,
134 after: 5,
135 },
136 CognitiveDelta {
137 name: "big".into(),
138 before: 10,
139 after: 20,
140 },
141 ];
142 let worst = worst_regression(&deltas, 15).unwrap();
143 assert_eq!(worst.name, "big");
144 assert!(deltas[1].crosses_threshold(15));
145 assert!(!deltas[0].crosses_threshold(15));
146 }
147
148 #[test]
149 fn notice_is_deterministic() {
150 let d = CognitiveDelta {
151 name: "foo".into(),
152 before: 8,
153 after: 16,
154 };
155 let n1 = format_gate_notice(&d, 15);
156 let n2 = format_gate_notice(&d, 15);
157 assert_eq!(n1, n2);
158 assert!(n1.contains("cognitive 8->16 (+8, >15)"));
159 }
160}