lean_ctx/core/knowledge/
persist.rs1use chrono::Utc;
2use std::collections::{HashMap, HashSet};
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 list_project_roots() -> Result<Vec<String>, String> {
88 let base = crate::core::data_dir::lean_ctx_data_dir()?.join("knowledge");
89 if !base.exists() {
90 return Ok(Vec::new());
91 }
92
93 let mut roots = Vec::new();
94 let mut seen = HashSet::new();
95 let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?;
96 for entry in entries.flatten() {
97 let path = entry.path().join("knowledge.json");
98 if !path.is_file() {
99 continue;
100 }
101 let Ok(content) = std::fs::read_to_string(&path) else {
102 continue;
103 };
104 let Ok(knowledge) = serde_json::from_str::<Self>(&content) else {
105 continue;
106 };
107 if seen.insert(knowledge.project_root.clone()) {
108 roots.push(knowledge.project_root);
109 }
110 }
111
112 roots.sort();
113 Ok(roots)
114 }
115
116 pub fn save(&self) -> Result<(), String> {
117 let dir = knowledge_dir(&self.project_hash)?;
118 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
119 #[cfg(unix)]
120 {
121 use std::os::unix::fs::PermissionsExt;
122 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
123 }
124
125 let path = dir.join("knowledge.json");
126 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
127 write_json_atomic(&dir, &path, &json)?;
128 Ok(())
129 }
130
131 pub(crate) fn with_project_lock<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
142 let hash = hash_project_root(project_root);
143 let lock = knowledge_lock(&hash);
144 let _guard = lock
145 .lock()
146 .unwrap_or_else(std::sync::PoisonError::into_inner);
147
148 let _file_lock = match knowledge_dir(&hash) {
152 Ok(dir) => {
153 let _ = std::fs::create_dir_all(&dir);
154 acquire_file_lock(&dir)
155 }
156 Err(_) => None,
157 };
158
159 f()
160 }
161
162 pub fn mutate_locked<T>(
171 project_root: &str,
172 f: impl FnOnce(&mut Self) -> T,
173 ) -> Result<(Self, T), String> {
174 Self::with_project_lock(project_root, || {
175 let mut knowledge = Self::load_or_create(project_root);
176 let out = f(&mut knowledge);
177 knowledge.save()?;
178 Ok((knowledge, out))
179 })
180 }
181
182 pub fn load(project_root: &str) -> Option<Self> {
183 let hash = hash_project_root(project_root);
184 let dir = knowledge_dir(&hash).ok()?;
185 let path = dir.join("knowledge.json");
186
187 if let Ok(content) = std::fs::read_to_string(&path) {
188 let size = content.len();
189 if size > 1_000_000 {
190 tracing::warn!(
191 "knowledge.json is large ({:.1} MB) — recall may be slow. \
192 Consider running ctx_knowledge(action=\"consolidate\") to compact it.",
193 size as f64 / 1_048_576.0,
194 );
195 }
196 if let Ok(mut k) = serde_json::from_str::<Self>(&content) {
197 k.rebuild_index();
198 return Some(k);
199 }
200 }
201
202 let old_hash = crate::core::project_hash::hash_path_only(project_root);
203 if old_hash != hash {
204 crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
205 if let Ok(content) = std::fs::read_to_string(&path)
206 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
207 {
208 k.project_hash = hash;
209 k.rebuild_index();
210 let _ = k.save();
211 return Some(k);
212 }
213 }
214
215 for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
220 if legacy_hash == hash {
221 continue;
222 }
223 crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
224 if let Ok(content) = std::fs::read_to_string(&path)
225 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
226 {
227 k.project_hash = hash;
228 k.rebuild_index();
229 let _ = k.save();
230 return Some(k);
231 }
232 }
233
234 None
235 }
236
237 pub fn load_or_create(project_root: &str) -> Self {
238 Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
239 }
240
241 pub fn migrate_legacy_empty_root(
244 target_root: &str,
245 policy: &MemoryPolicy,
246 ) -> Result<bool, String> {
247 if target_root.trim().is_empty() {
248 return Ok(false);
249 }
250
251 let Some(legacy) = Self::load("") else {
252 return Ok(false);
253 };
254
255 if !legacy.project_root.trim().is_empty() {
256 return Ok(false);
257 }
258 if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
259 return Ok(false);
260 }
261
262 let mut target = Self::load_or_create(target_root);
263
264 fn fact_key(f: &KnowledgeFact) -> String {
265 format!(
266 "{}|{}|{}|{}|{}",
267 f.category, f.key, f.value, f.source_session, f.created_at
268 )
269 }
270 fn pattern_key(p: &ProjectPattern) -> String {
271 format!(
272 "{}|{}|{}|{}",
273 p.pattern_type, p.description, p.source_session, p.created_at
274 )
275 }
276 fn history_key(h: &ConsolidatedInsight) -> String {
277 format!(
278 "{}|{}|{}",
279 h.summary,
280 h.from_sessions.join(","),
281 h.timestamp
282 )
283 }
284
285 let mut seen_facts: std::collections::HashSet<String> =
286 target.facts.iter().map(fact_key).collect();
287 for f in legacy.facts {
288 if seen_facts.insert(fact_key(&f)) {
289 target.facts.push(f);
290 }
291 }
292
293 let mut seen_patterns: std::collections::HashSet<String> =
294 target.patterns.iter().map(pattern_key).collect();
295 for p in legacy.patterns {
296 if seen_patterns.insert(pattern_key(&p)) {
297 target.patterns.push(p);
298 }
299 }
300
301 let mut seen_history: std::collections::HashSet<String> =
302 target.history.iter().map(history_key).collect();
303 for h in legacy.history {
304 if seen_history.insert(history_key(&h)) {
305 target.history.push(h);
306 }
307 }
308
309 target.facts.sort_by(|a, b| {
310 b.created_at
311 .cmp(&a.created_at)
312 .then_with(|| b.confidence.total_cmp(&a.confidence))
313 });
314 if target.facts.len() > policy.knowledge.max_facts {
315 target.facts.truncate(policy.knowledge.max_facts);
316 }
317 target
318 .patterns
319 .sort_by_key(|x| std::cmp::Reverse(x.created_at));
320 if target.patterns.len() > policy.knowledge.max_patterns {
321 target.patterns.truncate(policy.knowledge.max_patterns);
322 }
323 target
324 .history
325 .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
326 if target.history.len() > policy.knowledge.max_history {
327 target.history.truncate(policy.knowledge.max_history);
328 }
329
330 target.updated_at = Utc::now();
331 target.save()?;
332
333 let legacy_hash = crate::core::project_hash::hash_path_only("");
334 let legacy_dir = knowledge_dir(&legacy_hash)?;
335 let legacy_path = legacy_dir.join("knowledge.json");
336 if legacy_path.exists() {
337 let ts = Utc::now().format("%Y%m%d-%H%M%S");
338 let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
339 std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
340 }
341
342 Ok(true)
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use fs2::FileExt;
350
351 #[test]
352 fn file_lock_is_exclusive_across_handles() {
353 let dir = tempfile::tempdir().unwrap();
359 let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
360
361 let second = std::fs::OpenOptions::new()
362 .create(true)
363 .truncate(false)
364 .write(true)
365 .open(dir.path().join(".knowledge.lock"))
366 .unwrap();
367 assert!(
368 second.try_lock_exclusive().is_err(),
369 "a second handle must not acquire the lock while it is held"
370 );
371
372 drop(held);
373 let mut reacquired = false;
379 for _ in 0..50 {
380 if second.try_lock_exclusive().is_ok() {
381 reacquired = true;
382 break;
383 }
384 std::thread::sleep(std::time::Duration::from_millis(10));
385 }
386 assert!(
387 reacquired,
388 "lock must be acquirable within 500ms of release"
389 );
390 }
391
392 #[test]
393 fn write_json_atomic_leaves_valid_file_and_no_temp() {
394 let dir = tempfile::tempdir().unwrap();
395 let path = dir.path().join("knowledge.json");
396 write_json_atomic(dir.path(), &path, "{\"ok\":true}").unwrap();
397 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
398 let leftover = std::fs::read_dir(dir.path())
399 .unwrap()
400 .filter_map(Result::ok)
401 .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
402 assert!(!leftover, "no temp file should remain");
403 }
404
405 #[test]
406 fn load_rebuilds_ephemeral_index_without_changing_json() {
407 let _isolated = crate::core::data_dir::isolated_data_dir();
408 let root = "/tmp/knowledge-index-load";
409 let policy = MemoryPolicy::default();
410 let mut knowledge = ProjectKnowledge::new(root);
411 knowledge.remember(
412 "architecture",
413 "database",
414 "PostgreSQL",
415 "test",
416 0.9,
417 &policy,
418 );
419 knowledge.save().unwrap();
420
421 let loaded = ProjectKnowledge::load(root).expect("saved knowledge should load");
422 assert!(loaded.index.token_positions.contains_key("postgresql"));
423 assert_eq!(loaded.recall("postgresql").len(), 1);
424
425 let json = serde_json::to_string(&loaded).unwrap();
426 assert!(!json.contains("\"index\""));
427 }
428}