Skip to main content

oxios_markdown/
knowledge.rs

1//! KnowledgeBase — markdown knowledge base application layer.
2//!
3//! Integrates `VirtualFs`, `BacklinkIndex`, and all app-layer features
4//! (chat, journal, habits, checklist, etc.) into a single struct.
5//!
6//! **No kernel dependencies. No AI dependencies.**
7//! This crate can be used standalone by any channel (web, CLI, etc.)
8//! without going through the kernel.
9
10use std::collections::HashSet;
11use std::path::PathBuf;
12
13use anyhow::Result;
14use parking_lot::{Mutex as ParkingMutex, RwLock};
15
16/// Callback type for file change notifications.
17/// Used by [`KnowledgeLens`] to keep the semantic index in sync.
18pub 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::{FileEntry, Habits, KnowledgeConfig, CHAT_FILENAME, DIR_USER_ROOT};
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/// File change event emitted via `on_file_change` callbacks.
42#[derive(Debug, Clone)]
43pub enum FileChange {
44    /// A new file was created.
45    Created(String),
46    /// An existing file was updated.
47    Updated(String),
48    /// A file was deleted.
49    Deleted(String),
50    /// A file was moved or renamed.
51    Moved {
52        /// Original path before the move.
53        old: String,
54        /// New path after the move.
55        new: String,
56    },
57}
58
59/// Knowledge search hit (file-name based).
60#[derive(Debug, Clone)]
61pub struct NoteHit {
62    /// File path relative to knowledge root.
63    pub path: String,
64    /// Display name of the file.
65    pub name: String,
66    /// Content snippet.
67    pub snippet: String,
68    /// Number of backlinks pointing to this note.
69    pub backlink_count: usize,
70    /// Name similarity score (0–100).
71    pub name_similarity: i32,
72}
73
74/// Markdown knowledge base application layer.
75///
76/// Wraps [`VirtualFs`] for sandboxed file I/O, [`BacklinkIndex`] for
77/// link tracking, and provides all app-layer features (chat, journal,
78/// habits, checklist, etc.).
79///
80/// **No kernel dependencies.** Can be used standalone by any channel.
81pub struct KnowledgeBase {
82    /// Sandboxed filesystem.
83    fs: RwLock<VirtualFs>,
84    /// Bidirectional link index.
85    backlinks: RwLock<BacklinkIndex>,
86    /// Files written by agents (not by the user).
87    agent_writes: ParkingMutex<HashSet<String>>,
88    /// Callbacks invoked on file changes.
89    /// Used by [`KnowledgeLens`] to keep semantic index in sync.
90    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    /// Create a new KnowledgeBase for the given root directory.
103    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    /// Create a new KnowledgeBase scoped to a Space's subdirectory.
114    pub fn for_space(space_dir: &std::path::Path) -> Result<Self> {
115        Self::new(space_dir.join("knowledge"))
116    }
117
118    /// Get the root path of the knowledge base.
119    pub fn root(&self) -> PathBuf {
120        self.fs.read().root().to_path_buf()
121    }
122
123    /// Register a callback to be invoked on every file change.
124    ///
125    /// The callback receives `(path, FileChange)`.
126    /// Multiple callbacks can be registered.
127    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    /// Emit file change notifications to all registered callbacks.
135    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    // ── File I/O ───────────────────────────────────────────────────
142
143    /// Read a note's content.
144    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    /// Write a note — creates or overwrites.
153    ///
154    /// Writes the `.md` file via VirtualFs, updates the backlink index,
155    /// and notifies registered `on_file_change` callbacks.
156    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    /// Write a note with provenance metadata (RFC-022).
180    ///
181    /// Prepends a YAML frontmatter block with `oxios:` metadata,
182    /// then delegates to `note_write`. If the file already has an
183    /// `oxios:` frontmatter block, it is merged (preserving `saved_at`,
184    /// updating `quality`/`source`). If the file has non-Oxios
185    /// frontmatter (e.g., Obsidian tags), it is left intact and
186    /// the note is treated as user-authored — no metadata is added.
187    pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
188        // Check existing content for frontmatter
189        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                    // Has Oxios frontmatter — merge
195                    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                    // No Oxios frontmatter — user-authored or foreign frontmatter.
203                    // Don't touch it. Return Ok without writing.
204                    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    /// List notes that need Dream review (RFC-022).
219    ///
220    /// Scans the vault for `.md` files with `needs_review: true` in their
221    /// Oxios frontmatter. Reads only the frontmatter block (stops at the
222    /// closing `---`) for efficiency.
223    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                    if m.needs_review {
233                        result.push((path.clone(), m));
234                    }
235                }
236            }
237        }
238
239        // Oldest first — they've been raw the longest
240        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    /// Delete the note at `path`, removing it from the filesystem and
251    /// dropping any recorded backlinks for that file.
252    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    /// Restore a note's content without triggering file-change callbacks.
260    ///
261    /// Used when reverting to a previous git version — writes the file
262    /// and updates the backlink index, but does **not** fire `on_file_change`
263    /// callbacks. This prevents an infinite loop where restore → write →
264    /// callback → git commit → ... repeats.
265    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        // Intentionally skip notify_change()
271        Ok(())
272    }
273
274    /// Move/rename a note.
275    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    /// List notes in a directory.
292    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    // ── Search (file-name based only) ────────────────────────────
303
304    /// Search notes by file name fuzzy matching.
305    ///
306    /// **Note:** Semantic search is handled by `KnowledgeLens`,
307    /// not by this method.
308    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    // ── Backlinks & Graph ─────────────────────────────────────────
337
338    /// Get backlinks for a note.
339    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
340        self.backlinks.read().backlinks_for(path)
341    }
342
343    /// Get the full link graph for visualization.
344    pub fn link_graph(&self) -> LinkGraph {
345        self.backlinks.read().link_graph()
346    }
347
348    /// Index all markdown files in the knowledge base.
349    ///
350    /// Walks the entire directory tree and builds the backlink index.
351    /// Returns the number of files indexed.
352    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                if let Ok(content) = fs.read_path(&entry.name) {
371                    self.backlinks.write().index_file(&entry.name, &content);
372                    count += 1;
373                }
374            }
375        }
376
377        tracing::info!(files = count, "Knowledge base indexed");
378        Ok(count)
379    }
380
381    // ── Chat / Inbox ───────────────────────────────────────────────
382
383    /// Append a timestamped message to Chat.md.
384    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    /// Parse Chat.md into structured message blocks.
404    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    /// Delete a specific chat message by its content hash.
410    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    /// Rename a specific chat message by its content hash.
422    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    /// Move a chat message to a target file as a checklist item.
434    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    // ── Journal ───────────────────────────────────────────────────
448
449    /// Add a timestamped record to today's journal entry.
450    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    /// Add an emoji to today's journal header.
458    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    /// Get today's journal file path (e.g., "journal/2026.05 May.md").
466    pub fn journal_today_path(&self) -> String {
467        let tz = chrono::Local::now().offset().to_owned();
468        today_journal_filename(tz)
469    }
470
471    // ── Habits ───────────────────────────────────────────────────
472
473    /// Read habit tracking data for a given year.
474    pub fn habits(&self, year: i32) -> Result<Habits> {
475        let fs = self.fs.read();
476        Ok(habits(&fs, year)?)
477    }
478
479    /// Get last week's habit data.
480    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    /// Write habit data for a year.
487    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    // ── Config ────────────────────────────────────────────────────
494
495    /// Read the knowledge base config (config.json).
496    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    /// Write the knowledge base config.
505    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    // ── Checklist ────────────────────────────────────────────────
512
513    /// Parse checklist items from a file.
514    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    /// Get incomplete checklist items from a file.
523    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    /// Add a checklist item to a file.
529    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    /// Complete a checklist item by hash.
536    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    /// Remove a checklist item by text or hash.
548    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    /// Remove all completed checklist items.
560    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    // ── Worker ────────────────────────────────────────────────────
570
571    /// Run nightly cleanup.
572    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    /// Move due scheduled tasks to Chat.
579    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    // ── Stats ────────────────────────────────────────────────────
590
591    /// Get today's completion report.
592    pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
593        let fs = self.fs.read();
594        Ok(today_report(&fs)?)
595    }
596
597    /// Get list of files completed today.
598    pub fn done_today(&self) -> Result<Vec<FileEntry>> {
599        let fs = self.fs.read();
600        Ok(done_today(&fs)?)
601    }
602
603    // ── Utilities ───────────────────────────────────────────────
604
605    /// Convert markdown to HTML.
606    pub fn markdown_to_html(&self, md: &str) -> String {
607        markdown_to_html(md)
608    }
609
610    /// Find an emoji for a keyword.
611    pub fn auto_emoji(&self, text: &str) -> String {
612        emoji_for(text)
613    }
614
615    /// Generate world clock report for given timezone names.
616    pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
617        world_clock_for_names(timezone_names)
618    }
619
620    // ── Agent Write Tracking ──────────────────────────────────────
621
622    /// Mark a file as having been written by an agent.
623    pub fn mark_agent_write(&self, path: &str) {
624        self.agent_writes.lock().insert(path.to_string());
625    }
626
627    /// Check if a file was written by an agent.
628    pub fn is_agent_write(&self, path: &str) -> bool {
629        self.agent_writes.lock().contains(path)
630    }
631
632    /// Clear the agent-write marker for a file.
633    pub fn clear_agent_write(&self, path: &str) {
634        self.agent_writes.lock().remove(path);
635    }
636
637    // ── Text extraction ──────────────────────────────────────────
638
639    /// Extract text, images, and links from markdown content.
640    pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
641        crate::tgtxt::extract_text_imgs_links(text)
642    }
643
644    // ── Headings (for tag extraction) ─────────────────────────────
645
646    /// Extract headings from content for tag generation.
647    pub fn extract_headings(&self, content: &str) -> Vec<String> {
648        extract_headings(content).into_iter().take(5).collect()
649    }
650}
651
652// ---------------------------------------------------------------------------
653// Frontmatter helpers (RFC-022)
654// ---------------------------------------------------------------------------
655
656/// Parse Oxios frontmatter from a note's content.
657///
658/// Returns `(Some(NoteMeta), body)` if the `oxios:` key is present in the
659/// frontmatter. Returns `(None, original_content)` if there is no frontmatter
660/// or the frontmatter does not contain the `oxios:` key (e.g., user-written
661/// Obsidian frontmatter). In the latter case, the full original content
662/// (including any user frontmatter) is returned as the body.
663pub 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    // Find the closing ---
670    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; // skip \n---
675        let body = rest[body_start..].trim_start().to_string();
676
677        // Parse YAML looking for the `oxios:` key
678        if !yaml_block.contains("oxios:") {
679            // User frontmatter, not ours
680            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
697/// Format a NoteMeta as YAML frontmatter prepended to content.
698///
699/// `serde_yaml::to_string` produces flat YAML like `author: agent\nsource: Hook\n`.
700/// We must indent each line with 2 spaces so they become children of the
701/// `oxios:` mapping key.
702fn 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// ---------------------------------------------------------------------------
714// Tests
715// ---------------------------------------------------------------------------
716
717#[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        // Should return default for non-existent config
846        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        // markdown_to_html wraps content in a <p> tag by default, check for content
855        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}