lean_ctx/core/knowledge/
persist.rs1use chrono::Utc;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex, OnceLock};
5
6use super::ranking::hash_project_root;
7use super::types::{ConsolidatedInsight, KnowledgeFact, ProjectKnowledge, ProjectPattern};
8use crate::core::memory_policy::MemoryPolicy;
9
10fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
11 Ok(crate::core::data_dir::lean_ctx_data_dir()?
12 .join("knowledge")
13 .join(project_hash))
14}
15
16fn knowledge_lock(project_hash: &str) -> Arc<Mutex<()>> {
22 static KNOWLEDGE_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
23 let map = KNOWLEDGE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
24 let mut guard = map
25 .lock()
26 .unwrap_or_else(std::sync::PoisonError::into_inner);
27 guard
28 .entry(project_hash.to_string())
29 .or_insert_with(|| Arc::new(Mutex::new(())))
30 .clone()
31}
32
33fn acquire_file_lock(dir: &Path) -> Option<std::fs::File> {
40 use fs2::FileExt;
41 let lock_path = dir.join(".knowledge.lock");
42 let file = std::fs::OpenOptions::new()
43 .create(true)
44 .truncate(false)
45 .write(true)
46 .open(&lock_path)
47 .ok()?;
48 #[cfg(unix)]
49 {
50 use std::os::unix::fs::PermissionsExt;
51 let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600));
52 }
53 file.lock_exclusive().ok()?;
56 Some(file)
57}
58
59fn write_json_atomic(dir: &Path, path: &Path, json: &str) -> Result<(), String> {
65 let unique = format!(
66 "knowledge.json.tmp.{}.{}",
67 std::process::id(),
68 std::time::SystemTime::now()
69 .duration_since(std::time::UNIX_EPOCH)
70 .map_or(0, |d| d.as_nanos())
71 );
72 let tmp = dir.join(unique);
73 std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
74 #[cfg(unix)]
75 {
76 use std::os::unix::fs::PermissionsExt;
77 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
78 }
79 if let Err(e) = std::fs::rename(&tmp, path) {
80 let _ = std::fs::remove_file(&tmp);
81 return Err(e.to_string());
82 }
83 Ok(())
84}
85
86impl ProjectKnowledge {
87 pub fn save(&self) -> Result<(), String> {
88 let dir = knowledge_dir(&self.project_hash)?;
89 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
90 #[cfg(unix)]
91 {
92 use std::os::unix::fs::PermissionsExt;
93 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
94 }
95
96 let path = dir.join("knowledge.json");
97 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
98 write_json_atomic(&dir, &path, &json)?;
99 Ok(())
100 }
101
102 pub(crate) fn with_project_lock<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
113 let hash = hash_project_root(project_root);
114 let lock = knowledge_lock(&hash);
115 let _guard = lock
116 .lock()
117 .unwrap_or_else(std::sync::PoisonError::into_inner);
118
119 let _file_lock = match knowledge_dir(&hash) {
123 Ok(dir) => {
124 let _ = std::fs::create_dir_all(&dir);
125 acquire_file_lock(&dir)
126 }
127 Err(_) => None,
128 };
129
130 f()
131 }
132
133 pub fn mutate_locked<T>(
142 project_root: &str,
143 f: impl FnOnce(&mut Self) -> T,
144 ) -> Result<(Self, T), String> {
145 Self::with_project_lock(project_root, || {
146 let mut knowledge = Self::load_or_create(project_root);
147 let out = f(&mut knowledge);
148 knowledge.save()?;
149 Ok((knowledge, out))
150 })
151 }
152
153 pub fn load(project_root: &str) -> Option<Self> {
154 let hash = hash_project_root(project_root);
155 let dir = knowledge_dir(&hash).ok()?;
156 let path = dir.join("knowledge.json");
157
158 if let Ok(content) = std::fs::read_to_string(&path) {
159 let size = content.len();
160 if size > 1_000_000 {
161 tracing::warn!(
162 "knowledge.json is large ({:.1} MB) — recall may be slow. \
163 Consider running ctx_knowledge(action=\"consolidate\") to compact it.",
164 size as f64 / 1_048_576.0,
165 );
166 }
167 if let Ok(k) = serde_json::from_str::<Self>(&content) {
168 return Some(k);
169 }
170 }
171
172 let old_hash = crate::core::project_hash::hash_path_only(project_root);
173 if old_hash != hash {
174 crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
175 if let Ok(content) = std::fs::read_to_string(&path)
176 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
177 {
178 k.project_hash = hash;
179 let _ = k.save();
180 return Some(k);
181 }
182 }
183
184 for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
189 if legacy_hash == hash {
190 continue;
191 }
192 crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
193 if let Ok(content) = std::fs::read_to_string(&path)
194 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
195 {
196 k.project_hash = hash;
197 let _ = k.save();
198 return Some(k);
199 }
200 }
201
202 None
203 }
204
205 pub fn load_or_create(project_root: &str) -> Self {
206 Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
207 }
208
209 pub fn migrate_legacy_empty_root(
212 target_root: &str,
213 policy: &MemoryPolicy,
214 ) -> Result<bool, String> {
215 if target_root.trim().is_empty() {
216 return Ok(false);
217 }
218
219 let Some(legacy) = Self::load("") else {
220 return Ok(false);
221 };
222
223 if !legacy.project_root.trim().is_empty() {
224 return Ok(false);
225 }
226 if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
227 return Ok(false);
228 }
229
230 let mut target = Self::load_or_create(target_root);
231
232 fn fact_key(f: &KnowledgeFact) -> String {
233 format!(
234 "{}|{}|{}|{}|{}",
235 f.category, f.key, f.value, f.source_session, f.created_at
236 )
237 }
238 fn pattern_key(p: &ProjectPattern) -> String {
239 format!(
240 "{}|{}|{}|{}",
241 p.pattern_type, p.description, p.source_session, p.created_at
242 )
243 }
244 fn history_key(h: &ConsolidatedInsight) -> String {
245 format!(
246 "{}|{}|{}",
247 h.summary,
248 h.from_sessions.join(","),
249 h.timestamp
250 )
251 }
252
253 let mut seen_facts: std::collections::HashSet<String> =
254 target.facts.iter().map(fact_key).collect();
255 for f in legacy.facts {
256 if seen_facts.insert(fact_key(&f)) {
257 target.facts.push(f);
258 }
259 }
260
261 let mut seen_patterns: std::collections::HashSet<String> =
262 target.patterns.iter().map(pattern_key).collect();
263 for p in legacy.patterns {
264 if seen_patterns.insert(pattern_key(&p)) {
265 target.patterns.push(p);
266 }
267 }
268
269 let mut seen_history: std::collections::HashSet<String> =
270 target.history.iter().map(history_key).collect();
271 for h in legacy.history {
272 if seen_history.insert(history_key(&h)) {
273 target.history.push(h);
274 }
275 }
276
277 target.facts.sort_by(|a, b| {
278 b.created_at
279 .cmp(&a.created_at)
280 .then_with(|| b.confidence.total_cmp(&a.confidence))
281 });
282 if target.facts.len() > policy.knowledge.max_facts {
283 target.facts.truncate(policy.knowledge.max_facts);
284 }
285 target
286 .patterns
287 .sort_by_key(|x| std::cmp::Reverse(x.created_at));
288 if target.patterns.len() > policy.knowledge.max_patterns {
289 target.patterns.truncate(policy.knowledge.max_patterns);
290 }
291 target
292 .history
293 .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
294 if target.history.len() > policy.knowledge.max_history {
295 target.history.truncate(policy.knowledge.max_history);
296 }
297
298 target.updated_at = Utc::now();
299 target.save()?;
300
301 let legacy_hash = crate::core::project_hash::hash_path_only("");
302 let legacy_dir = knowledge_dir(&legacy_hash)?;
303 let legacy_path = legacy_dir.join("knowledge.json");
304 if legacy_path.exists() {
305 let ts = Utc::now().format("%Y%m%d-%H%M%S");
306 let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
307 std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
308 }
309
310 Ok(true)
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use fs2::FileExt;
318
319 #[test]
320 fn file_lock_is_exclusive_across_handles() {
321 let dir = tempfile::tempdir().unwrap();
327 let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
328
329 let second = std::fs::OpenOptions::new()
330 .create(true)
331 .truncate(false)
332 .write(true)
333 .open(dir.path().join(".knowledge.lock"))
334 .unwrap();
335 assert!(
336 second.try_lock_exclusive().is_err(),
337 "a second handle must not acquire the lock while it is held"
338 );
339
340 drop(held);
341 let mut reacquired = false;
347 for _ in 0..50 {
348 if second.try_lock_exclusive().is_ok() {
349 reacquired = true;
350 break;
351 }
352 std::thread::sleep(std::time::Duration::from_millis(10));
353 }
354 assert!(
355 reacquired,
356 "lock must be acquirable within 500ms of release"
357 );
358 }
359
360 #[test]
361 fn write_json_atomic_leaves_valid_file_and_no_temp() {
362 let dir = tempfile::tempdir().unwrap();
363 let path = dir.path().join("knowledge.json");
364 write_json_atomic(dir.path(), &path, "{\"ok\":true}").unwrap();
365 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
366 let leftover = std::fs::read_dir(dir.path())
367 .unwrap()
368 .filter_map(Result::ok)
369 .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
370 assert!(!leftover, "no temp file should remain");
371 }
372}