lean_ctx/core/gotcha_tracker/
persist.rs1use 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 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 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
73fn 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 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 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 #[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}