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_write(&self, path: &str, content: &str) -> Result<()> {
157 let fs = self.fs.read();
158 let is_new = fs.read_path(path).is_err();
159
160 fs.write_path(path, content)?;
161
162 {
163 let mut backlinks = self.backlinks.write();
164 backlinks.remove_file(path);
165 backlinks.index_file(path, content);
166 }
167
168 self.notify_change(
169 path,
170 if is_new {
171 FileChange::Created(path.to_string())
172 } else {
173 FileChange::Updated(path.to_string())
174 },
175 );
176 Ok(())
177 }
178
179 pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
188 let existing = self.note_read(path).ok().flatten();
190 let final_content = match existing {
191 Some(ref existing_content) => {
192 let (existing_meta, body) = parse_note_meta(existing_content);
193 match existing_meta {
194 Some(old_meta) => {
196 let merged = NoteMeta {
197 saved_at: old_meta.saved_at.or(meta.saved_at.clone()),
198 ..meta.clone()
199 };
200 format_frontmatter(&merged, if body.is_empty() { content } else { &body })
201 }
202 None => {
205 tracing::debug!(
206 path,
207 "Skipping note_write_with_meta on user-authored note"
208 );
209 return Ok(false);
210 }
211 }
212 }
213 None => format_frontmatter(meta, content),
214 };
215 self.note_write(path, &final_content).map(|_| true)
216 }
217
218 pub fn notes_needing_review(&self) -> Result<Vec<(String, NoteMeta)>> {
224 let fs = self.fs.read();
225 let mut result = Vec::new();
226
227 let files = fs.all_md_files()?;
228 for (path, _size) in &files {
229 if let Ok(content) = fs.read_path(path) {
230 let (meta, _body) = parse_note_meta(&content);
231 if let Some(m) = meta
232 && m.needs_review
233 {
234 result.push((path.clone(), m));
235 }
236 }
237 }
238
239 result.sort_by(|a, b| {
241 a.1.saved_at
242 .as_deref()
243 .unwrap_or("")
244 .cmp(b.1.saved_at.as_deref().unwrap_or(""))
245 });
246
247 Ok(result)
248 }
249
250 pub fn note_delete(&self, path: &str) -> Result<()> {
253 self.fs.read().delete_path(path)?;
254 self.backlinks.write().remove_file(path);
255 self.notify_change(path, FileChange::Deleted(path.to_string()));
256 Ok(())
257 }
258
259 pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
266 self.fs.read().write_path(path, content)?;
267 let mut backlinks = self.backlinks.write();
268 backlinks.remove_file(path);
269 backlinks.index_file(path, content);
270 Ok(())
272 }
273
274 pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
276 self.fs.read().rename_path(old_path, new_path)?;
277 self.backlinks.write().remove_file(old_path);
278 if let Some(content) = self.note_read(new_path)? {
279 self.backlinks.write().index_file(new_path, &content);
280 }
281 self.notify_change(
282 old_path,
283 FileChange::Moved {
284 old: old_path.to_string(),
285 new: new_path.to_string(),
286 },
287 );
288 Ok(())
289 }
290
291 pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
293 let fs = self.fs.read();
294 let dir = if dir.is_empty() || dir == "/" {
295 DIR_USER_ROOT
296 } else {
297 dir
298 };
299 Ok(fs.files_and_dirs(dir)?)
300 }
301
302 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
309 let fs = self.fs.read();
310 let files = fs.search_files_by_name(query)?;
311
312 let hits: Vec<NoteHit> = files
313 .into_iter()
314 .take(limit)
315 .map(|f| {
316 let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
317 f.name.clone()
318 } else {
319 format!("{}/{}", f.parent_dir, f.name)
320 };
321 let name_sim = similar(&f.display_name, query) as i32;
322 let bl_count = self.backlinks.read().backlink_count(&path);
323 NoteHit {
324 path,
325 name: f.display_name,
326 snippet: String::new(),
327 backlink_count: bl_count,
328 name_similarity: name_sim,
329 }
330 })
331 .collect();
332
333 Ok(hits)
334 }
335
336 pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
340 self.backlinks.read().backlinks_for(path)
341 }
342
343 pub fn link_graph(&self) -> LinkGraph {
345 self.backlinks.read().link_graph()
346 }
347
348 pub fn index_all(&self) -> Result<usize> {
353 let fs = self.fs.read();
354 let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
355 let mut count = 0;
356
357 for entry in &entries {
358 if entry.is_dir {
359 let sub = fs.files_and_dirs(&entry.name)?;
360 for sub_entry in &sub {
361 if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
362 let path = format!("{}/{}", entry.name, sub_entry.name);
363 if let Ok(content) = fs.read_path(&path) {
364 self.backlinks.write().index_file(&path, &content);
365 count += 1;
366 }
367 }
368 }
369 } else if entry.name.ends_with(".md")
370 && let Ok(content) = fs.read_path(&entry.name)
371 {
372 self.backlinks.write().index_file(&entry.name, &content);
373 count += 1;
374 }
375 }
376
377 tracing::info!(files = count, "Knowledge base indexed");
378 Ok(count)
379 }
380
381 pub fn chat_append(&self, message: &str) -> Result<()> {
385 let header = today_chat_header();
386 let timestamp = chrono::Local::now().format("`15:04`").to_string();
387 let entry = format!("{timestamp} {message}");
388
389 let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
390 if !content.contains(&header) {
391 if !content.trim_end().ends_with('\n') {
392 content.push('\n');
393 }
394 content.push_str(&header);
395 content.push('\n');
396 }
397 content.push_str(&entry);
398 content.push('\n');
399 self.note_write(CHAT_FILENAME, &content)?;
400 Ok(())
401 }
402
403 pub fn chat_messages(&self) -> Result<Vec<String>> {
405 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
406 Ok(read_chat_msgs(&content))
407 }
408
409 pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
411 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
412 match delete_chat_msg(&content, msg_hash) {
413 Ok(new_content) => {
414 self.note_write(CHAT_FILENAME, &new_content)?;
415 Ok(true)
416 }
417 Err(_) => Ok(false),
418 }
419 }
420
421 pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
423 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
424 match rename_chat_msg(&content, msg_hash, new_body) {
425 Ok(new_content) => {
426 self.note_write(CHAT_FILENAME, &new_content)?;
427 Ok(true)
428 }
429 Err(_) => Ok(false),
430 }
431 }
432
433 pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
435 let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
436 let target_content = self.note_read(target_path)?.unwrap_or_default();
437 let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
438 if new_chat != chat_content {
439 self.note_write(CHAT_FILENAME, &new_chat)?;
440 self.note_write(target_path, &new_target)?;
441 Ok(true)
442 } else {
443 Ok(false)
444 }
445 }
446
447 pub fn journal_add_record(&self, record: &str) -> Result<()> {
451 let fs = self.fs.read();
452 let tz = chrono::Local::now().offset().to_owned();
453 journal_add_record(&fs, record, tz)?;
454 Ok(())
455 }
456
457 pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
459 let fs = self.fs.read();
460 let tz = chrono::Local::now().offset().to_owned();
461 journal_add_emoji(&fs, emoji, tz)?;
462 Ok(())
463 }
464
465 pub fn journal_today_path(&self) -> String {
467 let tz = chrono::Local::now().offset().to_owned();
468 today_journal_filename(tz)
469 }
470
471 pub fn habits(&self, year: i32) -> Result<Habits> {
475 let fs = self.fs.read();
476 Ok(habits(&fs, year)?)
477 }
478
479 pub fn habits_last_week(&self) -> Result<Habits> {
481 let fs = self.fs.read();
482 let tz = chrono::Local::now().offset().to_owned();
483 Ok(last_week_habits(&fs, tz)?)
484 }
485
486 pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
488 let fs = self.fs.read();
489 write_habits(&fs, year, habits)?;
490 Ok(())
491 }
492
493 pub fn config(&self) -> Result<KnowledgeConfig> {
497 let fs = self.fs.read();
498 match fs.read_path("config.json") {
499 Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
500 Err(_) => Ok(KnowledgeConfig::default()),
501 }
502 }
503
504 pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
506 let json = serde_json::to_string_pretty(config)?;
507 self.note_write("config.json", &json)?;
508 Ok(())
509 }
510
511 pub fn checklist_items(
515 &self,
516 path: &str,
517 ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
518 let content = self.note_read(path)?.unwrap_or_default();
519 Ok(checklist_items(&content))
520 }
521
522 pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
524 let content = self.note_read(path)?.unwrap_or_default();
525 Ok(incomplete_checklist_items(&content))
526 }
527
528 pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
530 let content = self.note_read(path)?.unwrap_or_default();
531 let updated = add_checklist_item(&content, item, checked);
532 self.note_write(path, &updated)
533 }
534
535 pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
537 let content = self.note_read(path)?.unwrap_or_default();
538 let (new_content, found) = complete_checklist_item(&content, item_hash);
539 if !found.is_empty() {
540 self.note_write(path, &new_content)?;
541 Ok(true)
542 } else {
543 Ok(false)
544 }
545 }
546
547 pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
549 let content = self.note_read(path)?.unwrap_or_default();
550 let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
551 if !removed.is_empty() {
552 self.note_write(path, &new_content)?;
553 Ok(true)
554 } else {
555 Ok(false)
556 }
557 }
558
559 pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
561 let content = self.note_read(path)?.unwrap_or_default();
562 let (kept, removed) = remove_completed_checklist_items(&content);
563 if !removed.is_empty() {
564 self.note_write(path, &kept)?;
565 }
566 Ok((kept, removed))
567 }
568
569 pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
573 let fs = self.fs.read();
574 let config = self.config()?;
575 Ok(remove_completed_items(&fs, &config)?)
576 }
577
578 pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
580 let fs = self.fs.read();
581 let mut config = self.config()?;
582 let moved = move_due_tasks(&fs, &mut config)?;
583 if !moved.is_empty() {
584 self.set_config(&config)?;
585 }
586 Ok(moved)
587 }
588
589 pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
593 let fs = self.fs.read();
594 Ok(today_report(&fs)?)
595 }
596
597 pub fn done_today(&self) -> Result<Vec<FileEntry>> {
599 let fs = self.fs.read();
600 Ok(done_today(&fs)?)
601 }
602
603 pub fn markdown_to_html(&self, md: &str) -> String {
607 markdown_to_html(md)
608 }
609
610 pub fn auto_emoji(&self, text: &str) -> String {
612 emoji_for(text)
613 }
614
615 pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
617 world_clock_for_names(timezone_names)
618 }
619
620 pub fn mark_agent_write(&self, path: &str) {
624 self.agent_writes.lock().insert(path.to_string());
625 }
626
627 pub fn is_agent_write(&self, path: &str) -> bool {
629 self.agent_writes.lock().contains(path)
630 }
631
632 pub fn clear_agent_write(&self, path: &str) {
634 self.agent_writes.lock().remove(path);
635 }
636
637 pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
641 crate::tgtxt::extract_text_imgs_links(text)
642 }
643
644 pub fn extract_headings(&self, content: &str) -> Vec<String> {
648 extract_headings(content).into_iter().take(5).collect()
649 }
650}
651
652pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
664 let trimmed = content.trim_start();
665 if !trimmed.starts_with("---") {
666 return (None, content.to_string());
667 }
668
669 let after_first = &trimmed[3..];
671 let rest = after_first.trim_start_matches(['-', '\n', '\r']);
672 if let Some(end_offset) = rest.find("\n---") {
673 let yaml_block = &rest[..end_offset];
674 let body_start = end_offset + 4; let body = rest[body_start..].trim_start().to_string();
676
677 if !yaml_block.contains("oxios:") {
679 return (None, content.to_string());
681 }
682
683 #[derive(serde::Deserialize)]
684 struct FrontmatterWrapper {
685 oxios: NoteMeta,
686 }
687
688 match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
689 Ok(wrapper) => (Some(wrapper.oxios), body),
690 Err(_) => (None, content.to_string()),
691 }
692 } else {
693 (None, content.to_string())
694 }
695}
696
697fn format_frontmatter(meta: &NoteMeta, body: &str) -> String {
703 let yaml = serde_yaml::to_string(meta).unwrap_or_default();
704 let indented: String = yaml
705 .lines()
706 .filter(|l| !l.is_empty())
707 .map(|l| format!(" {l}"))
708 .collect::<Vec<_>>()
709 .join("\n");
710 format!("---\noxios:\n{}\n---\n\n{}", indented, body)
711}
712
713#[cfg(test)]
718mod tests {
719 use super::*;
720
721 fn make_test_kb() -> KnowledgeBase {
722 let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
723 KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
724 }
725
726 #[test]
727 fn test_note_write_and_read() {
728 let kb = make_test_kb();
729 kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
730 .unwrap();
731 let content = kb.note_read("brain/Rust.md").unwrap();
732 assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
733 }
734
735 #[test]
736 fn test_note_read_missing() {
737 let kb = make_test_kb();
738 assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
739 }
740
741 #[test]
742 fn test_note_delete() {
743 let kb = make_test_kb();
744 kb.note_write("del.md", "to delete").unwrap();
745 kb.note_delete("del.md").unwrap();
746 assert_eq!(kb.note_read("del.md").unwrap(), None);
747 }
748
749 #[test]
750 fn test_note_move() {
751 let kb = make_test_kb();
752 kb.note_write("old.md", "content").unwrap();
753 kb.note_move("old.md", "new.md").unwrap();
754 assert_eq!(kb.note_read("old.md").unwrap(), None);
755 assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
756 }
757
758 #[test]
759 fn test_backlinks() {
760 let kb = make_test_kb();
761 kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
762 .unwrap();
763 let bl = kb.backlinks_for("brain/Ownership.md");
764 assert_eq!(bl.len(), 1);
765 assert_eq!(bl[0].source_path, "brain/Rust.md");
766 }
767
768 #[test]
769 fn test_note_tree() {
770 let kb = make_test_kb();
771 kb.note_write("brain/Rust.md", "Rust").unwrap();
772 let entries = kb.note_tree("brain").unwrap();
773 assert!(!entries.is_empty());
774 }
775
776 #[test]
777 fn test_search_by_name() {
778 let kb = make_test_kb();
779 kb.note_write("brain/Rust.md", "Rust content").unwrap();
780 let hits = kb.search("Rust", 10).unwrap();
781 assert!(!hits.is_empty());
782 }
783
784 #[test]
785 fn test_link_graph() {
786 let kb = make_test_kb();
787 kb.note_write("a.md", "[b](b.md)").unwrap();
788 let graph = kb.link_graph();
789 assert!(!graph.edges.is_empty());
790 }
791
792 #[test]
793 fn test_agent_write_tracking() {
794 let kb = make_test_kb();
795 assert!(!kb.is_agent_write("test.md"));
796 kb.mark_agent_write("test.md");
797 assert!(kb.is_agent_write("test.md"));
798 kb.clear_agent_write("test.md");
799 assert!(!kb.is_agent_write("test.md"));
800 }
801
802 #[test]
803 fn test_index_all() {
804 let kb = make_test_kb();
805 kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
806 .unwrap();
807 kb.note_write("brain/Go.md", "Go language").unwrap();
808 kb.note_write("index.md", "Welcome").unwrap();
809 let count = kb.index_all().unwrap();
810 assert_eq!(count, 3);
811 let bl = kb.backlinks_for("brain/Go.md");
812 assert_eq!(bl.len(), 1);
813 }
814
815 #[test]
816 fn test_on_file_change_callback() {
817 let kb = make_test_kb();
818 let _called = std::sync::atomic::AtomicBool::new(false);
819 let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
820 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
821 let flag = path_clone.clone();
822
823 kb.on_file_change(move |path, change| {
824 let _ = path;
825 let _ = change;
826 flag.store(true, std::sync::atomic::Ordering::SeqCst);
827 });
828
829 kb.note_write("test.md", "hello").unwrap();
830 assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
831 }
832
833 #[test]
834 fn test_chat_append() {
835 let kb = make_test_kb();
836 kb.chat_append("Test message").unwrap();
837 let messages = kb.chat_messages().unwrap();
838 assert!(!messages.is_empty());
839 }
840
841 #[test]
842 fn test_config() {
843 let kb = make_test_kb();
844 let cfg = kb.config().unwrap();
845 let cfg2 = kb.config().unwrap();
847 assert_eq!(cfg.language, cfg2.language);
848 }
849
850 #[test]
851 fn test_markdown_to_html() {
852 let kb = make_test_kb();
853 let html = kb.markdown_to_html("# Hello\n\n**world**");
854 assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
856 assert!(html.contains("world"), "HTML should contain world: {html}");
857 }
858
859 #[test]
860 fn test_auto_emoji() {
861 let kb = make_test_kb();
862 let emoji = kb.auto_emoji("cooking pasta");
863 assert!(!emoji.is_empty());
864 }
865
866 #[test]
867 fn test_extract_headings() {
868 let kb = make_test_kb();
869 let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
870 assert!(headings.len() >= 2);
871 }
872
873 #[test]
874 fn test_frontmatter_roundtrip() {
875 let meta = NoteMeta {
876 author: "agent".to_string(),
877 source: NoteSource::Hook,
878 quality: NoteQuality::Raw,
879 needs_review: true,
880 session_id: Some("abc123".to_string()),
881 message_index: Some(3),
882 saved_at: Some("2026-06-13T00:00:00Z".to_string()),
883 };
884 let body = "## Test\n\nContent here.";
885 let formatted = format_frontmatter(&meta, body);
886 assert!(formatted.starts_with("---\noxios:\n"));
887 let (parsed_meta, parsed_body) = parse_note_meta(&formatted);
888 assert!(
889 parsed_meta.is_some(),
890 "Failed to parse round-tripped frontmatter"
891 );
892 let pm = parsed_meta.unwrap();
893 assert_eq!(pm.author, "agent");
894 assert_eq!(pm.session_id.as_deref(), Some("abc123"));
895 assert_eq!(pm.message_index, Some(3));
896 assert_eq!(parsed_body.trim(), body.trim());
897 }
898
899 #[test]
900 fn test_parse_user_frontmatter_ignored() {
901 let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
902 let (meta, body) = parse_note_meta(content);
903 assert!(
904 meta.is_none(),
905 "User frontmatter should not be parsed as NoteMeta"
906 );
907 assert!(
908 body.contains("tags: [rust, design]"),
909 "User frontmatter preserved"
910 );
911 }
912
913 #[test]
914 fn test_parse_no_frontmatter() {
915 let content = "# Just a note\nSome content.";
916 let (meta, body) = parse_note_meta(content);
917 assert!(meta.is_none());
918 assert_eq!(body, content);
919 }
920}