Skip to main content

nap_core/
context_docs.rs

1//! Context document metadata and dependency-graph management.
2//!
3//! Context documents are markdown (or any plaintext) files inside a Lore
4//! workspace that carry structured metadata and dependency edges.  They
5//! are the building blocks for AI context-graph assembly — the system
6//! that tells an agent "when user asks about task X, also surface
7//! documents Y and Z."
8//!
9//! ## Storage model
10//!
11//! Metadata is stored as Lore file-level metadata under the
12//! `nap.context_docs` key (a JSON map keyed by file path).  Dependency
13//! edges are stored as `nap.context_deps` (a JSON adjacency list).
14//!
15//! This avoids requiring a separate database or sidecar files — the
16//! VCS is the source of truth.
17//!
18//! ## Thread safety
19//!
20//! [`ContextDocsManager`] uses interior mutability (a `Mutex<Graph>`)
21//! for dependency-graph mutations.  Read operations take `&self`.
22
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::sync::Mutex;
26
27use serde::{Deserialize, Serialize};
28
29use crate::error::NapError;
30use crate::vcs::ContextDocument;
31
32// ---------------------------------------------------------------------------
33// In-memory graph
34// ---------------------------------------------------------------------------
35
36/// The in-memory context document graph.
37///
38/// Nodes are document paths.  Edges are dependency relationships:
39/// `edges[target] = set of source paths` — i.e. if document A depends on
40/// document B then `edges[B]` contains A.
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42struct ContextGraph {
43    /// Document metadata keyed by canonical path.
44    nodes: HashMap<String, HashMap<String, String>>,
45    /// Adjacency list: `edges[target] = {sources ... }`.
46    edges: HashMap<String, HashSet<String>>,
47}
48
49// ---------------------------------------------------------------------------
50// ContextDocsManager
51// ---------------------------------------------------------------------------
52
53/// Manages context-document metadata and dependency edges inside a Lore
54/// workspace.
55///
56/// ## Usage
57///
58/// ```ignore
59/// let manager = ContextDocsManager::new(workspace_path);
60/// manager.register("task-123.md", &[("status", "active")])?;
61/// manager.add_dependency("task-123.md", "characters/hero.yaml")?;
62/// let deps = manager.dependents_of("characters/hero.yaml")?;
63/// ```
64pub struct ContextDocsManager {
65    /// Workspace root path.
66    workspace_root: PathBuf,
67    /// Synchronised in-memory graph.
68    graph: Mutex<ContextGraph>,
69    /// Whether the graph was loaded from Lore metadata (dirty flag).
70    loaded: Mutex<bool>,
71}
72
73/// Path where context-doc metadata is persisted, relative to workspace root.
74const CONTEXT_DOCS_FILE: &str = ".lore/metadata/nap.context_docs";
75/// Path where context-doc dependency edges are persisted, relative to workspace root.
76const CONTEXT_DEPS_FILE: &str = ".lore/metadata/nap.context_deps";
77
78impl ContextDocsManager {
79    /// Create a new manager.
80    ///
81    /// The graph is not loaded from Lore until the first operation that
82    /// requires it (lazy init).
83    pub fn new(workspace_root: &Path) -> Self {
84        Self {
85            workspace_root: workspace_root.to_path_buf(),
86            graph: Mutex::new(ContextGraph::default()),
87            loaded: Mutex::new(false),
88        }
89    }
90
91    // ── Lazy load ────────────────────────────────────────────────────
92
93    /// Ensure the in-memory graph has been loaded from persisted metadata.
94    fn ensure_loaded(&self) -> Result<(), NapError> {
95        let mut loaded = self.loaded.lock().unwrap();
96        if *loaded {
97            return Ok(());
98        }
99
100        // Attempt to read context metadata from the local filesystem.
101        let docs_path = self.workspace_root.join(CONTEXT_DOCS_FILE);
102        let metadata_json = if docs_path.exists() {
103            std::fs::read_to_string(&docs_path).unwrap_or_else(|_| "{}".to_string())
104        } else {
105            "{}".to_string()
106        };
107
108        let deps_path = self.workspace_root.join(CONTEXT_DEPS_FILE);
109        let deps_json = if deps_path.exists() {
110            std::fs::read_to_string(&deps_path).unwrap_or_else(|_| "{}".to_string())
111        } else {
112            "{}".to_string()
113        };
114
115        let mut graph_lock = self.graph.lock().unwrap();
116
117        // Parse nodes.
118        if metadata_json != "{}" && !metadata_json.is_empty() {
119            let nodes: HashMap<String, HashMap<String, String>> =
120                serde_json::from_str(&metadata_json).map_err(|e| {
121                    NapError::Other(format!("failed to parse nap.context_docs metadata: {}", e))
122                })?;
123            graph_lock.nodes = nodes;
124        }
125
126        // Parse edges.
127        if deps_json != "{}" && !deps_json.is_empty() {
128            #[derive(Deserialize)]
129            struct DepsFile {
130                edges: HashMap<String, Vec<String>>,
131            }
132            let deps: DepsFile = serde_json::from_str(&deps_json)
133                .map_err(|e| NapError::Other(format!("failed to parse nap.context_deps: {}", e)))?;
134
135            for (target, sources) in deps.edges {
136                graph_lock
137                    .edges
138                    .insert(target, sources.into_iter().collect());
139            }
140        }
141
142        *loaded = true;
143        Ok(())
144    }
145
146    // ── Persist ──────────────────────────────────────────────────────
147
148    /// Write the current in-memory graph back to Lore metadata.
149    ///
150    /// This is a two-step process:
151    /// 1. Serialise nodes and edges to JSON.
152    /// 2. Write to `.lore/metadata/nap.context_docs` and
153    ///    `.lore/metadata/nap.context_deps` via the VCS.
154    pub fn persist(&self) -> Result<(), NapError> {
155        let graph_lock = self.graph.lock().unwrap();
156
157        let nodes_json = serde_json::to_string_pretty(&graph_lock.nodes).map_err(|e| {
158            NapError::Other(format!("failed to serialise context doc nodes: {}", e))
159        })?;
160
161        // Serialise edges as { target: [source, ...] }.
162        let edges_serialisable: HashMap<String, Vec<String>> = graph_lock
163            .edges
164            .iter()
165            .map(|(target, sources)| (target.clone(), sources.iter().cloned().collect()))
166            .collect();
167
168        let deps_json = serde_json::to_string_pretty(&serde_json::json!({
169            "edges": edges_serialisable
170        }))
171        .map_err(|e| NapError::Other(format!("failed to serialise context deps: {}", e)))?;
172
173        // Write metadata files.
174        let docs_path = self.workspace_root.join(".lore/metadata/nap.context_docs");
175        let deps_path = self.workspace_root.join(".lore/metadata/nap.context_deps");
176
177        if let Some(parent) = docs_path.parent() {
178            std::fs::create_dir_all(parent)
179                .map_err(|e| NapError::Other(format!("failed to create metadata dir: {}", e)))?;
180        }
181        if let Some(parent) = deps_path.parent() {
182            std::fs::create_dir_all(parent)
183                .map_err(|e| NapError::Other(format!("failed to create metadata dir: {}", e)))?;
184        }
185
186        std::fs::write(&docs_path, &nodes_json)
187            .map_err(|e| NapError::Other(format!("failed to write context doc metadata: {}", e)))?;
188        std::fs::write(&deps_path, &deps_json).map_err(|e| {
189            NapError::Other(format!("failed to write context deps metadata: {}", e))
190        })?;
191
192        Ok(())
193    }
194
195    // ── Document CRUD ────────────────────────────────────────────────
196
197    /// Register a document with its metadata.
198    ///
199    /// If the document already exists, its metadata is **merged** with
200    /// the new entries (new keys overwrite old ones; existing keys not in
201    /// `metadata` are preserved).
202    pub fn register(&self, path: &str, metadata: &[(&str, &str)]) -> Result<(), NapError> {
203        self.ensure_loaded()?;
204
205        let mut graph_lock = self.graph.lock().unwrap();
206        let entry = graph_lock.nodes.entry(path.to_string()).or_default();
207        for (k, v) in metadata {
208            entry.insert(k.to_string(), v.to_string());
209        }
210
211        Ok(())
212    }
213
214    /// Remove a document from the graph.
215    ///
216    /// Also removes all dependency edges where this document is the target
217    /// or a source.
218    pub fn unregister(&self, path: &str) -> Result<(), NapError> {
219        self.ensure_loaded()?;
220
221        let mut graph_lock = self.graph.lock().unwrap();
222        graph_lock.nodes.remove(path);
223
224        // Remove edges where path is a target (incoming deps).
225        graph_lock.edges.remove(path);
226
227        // Remove edges where path is a source (outgoing deps).
228        for sources in graph_lock.edges.values_mut() {
229            sources.remove(path);
230        }
231
232        Ok(())
233    }
234
235    /// Get all documents and their metadata as a list.
236    pub fn all_documents(&self) -> Result<Vec<ContextDocument>, NapError> {
237        self.ensure_loaded()?;
238
239        let graph_lock = self.graph.lock().unwrap();
240        let mut docs = Vec::with_capacity(graph_lock.nodes.len());
241
242        for (path, metadata) in &graph_lock.nodes {
243            let deps: Vec<String> = graph_lock
244                .edges
245                .iter()
246                .filter(|(_, sources)| sources.contains(path))
247                .map(|(target, _)| target.clone())
248                .collect();
249
250            docs.push(ContextDocument {
251                path: path.clone(),
252                metadata: metadata.clone(),
253                depends_on: deps,
254            });
255        }
256
257        Ok(docs)
258    }
259
260    /// Get a specific document by path.
261    pub fn get_document(&self, path: &str) -> Result<Option<ContextDocument>, NapError> {
262        self.ensure_loaded()?;
263
264        let graph_lock = self.graph.lock().unwrap();
265        let metadata = match graph_lock.nodes.get(path) {
266            Some(m) => m.clone(),
267            None => return Ok(None),
268        };
269
270        let deps: Vec<String> = graph_lock
271            .edges
272            .iter()
273            .filter(|(_, sources)| sources.contains(path))
274            .map(|(target, _)| target.clone())
275            .collect();
276
277        Ok(Some(ContextDocument {
278            path: path.to_string(),
279            metadata,
280            depends_on: deps,
281        }))
282    }
283
284    // ── Dependency management ────────────────────────────────────────
285
286    /// Declare that `source` depends on `target`.
287    ///
288    /// This adds `source` to the adjacency list of `target`.
289    /// Both paths must be registered; registering is implicit if they are
290    /// not yet known (the graph auto-vivifies them with empty metadata).
291    pub fn add_dependency(&self, source: &str, target: &str) -> Result<(), NapError> {
292        self.ensure_loaded()?;
293
294        let mut graph_lock = self.graph.lock().unwrap();
295
296        // Auto-vivify nodes if not yet registered.
297        graph_lock.nodes.entry(source.to_string()).or_default();
298        graph_lock.nodes.entry(target.to_string()).or_default();
299
300        graph_lock
301            .edges
302            .entry(target.to_string())
303            .or_default()
304            .insert(source.to_string());
305
306        Ok(())
307    }
308
309    /// Remove a dependency edge.
310    pub fn remove_dependency(&self, source: &str, target: &str) -> Result<(), NapError> {
311        self.ensure_loaded()?;
312
313        let mut graph_lock = self.graph.lock().unwrap();
314        if let Some(sources) = graph_lock.edges.get_mut(target) {
315            sources.remove(source);
316        }
317
318        Ok(())
319    }
320
321    /// Get the set of documents that directly depend on `target`.
322    pub fn dependents_of(&self, target: &str) -> Result<Vec<String>, NapError> {
323        self.ensure_loaded()?;
324
325        let graph_lock = self.graph.lock().unwrap();
326        let sources = graph_lock.edges.get(target).cloned().unwrap_or_default();
327        Ok(sources.into_iter().collect())
328    }
329
330    /// Get the set of documents that `source` depends on.
331    ///
332    /// This is a reverse lookup: we scan all edges for where `source`
333    /// appears as a dependent.
334    pub fn dependencies_of(&self, source: &str) -> Result<Vec<String>, NapError> {
335        self.ensure_loaded()?;
336
337        let graph_lock = self.graph.lock().unwrap();
338        let deps: Vec<String> = graph_lock
339            .edges
340            .iter()
341            .filter(|(_, sources)| sources.contains(source))
342            .map(|(target, _)| target.clone())
343            .collect();
344        Ok(deps)
345    }
346
347    /// Get the full dependency graph as a JSON string (for debugging or
348    /// context-graph assembly).
349    pub fn graph_as_json(&self) -> Result<String, NapError> {
350        self.ensure_loaded()?;
351
352        let graph_lock = self.graph.lock().unwrap();
353        serde_json::to_string_pretty(&*graph_lock)
354            .map_err(|e| NapError::Other(format!("failed to serialise context graph: {}", e)))
355    }
356}
357
358// ---------------------------------------------------------------------------
359// Tests
360// ---------------------------------------------------------------------------
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    /// Helper: create a temporary workspace with a fresh manager.
367    fn setup() -> (tempfile::TempDir, ContextDocsManager) {
368        let dir = tempfile::TempDir::new().unwrap();
369        let manager = ContextDocsManager::new(dir.path());
370        (dir, manager)
371    }
372
373    #[test]
374    fn test_register_and_get_document() {
375        let (_dir, manager) = setup();
376        manager
377            .register("task-123.md", &[("status", "active"), ("owner", "alice")])
378            .unwrap();
379
380        let doc = manager.get_document("task-123.md").unwrap().unwrap();
381        assert_eq!(doc.path, "task-123.md");
382        assert_eq!(doc.metadata.get("status").unwrap(), "active");
383        assert_eq!(doc.metadata.get("owner").unwrap(), "alice");
384    }
385
386    #[test]
387    fn test_get_nonexistent_document() {
388        let (_dir, manager) = setup();
389        let doc = manager.get_document("does-not-exist.md").unwrap();
390        assert!(doc.is_none());
391    }
392
393    #[test]
394    fn test_unregister_removes_node() {
395        let (_dir, manager) = setup();
396        manager.register("doc-a.md", &[]).unwrap();
397        manager.register("doc-b.md", &[]).unwrap();
398        manager.add_dependency("doc-a.md", "doc-b.md").unwrap();
399
400        manager.unregister("doc-a.md").unwrap();
401
402        // doc-a should not appear, and no edge to doc-b should remain.
403        assert!(manager.get_document("doc-a.md").unwrap().is_none());
404        let deps_of_b = manager.dependents_of("doc-b.md").unwrap();
405        assert!(deps_of_b.is_empty());
406    }
407
408    #[test]
409    fn test_add_dependency() {
410        let (_dir, manager) = setup();
411        manager.register("agent-log.txt", &[]).unwrap();
412        manager
413            .register("context/characters/hero.yaml", &[])
414            .unwrap();
415
416        manager
417            .add_dependency("agent-log.txt", "context/characters/hero.yaml")
418            .unwrap();
419
420        let deps = manager
421            .dependents_of("context/characters/hero.yaml")
422            .unwrap();
423        assert_eq!(deps, vec!["agent-log.txt"]);
424
425        let dep_of_agent = manager.dependencies_of("agent-log.txt").unwrap();
426        assert_eq!(dep_of_agent, vec!["context/characters/hero.yaml"]);
427    }
428
429    #[test]
430    fn test_remove_dependency() {
431        let (_dir, manager) = setup();
432        manager.register("a", &[]).unwrap();
433        manager.register("b", &[]).unwrap();
434        manager.add_dependency("a", "b").unwrap();
435        manager.remove_dependency("a", "b").unwrap();
436
437        assert!(manager.dependents_of("b").unwrap().is_empty());
438    }
439
440    #[test]
441    fn test_all_documents() {
442        let (_dir, manager) = setup();
443        manager.register("doc1", &[("k1", "v1")]).unwrap();
444        manager.register("doc2", &[("k2", "v2")]).unwrap();
445
446        let docs = manager.all_documents().unwrap();
447        assert_eq!(docs.len(), 2);
448        let paths: Vec<String> = docs.into_iter().map(|d| d.path).collect();
449        assert!(paths.contains(&"doc1".to_string()));
450        assert!(paths.contains(&"doc2".to_string()));
451    }
452
453    #[test]
454    fn test_graph_as_json() {
455        let (_dir, manager) = setup();
456        manager.register("doc1", &[]).unwrap();
457        let json = manager.graph_as_json().unwrap();
458        assert!(json.contains("doc1"));
459    }
460
461    #[test]
462    fn test_persist_and_lazy_reload_is_idempotent() {
463        let (_dir, manager) = setup();
464        manager.register("persist-test", &[("key", "val")]).unwrap();
465        // persist writes to the filesystem.
466        manager.persist().unwrap();
467        // The data is still in memory; the persist confirmed no errors.
468        let doc = manager.get_document("persist-test").unwrap().unwrap();
469        assert_eq!(doc.metadata.get("key").unwrap(), "val");
470    }
471
472    #[test]
473    fn test_auto_vivify_on_add_dependency() {
474        let (_dir, manager) = setup();
475        // Neither a nor b registered, but add_dependency should create them.
476        manager.add_dependency("a", "b").unwrap();
477        assert!(manager.get_document("a").unwrap().is_some());
478        assert!(manager.get_document("b").unwrap().is_some());
479        assert_eq!(manager.dependents_of("b").unwrap(), vec!["a"]);
480    }
481
482    #[test]
483    fn test_dependency_removes_orphan() {
484        let (_dir, manager) = setup();
485        manager.add_dependency("a", "b").unwrap();
486        manager.unregister("a").unwrap();
487
488        // b should have no dependents now.
489        assert!(manager.dependents_of("b").unwrap().is_empty());
490    }
491}