Skip to main content

lean_ctx/core/gotcha_tracker/
persist.rs

1use super::model::{Gotcha, GotchaStore, MAX_GOTCHAS, MAX_PENDING};
2use std::path::PathBuf;
3
4impl GotchaStore {
5    pub fn load(project_root: &str) -> Self {
6        let capacity = crate::core::config::Config::load()
7            .memory_policy_effective()
8            .map_or(MAX_GOTCHAS, |policy| policy.gotcha.max_gotchas_per_project);
9        let mut store = Self::load_unbounded(project_root);
10        store.capacity = capacity;
11        store
12    }
13
14    fn load_unbounded(project_root: &str) -> Self {
15        let hash = crate::core::project_hash::hash_project_root(project_root);
16        let path = gotcha_path(&hash);
17        // The project's identity changed and moved its hash: the gotchas of
18        // the partition whose knowledge records this path are its own.
19        if !path.exists()
20            && let Some(old) =
21                crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
22        {
23            let _ = crate::core::legacy_adoption::copy_knowledge_partition(&old, project_root);
24        }
25        if let Ok(content) = std::fs::read_to_string(&path)
26            && let Ok(mut store) = serde_json::from_str::<GotchaStore>(&content)
27        {
28            store.project_hash.clone_from(&hash);
29            store.apply_decay();
30            store.cross_session_boost();
31            // #451 shell-ding: keep persisted pending errors so a fail→fix that
32            // spans two `lean-ctx -c` processes still correlates — but drop
33            // expired ones (15-min TTL) and bound to the most recent MAX_PENDING
34            // so a stale file can never grow unbounded or resurrect old errors.
35            store.pending_errors.retain(|p| !p.is_expired());
36            if store.pending_errors.len() > MAX_PENDING {
37                let excess = store.pending_errors.len() - MAX_PENDING;
38                store.pending_errors.drain(0..excess);
39            }
40            return store;
41        }
42        Self::new(&hash)
43    }
44
45    pub fn save(&self, project_root: &str) -> Result<(), String> {
46        let hash = crate::core::project_hash::hash_project_root(project_root);
47        let path = gotcha_path(&hash);
48        if let Some(parent) = path.parent() {
49            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
50        }
51        let tmp = path.with_extension("tmp");
52        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
53        std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
54        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
55        if universal_gotchas_enabled() {
56            let universal = self.extract_universal();
57            if !universal.is_empty() {
58                let _ = save_universal_gotchas(&universal);
59            }
60        }
61        Ok(())
62    }
63}
64
65fn gotcha_path(project_hash: &str) -> PathBuf {
66    crate::core::data_dir::lean_ctx_data_dir()
67        .unwrap_or_else(|_| PathBuf::from("."))
68        .join("knowledge")
69        .join(project_hash)
70        .join("gotchas.json")
71}
72
73// ---------------------------------------------------------------------------
74// Universal gotchas (cross-project)
75// ---------------------------------------------------------------------------
76
77/// `boundary_policy.universal_gotchas_enabled` as configured. Off, no
78/// gotcha leaves its project: none is published to or read from the
79/// cross-project file.
80fn universal_gotchas_enabled() -> bool {
81    crate::core::config::Config::load_arc()
82        .boundary_policy
83        .universal_gotchas_enabled
84}
85
86pub fn load_universal_gotchas() -> Vec<Gotcha> {
87    if !universal_gotchas_enabled() {
88        return Vec::new();
89    }
90    let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() else {
91        return Vec::new();
92    };
93    let path = dir.join("universal-gotchas.json");
94    if let Ok(content) = std::fs::read_to_string(&path) {
95        serde_json::from_str(&content).unwrap_or_default()
96    } else {
97        Vec::new()
98    }
99}
100
101pub fn save_universal_gotchas(gotchas: &[Gotcha]) -> Result<(), String> {
102    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
103    let path = dir.join("universal-gotchas.json");
104    let tmp = path.with_extension("tmp");
105    let json = serde_json::to_string_pretty(gotchas).map_err(|e| e.to_string())?;
106    std::fs::write(&tmp, &json).map_err(|e| e.to_string())?;
107    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
108    Ok(())
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn pending_errors_persist_across_load_for_cross_process_correlation() {
117        // #451 shell-ding: in `lean-ctx -c` mode every command is its own
118        // process, so the fix correlation can only work if pending errors are
119        // persisted. Simulate process 1 (failing build) → process 2 (green run).
120        let _iso = crate::core::data_dir::isolated_data_dir();
121        let root = "/tmp/lean-ctx-gotcha-persist-test-xyzzy";
122
123        let mut p1 = GotchaStore::load(root);
124        assert!(p1.learn_from_shell(
125            "cargo build",
126            "error[E0382]: borrow of moved value: `x`",
127            1,
128            &[],
129            "p1",
130        ));
131        assert_eq!(p1.pending_errors.len(), 1);
132        p1.save(root).unwrap();
133
134        // Fresh process must SEE the persisted pending error (was cleared before).
135        let mut p2 = GotchaStore::load(root);
136        assert_eq!(
137            p2.pending_errors.len(),
138            1,
139            "pending error must survive a reload for cross-process correlation"
140        );
141        let changed = p2.learn_from_shell(
142            "cargo build",
143            "Finished `dev` profile [unoptimized + debuginfo]",
144            0,
145            &["src/main.rs".into()],
146            "p2",
147        );
148        assert!(
149            changed,
150            "green run must correlate the persisted pending error"
151        );
152        assert_eq!(p2.gotchas.len(), 1, "fix correlates across processes");
153        assert_eq!(p2.pending_errors.len(), 0, "pending consumed by the fix");
154    }
155
156    /// AP05-G10: the switch read the built-in default (always on), so a
157    /// configured `universal_gotchas_enabled = false` never stopped gotchas
158    /// from crossing projects.
159    #[test]
160    fn disabled_universal_gotchas_are_neither_read_nor_written() {
161        let _iso = crate::core::data_dir::isolated_data_dir();
162        let config = crate::core::paths::config_dir_read_only().expect("isolated config dir");
163        std::fs::create_dir_all(&config).unwrap();
164        std::fs::write(
165            config.join("config.toml"),
166            "[boundary_policy]\nuniversal_gotchas_enabled = false\n",
167        )
168        .unwrap();
169        let foreign = Gotcha::new(
170            super::super::model::GotchaCategory::Build,
171            super::super::model::GotchaSeverity::Critical,
172            "foreign-project-trigger",
173            "foreign fix",
174            super::super::model::GotchaSource::AgentReported {
175                session_id: "s".into(),
176            },
177            "s",
178        );
179        save_universal_gotchas(std::slice::from_ref(&foreign)).unwrap();
180        assert!(load_universal_gotchas().is_empty());
181    }
182}