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.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
48pub 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 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 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}