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 is_new = {
161 let fs = self.fs.write();
162 let is_new = fs.read_path(path).is_err();
163 fs.write_path(path, content)?;
164 is_new
165 };
166
167 {
168 let mut backlinks = self.backlinks.write();
169 backlinks.remove_file(path);
170 backlinks.index_file(path, content);
171 }
172
173 self.notify_change(
174 path,
175 if is_new {
176 FileChange::Created(path.to_string())
177 } else {
178 FileChange::Updated(path.to_string())
179 },
180 );
181 Ok(())
182 }
183
184 pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
193 let existing = self.note_read(path).ok().flatten();
195 let final_content = match existing {
196 Some(ref existing_content) => {
197 let (existing_meta, body) = parse_note_meta(existing_content);
198 match existing_meta {
199 Some(old_meta) => {
201 let merged = NoteMeta {
202 saved_at: old_meta.saved_at.or(meta.saved_at.clone()),
203 ..meta.clone()
204 };
205 format_frontmatter(&merged, if body.is_empty() { content } else { &body })
206 }
207 None => {
210 tracing::debug!(
211 path,
212 "Skipping note_write_with_meta on user-authored note"
213 );
214 return Ok(false);
215 }
216 }
217 }
218 None => format_frontmatter(meta, content),
219 };
220 self.note_write(path, &final_content).map(|_| true)
221 }
222
223 pub fn notes_needing_review(&self) -> Result<Vec<(String, NoteMeta)>> {
229 let fs = self.fs.read();
230 let mut result = Vec::new();
231
232 let files = fs.all_md_files()?;
233 for (path, _size) in &files {
234 if let Ok(content) = fs.read_path(path) {
235 let (meta, _body) = parse_note_meta(&content);
236 if let Some(m) = meta
237 && m.needs_review
238 {
239 result.push((path.clone(), m));
240 }
241 }
242 }
243
244 result.sort_by(|a, b| {
246 a.1.saved_at
247 .as_deref()
248 .unwrap_or("")
249 .cmp(b.1.saved_at.as_deref().unwrap_or(""))
250 });
251
252 Ok(result)
253 }
254 pub fn note_delete(&self, path: &str) -> Result<()> {
257 {
258 let fs = self.fs.write();
259 fs.delete_path(path)?;
260 }
261 self.backlinks.write().remove_file(path);
262 self.notify_change(path, FileChange::Deleted(path.to_string()));
263 Ok(())
264 }
265
266 pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
273 {
274 let fs = self.fs.write();
275 fs.write_path(path, content)?;
276 }
277 let mut backlinks = self.backlinks.write();
278 backlinks.remove_file(path);
279 backlinks.index_file(path, content);
280 Ok(())
282 }
283
284 pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
286 let new_content = {
289 let fs = self.fs.write();
290 fs.rename_path(old_path, new_path)?;
291 fs.read_path(new_path).ok()
292 };
293 {
294 let mut backlinks = self.backlinks.write();
295 backlinks.remove_file(old_path);
296 if let Some(content) = new_content {
297 backlinks.index_file(new_path, &content);
298 }
299 }
300 self.notify_change(
301 old_path,
302 FileChange::Moved {
303 old: old_path.to_string(),
304 new: new_path.to_string(),
305 },
306 );
307 Ok(())
308 }
309
310 pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
312 let fs = self.fs.read();
313 let dir = if dir.is_empty() || dir == "/" {
314 DIR_USER_ROOT
315 } else {
316 dir
317 };
318 Ok(fs.files_and_dirs(dir)?)
319 }
320
321 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
328 let fs = self.fs.read();
329 let files = fs.search_files_by_name(query)?;
330
331 let hits: Vec<NoteHit> = files
332 .into_iter()
333 .take(limit)
334 .map(|f| {
335 let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
336 f.name.clone()
337 } else {
338 format!("{}/{}", f.parent_dir, f.name)
339 };
340 let name_sim = similar(&f.display_name, query) as i32;
341 let bl_count = self.backlinks.read().backlink_count(&path);
342 NoteHit {
343 path,
344 name: f.display_name,
345 snippet: String::new(),
346 backlink_count: bl_count,
347 name_similarity: name_sim,
348 }
349 })
350 .collect();
351
352 Ok(hits)
353 }
354
355 pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
359 self.backlinks.read().backlinks_for(path)
360 }
361
362 pub fn link_graph(&self) -> LinkGraph {
364 self.backlinks.read().link_graph()
365 }
366
367 pub fn index_all(&self) -> Result<usize> {
372 let fs = self.fs.read();
373 let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
374 let mut count = 0;
375
376 for entry in &entries {
377 if entry.is_dir {
378 let sub = fs.files_and_dirs(&entry.name)?;
379 for sub_entry in &sub {
380 if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
381 let path = format!("{}/{}", entry.name, sub_entry.name);
382 if let Ok(content) = fs.read_path(&path) {
383 self.backlinks.write().index_file(&path, &content);
384 count += 1;
385 }
386 }
387 }
388 } else if entry.name.ends_with(".md")
389 && let Ok(content) = fs.read_path(&entry.name)
390 {
391 self.backlinks.write().index_file(&entry.name, &content);
392 count += 1;
393 }
394 }
395
396 tracing::info!(files = count, "Knowledge base indexed");
397 Ok(count)
398 }
399
400 pub fn chat_append(&self, message: &str) -> Result<()> {
404 let header = today_chat_header();
405 let timestamp = chrono::Local::now().format("`15:04`").to_string();
406 let entry = format!("{timestamp} {message}");
407
408 let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
409 if !content.contains(&header) {
410 if !content.trim_end().ends_with('\n') {
411 content.push('\n');
412 }
413 content.push_str(&header);
414 content.push('\n');
415 }
416 content.push_str(&entry);
417 content.push('\n');
418 self.note_write(CHAT_FILENAME, &content)?;
419 Ok(())
420 }
421
422 pub fn chat_messages(&self) -> Result<Vec<String>> {
424 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
425 Ok(read_chat_msgs(&content))
426 }
427
428 pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
430 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
431 match delete_chat_msg(&content, msg_hash) {
432 Ok(new_content) => {
433 self.note_write(CHAT_FILENAME, &new_content)?;
434 Ok(true)
435 }
436 Err(_) => Ok(false),
437 }
438 }
439
440 pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
442 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
443 match rename_chat_msg(&content, msg_hash, new_body) {
444 Ok(new_content) => {
445 self.note_write(CHAT_FILENAME, &new_content)?;
446 Ok(true)
447 }
448 Err(_) => Ok(false),
449 }
450 }
451
452 pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
454 let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
455 let target_content = self.note_read(target_path)?.unwrap_or_default();
456 let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
457 if new_chat != chat_content {
458 self.note_write(CHAT_FILENAME, &new_chat)?;
459 self.note_write(target_path, &new_target)?;
460 Ok(true)
461 } else {
462 Ok(false)
463 }
464 }
465
466 pub fn journal_add_record(&self, record: &str) -> Result<()> {
470 let fs = self.fs.write();
471 let tz = chrono::Local::now().offset().to_owned();
472 journal_add_record(&fs, record, tz)?;
473 Ok(())
474 }
475
476 pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
478 let fs = self.fs.write();
479 let tz = chrono::Local::now().offset().to_owned();
480 journal_add_emoji(&fs, emoji, tz)?;
481 Ok(())
482 }
483
484 pub fn journal_today_path(&self) -> String {
486 let tz = chrono::Local::now().offset().to_owned();
487 today_journal_filename(tz)
488 }
489
490 pub fn habits(&self, year: i32) -> Result<Habits> {
494 let fs = self.fs.read();
495 Ok(habits(&fs, year)?)
496 }
497
498 pub fn habits_last_week(&self) -> Result<Habits> {
500 let fs = self.fs.read();
501 let tz = chrono::Local::now().offset().to_owned();
502 Ok(last_week_habits(&fs, tz)?)
503 }
504
505 pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
507 let fs = self.fs.write();
508 write_habits(&fs, year, habits)?;
509 Ok(())
510 }
511
512 pub fn config(&self) -> Result<KnowledgeConfig> {
516 let fs = self.fs.read();
517 match fs.read_path("config.json") {
518 Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
519 Err(_) => Ok(KnowledgeConfig::default()),
520 }
521 }
522
523 pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
525 let json = serde_json::to_string_pretty(config)?;
526 self.note_write("config.json", &json)?;
527 Ok(())
528 }
529
530 pub fn checklist_items(
534 &self,
535 path: &str,
536 ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
537 let content = self.note_read(path)?.unwrap_or_default();
538 Ok(checklist_items(&content))
539 }
540
541 pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
543 let content = self.note_read(path)?.unwrap_or_default();
544 Ok(incomplete_checklist_items(&content))
545 }
546
547 pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
549 let content = self.note_read(path)?.unwrap_or_default();
550 let updated = add_checklist_item(&content, item, checked);
551 self.note_write(path, &updated)
552 }
553
554 pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
556 let content = self.note_read(path)?.unwrap_or_default();
557 let (new_content, found) = complete_checklist_item(&content, item_hash);
558 if !found.is_empty() {
559 self.note_write(path, &new_content)?;
560 Ok(true)
561 } else {
562 Ok(false)
563 }
564 }
565
566 pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
568 let content = self.note_read(path)?.unwrap_or_default();
569 let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
570 if !removed.is_empty() {
571 self.note_write(path, &new_content)?;
572 Ok(true)
573 } else {
574 Ok(false)
575 }
576 }
577
578 pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
580 let content = self.note_read(path)?.unwrap_or_default();
581 let (kept, removed) = remove_completed_checklist_items(&content);
582 if !removed.is_empty() {
583 self.note_write(path, &kept)?;
584 }
585 Ok((kept, removed))
586 }
587
588 pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
592 let config = self.config()?;
595 let fs = self.fs.write();
596 Ok(remove_completed_items(&fs, &config)?)
597 }
598
599 pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
601 let mut config = self.config()?;
605 let moved = {
606 let fs = self.fs.write();
607 move_due_tasks(&fs, &mut config)?
608 };
609 if !moved.is_empty() {
610 self.set_config(&config)?;
611 }
612 Ok(moved)
613 }
614
615 pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
619 let fs = self.fs.read();
620 Ok(today_report(&fs)?)
621 }
622
623 pub fn done_today(&self) -> Result<Vec<FileEntry>> {
625 let fs = self.fs.read();
626 Ok(done_today(&fs)?)
627 }
628
629 pub fn markdown_to_html(&self, md: &str) -> String {
633 markdown_to_html(md)
634 }
635
636 pub fn auto_emoji(&self, text: &str) -> String {
638 emoji_for(text)
639 }
640
641 pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
643 world_clock_for_names(timezone_names)
644 }
645
646 pub fn mark_agent_write(&self, path: &str) {
650 self.agent_writes.lock().insert(path.to_string());
651 }
652
653 pub fn is_agent_write(&self, path: &str) -> bool {
655 self.agent_writes.lock().contains(path)
656 }
657
658 pub fn clear_agent_write(&self, path: &str) {
660 self.agent_writes.lock().remove(path);
661 }
662
663 pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
667 crate::tgtxt::extract_text_imgs_links(text)
668 }
669
670 pub fn extract_headings(&self, content: &str) -> Vec<String> {
674 extract_headings(content).into_iter().take(5).collect()
675 }
676}
677
678pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
690 let trimmed = content.trim_start();
691 if !trimmed.starts_with("---") {
692 return (None, content.to_string());
693 }
694
695 let after_first = &trimmed[3..];
697 let rest = after_first.trim_start_matches(['-', '\n', '\r']);
698 if let Some(end_offset) = rest.find("\n---") {
699 let yaml_block = &rest[..end_offset];
700 let body_start = end_offset + 4; let body = rest[body_start..].trim_start().to_string();
702
703 if !yaml_block.contains("oxios:") {
705 return (None, content.to_string());
707 }
708
709 #[derive(serde::Deserialize)]
710 struct FrontmatterWrapper {
711 oxios: NoteMeta,
712 }
713
714 match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
715 Ok(wrapper) => (Some(wrapper.oxios), body),
716 Err(_) => (None, content.to_string()),
717 }
718 } else {
719 (None, content.to_string())
720 }
721}
722
723fn format_frontmatter(meta: &NoteMeta, body: &str) -> String {
729 let yaml = serde_yaml::to_string(meta).unwrap_or_default();
730 let indented: String = yaml
731 .lines()
732 .filter(|l| !l.is_empty())
733 .map(|l| format!(" {l}"))
734 .collect::<Vec<_>>()
735 .join("\n");
736 format!("---\noxios:\n{}\n---\n\n{}", indented, body)
737}
738
739#[cfg(test)]
744mod tests {
745 use super::*;
746
747 fn make_test_kb() -> KnowledgeBase {
748 let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
749 KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
750 }
751
752 #[test]
753 fn test_note_write_and_read() {
754 let kb = make_test_kb();
755 kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
756 .unwrap();
757 let content = kb.note_read("brain/Rust.md").unwrap();
758 assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
759 }
760
761 #[test]
762 fn test_note_read_missing() {
763 let kb = make_test_kb();
764 assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
765 }
766
767 #[test]
768 fn test_note_delete() {
769 let kb = make_test_kb();
770 kb.note_write("del.md", "to delete").unwrap();
771 kb.note_delete("del.md").unwrap();
772 assert_eq!(kb.note_read("del.md").unwrap(), None);
773 }
774
775 #[test]
776 fn test_note_move() {
777 let kb = make_test_kb();
778 kb.note_write("old.md", "content").unwrap();
779 kb.note_move("old.md", "new.md").unwrap();
780 assert_eq!(kb.note_read("old.md").unwrap(), None);
781 assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
782 }
783
784 #[test]
785 fn test_backlinks() {
786 let kb = make_test_kb();
787 kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
788 .unwrap();
789 let bl = kb.backlinks_for("brain/Ownership.md");
790 assert_eq!(bl.len(), 1);
791 assert_eq!(bl[0].source_path, "brain/Rust.md");
792 }
793
794 #[test]
795 fn test_note_tree() {
796 let kb = make_test_kb();
797 kb.note_write("brain/Rust.md", "Rust").unwrap();
798 let entries = kb.note_tree("brain").unwrap();
799 assert!(!entries.is_empty());
800 }
801
802 #[test]
803 fn test_search_by_name() {
804 let kb = make_test_kb();
805 kb.note_write("brain/Rust.md", "Rust content").unwrap();
806 let hits = kb.search("Rust", 10).unwrap();
807 assert!(!hits.is_empty());
808 }
809
810 #[test]
811 fn test_link_graph() {
812 let kb = make_test_kb();
813 kb.note_write("a.md", "[b](b.md)").unwrap();
814 let graph = kb.link_graph();
815 assert!(!graph.edges.is_empty());
816 }
817
818 #[test]
819 fn test_agent_write_tracking() {
820 let kb = make_test_kb();
821 assert!(!kb.is_agent_write("test.md"));
822 kb.mark_agent_write("test.md");
823 assert!(kb.is_agent_write("test.md"));
824 kb.clear_agent_write("test.md");
825 assert!(!kb.is_agent_write("test.md"));
826 }
827
828 #[test]
829 fn test_index_all() {
830 let kb = make_test_kb();
831 kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
832 .unwrap();
833 kb.note_write("brain/Go.md", "Go language").unwrap();
834 kb.note_write("index.md", "Welcome").unwrap();
835 let count = kb.index_all().unwrap();
836 assert_eq!(count, 3);
837 let bl = kb.backlinks_for("brain/Go.md");
838 assert_eq!(bl.len(), 1);
839 }
840
841 #[test]
842 fn test_on_file_change_callback() {
843 let kb = make_test_kb();
844 let _called = std::sync::atomic::AtomicBool::new(false);
845 let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
846 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
847 let flag = path_clone.clone();
848
849 kb.on_file_change(move |path, change| {
850 let _ = path;
851 let _ = change;
852 flag.store(true, std::sync::atomic::Ordering::SeqCst);
853 });
854
855 kb.note_write("test.md", "hello").unwrap();
856 assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
857 }
858
859 #[test]
860 fn test_chat_append() {
861 let kb = make_test_kb();
862 kb.chat_append("Test message").unwrap();
863 let messages = kb.chat_messages().unwrap();
864 assert!(!messages.is_empty());
865 }
866
867 #[test]
868 fn test_config() {
869 let kb = make_test_kb();
870 let cfg = kb.config().unwrap();
871 let cfg2 = kb.config().unwrap();
873 assert_eq!(cfg.language, cfg2.language);
874 }
875
876 #[test]
877 fn test_markdown_to_html() {
878 let kb = make_test_kb();
879 let html = kb.markdown_to_html("# Hello\n\n**world**");
880 assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
882 assert!(html.contains("world"), "HTML should contain world: {html}");
883 }
884
885 #[test]
886 fn test_auto_emoji() {
887 let kb = make_test_kb();
888 let emoji = kb.auto_emoji("cooking pasta");
889 assert!(!emoji.is_empty());
890 }
891
892 #[test]
893 fn test_extract_headings() {
894 let kb = make_test_kb();
895 let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
896 assert!(headings.len() >= 2);
897 }
898
899 #[test]
900 fn test_frontmatter_roundtrip() {
901 let meta = NoteMeta {
902 author: "agent".to_string(),
903 source: NoteSource::Hook,
904 quality: NoteQuality::Raw,
905 needs_review: true,
906 session_id: Some("abc123".to_string()),
907 message_index: Some(3),
908 saved_at: Some("2026-06-13T00:00:00Z".to_string()),
909 };
910 let body = "## Test\n\nContent here.";
911 let formatted = format_frontmatter(&meta, body);
912 assert!(formatted.starts_with("---\noxios:\n"));
913 let (parsed_meta, parsed_body) = parse_note_meta(&formatted);
914 assert!(
915 parsed_meta.is_some(),
916 "Failed to parse round-tripped frontmatter"
917 );
918 let pm = parsed_meta.unwrap();
919 assert_eq!(pm.author, "agent");
920 assert_eq!(pm.session_id.as_deref(), Some("abc123"));
921 assert_eq!(pm.message_index, Some(3));
922 assert_eq!(parsed_body.trim(), body.trim());
923 }
924
925 #[test]
926 fn test_parse_user_frontmatter_ignored() {
927 let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
928 let (meta, body) = parse_note_meta(content);
929 assert!(
930 meta.is_none(),
931 "User frontmatter should not be parsed as NoteMeta"
932 );
933 assert!(
934 body.contains("tags: [rust, design]"),
935 "User frontmatter preserved"
936 );
937 }
938
939 #[test]
940 fn test_parse_no_frontmatter() {
941 let content = "# Just a note\nSome content.";
942 let (meta, body) = parse_note_meta(content);
943 assert!(meta.is_none());
944 assert_eq!(body, content);
945 }
946}