Skip to main content

lean_ctx/core/code_health/
persist.rs

1//! Persisted project health (`health.json`) + stale-guarded background refresh.
2//!
3//! The engine computes the [`NavigabilityScore`] once per index build and stores
4//! it next to the graph index. Session-start and other surfaces then *read* it
5//! (no re-parse), realizing the plan's "compute once, fan-out everywhere".
6//!
7//! The refresh is gated by a fingerprint of the indexed source set, so a touch
8//! with no byte change never triggers a recompute (mirrors the index's own
9//! content-hash reuse).
10
11use 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
19/// Hotspots retained in the persisted score.
20const TOP_HOTSPOTS: usize = 10;
21
22/// Hotspots shown in the (budgeted) session-start block.
23const SESSION_HOTSPOTS: usize = 3;
24
25/// Project health persisted next to the graph index.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PersistedHealth {
28    /// Fingerprint of the indexed source set; recompute only when it changes.
29    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
39/// Load the persisted health for `root`, or `None` when absent/unreadable.
40pub 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
45/// Fingerprint of the indexed file set (path + content hash). Stable + cheap so
46/// it can gate the recompute without re-reading any source.
47fn 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
59/// Recompute + persist health only when the indexed source set changed. Safe to
60/// call from the background indexer (off the hot path); never panics.
61pub 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    // Phase 3: fan the (top-N) hotspots out across BM25 / property graph /
76    // knowledge as a replace-source, so health is queryable + cross-linked and
77    // resolved hotspots are pruned. Done before the score is moved below.
78    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
95/// Compact, deterministic session-start block. Empty when there is no persisted
96/// health or the project is clean (no hotspots), so it never adds noise.
97pub fn format_session_block(root: &str) -> String {
98    load(root)
99        .map(|h| render_session_block(&h))
100        .unwrap_or_default()
101}
102
103/// Pure renderer for the session-start block (deterministic, #498-safe). Empty
104/// when the project is clean.
105fn 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        // Only SESSION_HOTSPOTS lines, sorted worst-first as persisted.
170        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}