1use 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
16pub fn knowledge_dir_of(project_root: &str) -> Result<PathBuf, String> {
18 knowledge_dir(&hash_project_root(project_root))
19}
20
21const KNOWLEDGE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
27
28fn knowledge_lock(project_hash: &str) -> Arc<Mutex<()>> {
34 static KNOWLEDGE_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
35 let map = KNOWLEDGE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
36 let mut guard = map
37 .lock()
38 .unwrap_or_else(std::sync::PoisonError::into_inner);
39 guard
40 .entry(project_hash.to_string())
41 .or_insert_with(|| Arc::new(Mutex::new(())))
42 .clone()
43}
44
45#[cfg(feature = "embeddings")]
52fn acquire_file_lock(dir: &Path) -> Option<std::fs::File> {
53 use fs2::FileExt;
54 let lock_path = dir.join(".knowledge.lock");
55 let file = std::fs::OpenOptions::new()
56 .create(true)
57 .truncate(false)
58 .write(true)
59 .open(&lock_path)
60 .ok()?;
61 #[cfg(unix)]
62 {
63 use std::os::unix::fs::PermissionsExt;
64 let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600));
65 }
66 file.lock_exclusive().ok()?;
69 Some(file)
70}
71
72fn write_json_atomic(path: &Path, json: &str) -> Result<(), String> {
79 #[cfg(unix)]
80 let owner_only: Option<std::fs::Permissions> =
81 Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
82 #[cfg(not(unix))]
83 let owner_only: Option<std::fs::Permissions> = None;
84 crate::core::atomic_fs::try_atomic_write(path, json.as_bytes(), owner_only.as_ref())
85 .map_err(|_| "knowledge publication failed".to_string())
86}
87
88fn parse_store_cached(path: &Path) -> Result<ProjectKnowledge, &'static str> {
95 type Key = (u64, Option<std::time::SystemTime>, u64);
96 static PARSED: OnceLock<Mutex<HashMap<PathBuf, (Key, ProjectKnowledge)>>> = OnceLock::new();
97 const KEPT: usize = 8;
98
99 let metadata =
100 std::fs::symlink_metadata(path).map_err(|_| "knowledge input metadata unavailable")?;
101 if !metadata.is_file() {
102 return Err("knowledge input is unavailable or uninspectable");
103 }
104 #[cfg(unix)]
105 let inode = std::os::unix::fs::MetadataExt::ino(&metadata);
106 #[cfg(not(unix))]
107 let inode = 0;
108 let key: Key = (metadata.len(), metadata.modified().ok(), inode);
109 let cache = PARSED.get_or_init(Mutex::default);
110 if let Some((cached_key, knowledge)) = cache
111 .lock()
112 .unwrap_or_else(std::sync::PoisonError::into_inner)
113 .get(path)
114 && *cached_key == key
115 {
116 return Ok(knowledge.clone());
117 }
118 let text = crate::core::policy::diagnostics::Target::read_store_file(path)
119 .ok_or("knowledge input is unavailable or uninspectable")?;
120 let knowledge: ProjectKnowledge =
121 serde_json::from_str(&text).map_err(|_| "knowledge input is invalid")?;
122 let mut parsed = cache
123 .lock()
124 .unwrap_or_else(std::sync::PoisonError::into_inner);
125 if parsed.len() >= KEPT && !parsed.contains_key(path) {
126 parsed.clear();
127 }
128 parsed.insert(path.to_path_buf(), (key, knowledge.clone()));
129 Ok(knowledge)
130}
131
132fn store_is_policy_safe(
137 store: &serde_json::Value,
138 policy: &crate::core::policy::runtime::ActivePolicy,
139) -> bool {
140 let unchanged = |value: &serde_json::Value| {
141 crate::core::policy::diagnostics::inspect(value, Some(policy)).as_ref() == Some(value)
142 };
143 match store {
144 serde_json::Value::Object(fields) => fields.values().all(|field| match field {
145 serde_json::Value::Array(records) => records.iter().all(unchanged),
146 other => unchanged(other),
147 }),
148 other => unchanged(other),
149 }
150}
151
152pub(crate) const MACHINE_DERIVED_PREFIX: &str = "auto:";
159
160pub fn is_machine_derived(category: &str, key: &str) -> bool {
162 category.trim_start().starts_with(MACHINE_DERIVED_PREFIX)
163 || key.trim_start().starts_with(MACHINE_DERIVED_PREFIX)
164}
165
166pub(crate) fn machine_derived_writes_allowed() -> bool {
182 crate::core::auto_capture::is_enabled()
183}
184
185impl ProjectKnowledge {
186 pub fn add_fact(&mut self, mut fact: KnowledgeFact) -> bool {
189 let trimmed_cat = fact.category.trim().to_string();
190 fact.category = trimmed_cat;
191 let trimmed_val = fact.value.trim().to_string();
192 fact.value = trimmed_val;
193 if fact.category.is_empty() || fact.value.is_empty() {
194 return false;
195 }
196 if is_machine_derived(&fact.category, &fact.key) && !machine_derived_writes_allowed() {
200 return false;
201 }
202 let Some([category, key, value]) = super::core::admit_fact_fields(
204 &self.project_root,
205 [&fact.category, &fact.key, &fact.value],
206 ) else {
207 return false;
208 };
209 (fact.category, fact.key, fact.value) = (category, key, value);
210
211 if let Some(existing) = self.facts.iter_mut().find(|existing| {
212 existing.category == fact.category
213 && existing.value == fact.value
214 && existing.origin == fact.origin
215 }) {
216 if fact.last_confirmed > existing.last_confirmed {
217 existing.last_confirmed = fact.last_confirmed;
218 }
219 existing.confidence = existing.confidence.max(fact.confidence);
220 existing.confirmation_count = existing.confirmation_count.saturating_add(1);
221 existing.update_fidelity();
222 } else {
223 fact.update_fidelity();
224 self.facts.push(fact);
225 self.rebuild_index();
226 }
227
228 self.updated_at = Utc::now();
229 true
230 }
231
232 pub fn recent_decisions(&self, limit: usize) -> Vec<KnowledgeFact> {
233 let mut decisions: Vec<KnowledgeFact> = self
234 .facts
235 .iter()
236 .filter(|fact| {
237 let category = fact.category.to_ascii_lowercase();
238 fact.is_current()
239 && (category.contains("decision")
240 || category.contains("architecture")
241 || category.contains("solution"))
242 })
243 .cloned()
244 .collect();
245 decisions.sort_by(|a, b| {
246 b.last_confirmed
247 .cmp(&a.last_confirmed)
248 .then_with(|| b.created_at.cmp(&a.created_at))
249 });
250 decisions.truncate(limit);
251 decisions
252 }
253 pub fn search_semantic(&self, query: &str, limit: usize) -> Vec<KnowledgeFact> {
258 let query_lower = query.to_lowercase();
259 let terms: Vec<&str> = query_lower.split_whitespace().collect();
260 if terms.is_empty() {
261 return Vec::new();
262 }
263 let mut scored: Vec<(usize, &KnowledgeFact)> = self
264 .facts
265 .iter()
266 .filter(|f| f.valid_until.is_none_or(|t| t > chrono::Utc::now()))
267 .map(|fact| {
268 let haystack = format!(
269 "{} {} {}",
270 fact.category.to_lowercase(),
271 fact.value.to_lowercase(),
272 fact.key.to_lowercase(),
273 );
274 let score = terms.iter().filter(|t| haystack.contains(*t)).count();
275 (score, fact)
276 })
277 .filter(|(score, _)| *score > 0)
278 .collect();
279 scored.sort_by_key(|a| std::cmp::Reverse(a.0));
280 scored
281 .into_iter()
282 .take(limit)
283 .map(|(_, f)| f.clone())
284 .collect()
285 }
286
287 pub fn list_project_roots() -> Result<Vec<String>, String> {
288 let base = crate::core::data_dir::lean_ctx_data_dir()?.join("knowledge");
289 if !base.exists() {
290 return Ok(Vec::new());
291 }
292
293 let mut roots = Vec::new();
294 let mut seen = HashSet::new();
295 let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?;
296 for entry in entries.flatten() {
297 let path = entry.path().join("knowledge.json");
298 if !path.is_file() {
299 continue;
300 }
301 let Ok(content) = std::fs::read_to_string(&path) else {
302 continue;
303 };
304 let Ok(knowledge) = serde_json::from_str::<Self>(&content) else {
305 continue;
306 };
307 if seen.insert(knowledge.project_root.clone()) {
308 roots.push(knowledge.project_root);
309 }
310 }
311
312 roots.sort();
313 Ok(roots)
314 }
315
316 pub fn load_for_admission(
319 project_root: &str,
320 admission: &super::protection::KnowledgeAdmission,
321 ) -> Result<Option<Self>, String> {
322 let hash = hash_project_root(project_root);
323 let path = knowledge_dir(&hash)?.join("knowledge.json");
324 match std::fs::symlink_metadata(&path) {
325 Ok(metadata) if metadata.is_file() => {}
326 Ok(_) => return Err("knowledge store is not a regular file".into()),
327 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
328 let _ = Self::load_for_checked_capture(project_root)?;
331 admission.authorize_gateway()?;
332 return Ok(None);
333 }
334 Err(_) => return Err("knowledge store metadata unavailable".into()),
335 }
336 let raw = Self::load_for_checked_capture(project_root)?;
337 let view = admission.view(&raw)?.admit_sources();
338 admission.authorize_gateway()?;
339 Ok(Some(view))
340 }
341
342 pub fn load_for_checked_capture(project_root: &str) -> Result<Self, String> {
345 let hash = hash_project_root(project_root);
346 let path = knowledge_dir(&hash)?.join("knowledge.json");
347 match std::fs::symlink_metadata(&path) {
348 Ok(_) => {
349 let mut knowledge = parse_store_cached(&path)?;
350 let canonical = |root: &str| {
351 crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000)
352 };
353 if canonical(&knowledge.project_root) != canonical(project_root) {
357 return Err("knowledge input belongs to a different project".into());
358 }
359 knowledge.project_hash = hash;
360 knowledge.rebuild_index();
361 return Ok(knowledge);
362 }
363 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
364 Err(_) => return Err("knowledge input metadata unavailable".into()),
365 }
366 let mut legacy = crate::core::project_hash::legacy_unnormalized_hashes(project_root);
367 legacy.push(crate::core::project_hash::hash_path_only(project_root));
368 for old_hash in legacy.into_iter().filter(|old| old != &hash) {
369 match std::fs::symlink_metadata(knowledge_dir(&old_hash)?.join("knowledge.json")) {
370 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
371 _ => return Err("knowledge requires an explicit legacy migration".into()),
372 }
373 }
374 Ok(Self::new(project_root))
375 }
376
377 pub(crate) fn save_policy_checked(
378 &self,
379 source_tool: &str,
380 original_content: &str,
381 ) -> Result<(), String> {
382 if let Some(policy) =
383 crate::core::policy::runtime::for_project(Path::new(&self.project_root))?
384 {
385 if !policy.tool_allowed("ctx_knowledge")
386 || !policy.tool_allowed(source_tool)
387 || crate::core::policy::content::evaluate_text(original_content, &policy).blocked
388 {
389 return Err("knowledge persistence withheld by policy".into());
390 }
391 let original =
392 serde_json::to_value(self).map_err(|_| "knowledge is not inspectable")?;
393 if !store_is_policy_safe(&original, &policy) {
394 return Err("knowledge persistence requires policy-safe fields".into());
398 }
399 }
400 self.save_unlocked()
402 }
403
404 pub fn save(&self) -> Result<(), String> {
405 let admission = super::protection::KnowledgeAdmission::capture(&self.project_root)?;
406 if Path::new(&self.project_root).is_dir() {
407 crate::core::policy::runtime::with_explicit_project_source_view(
408 Path::new(&self.project_root),
409 |authority| {
410 Self::with_project_lock_checked(&self.project_root, || {
411 admission.authorize_publication(authority)?;
412 self.save_with_admission(&admission)
413 })?
414 },
415 )?
416 } else {
417 if crate::core::policy::runtime::inherited_source_view().is_some() {
418 return Err("knowledge project root unavailable".into());
419 }
420 Self::with_project_lock_checked(&self.project_root, || {
421 self.save_with_admission(&admission)
422 })?
423 }
424 }
425
426 fn save_unlocked(&self) -> Result<(), String> {
427 let admission = super::protection::KnowledgeAdmission::capture(&self.project_root)?;
428 self.save_with_admission(&admission)
429 }
430
431 fn save_with_admission(
432 &self,
433 admission: &super::protection::KnowledgeAdmission,
434 ) -> Result<(), String> {
435 let candidate = if self.withheld.is_empty() {
436 self.clone()
437 } else {
438 let existing = Self::load_for_checked_capture(&self.project_root)?;
439 self.complete_for_storage(&existing)?
440 };
441 candidate.save_complete_with_admission(admission)
442 }
443
444 fn save_complete_with_admission(
445 &self,
446 admission: &super::protection::KnowledgeAdmission,
447 ) -> Result<(), String> {
448 if self.project_hash != hash_project_root(&self.project_root) {
449 return Err("knowledge storage identity mismatch".into());
450 }
451 let existing = Self::load_for_checked_capture(&self.project_root)?;
452 admission.require_safe(&existing)?;
453 admission.require_safe(self)?;
454
455 let dir = knowledge_dir(&self.project_hash)?;
456 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
457 #[cfg(unix)]
458 {
459 use std::os::unix::fs::PermissionsExt;
460 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
461 }
462
463 let path = dir.join("knowledge.json");
464 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
465 admission.authorize_gateway()?;
466 write_json_atomic(&path, &json)?;
467 super::journal::append(
470 &dir,
471 &super::journal::diff(&existing.facts, &self.facts),
472 &Utc::now().to_rfc3339(),
473 );
474 Ok(())
475 }
476
477 #[cfg(feature = "embeddings")]
488 pub fn with_project_lock<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
489 let hash = hash_project_root(project_root);
490 let lock = knowledge_lock(&hash);
491 let _guard = lock
492 .lock()
493 .unwrap_or_else(std::sync::PoisonError::into_inner);
494
495 let _file_lock = match knowledge_dir(&hash) {
499 Ok(dir) => {
500 let _ = std::fs::create_dir_all(&dir);
501 acquire_file_lock(&dir)
502 }
503 Err(_) => None,
504 };
505
506 f()
507 }
508
509 pub(crate) fn with_project_lock_checked<T>(
518 project_root: &str,
519 f: impl FnOnce() -> T,
520 ) -> Result<T, String> {
521 use fs2::FileExt;
522 let hash = hash_project_root(project_root);
523 let lock = knowledge_lock(&hash);
524 let deadline = std::time::Instant::now() + KNOWLEDGE_LOCK_WAIT;
525 let _guard = loop {
526 match lock.try_lock() {
527 Ok(guard) => break guard,
528 Err(std::sync::TryLockError::WouldBlock)
529 if std::time::Instant::now() < deadline =>
530 {
531 std::thread::sleep(std::time::Duration::from_millis(5));
532 }
533 Err(_) => return Err("knowledge lock unavailable".into()),
534 }
535 };
536 let dir = knowledge_dir(&hash)?;
537 std::fs::create_dir_all(&dir).map_err(|_| "knowledge directory unavailable")?;
538 let file = crate::core::policy::diagnostics::open_append(&dir.join(".knowledge.lock"))
539 .map_err(|_| "knowledge file lock unavailable")?;
540 loop {
541 match file.try_lock_exclusive() {
542 Ok(()) => break,
543 Err(e)
544 if crate::core::file_lock::is_contended(&e)
545 && std::time::Instant::now() < deadline =>
546 {
547 std::thread::sleep(std::time::Duration::from_millis(5));
548 }
549 Err(_) => return Err("knowledge file lock unavailable".into()),
550 }
551 }
552 let output = f();
553 let _ = FileExt::unlock(&file);
554 Ok(output)
555 }
556
557 pub fn mutate_locked<T>(
558 project_root: &str,
559 f: impl FnOnce(&mut Self) -> T,
560 ) -> Result<(Self, T), String> {
561 let admission = super::protection::KnowledgeAdmission::capture(project_root)?;
562 Self::mutate_locked_with_admission(project_root, &admission, f)
563 }
564
565 pub fn mutate_locked_with_admission<T>(
566 project_root: &str,
567 admission: &super::protection::KnowledgeAdmission,
568 f: impl FnOnce(&mut Self) -> T,
569 ) -> Result<(Self, T), String> {
570 crate::core::providers::provenance::with_reuse_deadline(|| {
571 Self::mutate_locked_in_scope(project_root, admission, f)
572 })
573 }
574
575 fn mutate_locked_in_scope<T>(
576 project_root: &str,
577 admission: &super::protection::KnowledgeAdmission,
578 f: impl FnOnce(&mut Self) -> T,
579 ) -> Result<(Self, T), String> {
580 if Path::new(project_root).is_dir() {
581 crate::core::policy::runtime::with_explicit_project_source_view(
582 Path::new(project_root),
583 |authority| Self::mutate_locked_scoped(project_root, admission, Some(authority), f),
584 )?
585 } else {
586 if crate::core::policy::runtime::inherited_source_view().is_some() {
587 return Err("knowledge project root unavailable".into());
588 }
589 if let Some(request) = crate::core::policy::diagnostics::request_project()
590 && crate::core::pathutil::safe_canonicalize_bounded(&request, 2000)
591 != crate::core::pathutil::safe_canonicalize_bounded(
592 Path::new(project_root),
593 2000,
594 )
595 {
596 return Err("knowledge project does not match protected request".into());
597 }
598 Self::mutate_locked_scoped(project_root, admission, None, f)
599 }
600 }
601
602 fn mutate_locked_scoped<T>(
603 project_root: &str,
604 admission: &super::protection::KnowledgeAdmission,
605 authority: Option<&crate::core::policy::runtime::PublicationAuthority>,
606 f: impl FnOnce(&mut Self) -> T,
607 ) -> Result<(Self, T), String> {
608 Self::with_project_lock_checked(project_root, || {
609 let current = knowledge_dir(&hash_project_root(project_root))?.join("knowledge.json");
613 if !current.exists()
614 && let Some(old) =
615 crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
616 {
617 crate::core::legacy_adoption::copy_knowledge_partition(&old, project_root)?;
618 }
619 let raw = Self::load_for_checked_capture(project_root)?;
620 admission.require_safe(&raw)?;
621 let mut knowledge = raw.admit_sources();
622 let out = f(&mut knowledge);
623 admission.require_safe(&knowledge)?;
624 admission.authorize_gateway()?;
625 if let Some(authority) = authority {
626 authority.verify()?;
627 }
628 knowledge.save_with_admission(admission)?;
629 Ok((knowledge.admit_sources(), out))
630 })?
631 }
632
633 pub fn load(project_root: &str) -> Option<Self> {
634 let admission = super::protection::KnowledgeAdmission::capture(project_root).ok()?;
635 let legacy_source_safe = |legacy_hash: &str| {
636 let Ok(path) = knowledge_dir(legacy_hash) else {
637 return false;
638 };
639 let path = path.join("knowledge.json");
640 match std::fs::symlink_metadata(&path) {
641 Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
642 Err(_) => false,
643 Ok(_) => {
644 let Some(content) =
645 crate::core::policy::diagnostics::Target::read_store_file(&path)
646 else {
647 return false;
648 };
649 let Ok(legacy) = serde_json::from_str::<Self>(&content) else {
650 return false;
651 };
652 crate::core::pathutil::safe_canonicalize_bounded(
653 Path::new(&legacy.project_root),
654 2000,
655 ) == crate::core::pathutil::safe_canonicalize_bounded(
656 Path::new(project_root),
657 2000,
658 ) && admission.require_safe(&legacy).is_ok()
659 }
660 }
661 };
662 match super::protection::current(project_root) {
663 Ok(Some(_)) => {
664 let path = knowledge_dir(&hash_project_root(project_root))
665 .ok()?
666 .join("knowledge.json");
667 std::fs::symlink_metadata(path).ok()?;
668 let raw = Self::load_for_checked_capture(project_root).ok()?;
669 return admission.view(&raw).ok().map(Self::admit_sources);
670 }
671 Err(_) => return None,
672 Ok(None) => {}
673 }
674 let hash = hash_project_root(project_root);
675 let dir = knowledge_dir(&hash).ok()?;
676 let path = dir.join("knowledge.json");
677
678 if let Ok(mut k) = parse_store_cached(&path) {
679 if crate::core::pathutil::safe_canonicalize_bounded(Path::new(&k.project_root), 2000)
682 != crate::core::pathutil::safe_canonicalize_bounded(Path::new(project_root), 2000)
683 {
684 return None;
685 }
686 k.project_hash = hash;
687 k.rebuild_index();
688 return admission.view(&k).ok().map(Self::admit_sources);
689 }
690
691 let old_hash = crate::core::project_hash::hash_path_only(project_root);
692 if old_hash != hash {
693 if !legacy_source_safe(&old_hash) || admission.authorize_gateway().is_err() {
694 return None;
695 }
696 crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
697 if let Ok(content) = std::fs::read_to_string(&path)
698 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
699 {
700 k.project_hash = hash;
701 k.rebuild_index();
702 if admission.require_safe(&k).is_err()
703 || admission.authorize_gateway().is_err()
704 || k.save_with_admission(&admission).is_err()
705 {
706 return None;
707 }
708 return admission.view(&k).ok().map(Self::admit_sources);
709 }
710 }
711
712 for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
717 if legacy_hash == hash {
718 continue;
719 }
720 if !legacy_source_safe(&legacy_hash) || admission.authorize_gateway().is_err() {
721 return None;
722 }
723 crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
724 if let Ok(content) = std::fs::read_to_string(&path)
725 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
726 {
727 k.project_hash = hash;
728 k.rebuild_index();
729 if admission.require_safe(&k).is_err()
730 || admission.authorize_gateway().is_err()
731 || k.save_with_admission(&admission).is_err()
732 {
733 return None;
734 }
735 return admission.view(&k).ok().map(Self::admit_sources);
736 }
737 }
738
739 if let Some(old_hash) =
744 crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
745 {
746 if !legacy_source_safe(&old_hash) || admission.authorize_gateway().is_err() {
747 return None;
748 }
749 crate::core::legacy_adoption::copy_knowledge_partition(&old_hash, project_root).ok()?;
750 if let Ok(content) = std::fs::read_to_string(&path)
751 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
752 && crate::core::pathutil::safe_canonicalize_bounded(
753 Path::new(&k.project_root),
754 2000,
755 ) == crate::core::pathutil::safe_canonicalize_bounded(
756 Path::new(project_root),
757 2000,
758 )
759 {
760 k.project_hash = hash;
761 k.rebuild_index();
762 if admission.require_safe(&k).is_err()
763 || admission.authorize_gateway().is_err()
764 || k.save_with_admission(&admission).is_err()
765 {
766 return None;
767 }
768 return admission.view(&k).ok().map(Self::admit_sources);
769 }
770 }
771
772 None
773 }
774
775 pub fn load_or_create(project_root: &str) -> Self {
776 Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
777 }
778
779 pub fn migrate_legacy_empty_root(
782 target_root: &str,
783 policy: &MemoryPolicy,
784 ) -> Result<bool, String> {
785 if target_root.trim().is_empty() {
786 return Ok(false);
787 }
788
789 if super::protection::current(target_root)?.is_some() {
790 return Err("unscoped legacy knowledge requires explicit protected migration".into());
791 }
792 let mut legacy_store = None;
795 for hash in [
796 hash_project_root(""),
797 crate::core::project_hash::hash_path_only(""),
798 ] {
799 let path = knowledge_dir(&hash)?.join("knowledge.json");
800 match std::fs::symlink_metadata(&path) {
801 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
802 Err(_) => return Err("legacy knowledge input unavailable".into()),
803 Ok(_) => {}
804 }
805 let text = crate::core::policy::diagnostics::Target::read_store_file(&path)
806 .ok_or("legacy knowledge input is uninspectable")?;
807 let legacy: Self =
808 serde_json::from_str(&text).map_err(|_| "invalid legacy knowledge")?;
809 legacy_store = Some((legacy, path));
810 break;
811 }
812 let Some((legacy, legacy_path)) = legacy_store else {
813 return Ok(false);
814 };
815
816 if !legacy.project_root.trim().is_empty() {
817 return Ok(false);
818 }
819 if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
820 return Ok(false);
821 }
822
823 Self::with_project_lock_checked(target_root, || {
824 if super::protection::current(target_root)?.is_some() {
825 return Err("legacy migration withheld by changed project policy".into());
826 }
827 let mut target = Self::load_or_create(target_root);
828
829 fn fact_key(f: &KnowledgeFact) -> String {
830 format!(
831 "{}|{}|{}|{}|{}",
832 f.category, f.key, f.value, f.source_session, f.created_at
833 )
834 }
835 fn pattern_key(p: &ProjectPattern) -> String {
836 format!(
837 "{}|{}|{}|{}",
838 p.pattern_type, p.description, p.source_session, p.created_at
839 )
840 }
841 fn history_key(h: &ConsolidatedInsight) -> String {
842 format!(
843 "{}|{}|{}",
844 h.summary,
845 h.from_sessions.join(","),
846 h.timestamp
847 )
848 }
849
850 let mut seen_facts: std::collections::HashSet<String> =
851 target.facts.iter().map(fact_key).collect();
852 for f in legacy.facts {
853 if seen_facts.insert(fact_key(&f)) {
854 target.facts.push(f);
855 }
856 }
857
858 let mut seen_patterns: std::collections::HashSet<String> =
859 target.patterns.iter().map(pattern_key).collect();
860 for p in legacy.patterns {
861 if seen_patterns.insert(pattern_key(&p)) {
862 target.patterns.push(p);
863 }
864 }
865
866 let mut seen_history: std::collections::HashSet<String> =
867 target.history.iter().map(history_key).collect();
868 for h in legacy.history {
869 if seen_history.insert(history_key(&h)) {
870 target.history.push(h);
871 }
872 }
873
874 target.facts.sort_by(|a, b| {
875 b.created_at
876 .cmp(&a.created_at)
877 .then_with(|| b.confidence.total_cmp(&a.confidence))
878 });
879 target
880 .patterns
881 .sort_by_key(|x| std::cmp::Reverse(x.created_at));
882 if target.patterns.len() > policy.knowledge.max_patterns {
883 target.patterns.truncate(policy.knowledge.max_patterns);
884 }
885 target
886 .history
887 .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
888 if target.history.len() > policy.knowledge.max_history {
889 target.history.truncate(policy.knowledge.max_history);
890 }
891
892 target.updated_at = Utc::now();
893 if super::protection::current(target_root)?.is_some() {
894 return Err("legacy migration withheld by changed project policy".into());
895 }
896 target.save_unlocked()?;
897
898 let legacy_dir = legacy_path.parent().ok_or("legacy directory unavailable")?;
899 if legacy_path.exists() {
900 let ts = Utc::now().format("%Y%m%d-%H%M%S");
901 let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
902 std::fs::hard_link(&legacy_path, &backup)
903 .map_err(|_| "legacy backup could not be preserved")?;
904 std::fs::remove_file(&legacy_path)
905 .map_err(|_| "legacy backup retained but source cleanup failed")?;
906 }
907
908 Ok(true)
909 })?
910 }
911}
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916 #[cfg(feature = "embeddings")]
917 use fs2::FileExt;
918
919 fn machine_fact(category: &str, key: &str) -> KnowledgeFact {
922 let now = chrono::Utc::now();
923 KnowledgeFact {
924 anchors: Vec::new(),
925 review: None,
926 capture: Default::default(),
927 origin: crate::core::knowledge::FactOrigin::Local,
928 category: category.to_string(),
929 key: key.to_string(),
930 value: "Finding: run-guards.ts: Read run-guards.ts (92L)".to_string(),
931 source_session: "session-1".to_string(),
932 confidence: 0.8,
933 created_at: now,
934 last_confirmed: now,
935 retrieval_count: 0,
936 last_retrieved: None,
937 valid_from: None,
938 valid_until: None,
939 supersedes: None,
940 confirmation_count: 1,
941 feedback_up: 0,
942 feedback_down: 0,
943 last_feedback: None,
944 privacy: Default::default(),
945 sensitivity: Default::default(),
946 imported_from: None,
947 archetype: Default::default(),
948 fidelity: None,
949 revision_count: 0,
950 status: None,
951 validity: None,
952 }
953 }
954
955 #[test]
956 fn machine_derived_is_recognised_in_either_ingestion_shape() {
957 assert!(is_machine_derived("auto:blocker", "run-guards.ts"));
960 assert!(is_machine_derived("blocker", "auto:config.rs"));
961 assert!(!is_machine_derived("konventionen", "naming"));
962 assert!(!is_machine_derived("ops", "deploy"));
963 }
964
965 #[test]
966 fn a_curated_fact_is_never_affected_by_the_gate() {
967 let _data = crate::core::data_dir::isolated_data_dir();
972 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
973 let knowledge_root = tempfile::tempdir().unwrap();
974 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
975 assert!(
976 knowledge.add_fact(machine_fact("konventionen", "naming")),
977 "curated facts must always be accepted"
978 );
979 }
980
981 #[test]
982 fn disabled_capture_blocks_every_machine_derived_shape() {
983 let _env = crate::core::data_dir::test_env_lock();
987 crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "0");
988
989 let knowledge_root = tempfile::tempdir().unwrap();
990
991 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
992 let category_marked = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
993 let key_marked = knowledge.add_fact(machine_fact("blocker", "auto:config.rs"));
994
995 crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
996
997 assert!(!category_marked, "auto:* category must be refused");
998 assert!(!key_marked, "auto:* key must be refused");
999 assert!(
1000 knowledge.facts.is_empty(),
1001 "nothing machine-derived may reach the durable store"
1002 );
1003 }
1004
1005 #[test]
1006 fn enabled_capture_still_stores_machine_derived_facts() {
1007 let _env = crate::core::data_dir::test_env_lock();
1008 crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "1");
1009
1010 let knowledge_root = tempfile::tempdir().unwrap();
1011
1012 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
1013 let stored = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1014
1015 crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
1016
1017 assert!(stored, "the default behaviour must be unchanged");
1018 }
1019
1020 #[test]
1021 fn disabled_capture_does_not_refresh_existing_machine_facts() {
1022 let _env = crate::core::data_dir::test_env_lock();
1027 crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "1");
1028 let knowledge_root = tempfile::tempdir().unwrap();
1029 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
1030 knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1031 let confirmed_while_enabled = knowledge.facts[0].last_confirmed;
1032
1033 crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "0");
1034 let refreshed = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1035 crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
1036
1037 assert!(!refreshed);
1038 assert_eq!(knowledge.facts.len(), 1);
1039 assert_eq!(
1040 knowledge.facts[0].last_confirmed, confirmed_while_enabled,
1041 "a refused write must not refresh the existing fact"
1042 );
1043 }
1044
1045 #[cfg(feature = "embeddings")]
1046 #[test]
1047 fn file_lock_is_exclusive_across_handles() {
1048 let dir = tempfile::tempdir().unwrap();
1054 let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
1055
1056 let second = std::fs::OpenOptions::new()
1057 .create(true)
1058 .truncate(false)
1059 .write(true)
1060 .open(dir.path().join(".knowledge.lock"))
1061 .unwrap();
1062 assert!(
1063 second.try_lock_exclusive().is_err(),
1064 "a second handle must not acquire the lock while it is held"
1065 );
1066
1067 drop(held);
1068 let mut reacquired = false;
1074 for _ in 0..50 {
1075 if second.try_lock_exclusive().is_ok() {
1076 reacquired = true;
1077 break;
1078 }
1079 std::thread::sleep(std::time::Duration::from_millis(10));
1080 }
1081 assert!(
1082 reacquired,
1083 "lock must be acquirable within 500ms of release"
1084 );
1085 }
1086
1087 #[test]
1088 fn write_json_atomic_leaves_valid_file_and_no_temp() {
1089 let dir = tempfile::tempdir().unwrap();
1090 let path = dir.path().join("knowledge.json");
1091 write_json_atomic(&path, "{\"ok\":true}").unwrap();
1092 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
1093 let leftover = std::fs::read_dir(dir.path())
1094 .unwrap()
1095 .filter_map(Result::ok)
1096 .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
1097 assert!(!leftover, "no temp file should remain");
1098 }
1099
1100 #[test]
1101 fn load_rebuilds_ephemeral_index_without_changing_json() {
1102 let _isolated = crate::core::data_dir::isolated_data_dir();
1103 let project = tempfile::tempdir().unwrap();
1104 let root = project.path().to_str().unwrap();
1105 let policy = MemoryPolicy::default();
1106 let mut knowledge = ProjectKnowledge::new(root);
1107 knowledge.remember(
1108 "architecture",
1109 "database",
1110 "PostgreSQL",
1111 "test",
1112 0.9,
1113 &policy,
1114 );
1115 knowledge.save().unwrap();
1116
1117 let loaded = ProjectKnowledge::load(root).expect("saved knowledge should load");
1118 assert!(loaded.index.token_positions.contains_key("postgresql"));
1119 assert_eq!(loaded.recall("postgresql").len(), 1);
1120
1121 let json = serde_json::to_string(&loaded).unwrap();
1122 assert!(!json.contains("\"index\""));
1123 }
1124}