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