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::{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/// 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    /// Read a note's raw bytes — for binary assets (images, etc.) that aren't
153    /// valid UTF-8. Text notes should use [`note_read`].
154    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    /// Write a note — creates or overwrites.
163    ///
164    /// Writes the `.md` file via VirtualFs, updates the backlink index,
165    /// and notifies registered `on_file_change` callbacks.
166    pub fn note_write(&self, path: &str, content: &str) -> Result<()> {
167        // Hold the write lock across the read-check + write so concurrent
168        // writers cannot interleave their write_all calls (F1). Drop the
169        // lock before notifying callbacks to avoid reentrancy deadlocks.
170        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    /// Write a note with provenance metadata (RFC-022).
195    ///
196    /// Prepends a YAML frontmatter block with `oxios:` metadata,
197    /// then delegates to `note_write`. If the file already has an
198    /// `oxios:` frontmatter block, it is merged (preserving `saved_at`,
199    /// updating `quality`/`source`). If the file has non-Oxios
200    /// frontmatter (e.g., Obsidian tags), it is left intact and
201    /// the note is treated as user-authored — no metadata is added.
202    pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
203        // Check existing content for frontmatter
204        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                    // Has Oxios frontmatter — merge
210                    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                    // No Oxios frontmatter — user-authored or foreign frontmatter.
218                    // Don't touch it. Return Ok without writing.
219                    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    /// List notes that need Dream review (RFC-022).
234    ///
235    /// Scans the vault for `.md` files with `needs_review: true` in their
236    /// Oxios frontmatter. Reads only the frontmatter block (stops at the
237    /// closing `---`) for efficiency.
238    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        // Oldest first — they've been raw the longest
255        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    /// Delete the note at `path`, removing it from the filesystem and
265    /// dropping any recorded backlinks for that file.
266    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    /// Restore a note's content without triggering file-change callbacks.
277    ///
278    /// Used when reverting to a previous git version — writes the file
279    /// and updates the backlink index, but does **not** fire `on_file_change`
280    /// callbacks. This prevents an infinite loop where restore → write →
281    /// callback → git commit → ... repeats.
282    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        // Intentionally skip notify_change()
291        Ok(())
292    }
293
294    /// Move/rename a note.
295    pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
296        // Rename under the write lock, then read the destination's content
297        // before dropping the guard (note_read would re-acquire the lock).
298        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    /// List notes in a directory.
321    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    // ── Search (file-name based only) ────────────────────────────
332
333    /// Search notes by file name fuzzy matching.
334    ///
335    /// **Note:** Semantic search is handled by `KnowledgeLens`,
336    /// not by this method.
337    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    // ── Backlinks & Graph ─────────────────────────────────────────
366
367    /// Get backlinks for a note.
368    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
369        self.backlinks.read().backlinks_for(path)
370    }
371
372    /// Get the full link graph for visualization.
373    pub fn link_graph(&self) -> LinkGraph {
374        self.backlinks.read().link_graph()
375    }
376
377    /// Index all markdown files in the knowledge base.
378    ///
379    /// Walks the entire directory tree and builds the backlink index.
380    /// Returns the number of files indexed.
381    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    // ── Chat / Inbox ───────────────────────────────────────────────
411
412    /// Append a timestamped message to Chat.md.
413    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    /// Parse Chat.md into structured message blocks.
433    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    /// Delete a specific chat message by its content hash.
439    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    /// Rename a specific chat message by its content hash.
451    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    /// Move a chat message to a target file as a checklist item.
463    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    // ── Journal ───────────────────────────────────────────────────
477
478    /// Add a timestamped record to today's journal entry.
479    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    /// Add an emoji to today's journal header.
487    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    /// Get today's journal file path (e.g., "journal/2026.05 May.md").
495    pub fn journal_today_path(&self) -> String {
496        let tz = chrono::Local::now().offset().to_owned();
497        today_journal_filename(tz)
498    }
499
500    // ── Habits ───────────────────────────────────────────────────
501
502    /// Read habit tracking data for a given year.
503    pub fn habits(&self, year: i32) -> Result<Habits> {
504        let fs = self.fs.read();
505        Ok(habits(&fs, year)?)
506    }
507
508    /// Get last week's habit data.
509    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    /// Write habit data for a year.
516    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    // ── Config ────────────────────────────────────────────────────
523
524    /// Read the knowledge base config (config.json).
525    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    /// Write the knowledge base config.
534    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    // ── Checklist ────────────────────────────────────────────────
541
542    /// Parse checklist items from a file.
543    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    /// Get incomplete checklist items from a file.
552    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    /// Add a checklist item to a file.
558    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    /// Complete a checklist item by hash.
565    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    /// Remove a checklist item by text or hash.
577    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    /// Remove all completed checklist items.
589    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    // ── Worker ────────────────────────────────────────────────────
599
600    /// Run nightly cleanup.
601    pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
602        // Read config before acquiring the write lock — config() takes
603        // a read lock and would otherwise deadlock against our write guard.
604        let config = self.config()?;
605        let fs = self.fs.write();
606        Ok(remove_completed_items(&fs, &config)?)
607    }
608
609    /// Move due scheduled tasks to Chat.
610    pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
611        // Read config first, take the write lock only for the worker pass,
612        // then release it before set_config() (which calls note_write and
613        // would re-acquire the lock).
614        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    // ── Stats ────────────────────────────────────────────────────
626
627    /// Get today's completion report.
628    pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
629        let fs = self.fs.read();
630        Ok(today_report(&fs)?)
631    }
632
633    /// Get list of files completed today.
634    pub fn done_today(&self) -> Result<Vec<FileEntry>> {
635        let fs = self.fs.read();
636        Ok(done_today(&fs)?)
637    }
638
639    // ── Utilities ───────────────────────────────────────────────
640
641    /// Convert markdown to HTML.
642    pub fn markdown_to_html(&self, md: &str) -> String {
643        markdown_to_html(md)
644    }
645
646    /// Find an emoji for a keyword.
647    pub fn auto_emoji(&self, text: &str) -> String {
648        emoji_for(text)
649    }
650
651    /// Generate world clock report for given timezone names.
652    pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
653        world_clock_for_names(timezone_names)
654    }
655
656    // ── Agent Write Tracking ──────────────────────────────────────
657
658    /// Mark a file as having been written by an agent.
659    pub fn mark_agent_write(&self, path: &str) {
660        self.agent_writes.lock().insert(path.to_string());
661    }
662
663    /// Check if a file was written by an agent.
664    pub fn is_agent_write(&self, path: &str) -> bool {
665        self.agent_writes.lock().contains(path)
666    }
667
668    /// Clear the agent-write marker for a file.
669    pub fn clear_agent_write(&self, path: &str) {
670        self.agent_writes.lock().remove(path);
671    }
672
673    // ── Text extraction ──────────────────────────────────────────
674
675    /// Extract text, images, and links from markdown content.
676    pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
677        crate::tgtxt::extract_text_imgs_links(text)
678    }
679
680    // ── Headings (for tag extraction) ─────────────────────────────
681
682    /// Extract headings from content for tag generation.
683    pub fn extract_headings(&self, content: &str) -> Vec<String> {
684        extract_headings(content).into_iter().take(5).collect()
685    }
686}
687
688// ---------------------------------------------------------------------------
689// Frontmatter helpers (RFC-022)
690// ---------------------------------------------------------------------------
691
692/// Parse Oxios frontmatter from a note's content.
693///
694/// Returns `(Some(NoteMeta), body)` if the `oxios:` key is present in the
695/// frontmatter. Returns `(None, original_content)` if there is no frontmatter
696/// or the frontmatter does not contain the `oxios:` key (e.g., user-written
697/// Obsidian frontmatter). In the latter case, the full original content
698/// (including any user frontmatter) is returned as the body.
699pub 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    // Find the closing ---
706    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; // skip \n---
711        let body = rest[body_start..].trim_start().to_string();
712
713        // Parse YAML looking for the `oxios:` key
714        if !yaml_block.contains("oxios:") {
715            // User frontmatter, not ours
716            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
733/// Format a NoteMeta as YAML frontmatter prepended to content.
734///
735/// `serde_yaml::to_string` produces flat YAML like `author: agent\nsource: Hook\n`.
736/// We must indent each line with 2 spaces so they become children of the
737/// `oxios:` mapping key.
738fn 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// ---------------------------------------------------------------------------
750// Tests
751// ---------------------------------------------------------------------------
752
753#[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        // Should return default for non-existent config
882        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        // markdown_to_html wraps content in a <p> tag by default, check for content
891        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}