lean_ctx/core/gotcha_tracker/
persist.rs1use 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 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
53pub 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 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 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}