Skip to main content

lago_knowledge/
index.rs

1//! In-memory knowledge index for a Lago session's `.md` vault.
2//!
3//! Maps note names and paths to parsed `Note` structs and maintains
4//! a graph adjacency list derived from wikilinks.
5
6use std::collections::HashMap;
7use std::time::Instant;
8
9use lago_core::ManifestEntry;
10use lago_store::BlobStore;
11use thiserror::Error;
12
13use crate::frontmatter::parse_frontmatter;
14use crate::wikilink::extract_wikilinks;
15
16/// Errors specific to the knowledge index.
17#[derive(Debug, Error)]
18pub enum KnowledgeError {
19    #[error("blob not found: {0}")]
20    BlobNotFound(String),
21    #[error("invalid UTF-8 in blob: {0}")]
22    InvalidUtf8(String),
23    #[error("store error: {0}")]
24    Store(String),
25}
26
27/// A parsed `.md` note with structured metadata.
28#[derive(Debug, Clone)]
29pub struct Note {
30    /// Relative path in the manifest (e.g. `/docs/architecture.md`).
31    pub path: String,
32    /// Filename without `.md` extension.
33    pub name: String,
34    /// Parsed YAML frontmatter.
35    pub frontmatter: serde_yaml::Value,
36    /// Markdown body (without frontmatter).
37    pub body: String,
38    /// Extracted `[[wikilink]]` targets.
39    pub links: Vec<String>,
40}
41
42/// In-memory knowledge index for a session's vault.
43///
44/// Built from a Lago manifest + blob store. Provides name/path resolution,
45/// scored search, and graph traversal over wikilink edges.
46pub struct KnowledgeIndex {
47    /// name (lowercase) → relative path
48    pub(crate) name_map: HashMap<String, String>,
49    /// relative path (lowercase, no .md) → relative path (original case)
50    pub(crate) path_map: HashMap<String, String>,
51    /// path → parsed Note (cached)
52    pub(crate) notes: HashMap<String, Note>,
53    /// When this index was built.
54    built_at: Instant,
55}
56
57impl KnowledgeIndex {
58    /// Build an index from a Lago manifest and blob store.
59    ///
60    /// Reads all `.md` entries, parses frontmatter, extracts wikilinks,
61    /// and builds the name/path maps and graph adjacency list.
62    pub fn build(manifest: &[ManifestEntry], store: &BlobStore) -> Result<Self, KnowledgeError> {
63        let mut name_map = HashMap::new();
64        let mut path_map = HashMap::new();
65        let mut notes = HashMap::new();
66
67        for entry in manifest {
68            if !entry.path.ends_with(".md") {
69                continue;
70            }
71
72            // Read blob content
73            let data = store
74                .get(&entry.blob_hash)
75                .map_err(|e| KnowledgeError::Store(e.to_string()))?;
76            let content = String::from_utf8(data)
77                .map_err(|_| KnowledgeError::InvalidUtf8(entry.path.clone()))?;
78
79            // Parse
80            let (frontmatter, body) = parse_frontmatter(&content);
81            let links = extract_wikilinks(body);
82
83            // Derive name from path: /docs/architecture.md → architecture
84            let name = entry
85                .path
86                .rsplit('/')
87                .next()
88                .unwrap_or(&entry.path)
89                .trim_end_matches(".md")
90                .to_string();
91
92            let note = Note {
93                path: entry.path.clone(),
94                name: name.clone(),
95                frontmatter,
96                body: body.to_string(),
97                links: links.clone(),
98            };
99
100            // Name map: first-seen wins
101            let name_lower = name.to_lowercase();
102            name_map.entry(name_lower).or_insert(entry.path.clone());
103
104            // Path map: strip .md and lowercase for lookup
105            let path_key = entry
106                .path
107                .trim_start_matches('/')
108                .trim_end_matches(".md")
109                .to_lowercase();
110            path_map.entry(path_key).or_insert(entry.path.clone());
111
112            // Cache note
113            notes.insert(entry.path.clone(), note);
114        }
115
116        Ok(Self {
117            name_map,
118            path_map,
119            notes,
120            built_at: Instant::now(),
121        })
122    }
123
124    /// Resolve a wikilink target to a `Note`.
125    ///
126    /// Tries name lookup first, then path lookup. Strips heading anchors
127    /// (`Note#heading` → `Note`).
128    pub fn resolve_wikilink(&self, target: &str) -> Option<&Note> {
129        // Strip heading anchors
130        let clean = target.split('#').next().unwrap_or(target).trim();
131        let lower = clean.to_lowercase();
132
133        // Try name first
134        if let Some(path) = self.name_map.get(&lower) {
135            return self.notes.get(path);
136        }
137
138        // Try path (without .md)
139        if let Some(path) = self.path_map.get(&lower) {
140            return self.notes.get(path);
141        }
142
143        None
144    }
145
146    /// Get a note by its exact path.
147    pub fn get_note(&self, path: &str) -> Option<&Note> {
148        self.notes.get(path)
149    }
150
151    /// Get all notes in the index.
152    pub fn notes(&self) -> &HashMap<String, Note> {
153        &self.notes
154    }
155
156    /// Number of notes in the index.
157    pub fn len(&self) -> usize {
158        self.notes.len()
159    }
160
161    /// Whether the index is empty.
162    pub fn is_empty(&self) -> bool {
163        self.notes.is_empty()
164    }
165
166    /// Check if the index is stale based on a TTL.
167    pub fn is_stale(&self, ttl: std::time::Duration) -> bool {
168        self.built_at.elapsed() > ttl
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use tempfile::TempDir;
176
177    fn setup_store_with_files(files: &[(&str, &str)]) -> (TempDir, BlobStore, Vec<ManifestEntry>) {
178        let tmp = TempDir::new().unwrap();
179        let store = BlobStore::open(tmp.path()).unwrap();
180        let mut entries = Vec::new();
181
182        for (path, content) in files {
183            let hash = store.put(content.as_bytes()).unwrap();
184            entries.push(ManifestEntry {
185                path: path.to_string(),
186                blob_hash: hash,
187                size_bytes: content.len() as u64,
188                content_type: Some("text/markdown".to_string()),
189                updated_at: 0,
190            });
191        }
192
193        (tmp, store, entries)
194    }
195
196    #[test]
197    fn build_index_from_manifest() {
198        let files = [
199            (
200                "/notes/hello.md",
201                "---\ntitle: Hello\n---\n# Hello\n\nSee [[World]].",
202            ),
203            ("/notes/world.md", "# World\n\nSee [[Hello]]."),
204        ];
205
206        let (_tmp, store, entries) = setup_store_with_files(&files);
207        let index = KnowledgeIndex::build(&entries, &store).unwrap();
208
209        assert_eq!(index.len(), 2);
210
211        let hello = index.resolve_wikilink("Hello").unwrap();
212        assert_eq!(hello.name, "hello");
213        assert_eq!(hello.links, vec!["World"]);
214        assert_eq!(hello.frontmatter["title"].as_str(), Some("Hello"));
215
216        let world = index.resolve_wikilink("World").unwrap();
217        assert_eq!(world.name, "world");
218        assert_eq!(world.links, vec!["Hello"]);
219    }
220
221    #[test]
222    fn resolve_wikilink_with_heading() {
223        let files = [("/note.md", "# Note\n\nContent.")];
224        let (_tmp, store, entries) = setup_store_with_files(&files);
225        let index = KnowledgeIndex::build(&entries, &store).unwrap();
226
227        let note = index.resolve_wikilink("note#heading").unwrap();
228        assert_eq!(note.name, "note");
229    }
230
231    #[test]
232    fn resolve_missing_wikilink() {
233        let files = [("/note.md", "# Note")];
234        let (_tmp, store, entries) = setup_store_with_files(&files);
235        let index = KnowledgeIndex::build(&entries, &store).unwrap();
236
237        assert!(index.resolve_wikilink("nonexistent").is_none());
238    }
239
240    #[test]
241    fn skips_non_md_files() {
242        let files = [("/data.json", "{\"key\": \"value\"}")];
243        let (_tmp, store, entries) = setup_store_with_files(&files);
244        let index = KnowledgeIndex::build(&entries, &store).unwrap();
245
246        assert_eq!(index.len(), 0);
247    }
248
249    #[test]
250    fn stale_check() {
251        let files = [("/note.md", "# Note")];
252        let (_tmp, store, entries) = setup_store_with_files(&files);
253        let index = KnowledgeIndex::build(&entries, &store).unwrap();
254
255        assert!(!index.is_stale(std::time::Duration::from_secs(60)));
256        assert!(index.is_stale(std::time::Duration::ZERO));
257    }
258}