Skip to main content

lean_ctx/core/knowledge/
local_store.rs

1//! In-memory Knowledge Hub store for local sessions and tests.
2
3use super::query::KnowledgeQuery;
4use super::store::{KnowledgeStore, KnowledgeStoreError, object_id, validate_object};
5use super::supersession::mark_superseded;
6use lean_ctx_protocol::KnowledgeObjectV1;
7use std::collections::BTreeMap;
8
9/// Deterministic in-memory implementation of [`KnowledgeStore`].
10#[derive(Debug, Clone, Default)]
11pub struct LocalKnowledgeStore {
12    objects: BTreeMap<String, KnowledgeObjectV1>,
13}
14
15impl LocalKnowledgeStore {
16    /// Create an empty local store.
17    pub fn new() -> Self {
18        Self::default()
19    }
20
21    /// Number of objects currently held by the store.
22    pub fn len(&self) -> usize {
23        self.objects.len()
24    }
25
26    /// Whether the store contains no objects.
27    pub fn is_empty(&self) -> bool {
28        self.objects.is_empty()
29    }
30
31    /// Return all objects in stable object-id order.
32    pub fn all(&self) -> Vec<KnowledgeObjectV1> {
33        self.objects.values().cloned().collect()
34    }
35
36    /// Borrow all objects in stable object-id order.
37    pub fn values(&self) -> impl Iterator<Item = &KnowledgeObjectV1> {
38        self.objects.values()
39    }
40
41    /// Fetch an object without cloning it.
42    pub fn get_ref(&self, object_id: &str) -> Option<&KnowledgeObjectV1> {
43        self.objects.get(object_id)
44    }
45}
46
47impl KnowledgeStore for LocalKnowledgeStore {
48    fn get(&self, object_id: &str) -> Option<KnowledgeObjectV1> {
49        self.objects.get(object_id).cloned()
50    }
51
52    fn put(&mut self, object: KnowledgeObjectV1) -> Result<(), KnowledgeStoreError> {
53        validate_object(&object)?;
54        self.objects.insert(object_id(&object).to_owned(), object);
55        Ok(())
56    }
57
58    fn query(&self, query: &KnowledgeQuery) -> Vec<KnowledgeObjectV1> {
59        self.objects
60            .values()
61            .filter(|object| query.matches(object))
62            .cloned()
63            .collect()
64    }
65
66    fn supersede(
67        &mut self,
68        old_object_id: &str,
69        replacement: KnowledgeObjectV1,
70    ) -> Result<(), KnowledgeStoreError> {
71        validate_object(&replacement)?;
72        let replacement_id = object_id(&replacement).to_owned();
73        let old = self
74            .objects
75            .get_mut(old_object_id)
76            .ok_or_else(|| KnowledgeStoreError::NotFound(old_object_id.to_owned()))?;
77        mark_superseded(old, replacement_id);
78        self.objects
79            .insert(object_id(&replacement).to_owned(), replacement);
80        Ok(())
81    }
82
83    fn delete(&mut self, object_id: &str) -> bool {
84        self.objects.remove(object_id).is_some()
85    }
86}
87
88/// Explicit alias for callers that prefer the implementation-oriented name.
89pub type InMemoryKnowledgeStore = LocalKnowledgeStore;
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::core::knowledge::query::KnowledgeQuery;
95    use chrono::{Duration, Utc};
96    use lean_ctx_protocol::ExtensionsV1;
97    use lean_ctx_protocol::knowledge::{
98        AuthorityMetadata, ClassificationLevel, DataClassification, KnowledgeSourceType,
99        SourceReference, ValidityWindow,
100    };
101
102    fn object(id: &str, tag: &str) -> KnowledgeObjectV1 {
103        KnowledgeObjectV1 {
104            schema_version: 1,
105            source_ref: Some(SourceReference {
106                source_type: "git".to_owned(),
107                uri: "repo://lean-ctx".to_owned(),
108                commit_sha: Some("abc123".to_owned()),
109                access_timestamp: "2026-08-09T00:00:00Z".to_owned(),
110            }),
111            source_type: KnowledgeSourceType::Repository,
112            authority: Some(AuthorityMetadata {
113                owner: "team".to_owned(),
114                confidence_level: 0.9,
115                review_status: "reviewed".to_owned(),
116            }),
117            owner: "team".to_owned(),
118            classification: Some(DataClassification {
119                level: ClassificationLevel::Internal,
120                retention_days: Some(30),
121            }),
122            validity: Some(ValidityWindow {
123                valid_from: (Utc::now() - Duration::hours(1)).to_rfc3339(),
124                valid_until: Some((Utc::now() + Duration::hours(1)).to_rfc3339()),
125                superseded_by: None,
126            }),
127            supersedes: None,
128            content_hash: id.to_owned(),
129            evidence_digest: format!("evidence:{id}"),
130            policy_ref: "policy:local".to_owned(),
131            evidence_refs: vec![format!("evidence-ref:{id}")],
132            extra: {
133                let mut extra = ExtensionsV1::default();
134                extra
135                    .insert("tags", serde_json::json!([tag]))
136                    .expect("extension should be valid");
137                extra
138            },
139        }
140    }
141
142    #[test]
143    fn crud_and_query_are_deterministic() {
144        let mut store = LocalKnowledgeStore::new();
145        store.put(object("b", "architecture")).expect("put b");
146        store.put(object("a", "architecture")).expect("put a");
147        assert_eq!(store.len(), 2);
148        assert_eq!(store.get("a").expect("a").object_id(), "a");
149
150        let query = KnowledgeQuery {
151            source: Some("repo://lean-ctx".to_owned()),
152            classification: Some(ClassificationLevel::Internal),
153            valid_at: Some(Utc::now()),
154            tags: vec!["architecture".to_owned()],
155        };
156        let results = store.query(&query);
157        assert_eq!(
158            results
159                .iter()
160                .map(KnowledgeObjectV1::object_id)
161                .collect::<Vec<_>>(),
162            ["a", "b"]
163        );
164        assert!(store.delete("a"));
165        assert!(!store.delete("a"));
166    }
167
168    #[test]
169    fn supersession_keeps_old_history_and_links_replacement() {
170        let mut store = LocalKnowledgeStore::new();
171        store.put(object("old", "architecture")).expect("put old");
172        store
173            .supersede("old", object("new", "architecture"))
174            .expect("supersede old");
175
176        let old = store.get("old").expect("old history remains");
177        assert_eq!(
178            old.validity
179                .as_ref()
180                .and_then(|validity| validity.superseded_by.as_deref()),
181            Some("new")
182        );
183        assert!(store.get("new").is_some());
184    }
185}