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    /// 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        // Hold the write lock across the read-check + write so concurrent
158        // writers cannot interleave their write_all calls (F1). Drop the
159        // lock before notifying callbacks to avoid reentrancy deadlocks.
160        let is_new = {
161            let fs = self.fs.write();
162            let is_new = fs.read_path(path).is_err();
163            fs.write_path(path, content)?;
164            is_new
165        };
166
167        {
168            let mut backlinks = self.backlinks.write();
169            backlinks.remove_file(path);
170            backlinks.index_file(path, content);
171        }
172
173        self.notify_change(
174            path,
175            if is_new {
176                FileChange::Created(path.to_string())
177            } else {
178                FileChange::Updated(path.to_string())
179            },
180        );
181        Ok(())
182    }
183
184    /// Write a note with provenance metadata (RFC-022).
185    ///
186    /// Prepends a YAML frontmatter block with `oxios:` metadata,
187    /// then delegates to `note_write`. If the file already has an
188    /// `oxios:` frontmatter block, it is merged (preserving `saved_at`,
189    /// updating `quality`/`source`). If the file has non-Oxios
190    /// frontmatter (e.g., Obsidian tags), it is left intact and
191    /// the note is treated as user-authored — no metadata is added.
192    pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
193        // Check existing content for frontmatter
194        let existing = self.note_read(path).ok().flatten();
195        let final_content = match existing {
196            Some(ref existing_content) => {
197                let (existing_meta, body) = parse_note_meta(existing_content);
198                match existing_meta {
199                    // Has Oxios frontmatter — merge
200                    Some(old_meta) => {
201                        let merged = NoteMeta {
202                            saved_at: old_meta.saved_at.or(meta.saved_at.clone()),
203                            ..meta.clone()
204                        };
205                        format_frontmatter(&merged, if body.is_empty() { content } else { &body })
206                    }
207                    // No Oxios frontmatter — user-authored or foreign frontmatter.
208                    // Don't touch it. Return Ok without writing.
209                    None => {
210                        tracing::debug!(
211                            path,
212                            "Skipping note_write_with_meta on user-authored note"
213                        );
214                        return Ok(false);
215                    }
216                }
217            }
218            None => format_frontmatter(meta, content),
219        };
220        self.note_write(path, &final_content).map(|_| true)
221    }
222
223    /// List notes that need Dream review (RFC-022).
224    ///
225    /// Scans the vault for `.md` files with `needs_review: true` in their
226    /// Oxios frontmatter. Reads only the frontmatter block (stops at the
227    /// closing `---`) for efficiency.
228    pub fn notes_needing_review(&self) -> Result<Vec<(String, NoteMeta)>> {
229        let fs = self.fs.read();
230        let mut result = Vec::new();
231
232        let files = fs.all_md_files()?;
233        for (path, _size) in &files {
234            if let Ok(content) = fs.read_path(path) {
235                let (meta, _body) = parse_note_meta(&content);
236                if let Some(m) = meta
237                    && m.needs_review
238                {
239                    result.push((path.clone(), m));
240                }
241            }
242        }
243
244        // Oldest first — they've been raw the longest
245        result.sort_by(|a, b| {
246            a.1.saved_at
247                .as_deref()
248                .unwrap_or("")
249                .cmp(b.1.saved_at.as_deref().unwrap_or(""))
250        });
251
252        Ok(result)
253    }
254    /// Delete the note at `path`, removing it from the filesystem and
255    /// dropping any recorded backlinks for that file.
256    pub fn note_delete(&self, path: &str) -> Result<()> {
257        {
258            let fs = self.fs.write();
259            fs.delete_path(path)?;
260        }
261        self.backlinks.write().remove_file(path);
262        self.notify_change(path, FileChange::Deleted(path.to_string()));
263        Ok(())
264    }
265
266    /// Restore a note's content without triggering file-change callbacks.
267    ///
268    /// Used when reverting to a previous git version — writes the file
269    /// and updates the backlink index, but does **not** fire `on_file_change`
270    /// callbacks. This prevents an infinite loop where restore → write →
271    /// callback → git commit → ... repeats.
272    pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
273        {
274            let fs = self.fs.write();
275            fs.write_path(path, content)?;
276        }
277        let mut backlinks = self.backlinks.write();
278        backlinks.remove_file(path);
279        backlinks.index_file(path, content);
280        // Intentionally skip notify_change()
281        Ok(())
282    }
283
284    /// Move/rename a note.
285    pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
286        // Rename under the write lock, then read the destination's content
287        // before dropping the guard (note_read would re-acquire the lock).
288        let new_content = {
289            let fs = self.fs.write();
290            fs.rename_path(old_path, new_path)?;
291            fs.read_path(new_path).ok()
292        };
293        {
294            let mut backlinks = self.backlinks.write();
295            backlinks.remove_file(old_path);
296            if let Some(content) = new_content {
297                backlinks.index_file(new_path, &content);
298            }
299        }
300        self.notify_change(
301            old_path,
302            FileChange::Moved {
303                old: old_path.to_string(),
304                new: new_path.to_string(),
305            },
306        );
307        Ok(())
308    }
309
310    /// List notes in a directory.
311    pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
312        let fs = self.fs.read();
313        let dir = if dir.is_empty() || dir == "/" {
314            DIR_USER_ROOT
315        } else {
316            dir
317        };
318        Ok(fs.files_and_dirs(dir)?)
319    }
320
321    // ── Search (file-name based only) ────────────────────────────
322
323    /// Search notes by file name fuzzy matching.
324    ///
325    /// **Note:** Semantic search is handled by `KnowledgeLens`,
326    /// not by this method.
327    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
328        let fs = self.fs.read();
329        let files = fs.search_files_by_name(query)?;
330
331        let hits: Vec<NoteHit> = files
332            .into_iter()
333            .take(limit)
334            .map(|f| {
335                let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
336                    f.name.clone()
337                } else {
338                    format!("{}/{}", f.parent_dir, f.name)
339                };
340                let name_sim = similar(&f.display_name, query) as i32;
341                let bl_count = self.backlinks.read().backlink_count(&path);
342                NoteHit {
343                    path,
344                    name: f.display_name,
345                    snippet: String::new(),
346                    backlink_count: bl_count,
347                    name_similarity: name_sim,
348                }
349            })
350            .collect();
351
352        Ok(hits)
353    }
354
355    // ── Backlinks & Graph ─────────────────────────────────────────
356
357    /// Get backlinks for a note.
358    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
359        self.backlinks.read().backlinks_for(path)
360    }
361
362    /// Get the full link graph for visualization.
363    pub fn link_graph(&self) -> LinkGraph {
364        self.backlinks.read().link_graph()
365    }
366
367    /// Index all markdown files in the knowledge base.
368    ///
369    /// Walks the entire directory tree and builds the backlink index.
370    /// Returns the number of files indexed.
371    pub fn index_all(&self) -> Result<usize> {
372        let fs = self.fs.read();
373        let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
374        let mut count = 0;
375
376        for entry in &entries {
377            if entry.is_dir {
378                let sub = fs.files_and_dirs(&entry.name)?;
379                for sub_entry in &sub {
380                    if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
381                        let path = format!("{}/{}", entry.name, sub_entry.name);
382                        if let Ok(content) = fs.read_path(&path) {
383                            self.backlinks.write().index_file(&path, &content);
384                            count += 1;
385                        }
386                    }
387                }
388            } else if entry.name.ends_with(".md")
389                && let Ok(content) = fs.read_path(&entry.name)
390            {
391                self.backlinks.write().index_file(&entry.name, &content);
392                count += 1;
393            }
394        }
395
396        tracing::info!(files = count, "Knowledge base indexed");
397        Ok(count)
398    }
399
400    // ── Chat / Inbox ───────────────────────────────────────────────
401
402    /// Append a timestamped message to Chat.md.
403    pub fn chat_append(&self, message: &str) -> Result<()> {
404        let header = today_chat_header();
405        let timestamp = chrono::Local::now().format("`15:04`").to_string();
406        let entry = format!("{timestamp} {message}");
407
408        let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
409        if !content.contains(&header) {
410            if !content.trim_end().ends_with('\n') {
411                content.push('\n');
412            }
413            content.push_str(&header);
414            content.push('\n');
415        }
416        content.push_str(&entry);
417        content.push('\n');
418        self.note_write(CHAT_FILENAME, &content)?;
419        Ok(())
420    }
421
422    /// Parse Chat.md into structured message blocks.
423    pub fn chat_messages(&self) -> Result<Vec<String>> {
424        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
425        Ok(read_chat_msgs(&content))
426    }
427
428    /// Delete a specific chat message by its content hash.
429    pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
430        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
431        match delete_chat_msg(&content, msg_hash) {
432            Ok(new_content) => {
433                self.note_write(CHAT_FILENAME, &new_content)?;
434                Ok(true)
435            }
436            Err(_) => Ok(false),
437        }
438    }
439
440    /// Rename a specific chat message by its content hash.
441    pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
442        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
443        match rename_chat_msg(&content, msg_hash, new_body) {
444            Ok(new_content) => {
445                self.note_write(CHAT_FILENAME, &new_content)?;
446                Ok(true)
447            }
448            Err(_) => Ok(false),
449        }
450    }
451
452    /// Move a chat message to a target file as a checklist item.
453    pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
454        let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
455        let target_content = self.note_read(target_path)?.unwrap_or_default();
456        let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
457        if new_chat != chat_content {
458            self.note_write(CHAT_FILENAME, &new_chat)?;
459            self.note_write(target_path, &new_target)?;
460            Ok(true)
461        } else {
462            Ok(false)
463        }
464    }
465
466    // ── Journal ───────────────────────────────────────────────────
467
468    /// Add a timestamped record to today's journal entry.
469    pub fn journal_add_record(&self, record: &str) -> Result<()> {
470        let fs = self.fs.write();
471        let tz = chrono::Local::now().offset().to_owned();
472        journal_add_record(&fs, record, tz)?;
473        Ok(())
474    }
475
476    /// Add an emoji to today's journal header.
477    pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
478        let fs = self.fs.write();
479        let tz = chrono::Local::now().offset().to_owned();
480        journal_add_emoji(&fs, emoji, tz)?;
481        Ok(())
482    }
483
484    /// Get today's journal file path (e.g., "journal/2026.05 May.md").
485    pub fn journal_today_path(&self) -> String {
486        let tz = chrono::Local::now().offset().to_owned();
487        today_journal_filename(tz)
488    }
489
490    // ── Habits ───────────────────────────────────────────────────
491
492    /// Read habit tracking data for a given year.
493    pub fn habits(&self, year: i32) -> Result<Habits> {
494        let fs = self.fs.read();
495        Ok(habits(&fs, year)?)
496    }
497
498    /// Get last week's habit data.
499    pub fn habits_last_week(&self) -> Result<Habits> {
500        let fs = self.fs.read();
501        let tz = chrono::Local::now().offset().to_owned();
502        Ok(last_week_habits(&fs, tz)?)
503    }
504
505    /// Write habit data for a year.
506    pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
507        let fs = self.fs.write();
508        write_habits(&fs, year, habits)?;
509        Ok(())
510    }
511
512    // ── Config ────────────────────────────────────────────────────
513
514    /// Read the knowledge base config (config.json).
515    pub fn config(&self) -> Result<KnowledgeConfig> {
516        let fs = self.fs.read();
517        match fs.read_path("config.json") {
518            Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
519            Err(_) => Ok(KnowledgeConfig::default()),
520        }
521    }
522
523    /// Write the knowledge base config.
524    pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
525        let json = serde_json::to_string_pretty(config)?;
526        self.note_write("config.json", &json)?;
527        Ok(())
528    }
529
530    // ── Checklist ────────────────────────────────────────────────
531
532    /// Parse checklist items from a file.
533    pub fn checklist_items(
534        &self,
535        path: &str,
536    ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
537        let content = self.note_read(path)?.unwrap_or_default();
538        Ok(checklist_items(&content))
539    }
540
541    /// Get incomplete checklist items from a file.
542    pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
543        let content = self.note_read(path)?.unwrap_or_default();
544        Ok(incomplete_checklist_items(&content))
545    }
546
547    /// Add a checklist item to a file.
548    pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
549        let content = self.note_read(path)?.unwrap_or_default();
550        let updated = add_checklist_item(&content, item, checked);
551        self.note_write(path, &updated)
552    }
553
554    /// Complete a checklist item by hash.
555    pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
556        let content = self.note_read(path)?.unwrap_or_default();
557        let (new_content, found) = complete_checklist_item(&content, item_hash);
558        if !found.is_empty() {
559            self.note_write(path, &new_content)?;
560            Ok(true)
561        } else {
562            Ok(false)
563        }
564    }
565
566    /// Remove a checklist item by text or hash.
567    pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
568        let content = self.note_read(path)?.unwrap_or_default();
569        let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
570        if !removed.is_empty() {
571            self.note_write(path, &new_content)?;
572            Ok(true)
573        } else {
574            Ok(false)
575        }
576    }
577
578    /// Remove all completed checklist items.
579    pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
580        let content = self.note_read(path)?.unwrap_or_default();
581        let (kept, removed) = remove_completed_checklist_items(&content);
582        if !removed.is_empty() {
583            self.note_write(path, &kept)?;
584        }
585        Ok((kept, removed))
586    }
587
588    // ── Worker ────────────────────────────────────────────────────
589
590    /// Run nightly cleanup.
591    pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
592        // Read config before acquiring the write lock — config() takes
593        // a read lock and would otherwise deadlock against our write guard.
594        let config = self.config()?;
595        let fs = self.fs.write();
596        Ok(remove_completed_items(&fs, &config)?)
597    }
598
599    /// Move due scheduled tasks to Chat.
600    pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
601        // Read config first, take the write lock only for the worker pass,
602        // then release it before set_config() (which calls note_write and
603        // would re-acquire the lock).
604        let mut config = self.config()?;
605        let moved = {
606            let fs = self.fs.write();
607            move_due_tasks(&fs, &mut config)?
608        };
609        if !moved.is_empty() {
610            self.set_config(&config)?;
611        }
612        Ok(moved)
613    }
614
615    // ── Stats ────────────────────────────────────────────────────
616
617    /// Get today's completion report.
618    pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
619        let fs = self.fs.read();
620        Ok(today_report(&fs)?)
621    }
622
623    /// Get list of files completed today.
624    pub fn done_today(&self) -> Result<Vec<FileEntry>> {
625        let fs = self.fs.read();
626        Ok(done_today(&fs)?)
627    }
628
629    // ── Utilities ───────────────────────────────────────────────
630
631    /// Convert markdown to HTML.
632    pub fn markdown_to_html(&self, md: &str) -> String {
633        markdown_to_html(md)
634    }
635
636    /// Find an emoji for a keyword.
637    pub fn auto_emoji(&self, text: &str) -> String {
638        emoji_for(text)
639    }
640
641    /// Generate world clock report for given timezone names.
642    pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
643        world_clock_for_names(timezone_names)
644    }
645
646    // ── Agent Write Tracking ──────────────────────────────────────
647
648    /// Mark a file as having been written by an agent.
649    pub fn mark_agent_write(&self, path: &str) {
650        self.agent_writes.lock().insert(path.to_string());
651    }
652
653    /// Check if a file was written by an agent.
654    pub fn is_agent_write(&self, path: &str) -> bool {
655        self.agent_writes.lock().contains(path)
656    }
657
658    /// Clear the agent-write marker for a file.
659    pub fn clear_agent_write(&self, path: &str) {
660        self.agent_writes.lock().remove(path);
661    }
662
663    // ── Text extraction ──────────────────────────────────────────
664
665    /// Extract text, images, and links from markdown content.
666    pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
667        crate::tgtxt::extract_text_imgs_links(text)
668    }
669
670    // ── Headings (for tag extraction) ─────────────────────────────
671
672    /// Extract headings from content for tag generation.
673    pub fn extract_headings(&self, content: &str) -> Vec<String> {
674        extract_headings(content).into_iter().take(5).collect()
675    }
676}
677
678// ---------------------------------------------------------------------------
679// Frontmatter helpers (RFC-022)
680// ---------------------------------------------------------------------------
681
682/// Parse Oxios frontmatter from a note's content.
683///
684/// Returns `(Some(NoteMeta), body)` if the `oxios:` key is present in the
685/// frontmatter. Returns `(None, original_content)` if there is no frontmatter
686/// or the frontmatter does not contain the `oxios:` key (e.g., user-written
687/// Obsidian frontmatter). In the latter case, the full original content
688/// (including any user frontmatter) is returned as the body.
689pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
690    let trimmed = content.trim_start();
691    if !trimmed.starts_with("---") {
692        return (None, content.to_string());
693    }
694
695    // Find the closing ---
696    let after_first = &trimmed[3..];
697    let rest = after_first.trim_start_matches(['-', '\n', '\r']);
698    if let Some(end_offset) = rest.find("\n---") {
699        let yaml_block = &rest[..end_offset];
700        let body_start = end_offset + 4; // skip \n---
701        let body = rest[body_start..].trim_start().to_string();
702
703        // Parse YAML looking for the `oxios:` key
704        if !yaml_block.contains("oxios:") {
705            // User frontmatter, not ours
706            return (None, content.to_string());
707        }
708
709        #[derive(serde::Deserialize)]
710        struct FrontmatterWrapper {
711            oxios: NoteMeta,
712        }
713
714        match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
715            Ok(wrapper) => (Some(wrapper.oxios), body),
716            Err(_) => (None, content.to_string()),
717        }
718    } else {
719        (None, content.to_string())
720    }
721}
722
723/// Format a NoteMeta as YAML frontmatter prepended to content.
724///
725/// `serde_yaml::to_string` produces flat YAML like `author: agent\nsource: Hook\n`.
726/// We must indent each line with 2 spaces so they become children of the
727/// `oxios:` mapping key.
728fn format_frontmatter(meta: &NoteMeta, body: &str) -> String {
729    let yaml = serde_yaml::to_string(meta).unwrap_or_default();
730    let indented: String = yaml
731        .lines()
732        .filter(|l| !l.is_empty())
733        .map(|l| format!("  {l}"))
734        .collect::<Vec<_>>()
735        .join("\n");
736    format!("---\noxios:\n{}\n---\n\n{}", indented, body)
737}
738
739// ---------------------------------------------------------------------------
740// Tests
741// ---------------------------------------------------------------------------
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    fn make_test_kb() -> KnowledgeBase {
748        let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
749        KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
750    }
751
752    #[test]
753    fn test_note_write_and_read() {
754        let kb = make_test_kb();
755        kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
756            .unwrap();
757        let content = kb.note_read("brain/Rust.md").unwrap();
758        assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
759    }
760
761    #[test]
762    fn test_note_read_missing() {
763        let kb = make_test_kb();
764        assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
765    }
766
767    #[test]
768    fn test_note_delete() {
769        let kb = make_test_kb();
770        kb.note_write("del.md", "to delete").unwrap();
771        kb.note_delete("del.md").unwrap();
772        assert_eq!(kb.note_read("del.md").unwrap(), None);
773    }
774
775    #[test]
776    fn test_note_move() {
777        let kb = make_test_kb();
778        kb.note_write("old.md", "content").unwrap();
779        kb.note_move("old.md", "new.md").unwrap();
780        assert_eq!(kb.note_read("old.md").unwrap(), None);
781        assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
782    }
783
784    #[test]
785    fn test_backlinks() {
786        let kb = make_test_kb();
787        kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
788            .unwrap();
789        let bl = kb.backlinks_for("brain/Ownership.md");
790        assert_eq!(bl.len(), 1);
791        assert_eq!(bl[0].source_path, "brain/Rust.md");
792    }
793
794    #[test]
795    fn test_note_tree() {
796        let kb = make_test_kb();
797        kb.note_write("brain/Rust.md", "Rust").unwrap();
798        let entries = kb.note_tree("brain").unwrap();
799        assert!(!entries.is_empty());
800    }
801
802    #[test]
803    fn test_search_by_name() {
804        let kb = make_test_kb();
805        kb.note_write("brain/Rust.md", "Rust content").unwrap();
806        let hits = kb.search("Rust", 10).unwrap();
807        assert!(!hits.is_empty());
808    }
809
810    #[test]
811    fn test_link_graph() {
812        let kb = make_test_kb();
813        kb.note_write("a.md", "[b](b.md)").unwrap();
814        let graph = kb.link_graph();
815        assert!(!graph.edges.is_empty());
816    }
817
818    #[test]
819    fn test_agent_write_tracking() {
820        let kb = make_test_kb();
821        assert!(!kb.is_agent_write("test.md"));
822        kb.mark_agent_write("test.md");
823        assert!(kb.is_agent_write("test.md"));
824        kb.clear_agent_write("test.md");
825        assert!(!kb.is_agent_write("test.md"));
826    }
827
828    #[test]
829    fn test_index_all() {
830        let kb = make_test_kb();
831        kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
832            .unwrap();
833        kb.note_write("brain/Go.md", "Go language").unwrap();
834        kb.note_write("index.md", "Welcome").unwrap();
835        let count = kb.index_all().unwrap();
836        assert_eq!(count, 3);
837        let bl = kb.backlinks_for("brain/Go.md");
838        assert_eq!(bl.len(), 1);
839    }
840
841    #[test]
842    fn test_on_file_change_callback() {
843        let kb = make_test_kb();
844        let _called = std::sync::atomic::AtomicBool::new(false);
845        let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
846            std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
847        let flag = path_clone.clone();
848
849        kb.on_file_change(move |path, change| {
850            let _ = path;
851            let _ = change;
852            flag.store(true, std::sync::atomic::Ordering::SeqCst);
853        });
854
855        kb.note_write("test.md", "hello").unwrap();
856        assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
857    }
858
859    #[test]
860    fn test_chat_append() {
861        let kb = make_test_kb();
862        kb.chat_append("Test message").unwrap();
863        let messages = kb.chat_messages().unwrap();
864        assert!(!messages.is_empty());
865    }
866
867    #[test]
868    fn test_config() {
869        let kb = make_test_kb();
870        let cfg = kb.config().unwrap();
871        // Should return default for non-existent config
872        let cfg2 = kb.config().unwrap();
873        assert_eq!(cfg.language, cfg2.language);
874    }
875
876    #[test]
877    fn test_markdown_to_html() {
878        let kb = make_test_kb();
879        let html = kb.markdown_to_html("# Hello\n\n**world**");
880        // markdown_to_html wraps content in a <p> tag by default, check for content
881        assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
882        assert!(html.contains("world"), "HTML should contain world: {html}");
883    }
884
885    #[test]
886    fn test_auto_emoji() {
887        let kb = make_test_kb();
888        let emoji = kb.auto_emoji("cooking pasta");
889        assert!(!emoji.is_empty());
890    }
891
892    #[test]
893    fn test_extract_headings() {
894        let kb = make_test_kb();
895        let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
896        assert!(headings.len() >= 2);
897    }
898
899    #[test]
900    fn test_frontmatter_roundtrip() {
901        let meta = NoteMeta {
902            author: "agent".to_string(),
903            source: NoteSource::Hook,
904            quality: NoteQuality::Raw,
905            needs_review: true,
906            session_id: Some("abc123".to_string()),
907            message_index: Some(3),
908            saved_at: Some("2026-06-13T00:00:00Z".to_string()),
909        };
910        let body = "## Test\n\nContent here.";
911        let formatted = format_frontmatter(&meta, body);
912        assert!(formatted.starts_with("---\noxios:\n"));
913        let (parsed_meta, parsed_body) = parse_note_meta(&formatted);
914        assert!(
915            parsed_meta.is_some(),
916            "Failed to parse round-tripped frontmatter"
917        );
918        let pm = parsed_meta.unwrap();
919        assert_eq!(pm.author, "agent");
920        assert_eq!(pm.session_id.as_deref(), Some("abc123"));
921        assert_eq!(pm.message_index, Some(3));
922        assert_eq!(parsed_body.trim(), body.trim());
923    }
924
925    #[test]
926    fn test_parse_user_frontmatter_ignored() {
927        let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
928        let (meta, body) = parse_note_meta(content);
929        assert!(
930            meta.is_none(),
931            "User frontmatter should not be parsed as NoteMeta"
932        );
933        assert!(
934            body.contains("tags: [rust, design]"),
935            "User frontmatter preserved"
936        );
937    }
938
939    #[test]
940    fn test_parse_no_frontmatter() {
941        let content = "# Just a note\nSome content.";
942        let (meta, body) = parse_note_meta(content);
943        assert!(meta.is_none());
944        assert_eq!(body, content);
945    }
946}