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 search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
338 let fs = self.fs.read();
339 let files = fs.search_files_by_name(query)?;
340
341 let hits: Vec<NoteHit> = files
342 .into_iter()
343 .take(limit)
344 .map(|f| {
345 let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
346 f.name.clone()
347 } else {
348 format!("{}/{}", f.parent_dir, f.name)
349 };
350 let name_sim = similar(&f.display_name, query) as i32;
351 let bl_count = self.backlinks.read().backlink_count(&path);
352 NoteHit {
353 path,
354 name: f.display_name,
355 snippet: String::new(),
356 backlink_count: bl_count,
357 name_similarity: name_sim,
358 }
359 })
360 .collect();
361
362 Ok(hits)
363 }
364
365 pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
369 self.backlinks.read().backlinks_for(path)
370 }
371
372 pub fn link_graph(&self) -> LinkGraph {
374 self.backlinks.read().link_graph()
375 }
376
377 pub fn index_all(&self) -> Result<usize> {
382 let fs = self.fs.read();
383 let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
384 let mut count = 0;
385
386 for entry in &entries {
387 if entry.is_dir {
388 let sub = fs.files_and_dirs(&entry.name)?;
389 for sub_entry in &sub {
390 if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
391 let path = format!("{}/{}", entry.name, sub_entry.name);
392 if let Ok(content) = fs.read_path(&path) {
393 self.backlinks.write().index_file(&path, &content);
394 count += 1;
395 }
396 }
397 }
398 } else if entry.name.ends_with(".md")
399 && let Ok(content) = fs.read_path(&entry.name)
400 {
401 self.backlinks.write().index_file(&entry.name, &content);
402 count += 1;
403 }
404 }
405
406 tracing::info!(files = count, "Knowledge base indexed");
407 Ok(count)
408 }
409
410 pub fn chat_append(&self, message: &str) -> Result<()> {
414 let header = today_chat_header();
415 let timestamp = chrono::Local::now().format("`15:04`").to_string();
416 let entry = format!("{timestamp} {message}");
417
418 let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
419 if !content.contains(&header) {
420 if !content.trim_end().ends_with('\n') {
421 content.push('\n');
422 }
423 content.push_str(&header);
424 content.push('\n');
425 }
426 content.push_str(&entry);
427 content.push('\n');
428 self.note_write(CHAT_FILENAME, &content)?;
429 Ok(())
430 }
431
432 pub fn chat_messages(&self) -> Result<Vec<String>> {
434 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
435 Ok(read_chat_msgs(&content))
436 }
437
438 pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
440 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
441 match delete_chat_msg(&content, msg_hash) {
442 Ok(new_content) => {
443 self.note_write(CHAT_FILENAME, &new_content)?;
444 Ok(true)
445 }
446 Err(_) => Ok(false),
447 }
448 }
449
450 pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
452 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
453 match rename_chat_msg(&content, msg_hash, new_body) {
454 Ok(new_content) => {
455 self.note_write(CHAT_FILENAME, &new_content)?;
456 Ok(true)
457 }
458 Err(_) => Ok(false),
459 }
460 }
461
462 pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
464 let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
465 let target_content = self.note_read(target_path)?.unwrap_or_default();
466 let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
467 if new_chat != chat_content {
468 self.note_write(CHAT_FILENAME, &new_chat)?;
469 self.note_write(target_path, &new_target)?;
470 Ok(true)
471 } else {
472 Ok(false)
473 }
474 }
475
476 pub fn journal_add_record(&self, record: &str) -> Result<()> {
480 let fs = self.fs.write();
481 let tz = chrono::Local::now().offset().to_owned();
482 journal_add_record(&fs, record, tz)?;
483 Ok(())
484 }
485
486 pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
488 let fs = self.fs.write();
489 let tz = chrono::Local::now().offset().to_owned();
490 journal_add_emoji(&fs, emoji, tz)?;
491 Ok(())
492 }
493
494 pub fn journal_today_path(&self) -> String {
496 let tz = chrono::Local::now().offset().to_owned();
497 today_journal_filename(tz)
498 }
499
500 pub fn habits(&self, year: i32) -> Result<Habits> {
504 let fs = self.fs.read();
505 Ok(habits(&fs, year)?)
506 }
507
508 pub fn habits_last_week(&self) -> Result<Habits> {
510 let fs = self.fs.read();
511 let tz = chrono::Local::now().offset().to_owned();
512 Ok(last_week_habits(&fs, tz)?)
513 }
514
515 pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
517 let fs = self.fs.write();
518 write_habits(&fs, year, habits)?;
519 Ok(())
520 }
521
522 pub fn config(&self) -> Result<KnowledgeConfig> {
526 let fs = self.fs.read();
527 match fs.read_path("config.json") {
528 Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
529 Err(_) => Ok(KnowledgeConfig::default()),
530 }
531 }
532
533 pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
535 let json = serde_json::to_string_pretty(config)?;
536 self.note_write("config.json", &json)?;
537 Ok(())
538 }
539
540 pub fn checklist_items(
544 &self,
545 path: &str,
546 ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
547 let content = self.note_read(path)?.unwrap_or_default();
548 Ok(checklist_items(&content))
549 }
550
551 pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
553 let content = self.note_read(path)?.unwrap_or_default();
554 Ok(incomplete_checklist_items(&content))
555 }
556
557 pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
559 let content = self.note_read(path)?.unwrap_or_default();
560 let updated = add_checklist_item(&content, item, checked);
561 self.note_write(path, &updated)
562 }
563
564 pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
566 let content = self.note_read(path)?.unwrap_or_default();
567 let (new_content, found) = complete_checklist_item(&content, item_hash);
568 if !found.is_empty() {
569 self.note_write(path, &new_content)?;
570 Ok(true)
571 } else {
572 Ok(false)
573 }
574 }
575
576 pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
578 let content = self.note_read(path)?.unwrap_or_default();
579 let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
580 if !removed.is_empty() {
581 self.note_write(path, &new_content)?;
582 Ok(true)
583 } else {
584 Ok(false)
585 }
586 }
587
588 pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
590 let content = self.note_read(path)?.unwrap_or_default();
591 let (kept, removed) = remove_completed_checklist_items(&content);
592 if !removed.is_empty() {
593 self.note_write(path, &kept)?;
594 }
595 Ok((kept, removed))
596 }
597
598 pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
602 let config = self.config()?;
605 let fs = self.fs.write();
606 Ok(remove_completed_items(&fs, &config)?)
607 }
608
609 pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
611 let mut config = self.config()?;
615 let moved = {
616 let fs = self.fs.write();
617 move_due_tasks(&fs, &mut config)?
618 };
619 if !moved.is_empty() {
620 self.set_config(&config)?;
621 }
622 Ok(moved)
623 }
624
625 pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
629 let fs = self.fs.read();
630 Ok(today_report(&fs)?)
631 }
632
633 pub fn done_today(&self) -> Result<Vec<FileEntry>> {
635 let fs = self.fs.read();
636 Ok(done_today(&fs)?)
637 }
638
639 pub fn markdown_to_html(&self, md: &str) -> String {
643 markdown_to_html(md)
644 }
645
646 pub fn auto_emoji(&self, text: &str) -> String {
648 emoji_for(text)
649 }
650
651 pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
653 world_clock_for_names(timezone_names)
654 }
655
656 pub fn mark_agent_write(&self, path: &str) {
660 self.agent_writes.lock().insert(path.to_string());
661 }
662
663 pub fn is_agent_write(&self, path: &str) -> bool {
665 self.agent_writes.lock().contains(path)
666 }
667
668 pub fn clear_agent_write(&self, path: &str) {
670 self.agent_writes.lock().remove(path);
671 }
672
673 pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
677 crate::tgtxt::extract_text_imgs_links(text)
678 }
679
680 pub fn extract_headings(&self, content: &str) -> Vec<String> {
684 extract_headings(content).into_iter().take(5).collect()
685 }
686}
687
688pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
700 let trimmed = content.trim_start();
701 if !trimmed.starts_with("---") {
702 return (None, content.to_string());
703 }
704
705 let after_first = &trimmed[3..];
707 let rest = after_first.trim_start_matches(['-', '\n', '\r']);
708 if let Some(end_offset) = rest.find("\n---") {
709 let yaml_block = &rest[..end_offset];
710 let body_start = end_offset + 4; let body = rest[body_start..].trim_start().to_string();
712
713 if !yaml_block.contains("oxios:") {
715 return (None, content.to_string());
717 }
718
719 #[derive(serde::Deserialize)]
720 struct FrontmatterWrapper {
721 oxios: NoteMeta,
722 }
723
724 match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
725 Ok(wrapper) => (Some(wrapper.oxios), body),
726 Err(_) => (None, content.to_string()),
727 }
728 } else {
729 (None, content.to_string())
730 }
731}
732
733fn format_frontmatter(meta: &NoteMeta, body: &str) -> String {
739 let yaml = serde_yaml::to_string(meta).unwrap_or_default();
740 let indented: String = yaml
741 .lines()
742 .filter(|l| !l.is_empty())
743 .map(|l| format!(" {l}"))
744 .collect::<Vec<_>>()
745 .join("\n");
746 format!("---\noxios:\n{}\n---\n\n{}", indented, body)
747}
748
749#[cfg(test)]
754mod tests {
755 use super::*;
756
757 fn make_test_kb() -> KnowledgeBase {
758 let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
759 KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
760 }
761
762 #[test]
763 fn test_note_write_and_read() {
764 let kb = make_test_kb();
765 kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
766 .unwrap();
767 let content = kb.note_read("brain/Rust.md").unwrap();
768 assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
769 }
770
771 #[test]
772 fn test_note_read_missing() {
773 let kb = make_test_kb();
774 assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
775 }
776
777 #[test]
778 fn test_note_delete() {
779 let kb = make_test_kb();
780 kb.note_write("del.md", "to delete").unwrap();
781 kb.note_delete("del.md").unwrap();
782 assert_eq!(kb.note_read("del.md").unwrap(), None);
783 }
784
785 #[test]
786 fn test_note_move() {
787 let kb = make_test_kb();
788 kb.note_write("old.md", "content").unwrap();
789 kb.note_move("old.md", "new.md").unwrap();
790 assert_eq!(kb.note_read("old.md").unwrap(), None);
791 assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
792 }
793
794 #[test]
795 fn test_backlinks() {
796 let kb = make_test_kb();
797 kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
798 .unwrap();
799 let bl = kb.backlinks_for("brain/Ownership.md");
800 assert_eq!(bl.len(), 1);
801 assert_eq!(bl[0].source_path, "brain/Rust.md");
802 }
803
804 #[test]
805 fn test_note_tree() {
806 let kb = make_test_kb();
807 kb.note_write("brain/Rust.md", "Rust").unwrap();
808 let entries = kb.note_tree("brain").unwrap();
809 assert!(!entries.is_empty());
810 }
811
812 #[test]
813 fn test_search_by_name() {
814 let kb = make_test_kb();
815 kb.note_write("brain/Rust.md", "Rust content").unwrap();
816 let hits = kb.search("Rust", 10).unwrap();
817 assert!(!hits.is_empty());
818 }
819
820 #[test]
821 fn test_link_graph() {
822 let kb = make_test_kb();
823 kb.note_write("a.md", "[b](b.md)").unwrap();
824 let graph = kb.link_graph();
825 assert!(!graph.edges.is_empty());
826 }
827
828 #[test]
829 fn test_agent_write_tracking() {
830 let kb = make_test_kb();
831 assert!(!kb.is_agent_write("test.md"));
832 kb.mark_agent_write("test.md");
833 assert!(kb.is_agent_write("test.md"));
834 kb.clear_agent_write("test.md");
835 assert!(!kb.is_agent_write("test.md"));
836 }
837
838 #[test]
839 fn test_index_all() {
840 let kb = make_test_kb();
841 kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
842 .unwrap();
843 kb.note_write("brain/Go.md", "Go language").unwrap();
844 kb.note_write("index.md", "Welcome").unwrap();
845 let count = kb.index_all().unwrap();
846 assert_eq!(count, 3);
847 let bl = kb.backlinks_for("brain/Go.md");
848 assert_eq!(bl.len(), 1);
849 }
850
851 #[test]
852 fn test_on_file_change_callback() {
853 let kb = make_test_kb();
854 let _called = std::sync::atomic::AtomicBool::new(false);
855 let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
856 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
857 let flag = path_clone.clone();
858
859 kb.on_file_change(move |path, change| {
860 let _ = path;
861 let _ = change;
862 flag.store(true, std::sync::atomic::Ordering::SeqCst);
863 });
864
865 kb.note_write("test.md", "hello").unwrap();
866 assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
867 }
868
869 #[test]
870 fn test_chat_append() {
871 let kb = make_test_kb();
872 kb.chat_append("Test message").unwrap();
873 let messages = kb.chat_messages().unwrap();
874 assert!(!messages.is_empty());
875 }
876
877 #[test]
878 fn test_config() {
879 let kb = make_test_kb();
880 let cfg = kb.config().unwrap();
881 let cfg2 = kb.config().unwrap();
883 assert_eq!(cfg.language, cfg2.language);
884 }
885
886 #[test]
887 fn test_markdown_to_html() {
888 let kb = make_test_kb();
889 let html = kb.markdown_to_html("# Hello\n\n**world**");
890 assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
892 assert!(html.contains("world"), "HTML should contain world: {html}");
893 }
894
895 #[test]
896 fn test_auto_emoji() {
897 let kb = make_test_kb();
898 let emoji = kb.auto_emoji("cooking pasta");
899 assert!(!emoji.is_empty());
900 }
901
902 #[test]
903 fn test_extract_headings() {
904 let kb = make_test_kb();
905 let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
906 assert!(headings.len() >= 2);
907 }
908
909 #[test]
910 fn test_frontmatter_roundtrip() {
911 let meta = NoteMeta {
912 author: "agent".to_string(),
913 source: NoteSource::Hook,
914 quality: NoteQuality::Raw,
915 needs_review: true,
916 session_id: Some("abc123".to_string()),
917 message_index: Some(3),
918 saved_at: Some("2026-06-13T00:00:00Z".to_string()),
919 };
920 let body = "## Test\n\nContent here.";
921 let formatted = format_frontmatter(&meta, body);
922 assert!(formatted.starts_with("---\noxios:\n"));
923 let (parsed_meta, parsed_body) = parse_note_meta(&formatted);
924 assert!(
925 parsed_meta.is_some(),
926 "Failed to parse round-tripped frontmatter"
927 );
928 let pm = parsed_meta.unwrap();
929 assert_eq!(pm.author, "agent");
930 assert_eq!(pm.session_id.as_deref(), Some("abc123"));
931 assert_eq!(pm.message_index, Some(3));
932 assert_eq!(parsed_body.trim(), body.trim());
933 }
934
935 #[test]
936 fn test_parse_user_frontmatter_ignored() {
937 let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
938 let (meta, body) = parse_note_meta(content);
939 assert!(
940 meta.is_none(),
941 "User frontmatter should not be parsed as NoteMeta"
942 );
943 assert!(
944 body.contains("tags: [rust, design]"),
945 "User frontmatter preserved"
946 );
947 }
948
949 #[test]
950 fn test_parse_no_frontmatter() {
951 let content = "# Just a note\nSome content.";
952 let (meta, body) = parse_note_meta(content);
953 assert!(meta.is_none());
954 assert_eq!(body, content);
955 }
956}