1use std::collections::HashSet;
11use std::path::PathBuf;
12
13use anyhow::Result;
14use parking_lot::{Mutex as ParkingMutex, RwLock};
15
16pub type FileChangeCallback = Box<dyn Fn(&str, FileChange) + Send + Sync>;
19
20use crate::backlinks::{Backlink, BacklinkIndex, LinkGraph};
21use crate::chat::{delete_chat_msg, move_from_chat, read_chat_msgs, rename_chat_msg};
22use crate::checklist::{
23 add_checklist_item, checklist_items, complete_checklist_item, incomplete_checklist_items,
24 remove_checklist_item, remove_completed_checklist_items,
25};
26use crate::fs::VirtualFs;
27use crate::habits::{habits, last_week_habits, write_habits};
28use crate::html::markdown_to_html;
29use crate::i18n::emoji_for;
30use crate::journal::{add_emoji as journal_add_emoji, add_record as journal_add_record};
31use crate::parser::{extract_headings, similar};
32use crate::plugins::world_clock_for_names;
33use crate::stats::{done_today, today_report};
34use crate::types::NoteMeta;
35use crate::types::{CHAT_FILENAME, DIR_USER_ROOT, FileEntry, Habits, KnowledgeConfig};
36#[cfg(test)]
37use crate::types::{NoteQuality, NoteSource};
38use crate::worker::{move_due_tasks, remove_completed_items};
39use crate::{today_chat_header, today_journal_filename};
40
41#[derive(Debug, Clone)]
43pub enum FileChange {
44 Created(String),
46 Updated(String),
48 Deleted(String),
50 Moved {
52 old: String,
54 new: String,
56 },
57}
58
59#[derive(Debug, Clone)]
61pub struct NoteHit {
62 pub path: String,
64 pub name: String,
66 pub snippet: String,
68 pub backlink_count: usize,
70 pub name_similarity: i32,
72}
73
74pub struct KnowledgeBase {
82 fs: RwLock<VirtualFs>,
84 backlinks: RwLock<BacklinkIndex>,
86 agent_writes: ParkingMutex<HashSet<String>>,
88 on_change: RwLock<Vec<FileChangeCallback>>,
91}
92
93impl std::fmt::Debug for KnowledgeBase {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("KnowledgeBase")
96 .field("root", &self.fs.read().root())
97 .finish()
98 }
99}
100
101impl KnowledgeBase {
102 pub fn new(root: PathBuf) -> Result<Self> {
104 let fs = VirtualFs::new(root)?;
105 Ok(Self {
106 fs: RwLock::new(fs),
107 backlinks: RwLock::new(BacklinkIndex::new()),
108 agent_writes: ParkingMutex::new(HashSet::new()),
109 on_change: RwLock::new(Vec::new()),
110 })
111 }
112
113 pub fn for_space(space_dir: &std::path::Path) -> Result<Self> {
115 Self::new(space_dir.join("knowledge"))
116 }
117
118 pub fn root(&self) -> PathBuf {
120 self.fs.read().root().to_path_buf()
121 }
122
123 pub fn on_file_change<F>(&self, f: F)
128 where
129 F: Fn(&str, FileChange) + Send + Sync + 'static,
130 {
131 self.on_change.write().push(Box::new(f));
132 }
133
134 fn notify_change(&self, path: &str, change: FileChange) {
136 for cb in self.on_change.read().iter() {
137 cb(path, change.clone());
138 }
139 }
140
141 pub fn note_read(&self, path: &str) -> Result<Option<String>> {
145 let fs = self.fs.read();
146 match fs.read_path(path) {
147 Ok(content) => Ok(Some(content)),
148 Err(_) => Ok(None),
149 }
150 }
151
152 pub fn note_read_bytes(&self, path: &str) -> Result<Option<Vec<u8>>> {
155 let fs = self.fs.read();
156 match fs.read_path_bytes(path) {
157 Ok(bytes) => Ok(Some(bytes)),
158 Err(_) => Ok(None),
159 }
160 }
161
162 pub fn note_write(&self, path: &str, content: &str) -> Result<()> {
167 let is_new = {
171 let fs = self.fs.write();
172 let is_new = fs.read_path(path).is_err();
173 fs.write_path(path, content)?;
174 is_new
175 };
176
177 {
178 let mut backlinks = self.backlinks.write();
179 backlinks.remove_file(path);
180 backlinks.index_file(path, content);
181 }
182
183 self.notify_change(
184 path,
185 if is_new {
186 FileChange::Created(path.to_string())
187 } else {
188 FileChange::Updated(path.to_string())
189 },
190 );
191 Ok(())
192 }
193
194 pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
203 let existing = self.note_read(path).ok().flatten();
205 let final_content = match existing {
206 Some(ref existing_content) => {
207 let (existing_meta, body) = parse_note_meta(existing_content);
208 match existing_meta {
209 Some(old_meta) => {
211 let merged = NoteMeta {
212 saved_at: old_meta.saved_at.or(meta.saved_at.clone()),
213 ..meta.clone()
214 };
215 format_frontmatter(&merged, if body.is_empty() { content } else { &body })
216 }
217 None => {
220 tracing::debug!(
221 path,
222 "Skipping note_write_with_meta on user-authored note"
223 );
224 return Ok(false);
225 }
226 }
227 }
228 None => format_frontmatter(meta, content),
229 };
230 self.note_write(path, &final_content).map(|_| true)
231 }
232
233 pub fn notes_needing_review(&self) -> Result<Vec<(String, NoteMeta)>> {
239 let fs = self.fs.read();
240 let mut result = Vec::new();
241
242 let files = fs.all_md_files()?;
243 for (path, _size) in &files {
244 if let Ok(content) = fs.read_path(path) {
245 let (meta, _body) = parse_note_meta(&content);
246 if let Some(m) = meta
247 && m.needs_review
248 {
249 result.push((path.clone(), m));
250 }
251 }
252 }
253
254 result.sort_by(|a, b| {
256 a.1.saved_at
257 .as_deref()
258 .unwrap_or("")
259 .cmp(b.1.saved_at.as_deref().unwrap_or(""))
260 });
261
262 Ok(result)
263 }
264 pub fn note_delete(&self, path: &str) -> Result<()> {
267 {
268 let fs = self.fs.write();
269 fs.delete_path(path)?;
270 }
271 self.backlinks.write().remove_file(path);
272 self.notify_change(path, FileChange::Deleted(path.to_string()));
273 Ok(())
274 }
275
276 pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
283 {
284 let fs = self.fs.write();
285 fs.write_path(path, content)?;
286 }
287 let mut backlinks = self.backlinks.write();
288 backlinks.remove_file(path);
289 backlinks.index_file(path, content);
290 Ok(())
292 }
293
294 pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
296 let new_content = {
299 let fs = self.fs.write();
300 fs.rename_path(old_path, new_path)?;
301 fs.read_path(new_path).ok()
302 };
303 {
304 let mut backlinks = self.backlinks.write();
305 backlinks.remove_file(old_path);
306 if let Some(content) = new_content {
307 backlinks.index_file(new_path, &content);
308 }
309 }
310 self.notify_change(
311 old_path,
312 FileChange::Moved {
313 old: old_path.to_string(),
314 new: new_path.to_string(),
315 },
316 );
317 Ok(())
318 }
319
320 pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
322 let fs = self.fs.read();
323 let dir = if dir.is_empty() || dir == "/" {
324 DIR_USER_ROOT
325 } else {
326 dir
327 };
328 Ok(fs.files_and_dirs(dir)?)
329 }
330
331 pub fn list_all_md_files(&self) -> Result<Vec<(String, i64)>> {
334 let fs = self.fs.read();
335 Ok(fs.all_md_files()?)
336 }
337
338 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
345 let fs = self.fs.read();
346 let files = fs.search_files_by_name(query)?;
347
348 let hits: Vec<NoteHit> = files
349 .into_iter()
350 .take(limit)
351 .map(|f| {
352 let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
353 f.name.clone()
354 } else {
355 format!("{}/{}", f.parent_dir, f.name)
356 };
357 let name_sim = similar(&f.display_name, query) as i32;
358 let bl_count = self.backlinks.read().backlink_count(&path);
359 NoteHit {
360 path,
361 name: f.display_name,
362 snippet: String::new(),
363 backlink_count: bl_count,
364 name_similarity: name_sim,
365 }
366 })
367 .collect();
368
369 Ok(hits)
370 }
371
372 pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
376 self.backlinks.read().backlinks_for(path)
377 }
378
379 pub fn link_graph(&self) -> LinkGraph {
381 self.backlinks.read().link_graph()
382 }
383
384 pub fn index_all(&self) -> Result<usize> {
389 let fs = self.fs.read();
390 let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
391 let mut count = 0;
392
393 for entry in &entries {
394 if entry.is_dir {
395 let sub = fs.files_and_dirs(&entry.name)?;
396 for sub_entry in &sub {
397 if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
398 let path = format!("{}/{}", entry.name, sub_entry.name);
399 if let Ok(content) = fs.read_path(&path) {
400 self.backlinks.write().index_file(&path, &content);
401 count += 1;
402 }
403 }
404 }
405 } else if entry.name.ends_with(".md")
406 && let Ok(content) = fs.read_path(&entry.name)
407 {
408 self.backlinks.write().index_file(&entry.name, &content);
409 count += 1;
410 }
411 }
412
413 tracing::info!(files = count, "Knowledge base indexed");
414 Ok(count)
415 }
416
417 pub fn chat_append(&self, message: &str) -> Result<()> {
421 let header = today_chat_header();
422 let timestamp = chrono::Local::now().format("`15:04`").to_string();
423 let entry = format!("- [ ] {timestamp} {message}");
424
425 let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
426 if !content.contains(&header) {
427 if !content.trim_end().ends_with('\n') {
428 content.push('\n');
429 }
430 content.push_str(&header);
431 content.push('\n');
432 }
433 content.push_str(&entry);
434 content.push('\n');
435 self.note_write(CHAT_FILENAME, &content)?;
436 Ok(())
437 }
438
439 pub fn chat_messages(&self) -> Result<Vec<String>> {
441 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
442 Ok(read_chat_msgs(&content))
443 }
444
445 pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
447 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
448 match delete_chat_msg(&content, msg_hash) {
449 Ok(new_content) => {
450 self.note_write(CHAT_FILENAME, &new_content)?;
451 Ok(true)
452 }
453 Err(_) => Ok(false),
454 }
455 }
456
457 pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
459 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
460 match rename_chat_msg(&content, msg_hash, new_body) {
461 Ok(new_content) => {
462 self.note_write(CHAT_FILENAME, &new_content)?;
463 Ok(true)
464 }
465 Err(_) => Ok(false),
466 }
467 }
468
469 pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
471 let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
472 let target_content = self.note_read(target_path)?.unwrap_or_default();
473 let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
474 if new_chat != chat_content {
475 self.note_write(CHAT_FILENAME, &new_chat)?;
476 self.note_write(target_path, &new_target)?;
477 Ok(true)
478 } else {
479 Ok(false)
480 }
481 }
482
483 pub fn journal_add_record(&self, record: &str) -> Result<()> {
487 let fs = self.fs.write();
488 let tz = chrono::Local::now().offset().to_owned();
489 journal_add_record(&fs, record, tz)?;
490 Ok(())
491 }
492
493 pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
495 let fs = self.fs.write();
496 let tz = chrono::Local::now().offset().to_owned();
497 journal_add_emoji(&fs, emoji, tz)?;
498 Ok(())
499 }
500
501 pub fn journal_today_path(&self) -> String {
503 let tz = chrono::Local::now().offset().to_owned();
504 today_journal_filename(tz)
505 }
506
507 pub fn habits(&self, year: i32) -> Result<Habits> {
511 let fs = self.fs.read();
512 Ok(habits(&fs, year)?)
513 }
514
515 pub fn habits_last_week(&self) -> Result<Habits> {
517 let fs = self.fs.read();
518 let tz = chrono::Local::now().offset().to_owned();
519 Ok(last_week_habits(&fs, tz)?)
520 }
521
522 pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
524 let fs = self.fs.write();
525 write_habits(&fs, year, habits)?;
526 Ok(())
527 }
528
529 pub fn config(&self) -> Result<KnowledgeConfig> {
533 let fs = self.fs.read();
534 match fs.read_path("config.json") {
535 Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
536 Err(_) => Ok(KnowledgeConfig::default()),
537 }
538 }
539
540 pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
542 let json = serde_json::to_string_pretty(config)?;
543 self.note_write("config.json", &json)?;
544 Ok(())
545 }
546
547 pub fn checklist_items(
551 &self,
552 path: &str,
553 ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
554 let content = self.note_read(path)?.unwrap_or_default();
555 Ok(checklist_items(&content))
556 }
557
558 pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
560 let content = self.note_read(path)?.unwrap_or_default();
561 Ok(incomplete_checklist_items(&content))
562 }
563
564 pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
566 let content = self.note_read(path)?.unwrap_or_default();
567 let updated = add_checklist_item(&content, item, checked);
568 self.note_write(path, &updated)
569 }
570
571 pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
573 let content = self.note_read(path)?.unwrap_or_default();
574 let (new_content, found) = complete_checklist_item(&content, item_hash);
575 if !found.is_empty() {
576 self.note_write(path, &new_content)?;
577 Ok(true)
578 } else {
579 Ok(false)
580 }
581 }
582
583 pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
585 let content = self.note_read(path)?.unwrap_or_default();
586 let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
587 if !removed.is_empty() {
588 self.note_write(path, &new_content)?;
589 Ok(true)
590 } else {
591 Ok(false)
592 }
593 }
594
595 pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
597 let content = self.note_read(path)?.unwrap_or_default();
598 let (kept, removed) = remove_completed_checklist_items(&content);
599 if !removed.is_empty() {
600 self.note_write(path, &kept)?;
601 }
602 Ok((kept, removed))
603 }
604
605 pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
609 let config = self.config()?;
612 let fs = self.fs.write();
613 Ok(remove_completed_items(&fs, &config)?)
614 }
615
616 pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
618 let mut config = self.config()?;
622 let moved = {
623 let fs = self.fs.write();
624 move_due_tasks(&fs, &mut config)?
625 };
626 if !moved.is_empty() {
627 self.set_config(&config)?;
628 }
629 Ok(moved)
630 }
631
632 pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
636 let fs = self.fs.read();
637 Ok(today_report(&fs)?)
638 }
639
640 pub fn done_today(&self) -> Result<Vec<FileEntry>> {
642 let fs = self.fs.read();
643 Ok(done_today(&fs)?)
644 }
645
646 pub fn markdown_to_html(&self, md: &str) -> String {
650 markdown_to_html(md)
651 }
652
653 pub fn auto_emoji(&self, text: &str) -> String {
655 emoji_for(text)
656 }
657
658 pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
660 world_clock_for_names(timezone_names)
661 }
662
663 pub fn mark_agent_write(&self, path: &str) {
667 self.agent_writes.lock().insert(path.to_string());
668 }
669
670 pub fn is_agent_write(&self, path: &str) -> bool {
672 self.agent_writes.lock().contains(path)
673 }
674
675 pub fn clear_agent_write(&self, path: &str) {
677 self.agent_writes.lock().remove(path);
678 }
679
680 pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
684 crate::tgtxt::extract_text_imgs_links(text)
685 }
686
687 pub fn extract_headings(&self, content: &str) -> Vec<String> {
691 extract_headings(content).into_iter().take(5).collect()
692 }
693}
694
695pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
707 let trimmed = content.trim_start();
708 if !trimmed.starts_with("---") {
709 return (None, content.to_string());
710 }
711
712 let after_first = &trimmed[3..];
714 let rest = after_first.trim_start_matches(['-', '\n', '\r']);
715 if let Some(end_offset) = rest.find("\n---") {
716 let yaml_block = &rest[..end_offset];
717 let body_start = end_offset + 4; let body = rest[body_start..].trim_start().to_string();
719
720 if !yaml_block.contains("oxios:") {
722 return (None, content.to_string());
724 }
725
726 #[derive(serde::Deserialize)]
727 struct FrontmatterWrapper {
728 oxios: NoteMeta,
729 }
730
731 match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
732 Ok(wrapper) => (Some(wrapper.oxios), body),
733 Err(_) => (None, content.to_string()),
734 }
735 } else {
736 (None, content.to_string())
737 }
738}
739
740fn format_frontmatter(meta: &NoteMeta, body: &str) -> String {
746 let yaml = serde_yaml::to_string(meta).unwrap_or_default();
747 let indented: String = yaml
748 .lines()
749 .filter(|l| !l.is_empty())
750 .map(|l| format!(" {l}"))
751 .collect::<Vec<_>>()
752 .join("\n");
753 format!("---\noxios:\n{}\n---\n\n{}", indented, body)
754}
755
756#[cfg(test)]
761mod tests {
762 use super::*;
763
764 fn make_test_kb() -> KnowledgeBase {
765 let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
766 KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
767 }
768
769 #[test]
770 fn test_note_write_and_read() {
771 let kb = make_test_kb();
772 kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
773 .unwrap();
774 let content = kb.note_read("brain/Rust.md").unwrap();
775 assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
776 }
777
778 #[test]
779 fn test_note_read_missing() {
780 let kb = make_test_kb();
781 assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
782 }
783
784 #[test]
785 fn test_note_delete() {
786 let kb = make_test_kb();
787 kb.note_write("del.md", "to delete").unwrap();
788 kb.note_delete("del.md").unwrap();
789 assert_eq!(kb.note_read("del.md").unwrap(), None);
790 }
791
792 #[test]
793 fn test_note_move() {
794 let kb = make_test_kb();
795 kb.note_write("old.md", "content").unwrap();
796 kb.note_move("old.md", "new.md").unwrap();
797 assert_eq!(kb.note_read("old.md").unwrap(), None);
798 assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
799 }
800
801 #[test]
802 fn test_backlinks() {
803 let kb = make_test_kb();
804 kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
805 .unwrap();
806 let bl = kb.backlinks_for("brain/Ownership.md");
807 assert_eq!(bl.len(), 1);
808 assert_eq!(bl[0].source_path, "brain/Rust.md");
809 }
810
811 #[test]
812 fn test_note_tree() {
813 let kb = make_test_kb();
814 kb.note_write("brain/Rust.md", "Rust").unwrap();
815 let entries = kb.note_tree("brain").unwrap();
816 assert!(!entries.is_empty());
817 }
818
819 #[test]
820 fn test_search_by_name() {
821 let kb = make_test_kb();
822 kb.note_write("brain/Rust.md", "Rust content").unwrap();
823 let hits = kb.search("Rust", 10).unwrap();
824 assert!(!hits.is_empty());
825 }
826
827 #[test]
828 fn test_link_graph() {
829 let kb = make_test_kb();
830 kb.note_write("a.md", "[b](b.md)").unwrap();
831 let graph = kb.link_graph();
832 assert!(!graph.edges.is_empty());
833 }
834
835 #[test]
836 fn test_agent_write_tracking() {
837 let kb = make_test_kb();
838 assert!(!kb.is_agent_write("test.md"));
839 kb.mark_agent_write("test.md");
840 assert!(kb.is_agent_write("test.md"));
841 kb.clear_agent_write("test.md");
842 assert!(!kb.is_agent_write("test.md"));
843 }
844
845 #[test]
846 fn test_index_all() {
847 let kb = make_test_kb();
848 kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
849 .unwrap();
850 kb.note_write("brain/Go.md", "Go language").unwrap();
851 kb.note_write("index.md", "Welcome").unwrap();
852 let count = kb.index_all().unwrap();
853 assert_eq!(count, 3);
854 let bl = kb.backlinks_for("brain/Go.md");
855 assert_eq!(bl.len(), 1);
856 }
857
858 #[test]
859 fn test_on_file_change_callback() {
860 let kb = make_test_kb();
861 let _called = std::sync::atomic::AtomicBool::new(false);
862 let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
863 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
864 let flag = path_clone.clone();
865
866 kb.on_file_change(move |path, change| {
867 let _ = path;
868 let _ = change;
869 flag.store(true, std::sync::atomic::Ordering::SeqCst);
870 });
871
872 kb.note_write("test.md", "hello").unwrap();
873 assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
874 }
875
876 #[test]
877 fn test_chat_append() {
878 let kb = make_test_kb();
879 kb.chat_append("Test message").unwrap();
880 let messages = kb.chat_messages().unwrap();
881 assert!(
885 messages
886 .iter()
887 .any(|m| m.starts_with("- [") && m.contains("Test message")),
888 "captured message should be a parseable marker block: {messages:?}"
889 );
890 }
891
892 #[test]
893 fn test_config() {
894 let kb = make_test_kb();
895 let cfg = kb.config().unwrap();
896 let cfg2 = kb.config().unwrap();
898 assert_eq!(cfg.language, cfg2.language);
899 }
900
901 #[test]
902 fn test_markdown_to_html() {
903 let kb = make_test_kb();
904 let html = kb.markdown_to_html("# Hello\n\n**world**");
905 assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
907 assert!(html.contains("world"), "HTML should contain world: {html}");
908 }
909
910 #[test]
911 fn test_auto_emoji() {
912 let kb = make_test_kb();
913 let emoji = kb.auto_emoji("cooking pasta");
914 assert!(!emoji.is_empty());
915 }
916
917 #[test]
918 fn test_extract_headings() {
919 let kb = make_test_kb();
920 let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
921 assert!(headings.len() >= 2);
922 }
923
924 #[test]
925 fn test_frontmatter_roundtrip() {
926 let meta = NoteMeta {
927 author: "agent".to_string(),
928 source: NoteSource::Hook,
929 quality: NoteQuality::Raw,
930 needs_review: true,
931 session_id: Some("abc123".to_string()),
932 message_index: Some(3),
933 saved_at: Some("2026-06-13T00:00:00Z".to_string()),
934 };
935 let body = "## Test\n\nContent here.";
936 let formatted = format_frontmatter(&meta, body);
937 assert!(formatted.starts_with("---\noxios:\n"));
938 let (parsed_meta, parsed_body) = parse_note_meta(&formatted);
939 assert!(
940 parsed_meta.is_some(),
941 "Failed to parse round-tripped frontmatter"
942 );
943 let pm = parsed_meta.unwrap();
944 assert_eq!(pm.author, "agent");
945 assert_eq!(pm.session_id.as_deref(), Some("abc123"));
946 assert_eq!(pm.message_index, Some(3));
947 assert_eq!(parsed_body.trim(), body.trim());
948 }
949
950 #[test]
951 fn test_parse_user_frontmatter_ignored() {
952 let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
953 let (meta, body) = parse_note_meta(content);
954 assert!(
955 meta.is_none(),
956 "User frontmatter should not be parsed as NoteMeta"
957 );
958 assert!(
959 body.contains("tags: [rust, design]"),
960 "User frontmatter preserved"
961 );
962 }
963
964 #[test]
965 fn test_parse_no_frontmatter() {
966 let content = "# Just a note\nSome content.";
967 let (meta, body) = parse_note_meta(content);
968 assert!(meta.is_none());
969 assert_eq!(body, content);
970 }
971}