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(k) = serde_json::from_str::<Self>(&content) {
197 return Some(k);
198 }
199 }
200
201 let old_hash = crate::core::project_hash::hash_path_only(project_root);
202 if old_hash != hash {
203 crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
204 if let Ok(content) = std::fs::read_to_string(&path)
205 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
206 {
207 k.project_hash = hash;
208 let _ = k.save();
209 return Some(k);
210 }
211 }
212
213 for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
218 if legacy_hash == hash {
219 continue;
220 }
221 crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
222 if let Ok(content) = std::fs::read_to_string(&path)
223 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
224 {
225 k.project_hash = hash;
226 let _ = k.save();
227 return Some(k);
228 }
229 }
230
231 None
232 }
233
234 pub fn load_or_create(project_root: &str) -> Self {
235 Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
236 }
237
238 pub fn migrate_legacy_empty_root(
241 target_root: &str,
242 policy: &MemoryPolicy,
243 ) -> Result<bool, String> {
244 if target_root.trim().is_empty() {
245 return Ok(false);
246 }
247
248 let Some(legacy) = Self::load("") else {
249 return Ok(false);
250 };
251
252 if !legacy.project_root.trim().is_empty() {
253 return Ok(false);
254 }
255 if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
256 return Ok(false);
257 }
258
259 let mut target = Self::load_or_create(target_root);
260
261 fn fact_key(f: &KnowledgeFact) -> String {
262 format!(
263 "{}|{}|{}|{}|{}",
264 f.category, f.key, f.value, f.source_session, f.created_at
265 )
266 }
267 fn pattern_key(p: &ProjectPattern) -> String {
268 format!(
269 "{}|{}|{}|{}",
270 p.pattern_type, p.description, p.source_session, p.created_at
271 )
272 }
273 fn history_key(h: &ConsolidatedInsight) -> String {
274 format!(
275 "{}|{}|{}",
276 h.summary,
277 h.from_sessions.join(","),
278 h.timestamp
279 )
280 }
281
282 let mut seen_facts: std::collections::HashSet<String> =
283 target.facts.iter().map(fact_key).collect();
284 for f in legacy.facts {
285 if seen_facts.insert(fact_key(&f)) {
286 target.facts.push(f);
287 }
288 }
289
290 let mut seen_patterns: std::collections::HashSet<String> =
291 target.patterns.iter().map(pattern_key).collect();
292 for p in legacy.patterns {
293 if seen_patterns.insert(pattern_key(&p)) {
294 target.patterns.push(p);
295 }
296 }
297
298 let mut seen_history: std::collections::HashSet<String> =
299 target.history.iter().map(history_key).collect();
300 for h in legacy.history {
301 if seen_history.insert(history_key(&h)) {
302 target.history.push(h);
303 }
304 }
305
306 target.facts.sort_by(|a, b| {
307 b.created_at
308 .cmp(&a.created_at)
309 .then_with(|| b.confidence.total_cmp(&a.confidence))
310 });
311 if target.facts.len() > policy.knowledge.max_facts {
312 target.facts.truncate(policy.knowledge.max_facts);
313 }
314 target
315 .patterns
316 .sort_by_key(|x| std::cmp::Reverse(x.created_at));
317 if target.patterns.len() > policy.knowledge.max_patterns {
318 target.patterns.truncate(policy.knowledge.max_patterns);
319 }
320 target
321 .history
322 .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
323 if target.history.len() > policy.knowledge.max_history {
324 target.history.truncate(policy.knowledge.max_history);
325 }
326
327 target.updated_at = Utc::now();
328 target.save()?;
329
330 let legacy_hash = crate::core::project_hash::hash_path_only("");
331 let legacy_dir = knowledge_dir(&legacy_hash)?;
332 let legacy_path = legacy_dir.join("knowledge.json");
333 if legacy_path.exists() {
334 let ts = Utc::now().format("%Y%m%d-%H%M%S");
335 let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
336 std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
337 }
338
339 Ok(true)
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use fs2::FileExt;
347
348 #[test]
349 fn file_lock_is_exclusive_across_handles() {
350 let dir = tempfile::tempdir().unwrap();
356 let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
357
358 let second = std::fs::OpenOptions::new()
359 .create(true)
360 .truncate(false)
361 .write(true)
362 .open(dir.path().join(".knowledge.lock"))
363 .unwrap();
364 assert!(
365 second.try_lock_exclusive().is_err(),
366 "a second handle must not acquire the lock while it is held"
367 );
368
369 drop(held);
370 let mut reacquired = false;
376 for _ in 0..50 {
377 if second.try_lock_exclusive().is_ok() {
378 reacquired = true;
379 break;
380 }
381 std::thread::sleep(std::time::Duration::from_millis(10));
382 }
383 assert!(
384 reacquired,
385 "lock must be acquirable within 500ms of release"
386 );
387 }
388
389 #[test]
390 fn write_json_atomic_leaves_valid_file_and_no_temp() {
391 let dir = tempfile::tempdir().unwrap();
392 let path = dir.path().join("knowledge.json");
393 write_json_atomic(dir.path(), &path, "{\"ok\":true}").unwrap();
394 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
395 let leftover = std::fs::read_dir(dir.path())
396 .unwrap()
397 .filter_map(Result::ok)
398 .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
399 assert!(!leftover, "no temp file should remain");
400 }
401}