1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42struct ContextGraph {
43 nodes: HashMap<String, HashMap<String, String>>,
45 edges: HashMap<String, HashSet<String>>,
47}
48
49pub struct ContextDocsManager {
65 workspace_root: PathBuf,
67 graph: Mutex<ContextGraph>,
69 loaded: Mutex<bool>,
71}
72
73const CONTEXT_DOCS_FILE: &str = ".lore/metadata/nap.context_docs";
75const CONTEXT_DEPS_FILE: &str = ".lore/metadata/nap.context_deps";
77
78impl ContextDocsManager {
79 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 fn ensure_loaded(&self) -> Result<(), NapError> {
95 let mut loaded = self.loaded.lock().unwrap();
96 if *loaded {
97 return Ok(());
98 }
99
100 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 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 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 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 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 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 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 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 graph_lock.edges.remove(path);
226
227 for sources in graph_lock.edges.values_mut() {
229 sources.remove(path);
230 }
231
232 Ok(())
233 }
234
235 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 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 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 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 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 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 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 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#[cfg(test)]
363mod tests {
364 use super::*;
365
366 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 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 manager.persist().unwrap();
467 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 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 assert!(manager.dependents_of("b").unwrap().is_empty());
490 }
491}