lean_ctx/core/code_health/
persist.rs1use super::scan::scan_project;
12use super::score::NavigabilityScore;
13use crate::core::graph_index::ProjectIndex;
14use serde::{Deserialize, Serialize};
15use std::path::{Path, PathBuf};
16
17const HEALTH_FILE: &str = "health.json";
18
19const TOP_HOTSPOTS: usize = 10;
21
22const SESSION_HOTSPOTS: usize = 3;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PersistedHealth {
28 pub fingerprint: String,
30 pub threshold: u32,
31 pub naming_count: usize,
32 pub score: NavigabilityScore,
33}
34
35fn health_path(root: &str) -> Option<PathBuf> {
36 Some(ProjectIndex::index_dir(root)?.join(HEALTH_FILE))
37}
38
39pub fn load(root: &str) -> Option<PersistedHealth> {
41 let bytes = std::fs::read(health_path(root)?).ok()?;
42 serde_json::from_slice(&bytes).ok()
43}
44
45fn fingerprint(index: &ProjectIndex) -> String {
48 let mut entries: Vec<String> = index
49 .files
50 .values()
51 .map(|f| format!("{}:{}", f.path, f.hash))
52 .collect();
53 entries.sort();
54 blake3::hash(entries.join("\n").as_bytes())
55 .to_hex()
56 .to_string()
57}
58
59pub fn refresh_if_stale(root: &str, index: &ProjectIndex) {
62 let Some(path) = health_path(root) else {
63 return;
64 };
65 let fp = fingerprint(index);
66 if load(root).is_some_and(|existing| existing.fingerprint == fp) {
67 return;
68 }
69
70 let threshold = crate::core::config::Config::load()
71 .code_health
72 .cognitive_threshold;
73 let health = scan_project(Path::new(root), threshold, None, TOP_HOTSPOTS);
74
75 super::fabric::apply(root, &health);
79
80 let persisted = PersistedHealth {
81 fingerprint: fp,
82 threshold,
83 naming_count: health.naming_count,
84 score: health.score,
85 };
86
87 if let Ok(json) = serde_json::to_vec_pretty(&persisted) {
88 if let Some(parent) = path.parent() {
89 let _ = std::fs::create_dir_all(parent);
90 }
91 let _ = std::fs::write(path, json);
92 }
93}
94
95pub fn format_session_block(root: &str) -> String {
98 load(root)
99 .map(|h| render_session_block(&h))
100 .unwrap_or_default()
101}
102
103fn render_session_block(health: &PersistedHealth) -> String {
106 let s = &health.score;
107 if s.hotspots.is_empty() {
108 return String::new();
109 }
110
111 let mut out = String::from("--- CODE HEALTH (top hotspots) ---\n");
112 out.push_str(&format!(
113 "navigability {}/100 · {} fn over cc>{} · worst {}\n",
114 s.score, s.over_threshold, health.threshold, s.worst_cognitive
115 ));
116 for h in s.hotspots.iter().take(SESSION_HOTSPOTS) {
117 out.push_str(&format!(
118 "- {}:{} {} cc={}\n",
119 h.file, h.line, h.symbol, h.cognitive
120 ));
121 }
122 out.push_str("(ctx_quality / lean-ctx health for full report)\n---");
123 out
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::core::code_health::score::Hotspot;
130
131 fn persisted(hotspots: Vec<Hotspot>) -> PersistedHealth {
132 PersistedHealth {
133 fingerprint: "fp".into(),
134 threshold: 15,
135 naming_count: 0,
136 score: NavigabilityScore {
137 score: 70,
138 total_functions: 20,
139 over_threshold: hotspots.len(),
140 worst_cognitive: hotspots.iter().map(|h| h.cognitive).max().unwrap_or(0),
141 import_cycles: 0,
142 estimated_waste_usd: 0.0,
143 hotspots,
144 },
145 }
146 }
147
148 #[test]
149 fn session_block_empty_when_clean() {
150 assert!(render_session_block(&persisted(Vec::new())).is_empty());
151 }
152
153 #[test]
154 fn session_block_is_deterministic_and_caps_hotspots() {
155 let hs: Vec<Hotspot> = (0..5)
156 .map(|i| Hotspot {
157 file: format!("src/f{i}.rs"),
158 symbol: format!("fn{i}"),
159 line: i + 1,
160 cognitive: 30 - i as u32,
161 })
162 .collect();
163 let ph = persisted(hs);
164
165 let a = render_session_block(&ph);
166 let b = render_session_block(&ph);
167 assert_eq!(a, b, "session block must be byte-stable (#498)");
168 assert!(a.contains("navigability 70/100"));
169 assert_eq!(a.matches("\n- ").count(), SESSION_HOTSPOTS);
171 assert!(a.contains("src/f0.rs:1 fn0 cc=30"));
172 }
173
174 #[test]
175 fn missing_health_yields_empty_block() {
176 let tmp = tempfile::tempdir().unwrap();
177 let root = tmp.path().to_string_lossy().to_string();
178 assert!(format_session_block(&root).is_empty());
179 }
180}