Skip to main content

lean_ctx/core/gotcha_tracker/
persist.rs

1use super::model::{Gotcha, GotchaStore, MAX_PENDING};
2use std::path::PathBuf;
3
4impl GotchaStore {
5    pub fn load(project_root: &str) -> Self {
6        let hash = crate::core::project_hash::hash_project_root(project_root);
7        let path = gotcha_path(&hash);
8        if let Ok(content) = std::fs::read_to_string(&path)
9            && let Ok(mut store) = serde_json::from_str::<GotchaStore>(&content)
10        {
11            store.apply_decay();
12            store.cross_session_boost();
13            // #451 shell-ding: keep persisted pending errors so a fail→fix that
14            // spans two `lean-ctx -c` processes still correlates — but drop
15            // expired ones (15-min TTL) and bound to the most recent MAX_PENDING
16            // so a stale file can never grow unbounded or resurrect old errors.
17            store.pending_errors.retain(|p| !p.is_expired());
18            if store.pending_errors.len() > MAX_PENDING {
19                let excess = store.pending_errors.len() - MAX_PENDING;
20                store.pending_errors.drain(0..excess);
21            }
22            return store;
23        }
24        Self::new(&hash)
25    }
26
27    pub fn save(&self, project_root: &str) -> Result<(), String> {
28        let hash = crate::core::project_hash::hash_project_root(project_root);
29        let path = gotcha_path(&hash);
30        if let Some(parent) = path.parent() {
31            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
32        }
33        let tmp = path.with_extension("tmp");
34        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
35        std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
36        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
37        let universal = self.extract_universal();
38        if !universal.is_empty() {
39            let _ = save_universal_gotchas(&universal);
40        }
41        Ok(())
42    }
43}
44
45fn gotcha_path(project_hash: &str) -> PathBuf {
46    crate::core::data_dir::lean_ctx_data_dir()
47        .unwrap_or_else(|_| PathBuf::from("."))
48        .join("knowledge")
49        .join(project_hash)
50        .join("gotchas.json")
51}
52
53// ---------------------------------------------------------------------------
54// Universal gotchas (cross-project)
55// ---------------------------------------------------------------------------
56
57pub fn load_universal_gotchas() -> Vec<Gotcha> {
58    let policy = crate::core::memory_boundary::BoundaryPolicy::default();
59    if !policy.universal_gotchas_enabled {
60        return Vec::new();
61    }
62    let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() else {
63        return Vec::new();
64    };
65    let path = dir.join("universal-gotchas.json");
66    if let Ok(content) = std::fs::read_to_string(&path) {
67        serde_json::from_str(&content).unwrap_or_default()
68    } else {
69        Vec::new()
70    }
71}
72
73pub fn save_universal_gotchas(gotchas: &[Gotcha]) -> Result<(), String> {
74    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
75    let path = dir.join("universal-gotchas.json");
76    let tmp = path.with_extension("tmp");
77    let json = serde_json::to_string_pretty(gotchas).map_err(|e| e.to_string())?;
78    std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
79    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
80    Ok(())
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn pending_errors_persist_across_load_for_cross_process_correlation() {
89        // #451 shell-ding: in `lean-ctx -c` mode every command is its own
90        // process, so the fix correlation can only work if pending errors are
91        // persisted. Simulate process 1 (failing build) → process 2 (green run).
92        let _iso = crate::core::data_dir::isolated_data_dir();
93        let root = "/tmp/lean-ctx-gotcha-persist-test-xyzzy";
94
95        let mut p1 = GotchaStore::load(root);
96        assert!(p1.learn_from_shell(
97            "cargo build",
98            "error[E0382]: borrow of moved value: `x`",
99            1,
100            &[],
101            "p1",
102        ));
103        assert_eq!(p1.pending_errors.len(), 1);
104        p1.save(root).unwrap();
105
106        // Fresh process must SEE the persisted pending error (was cleared before).
107        let mut p2 = GotchaStore::load(root);
108        assert_eq!(
109            p2.pending_errors.len(),
110            1,
111            "pending error must survive a reload for cross-process correlation"
112        );
113        let changed = p2.learn_from_shell(
114            "cargo build",
115            "Finished `dev` profile [unoptimized + debuginfo]",
116            0,
117            &["src/main.rs".into()],
118            "p2",
119        );
120        assert!(
121            changed,
122            "green run must correlate the persisted pending error"
123        );
124        assert_eq!(p2.gotchas.len(), 1, "fix correlates across processes");
125        assert_eq!(p2.pending_errors.len(), 0, "pending consumed by the fix");
126    }
127}