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    /// List all markdown files in the knowledge base (path, size).
332    /// Used by startup git reconciliation to detect post-crash drift.
333    pub fn list_all_md_files(&self) -> Result<Vec<(String, i64)>> {
334        let fs = self.fs.read();
335        Ok(fs.all_md_files()?)
336    }
337
338    // ── Search (file-name based only) ────────────────────────────
339
340    /// Search notes by file name fuzzy matching.
341    ///
342    /// **Note:** Semantic search is handled by `KnowledgeLens`,
343    /// not by this method.
344    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
345        let fs = self.fs.read();
346        let files = fs.search_files_by_name(query)?;
347
348        let hits: Vec<NoteHit> = files
349            .into_iter()
350            .take(limit)
351            .map(|f| {
352                let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
353                    f.name.clone()
354                } else {
355                    format!("{}/{}", f.parent_dir, f.name)
356                };
357                let name_sim = similar(&f.display_name, query) as i32;
358                let bl_count = self.backlinks.read().backlink_count(&path);
359                NoteHit {
360                    path,
361                    name: f.display_name,
362                    snippet: String::new(),
363                    backlink_count: bl_count,
364                    name_similarity: name_sim,
365                }
366            })
367            .collect();
368
369        Ok(hits)
370    }
371
372    // ── Backlinks & Graph ─────────────────────────────────────────
373
374    /// Get backlinks for a note.
375    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
376        self.backlinks.read().backlinks_for(path)
377    }
378
379    /// Get the full link graph for visualization.
380    pub fn link_graph(&self) -> LinkGraph {
381        self.backlinks.read().link_graph()
382    }
383
384    /// Index all markdown files in the knowledge base.
385    ///
386    /// Walks the entire directory tree and builds the backlink index.
387    /// Returns the number of files indexed.
388    pub fn index_all(&self) -> Result<usize> {
389        let fs = self.fs.read();
390        let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
391        let mut count = 0;
392
393        for entry in &entries {
394            if entry.is_dir {
395                let sub = fs.files_and_dirs(&entry.name)?;
396                for sub_entry in &sub {
397                    if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
398                        let path = format!("{}/{}", entry.name, sub_entry.name);
399                        if let Ok(content) = fs.read_path(&path) {
400                            self.backlinks.write().index_file(&path, &content);
401                            count += 1;
402                        }
403                    }
404                }
405            } else if entry.name.ends_with(".md")
406                && let Ok(content) = fs.read_path(&entry.name)
407            {
408                self.backlinks.write().index_file(&entry.name, &content);
409                count += 1;
410            }
411        }
412
413        tracing::info!(files = count, "Knowledge base indexed");
414        Ok(count)
415    }
416
417    // ── Chat / Inbox ───────────────────────────────────────────────
418
419    /// Append a timestamped message to Chat.md.
420    pub fn chat_append(&self, message: &str) -> Result<()> {
421        let header = today_chat_header();
422        let timestamp = chrono::Local::now().format("`15:04`").to_string();
423        let entry = format!("- [ ] {timestamp} {message}");
424
425        let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
426        if !content.contains(&header) {
427            if !content.trim_end().ends_with('\n') {
428                content.push('\n');
429            }
430            content.push_str(&header);
431            content.push('\n');
432        }
433        content.push_str(&entry);
434        content.push('\n');
435        self.note_write(CHAT_FILENAME, &content)?;
436        Ok(())
437    }
438
439    /// Parse Chat.md into structured message blocks.
440    pub fn chat_messages(&self) -> Result<Vec<String>> {
441        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
442        Ok(read_chat_msgs(&content))
443    }
444
445    /// Delete a specific chat message by its content hash.
446    pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
447        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
448        match delete_chat_msg(&content, msg_hash) {
449            Ok(new_content) => {
450                self.note_write(CHAT_FILENAME, &new_content)?;
451                Ok(true)
452            }
453            Err(_) => Ok(false),
454        }
455    }
456
457    /// Rename a specific chat message by its content hash.
458    pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
459        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
460        match rename_chat_msg(&content, msg_hash, new_body) {
461            Ok(new_content) => {
462                self.note_write(CHAT_FILENAME, &new_content)?;
463                Ok(true)
464            }
465            Err(_) => Ok(false),
466        }
467    }
468
469    /// Move a chat message to a target file as a checklist item.
470    pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
471        let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
472        let target_content = self.note_read(target_path)?.unwrap_or_default();
473        let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
474        if new_chat != chat_content {
475            self.note_write(CHAT_FILENAME, &new_chat)?;
476            self.note_write(target_path, &new_target)?;
477            Ok(true)
478        } else {
479            Ok(false)
480        }
481    }
482
483    // ── Journal ───────────────────────────────────────────────────
484
485    /// Add a timestamped record to today's journal entry.
486    pub fn journal_add_record(&self, record: &str) -> Result<()> {
487        let fs = self.fs.write();
488        let tz = chrono::Local::now().offset().to_owned();
489        journal_add_record(&fs, record, tz)?;
490        Ok(())
491    }
492
493    /// Add an emoji to today's journal header.
494    pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
495        let fs = self.fs.write();
496        let tz = chrono::Local::now().offset().to_owned();
497        journal_add_emoji(&fs, emoji, tz)?;
498        Ok(())
499    }
500
501    /// Get today's journal file path (e.g., "journal/2026.05 May.md").
502    pub fn journal_today_path(&self) -> String {
503        let tz = chrono::Local::now().offset().to_owned();
504        today_journal_filename(tz)
505    }
506
507    // ── Habits ───────────────────────────────────────────────────
508
509    /// Read habit tracking data for a given year.
510    pub fn habits(&self, year: i32) -> Result<Habits> {
511        let fs = self.fs.read();
512        Ok(habits(&fs, year)?)
513    }
514
515    /// Get last week's habit data.
516    pub fn habits_last_week(&self) -> Result<Habits> {
517        let fs = self.fs.read();
518        let tz = chrono::Local::now().offset().to_owned();
519        Ok(last_week_habits(&fs, tz)?)
520    }
521
522    /// Write habit data for a year.
523    pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
524        let fs = self.fs.write();
525        write_habits(&fs, year, habits)?;
526        Ok(())
527    }
528
529    // ── Config ────────────────────────────────────────────────────
530
531    /// Read the knowledge base config (config.json).
532    pub fn config(&self) -> Result<KnowledgeConfig> {
533        let fs = self.fs.read();
534        match fs.read_path("config.json") {
535            Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
536            Err(_) => Ok(KnowledgeConfig::default()),
537        }
538    }
539
540    /// Write the knowledge base config.
541    pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
542        let json = serde_json::to_string_pretty(config)?;
543        self.note_write("config.json", &json)?;
544        Ok(())
545    }
546
547    // ── Checklist ────────────────────────────────────────────────
548
549    /// Parse checklist items from a file.
550    pub fn checklist_items(
551        &self,
552        path: &str,
553    ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
554        let content = self.note_read(path)?.unwrap_or_default();
555        Ok(checklist_items(&content))
556    }
557
558    /// Get incomplete checklist items from a file.
559    pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
560        let content = self.note_read(path)?.unwrap_or_default();
561        Ok(incomplete_checklist_items(&content))
562    }
563
564    /// Add a checklist item to a file.
565    pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
566        let content = self.note_read(path)?.unwrap_or_default();
567        let updated = add_checklist_item(&content, item, checked);
568        self.note_write(path, &updated)
569    }
570
571    /// Complete a checklist item by hash.
572    pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
573        let content = self.note_read(path)?.unwrap_or_default();
574        let (new_content, found) = complete_checklist_item(&content, item_hash);
575        if !found.is_empty() {
576            self.note_write(path, &new_content)?;
577            Ok(true)
578        } else {
579            Ok(false)
580        }
581    }
582
583    /// Remove a checklist item by text or hash.
584    pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
585        let content = self.note_read(path)?.unwrap_or_default();
586        let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
587        if !removed.is_empty() {
588            self.note_write(path, &new_content)?;
589            Ok(true)
590        } else {
591            Ok(false)
592        }
593    }
594
595    /// Remove all completed checklist items.
596    pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
597        let content = self.note_read(path)?.unwrap_or_default();
598        let (kept, removed) = remove_completed_checklist_items(&content);
599        if !removed.is_empty() {
600            self.note_write(path, &kept)?;
601        }
602        Ok((kept, removed))
603    }
604
605    // ── Worker ────────────────────────────────────────────────────
606
607    /// Run nightly cleanup.
608    pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
609        // Read config before acquiring the write lock — config() takes
610        // a read lock and would otherwise deadlock against our write guard.
611        let config = self.config()?;
612        let fs = self.fs.write();
613        Ok(remove_completed_items(&fs, &config)?)
614    }
615
616    /// Move due scheduled tasks to Chat.
617    pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
618        // Read config first, take the write lock only for the worker pass,
619        // then release it before set_config() (which calls note_write and
620        // would re-acquire the lock).
621        let mut config = self.config()?;
622        let moved = {
623            let fs = self.fs.write();
624            move_due_tasks(&fs, &mut config)?
625        };
626        if !moved.is_empty() {
627            self.set_config(&config)?;
628        }
629        Ok(moved)
630    }
631
632    // ── Stats ────────────────────────────────────────────────────
633
634    /// Get today's completion report.
635    pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
636        let fs = self.fs.read();
637        Ok(today_report(&fs)?)
638    }
639
640    /// Get list of files completed today.
641    pub fn done_today(&self) -> Result<Vec<FileEntry>> {
642        let fs = self.fs.read();
643        Ok(done_today(&fs)?)
644    }
645
646    // ── Utilities ───────────────────────────────────────────────
647
648    /// Convert markdown to HTML.
649    pub fn markdown_to_html(&self, md: &str) -> String {
650        markdown_to_html(md)
651    }
652
653    /// Find an emoji for a keyword.
654    pub fn auto_emoji(&self, text: &str) -> String {
655        emoji_for(text)
656    }
657
658    /// Generate world clock report for given timezone names.
659    pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
660        world_clock_for_names(timezone_names)
661    }
662
663    // ── Agent Write Tracking ──────────────────────────────────────
664
665    /// Mark a file as having been written by an agent.
666    pub fn mark_agent_write(&self, path: &str) {
667        self.agent_writes.lock().insert(path.to_string());
668    }
669
670    /// Check if a file was written by an agent.
671    pub fn is_agent_write(&self, path: &str) -> bool {
672        self.agent_writes.lock().contains(path)
673    }
674
675    /// Clear the agent-write marker for a file.
676    pub fn clear_agent_write(&self, path: &str) {
677        self.agent_writes.lock().remove(path);
678    }
679
680    // ── Text extraction ──────────────────────────────────────────
681
682    /// Extract text, images, and links from markdown content.
683    pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
684        crate::tgtxt::extract_text_imgs_links(text)
685    }
686
687    // ── Headings (for tag extraction) ─────────────────────────────
688
689    /// Extract headings from content for tag generation.
690    pub fn extract_headings(&self, content: &str) -> Vec<String> {
691        extract_headings(content).into_iter().take(5).collect()
692    }
693}
694
695// ---------------------------------------------------------------------------
696// Frontmatter helpers (RFC-022)
697// ---------------------------------------------------------------------------
698
699/// Parse Oxios frontmatter from a note's content.
700///
701/// Returns `(Some(NoteMeta), body)` if the `oxios:` key is present in the
702/// frontmatter. Returns `(None, original_content)` if there is no frontmatter
703/// or the frontmatter does not contain the `oxios:` key (e.g., user-written
704/// Obsidian frontmatter). In the latter case, the full original content
705/// (including any user frontmatter) is returned as the body.
706pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
707    let trimmed = content.trim_start();
708    if !trimmed.starts_with("---") {
709        return (None, content.to_string());
710    }
711
712    // Find the closing ---
713    let after_first = &trimmed[3..];
714    let rest = after_first.trim_start_matches(['-', '\n', '\r']);
715    if let Some(end_offset) = rest.find("\n---") {
716        let yaml_block = &rest[..end_offset];
717        let body_start = end_offset + 4; // skip \n---
718        let body = rest[body_start..].trim_start().to_string();
719
720        // Parse YAML looking for the `oxios:` key
721        if !yaml_block.contains("oxios:") {
722            // User frontmatter, not ours
723            return (None, content.to_string());
724        }
725
726        #[derive(serde::Deserialize)]
727        struct FrontmatterWrapper {
728            oxios: NoteMeta,
729        }
730
731        match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
732            Ok(wrapper) => (Some(wrapper.oxios), body),
733            Err(_) => (None, content.to_string()),
734        }
735    } else {
736        (None, content.to_string())
737    }
738}
739
740/// Format a NoteMeta as YAML frontmatter prepended to content.
741///
742/// `serde_yaml::to_string` produces flat YAML like `author: agent\nsource: Hook\n`.
743/// We must indent each line with 2 spaces so they become children of the
744/// `oxios:` mapping key.
745fn format_frontmatter(meta: &NoteMeta, body: &str) -> String {
746    let yaml = serde_yaml::to_string(meta).unwrap_or_default();
747    let indented: String = yaml
748        .lines()
749        .filter(|l| !l.is_empty())
750        .map(|l| format!("  {l}"))
751        .collect::<Vec<_>>()
752        .join("\n");
753    format!("---\noxios:\n{}\n---\n\n{}", indented, body)
754}
755
756// ---------------------------------------------------------------------------
757// Tests
758// ---------------------------------------------------------------------------
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    fn make_test_kb() -> KnowledgeBase {
765        let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
766        KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
767    }
768
769    #[test]
770    fn test_note_write_and_read() {
771        let kb = make_test_kb();
772        kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
773            .unwrap();
774        let content = kb.note_read("brain/Rust.md").unwrap();
775        assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
776    }
777
778    #[test]
779    fn test_note_read_missing() {
780        let kb = make_test_kb();
781        assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
782    }
783
784    #[test]
785    fn test_note_delete() {
786        let kb = make_test_kb();
787        kb.note_write("del.md", "to delete").unwrap();
788        kb.note_delete("del.md").unwrap();
789        assert_eq!(kb.note_read("del.md").unwrap(), None);
790    }
791
792    #[test]
793    fn test_note_move() {
794        let kb = make_test_kb();
795        kb.note_write("old.md", "content").unwrap();
796        kb.note_move("old.md", "new.md").unwrap();
797        assert_eq!(kb.note_read("old.md").unwrap(), None);
798        assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
799    }
800
801    #[test]
802    fn test_backlinks() {
803        let kb = make_test_kb();
804        kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
805            .unwrap();
806        let bl = kb.backlinks_for("brain/Ownership.md");
807        assert_eq!(bl.len(), 1);
808        assert_eq!(bl[0].source_path, "brain/Rust.md");
809    }
810
811    #[test]
812    fn test_note_tree() {
813        let kb = make_test_kb();
814        kb.note_write("brain/Rust.md", "Rust").unwrap();
815        let entries = kb.note_tree("brain").unwrap();
816        assert!(!entries.is_empty());
817    }
818
819    #[test]
820    fn test_search_by_name() {
821        let kb = make_test_kb();
822        kb.note_write("brain/Rust.md", "Rust content").unwrap();
823        let hits = kb.search("Rust", 10).unwrap();
824        assert!(!hits.is_empty());
825    }
826
827    #[test]
828    fn test_link_graph() {
829        let kb = make_test_kb();
830        kb.note_write("a.md", "[b](b.md)").unwrap();
831        let graph = kb.link_graph();
832        assert!(!graph.edges.is_empty());
833    }
834
835    #[test]
836    fn test_agent_write_tracking() {
837        let kb = make_test_kb();
838        assert!(!kb.is_agent_write("test.md"));
839        kb.mark_agent_write("test.md");
840        assert!(kb.is_agent_write("test.md"));
841        kb.clear_agent_write("test.md");
842        assert!(!kb.is_agent_write("test.md"));
843    }
844
845    #[test]
846    fn test_index_all() {
847        let kb = make_test_kb();
848        kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
849            .unwrap();
850        kb.note_write("brain/Go.md", "Go language").unwrap();
851        kb.note_write("index.md", "Welcome").unwrap();
852        let count = kb.index_all().unwrap();
853        assert_eq!(count, 3);
854        let bl = kb.backlinks_for("brain/Go.md");
855        assert_eq!(bl.len(), 1);
856    }
857
858    #[test]
859    fn test_on_file_change_callback() {
860        let kb = make_test_kb();
861        let _called = std::sync::atomic::AtomicBool::new(false);
862        let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
863            std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
864        let flag = path_clone.clone();
865
866        kb.on_file_change(move |path, change| {
867            let _ = path;
868            let _ = change;
869            flag.store(true, std::sync::atomic::Ordering::SeqCst);
870        });
871
872        kb.note_write("test.md", "hello").unwrap();
873        assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
874    }
875
876    #[test]
877    fn test_chat_append() {
878        let kb = make_test_kb();
879        kb.chat_append("Test message").unwrap();
880        let messages = kb.chat_messages().unwrap();
881        // The captured message must be a parseable marker block (- [ ] `HH:MM` text),
882        // not merged into the date header. chat_append must emit the `- [ ]` prefix
883        // that read_chat_msgs splits on.
884        assert!(
885            messages
886                .iter()
887                .any(|m| m.starts_with("- [") && m.contains("Test message")),
888            "captured message should be a parseable marker block: {messages:?}"
889        );
890    }
891
892    #[test]
893    fn test_config() {
894        let kb = make_test_kb();
895        let cfg = kb.config().unwrap();
896        // Should return default for non-existent config
897        let cfg2 = kb.config().unwrap();
898        assert_eq!(cfg.language, cfg2.language);
899    }
900
901    #[test]
902    fn test_markdown_to_html() {
903        let kb = make_test_kb();
904        let html = kb.markdown_to_html("# Hello\n\n**world**");
905        // markdown_to_html wraps content in a <p> tag by default, check for content
906        assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
907        assert!(html.contains("world"), "HTML should contain world: {html}");
908    }
909
910    #[test]
911    fn test_auto_emoji() {
912        let kb = make_test_kb();
913        let emoji = kb.auto_emoji("cooking pasta");
914        assert!(!emoji.is_empty());
915    }
916
917    #[test]
918    fn test_extract_headings() {
919        let kb = make_test_kb();
920        let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
921        assert!(headings.len() >= 2);
922    }
923
924    #[test]
925    fn test_frontmatter_roundtrip() {
926        let meta = NoteMeta {
927            author: "agent".to_string(),
928            source: NoteSource::Hook,
929            quality: NoteQuality::Raw,
930            needs_review: true,
931            session_id: Some("abc123".to_string()),
932            message_index: Some(3),
933            saved_at: Some("2026-06-13T00:00:00Z".to_string()),
934        };
935        let body = "## Test\n\nContent here.";
936        let formatted = format_frontmatter(&meta, body);
937        assert!(formatted.starts_with("---\noxios:\n"));
938        let (parsed_meta, parsed_body) = parse_note_meta(&formatted);
939        assert!(
940            parsed_meta.is_some(),
941            "Failed to parse round-tripped frontmatter"
942        );
943        let pm = parsed_meta.unwrap();
944        assert_eq!(pm.author, "agent");
945        assert_eq!(pm.session_id.as_deref(), Some("abc123"));
946        assert_eq!(pm.message_index, Some(3));
947        assert_eq!(parsed_body.trim(), body.trim());
948    }
949
950    #[test]
951    fn test_parse_user_frontmatter_ignored() {
952        let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
953        let (meta, body) = parse_note_meta(content);
954        assert!(
955            meta.is_none(),
956            "User frontmatter should not be parsed as NoteMeta"
957        );
958        assert!(
959            body.contains("tags: [rust, design]"),
960            "User frontmatter preserved"
961        );
962    }
963
964    #[test]
965    fn test_parse_no_frontmatter() {
966        let content = "# Just a note\nSome content.";
967        let (meta, body) = parse_note_meta(content);
968        assert!(meta.is_none());
969        assert_eq!(body, content);
970    }
971}