1use 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#[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#[derive(Debug, Clone)]
29pub struct Note {
30 pub path: String,
32 pub name: String,
34 pub frontmatter: serde_yaml::Value,
36 pub body: String,
38 pub links: Vec<String>,
40}
41
42pub struct KnowledgeIndex {
47 pub(crate) name_map: HashMap<String, String>,
49 pub(crate) path_map: HashMap<String, String>,
51 pub(crate) notes: HashMap<String, Note>,
53 built_at: Instant,
55}
56
57impl KnowledgeIndex {
58 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 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 let (frontmatter, body) = parse_frontmatter(&content);
81 let links = extract_wikilinks(body);
82
83 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 let name_lower = name.to_lowercase();
102 name_map.entry(name_lower).or_insert(entry.path.clone());
103
104 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 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 pub fn resolve_wikilink(&self, target: &str) -> Option<&Note> {
129 let clean = target.split('#').next().unwrap_or(target).trim();
131 let lower = clean.to_lowercase();
132
133 if let Some(path) = self.name_map.get(&lower) {
135 return self.notes.get(path);
136 }
137
138 if let Some(path) = self.path_map.get(&lower) {
140 return self.notes.get(path);
141 }
142
143 None
144 }
145
146 pub fn get_note(&self, path: &str) -> Option<&Note> {
148 self.notes.get(path)
149 }
150
151 pub fn notes(&self) -> &HashMap<String, Note> {
153 &self.notes
154 }
155
156 pub fn len(&self) -> usize {
158 self.notes.len()
159 }
160
161 pub fn is_empty(&self) -> bool {
163 self.notes.is_empty()
164 }
165
166 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}