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 time::OffsetDateTime;
21
22use oxi_frontmatter::{NoteFormat, Parsed, WriteOutcome};
23
24use crate::backlinks::{Backlink, BacklinkIndex, LinkGraph};
25use crate::chat::{delete_chat_msg, move_from_chat, read_chat_msgs, rename_chat_msg};
26use crate::checklist::{
27    add_checklist_item, checklist_items, complete_checklist_item, incomplete_checklist_items,
28    remove_checklist_item, remove_completed_checklist_items,
29};
30use crate::frontformat;
31use crate::fs::VirtualFs;
32use crate::fs::split_posix_path;
33use crate::habits::{habits, last_week_habits, write_habits};
34use crate::html::markdown_to_html;
35use crate::i18n::emoji_for;
36use crate::journal::{add_emoji as journal_add_emoji, add_record as journal_add_record};
37use crate::parser::{
38    StemIndex, extract_headings, rewrite_link_targets, rewrite_wikilink_targets, similar,
39};
40use crate::plugins::world_clock_for_names;
41use crate::stats::{done_today, today_report};
42use crate::types::NoteMeta;
43use crate::types::{CHAT_FILENAME, DIR_USER_ROOT, FileEntry, Habits, KnowledgeConfig};
44#[cfg(test)]
45use crate::types::{NoteQuality, NoteSource};
46use crate::worker::{move_due_tasks, remove_completed_items};
47use crate::{today_chat_header, today_journal_filename};
48
49/// File change event emitted via `on_file_change` callbacks.
50#[derive(Debug, Clone)]
51pub enum FileChange {
52    /// A new file was created.
53    Created(String),
54    /// An existing file was updated.
55    Updated(String),
56    /// A file was deleted.
57    Deleted(String),
58    /// A file was moved or renamed.
59    Moved {
60        /// Original path before the move.
61        old: String,
62        /// New path after the move.
63        new: String,
64    },
65}
66
67/// Knowledge search hit (file-name based).
68#[derive(Debug, Clone)]
69pub struct NoteHit {
70    /// File path relative to knowledge root.
71    pub path: String,
72    /// Display name of the file.
73    pub name: String,
74    /// Content snippet.
75    pub snippet: String,
76    /// Number of backlinks pointing to this note.
77    pub backlink_count: usize,
78    /// Name similarity score (0–100).
79    pub name_similarity: i32,
80}
81
82/// Markdown knowledge base application layer.
83///
84/// Wraps [`VirtualFs`] for sandboxed file I/O, [`BacklinkIndex`] for
85/// link tracking, and provides all app-layer features (chat, journal,
86/// habits, checklist, etc.).
87///
88/// **No kernel dependencies.** Can be used standalone by any channel.
89pub struct KnowledgeBase {
90    /// Sandboxed filesystem.
91    fs: RwLock<VirtualFs>,
92    /// Bidirectional link index.
93    backlinks: RwLock<BacklinkIndex>,
94    /// Files written by agents (not by the user).
95    agent_writes: ParkingMutex<HashSet<String>>,
96    /// Callbacks invoked on file changes.
97    /// Used by [`KnowledgeLens`] to keep semantic index in sync.
98    on_change: RwLock<Vec<FileChangeCallback>>,
99}
100
101impl std::fmt::Debug for KnowledgeBase {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_struct("KnowledgeBase")
104            .field("root", &self.fs.read().root())
105            .finish()
106    }
107}
108
109impl KnowledgeBase {
110    /// Create a new KnowledgeBase for the given root directory.
111    pub fn new(root: PathBuf) -> Result<Self> {
112        let fs = VirtualFs::new(root)?;
113        Ok(Self {
114            fs: RwLock::new(fs),
115            backlinks: RwLock::new(BacklinkIndex::new()),
116            agent_writes: ParkingMutex::new(HashSet::new()),
117            on_change: RwLock::new(Vec::new()),
118        })
119    }
120
121    /// Create a new KnowledgeBase scoped to a Space's subdirectory.
122    pub fn for_space(space_dir: &std::path::Path) -> Result<Self> {
123        Self::new(space_dir.join("knowledge"))
124    }
125
126    /// Get the root path of the knowledge base.
127    pub fn root(&self) -> PathBuf {
128        self.fs.read().root().to_path_buf()
129    }
130
131    /// Register a callback to be invoked on every file change.
132    ///
133    /// The callback receives `(path, FileChange)`.
134    /// Multiple callbacks can be registered.
135    pub fn on_file_change<F>(&self, f: F)
136    where
137        F: Fn(&str, FileChange) + Send + Sync + 'static,
138    {
139        self.on_change.write().push(Box::new(f));
140    }
141
142    /// Emit file change notifications to all registered callbacks.
143    pub(crate) fn notify_change(&self, path: &str, change: FileChange) {
144        for cb in self.on_change.read().iter() {
145            cb(path, change.clone());
146        }
147    }
148
149    /// Canonicalized containment check (F4): resolve `path` through
150    /// [`VirtualFs::safe_path`] so a symlinked directory component
151    /// pointing outside the root is rejected before any
152    /// frontformat delegation (which only performs string-level
153    /// path hardening).
154    fn assert_within_root(&self, path: &str) -> Result<()> {
155        let (dir, filename) = split_posix_path(path);
156        self.fs
157            .read()
158            .safe_path(dir, filename)
159            .map_err(|e| anyhow::anyhow!("unsafe path {path:?}: {e}"))?;
160        Ok(())
161    }
162
163    // ── File I/O ───────────────────────────────────────────────────
164
165    /// Read a note's content.
166    pub fn note_read(&self, path: &str) -> Result<Option<String>> {
167        let fs = self.fs.read();
168        match fs.read_path(path) {
169            Ok(content) => Ok(Some(content)),
170            Err(_) => Ok(None),
171        }
172    }
173
174    /// Read a note's raw bytes — for binary assets (images, etc.) that aren't
175    /// valid UTF-8. Text notes should use [`note_read`].
176    pub fn note_read_bytes(&self, path: &str) -> Result<Option<Vec<u8>>> {
177        let fs = self.fs.read();
178        match fs.read_path_bytes(path) {
179            Ok(bytes) => Ok(Some(bytes)),
180            Err(_) => Ok(None),
181        }
182    }
183
184    /// Build a lowercase-stem → paths[] index over every `.md` file in the KB.
185    ///
186    /// Used by `resolve_wikilink` to canonicalize `[[bare-stem]]` targets
187    /// during indexing and (transitively) to decide which wikilinks are
188    /// safe to rewrite on rename. Walks the whole tree; cheap for the
189    /// documented personal-KB scale (hundreds of files).
190    ///
191    /// MUST be called BEFORE acquiring the backlinks write lock — it takes
192    /// the fs read lock, and we never nest the two.
193    fn build_stem_index(&self) -> StemIndex {
194        let mut index: StemIndex = StemIndex::new();
195        let files = match self.list_all_md_files() {
196            Ok(f) => f,
197            Err(e) => {
198                tracing::warn!(error = %e, "stem_index walk failed; wikilinks stay unresolved");
199                return index;
200            }
201        };
202        for (path, _size) in files {
203            let stem = match path.rsplit('/').next() {
204                Some(b) => b.trim_end_matches(".md"),
205                None => path.as_str().trim_end_matches(".md"),
206            }
207            .to_lowercase();
208            index.entry(stem).or_default().push(path);
209        }
210        index
211    }
212    /// Write a note — creates or overwrites.
213    ///
214    /// Routes through [`crate::frontformat::write_note`] so memo paths
215    /// carry a canonical `oxios:` frontmatter block (id/created/updated)
216    /// while system paths (Chat.md, journal/, etc.) stay raw.
217    ///
218    /// **No-op precedence (§5.3.2):** when `frontformat::write_note`
219    /// returns `WriteOutcome::NoOp` — meaning the merged memo is
220    /// semantically identical to the on-disk file, OR the system-path
221    /// bytes already match — we return `Ok(())` *before* reindexing
222    /// backlinks or firing `on_file_change` callbacks. The invariant:
223    /// no pointless churn.
224    pub fn note_write(&self, path: &str, content: &str) -> Result<()> {
225        // F4 containment (round-1 review fix): frontformat's
226        // assert_safe_rel is string-only, so a symlinked directory
227        // component could otherwise escape the root. Resolve through
228        // VirtualFs::safe_path — the same canonicalized containment
229        // check fs.write_path performed before T12 — before
230        // delegating the bytes to frontformat.
231        self.assert_within_root(path)?;
232
233        // Capture root under a brief read lock, then release it before
234        // the (potentially slow) frontformat IO so we never nest the
235        // fs write lock under a fs read lock from the same thread.
236        let root = self.fs.read().root().to_path_buf();
237        let was_new = !root.join(path).exists();
238
239        let now = OffsetDateTime::now_utc();
240        let outcome = frontformat::write_note(&root, path, content, now)
241            .map_err(|e| anyhow::anyhow!("frontformat::write_note({path}) failed: {e}"))?;
242
243        if matches!(outcome, WriteOutcome::NoOp) {
244            // §5.3.2 no-op precedence — leave the backlinks index and
245            // callbacks alone. The file on disk is identical to what
246            // we would have written.
247            return Ok(());
248        }
249
250        // Build the stem index BEFORE taking the backlinks write lock
251        // (fs read lock nests under nothing here).
252        let stem_index = self.build_stem_index();
253        {
254            let mut backlinks = self.backlinks.write();
255            backlinks.remove_file(path);
256            backlinks.index_file_with(path, content, &stem_index);
257        }
258
259        self.notify_change(
260            path,
261            if was_new {
262                FileChange::Created(path.to_string())
263            } else {
264                FileChange::Updated(path.to_string())
265            },
266        );
267        Ok(())
268    }
269
270    /// Write a note with provenance metadata (RFC-022).
271    ///
272    /// Merges the provided [`NoteMeta`] into the file's `oxios:`
273    /// table (synthesizing id/created/updated on a fresh memo;
274    /// preserving id/created across a re-write) via
275    /// [`crate::frontformat::with_oxios_table`], then delegates to
276    /// [`Self::note_write`].
277    ///
278    /// **User-authored refusal:** if the file already exists and its
279    /// frontmatter block contains no `oxios:` table — i.e., the
280    /// frontmatter is user-authored (Obsidian tags, custom keys) —
281    /// we return `Ok(false)` and leave the file untouched. The brief
282    /// §5.3.2 specifies that user-authored frontmatter is sacred; an
283    /// agent metadata write must never overwrite it.
284    pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
285        // System paths (Chat.md, journal/, non-.md) never carry
286        // frontmatter — refuse so the caller can fall back to a raw
287        // note_write instead of us silently polluting the file with
288        // an oxios: block that write_note would write verbatim (raw).
289        if frontformat::is_system_path(path) {
290            tracing::debug!(
291                path,
292                "Skipping note_write_with_meta on system path (no frontmatter allowed)"
293            );
294            return Ok(false);
295        }
296
297        // Round-1 review fix: the refusal must be EXACT — only a file
298        // whose existing frontmatter block (Parsed::Memo) carries NO
299        // `oxios:` table is user-authored. A BodyOnly file (no
300        // frontmatter at all) proceeds and gains the table; malformed
301        // frontmatter proceeds and surfaces a hard parse error from
302        // the write path (never silently refused nor repaired).
303        let existing = self.note_read(path).ok().flatten();
304        let user_authored = matches!(
305            existing.as_deref().map(|s| oxi_frontmatter::parse(s, NoteFormat::Markdown)),
306            Some(Ok(Parsed::Memo { ref table, .. })) if !table.contains_key("oxios")
307        );
308
309        if user_authored {
310            tracing::debug!(
311                path,
312                "Skipping note_write_with_meta on user-authored note (frontmatter without oxios:)"
313            );
314            return Ok(false);
315        }
316
317        // Build the merged content (with the oxios: row layered on
318        // top of the existing frontmatter OR freshly synthesized on a
319        // brand-new file). with_oxios_table parses the caller's
320        // `content` and emits the canonical form — for BodyOnly input
321        // it produces a fresh frontmatter block; for Memo input it
322        // preserves every non-oxios key (id/created/tags/aliases/etc.)
323        // alongside our new oxios: row.
324        let merged = frontformat::with_oxios_table(content, meta)
325            .map_err(|e| anyhow::anyhow!("frontformat::with_oxios_table({path}) failed: {e}"))?;
326
327        self.note_write(path, &merged).map(|_| true)
328    }
329
330    /// List notes that need Dream review (RFC-022).
331    ///
332    /// Scans the vault for `.md` files with `needs_review: true` in
333    /// their `oxios:` frontmatter. Routes through
334    /// [`crate::frontformat::read_note_meta`] so the frontmatter
335    /// grammar is the v4 / `oxi-frontmatter` v0.1 contract, not the
336    /// bespoke parser that lived in this module before T12.
337    pub fn notes_needing_review(&self) -> Result<Vec<(String, NoteMeta)>> {
338        let fs = self.fs.read();
339        let mut result = Vec::new();
340
341        let files = fs.all_md_files()?;
342        for (path, _size) in &files {
343            // Skip system paths outright — they never carry an oxios:
344            // table, and parsing them through read_note_meta would
345            // surface a body-only result we have to filter anyway.
346            if frontformat::is_system_path(path) {
347                continue;
348            }
349            let content = match fs.read_path(path) {
350                Ok(c) => c,
351                Err(_) => continue,
352            };
353            // Malformed frontmatter is a hard parse error per the
354            // frontmatter spec; we don't silently repair.
355            match frontformat::read_note_meta(&content) {
356                Ok(Some(m)) if m.needs_review => result.push((path.clone(), m)),
357                Ok(_) => {}
358                Err(e) => {
359                    tracing::warn!(
360                        path = %path,
361                        error = %e,
362                        "skipping notes_needing_review scan on malformed frontmatter"
363                    );
364                }
365            }
366        }
367
368        // Oldest first — they've been raw the longest
369        result.sort_by(|a, b| {
370            a.1.saved_at
371                .as_deref()
372                .unwrap_or("")
373                .cmp(b.1.saved_at.as_deref().unwrap_or(""))
374        });
375
376        Ok(result)
377    }
378    /// Delete the note at `path`, removing it from the filesystem and
379    /// dropping any recorded backlinks for that file.
380    pub fn note_delete(&self, path: &str) -> Result<()> {
381        {
382            let fs = self.fs.write();
383            fs.delete_path(path)?;
384        }
385        self.backlinks.write().remove_file(path);
386        self.notify_change(path, FileChange::Deleted(path.to_string()));
387        Ok(())
388    }
389
390    /// Restore a note's content without triggering file-change callbacks.
391    ///
392    /// Used when reverting to a previous git version — writes the file
393    /// through [`crate::frontformat::write_note`] (so pre-migration
394    /// blobs gain synthesized id/created/updated while keeping their
395    /// `oxios:` row and editor-supplied keys), updates the backlink
396    /// index, but does **not** fire `on_file_change` callbacks. This
397    /// prevents an infinite loop where restore → write → callback →
398    /// git commit → ... repeats.
399    pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
400        // F4 containment — same canonicalized check as note_write.
401        self.assert_within_root(path)?;
402
403        let root = self.fs.read().root().to_path_buf();
404        let now = OffsetDateTime::now_utc();
405        // write_note preserves the live file's id/created if present
406        // (they land in the merge base); synthesizes fresh ones when
407        // the incoming content lacks them; and returns NoOp without
408        // touching the file when the merged memo is byte-identical
409        // to what's on disk. We suppress notify_change() regardless.
410        let outcome = frontformat::write_note(&root, path, content, now)
411            .map_err(|e| anyhow::anyhow!("frontformat::write_note({path}) failed: {e}"))?;
412
413        // On a real write, refresh the backlink index; on NoOp the
414        // file didn't change so the index is still accurate.
415        if matches!(outcome, WriteOutcome::Written) {
416            let stem_index = self.build_stem_index();
417            let mut backlinks = self.backlinks.write();
418            backlinks.remove_file(path);
419            backlinks.index_file_with(path, content, &stem_index);
420        }
421        // Intentionally skip notify_change() — restore is the "quiet
422        // git revert" path.
423        Ok(())
424    }
425    /// Move/rename a note.
426    ///
427    /// In addition to the filesystem rename and backlink reindex, this
428    /// rewrites every `[text](old_path)]` reference in **other** notes
429    /// (and any self-reference in the moved note) to point at `new_path`,
430    /// AND every `[[target]]` wikilink that resolves to old_path (with
431    /// ambiguity guard for bare stems). Without this, renaming a note
432    /// that other notes link to would silently orphan those links — a
433    /// latent bug that affected both the F2 sidebar rename and the
434    /// H1-driven rename.
435    pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
436        // 0. Build the stem index BEFORE renaming. The bare-stem ambiguity
437        //    check in `rewrite_wikilink_targets` needs old_path still
438        //    present in the tree; after the rename, old_path is gone and
439        //    the stem count would undercount. This is the rewrite-time
440        //    index; step 5 builds a second (post-rename) one for reindex.
441        let pre_stem_index = self.build_stem_index();
442
443        // 1. Rename on disk + read the moved file's content under the fs lock.
444        let new_content = {
445            let fs = self.fs.write();
446            fs.rename_path(old_path, new_path)?;
447            fs.read_path(new_path).ok()
448        };
449
450        // 2. Snapshot the set of files that link to old_path BEFORE we
451        //    tear down the index entry. Done under a read lock; the
452        //    actual rewrites happen outside the lock to keep the critical
453        //    section short.
454        let sources: HashSet<String> = {
455            let backlinks = self.backlinks.read();
456            backlinks.sources_for(old_path)
457        };
458
459        // 3. Rewrite self-references in the moved note (a note can link
460        //    to itself by its old name). This is what gets indexed and
461        //    persisted.
462        let indexed_content = match &new_content {
463            Some(c) => {
464                let (md_done, _) = rewrite_link_targets(c, old_path, new_path);
465                let (wiki_done, _) =
466                    rewrite_wikilink_targets(&md_done, old_path, new_path, Some(&pre_stem_index));
467                if &wiki_done != c {
468                    // Persist the self-reference fix.
469                    let _ = self.fs.write().write_path(new_path, &wiki_done);
470                }
471                wiki_done
472            }
473            None => String::new(),
474        };
475
476        // 4. Rewrite references in every other note that linked to the
477        //    old path. Collect (path, new_content) pairs to write + reindex.
478        let mut touched: Vec<(String, String)> = Vec::with_capacity(sources.len());
479        for src in &sources {
480            if src == old_path || src == new_path {
481                // Self-links already handled above; skip the moved file.
482                continue;
483            }
484            if let Ok(content) = self.fs.read().read_path(src) {
485                let (md_done, n_md) = rewrite_link_targets(&content, old_path, new_path);
486                let (final_done, n_wiki) =
487                    rewrite_wikilink_targets(&md_done, old_path, new_path, Some(&pre_stem_index));
488                if (n_md > 0 || n_wiki > 0) && final_done != content {
489                    touched.push((src.clone(), final_done));
490                }
491            }
492        }
493
494        // 5. Apply reindex: drop old, index new (with rewritten content),
495        //    and reindex every touched source. Build the post-rename stem
496        //    index so wikilinks in the reindexed notes re-resolve against
497        //    the now-current tree.
498        let post_stem_index = self.build_stem_index();
499        {
500            let mut backlinks = self.backlinks.write();
501            backlinks.remove_file(old_path);
502            if !indexed_content.is_empty() {
503                backlinks.index_file_with(new_path, &indexed_content, &post_stem_index);
504            }
505            for (src, content) in &touched {
506                backlinks.index_file_with(src, content, &post_stem_index);
507            }
508        }
509
510        // 6. Persist the rewritten sources. Done AFTER reindexing so a
511        //    crash between write and reindex leaves the index pointing at
512        //    the on-disk content (idempotent on next scan).
513        if !touched.is_empty() {
514            let fs = self.fs.write();
515            for (src, content) in &touched {
516                let _ = fs.write_path(src, content);
517            }
518        }
519
520        self.notify_change(
521            old_path,
522            FileChange::Moved {
523                old: old_path.to_string(),
524                new: new_path.to_string(),
525            },
526        );
527        Ok(())
528    }
529
530    /// List notes in a directory.
531    pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
532        let fs = self.fs.read();
533        let dir = if dir.is_empty() || dir == "/" {
534            DIR_USER_ROOT
535        } else {
536            dir
537        };
538        Ok(fs.files_and_dirs(dir)?)
539    }
540
541    /// List all markdown files in the knowledge base (path, size).
542    /// Used by startup git reconciliation to detect post-crash drift.
543    pub fn list_all_md_files(&self) -> Result<Vec<(String, i64)>> {
544        let fs = self.fs.read();
545        Ok(fs.all_md_files()?)
546    }
547
548    // ── Search (file-name based only) ────────────────────────────
549
550    /// Search notes by file name fuzzy matching.
551    ///
552    /// **Note:** Semantic search is handled by `KnowledgeLens`,
553    /// not by this method.
554    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
555        let fs = self.fs.read();
556        let files = fs.search_files_by_name(query)?;
557
558        let hits: Vec<NoteHit> = files
559            .into_iter()
560            .take(limit)
561            .map(|f| {
562                let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
563                    f.name.clone()
564                } else {
565                    format!("{}/{}", f.parent_dir, f.name)
566                };
567                let name_sim = similar(&f.display_name, query) as i32;
568                let bl_count = self.backlinks.read().backlink_count(&path);
569                NoteHit {
570                    path,
571                    name: f.display_name,
572                    snippet: String::new(),
573                    backlink_count: bl_count,
574                    name_similarity: name_sim,
575                }
576            })
577            .collect();
578
579        Ok(hits)
580    }
581
582    // ── Backlinks & Graph ─────────────────────────────────────────
583
584    /// Get backlinks for a note.
585    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
586        self.backlinks.read().backlinks_for(path)
587    }
588
589    /// Get the full link graph for visualization.
590    pub fn link_graph(&self) -> LinkGraph {
591        self.backlinks.read().link_graph()
592    }
593
594    /// Index all markdown files in the knowledge base.
595    ///
596    /// Walks the entire directory tree (at any depth) and builds the
597    /// backlink index, including wikilink targets resolved against a
598    /// stem index built from the same walk. Returns the number of files
599    /// indexed.
600    pub fn index_all(&self) -> Result<usize> {
601        // Read every file's content under the fs read lock first; we need
602        // the contents anyway and this avoids re-acquiring per file.
603        let (paths_contents, stem_index) = {
604            let fs = self.fs.read();
605            let all = fs.all_md_files()?;
606            let stem_index = {
607                let mut idx: StemIndex = StemIndex::new();
608                for (path, _size) in &all {
609                    let stem = path
610                        .rsplit('/')
611                        .next()
612                        .unwrap_or(path.as_str())
613                        .trim_end_matches(".md")
614                        .to_lowercase();
615                    idx.entry(stem).or_default().push(path.clone());
616                }
617                idx
618            };
619            let mut paths_contents: Vec<(String, String)> = Vec::with_capacity(all.len());
620            for (path, _size) in &all {
621                if let Ok(content) = fs.read_path(path) {
622                    paths_contents.push((path.clone(), content));
623                }
624            }
625            (paths_contents, stem_index)
626        };
627
628        let mut count = 0;
629        {
630            let mut backlinks = self.backlinks.write();
631            backlinks.clear();
632            for (path, content) in &paths_contents {
633                backlinks.index_file_with(path, content, &stem_index);
634                count += 1;
635            }
636        }
637
638        tracing::info!(files = count, "Knowledge base indexed");
639        Ok(count)
640    }
641
642    /// Reindex a single note after an external change (vault watcher).
643    ///
644    /// Extracted from [`KnowledgeBase::index_all`] internals: rebuilds
645    /// the stem index (so wikilinks resolve against the current file
646    /// set), then runs the single-file backlink indexing pass
647    /// (`remove_file` + `index_file_with`, which replaces the file's
648    /// previous links instead of accumulating them). The file is only
649    /// read, never written. Fails if the file cannot be read.
650    pub fn reindex_one(&self, path: &str) -> Result<()> {
651        // Brief fs read guard — released before build_stem_index takes
652        // its own, so the two never nest.
653        let content = {
654            let fs = self.fs.read();
655            fs.read_path(path)?
656        };
657        let stem_index = self.build_stem_index();
658        let mut backlinks = self.backlinks.write();
659        backlinks.remove_file(path);
660        backlinks.index_file_with(path, &content, &stem_index);
661        Ok(())
662    }
663
664    /// Drop a note from the index after an external deletion
665    /// (vault watcher). Does not touch the filesystem.
666    pub fn forget_file(&self, path: &str) {
667        self.backlinks.write().remove_file(path);
668    }
669
670    // ── Chat / Inbox ───────────────────────────────────────────────
671
672    /// Append a timestamped message to Chat.md.
673    pub fn chat_append(&self, message: &str) -> Result<()> {
674        let header = today_chat_header();
675        let timestamp = chrono::Local::now().format("`15:04`").to_string();
676        let entry = format!("- [ ] {timestamp} {message}");
677
678        let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
679        if !content.contains(&header) {
680            if !content.trim_end().ends_with('\n') {
681                content.push('\n');
682            }
683            content.push_str(&header);
684            content.push('\n');
685        }
686        content.push_str(&entry);
687        content.push('\n');
688        self.note_write(CHAT_FILENAME, &content)?;
689        Ok(())
690    }
691
692    /// Parse Chat.md into structured message blocks.
693    pub fn chat_messages(&self) -> Result<Vec<String>> {
694        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
695        Ok(read_chat_msgs(&content))
696    }
697
698    /// Delete a specific chat message by its content hash.
699    pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
700        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
701        match delete_chat_msg(&content, msg_hash) {
702            Ok(new_content) => {
703                self.note_write(CHAT_FILENAME, &new_content)?;
704                Ok(true)
705            }
706            Err(_) => Ok(false),
707        }
708    }
709
710    /// Rename a specific chat message by its content hash.
711    pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
712        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
713        match rename_chat_msg(&content, msg_hash, new_body) {
714            Ok(new_content) => {
715                self.note_write(CHAT_FILENAME, &new_content)?;
716                Ok(true)
717            }
718            Err(_) => Ok(false),
719        }
720    }
721
722    /// Move a chat message to a target file as a checklist item.
723    pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
724        let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
725        let target_content = self.note_read(target_path)?.unwrap_or_default();
726        let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
727        if new_chat != chat_content {
728            self.note_write(CHAT_FILENAME, &new_chat)?;
729            self.note_write(target_path, &new_target)?;
730            Ok(true)
731        } else {
732            Ok(false)
733        }
734    }
735
736    // ── Journal ───────────────────────────────────────────────────
737
738    /// Add a timestamped record to today's journal entry.
739    pub fn journal_add_record(&self, record: &str) -> Result<()> {
740        let fs = self.fs.write();
741        let tz = chrono::Local::now().offset().to_owned();
742        journal_add_record(&fs, record, tz)?;
743        Ok(())
744    }
745
746    /// Add an emoji to today's journal header.
747    pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
748        let fs = self.fs.write();
749        let tz = chrono::Local::now().offset().to_owned();
750        journal_add_emoji(&fs, emoji, tz)?;
751        Ok(())
752    }
753
754    /// Get today's journal file path (e.g., "journal/2026.05 May.md").
755    pub fn journal_today_path(&self) -> String {
756        let tz = chrono::Local::now().offset().to_owned();
757        today_journal_filename(tz)
758    }
759
760    // ── Habits ───────────────────────────────────────────────────
761
762    /// Read habit tracking data for a given year.
763    pub fn habits(&self, year: i32) -> Result<Habits> {
764        let fs = self.fs.read();
765        Ok(habits(&fs, year)?)
766    }
767
768    /// Get last week's habit data.
769    pub fn habits_last_week(&self) -> Result<Habits> {
770        let fs = self.fs.read();
771        let tz = chrono::Local::now().offset().to_owned();
772        Ok(last_week_habits(&fs, tz)?)
773    }
774
775    /// Write habit data for a year.
776    pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
777        let fs = self.fs.write();
778        write_habits(&fs, year, habits)?;
779        Ok(())
780    }
781
782    // ── Config ────────────────────────────────────────────────────
783
784    /// Read the knowledge base config (config.json).
785    pub fn config(&self) -> Result<KnowledgeConfig> {
786        let fs = self.fs.read();
787        match fs.read_path("config.json") {
788            Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
789            Err(_) => Ok(KnowledgeConfig::default()),
790        }
791    }
792
793    /// Write the knowledge base config.
794    pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
795        let json = serde_json::to_string_pretty(config)?;
796        self.note_write("config.json", &json)?;
797        Ok(())
798    }
799
800    // ── Checklist ────────────────────────────────────────────────
801
802    /// Parse checklist items from a file.
803    pub fn checklist_items(
804        &self,
805        path: &str,
806    ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
807        let content = self.note_read(path)?.unwrap_or_default();
808        Ok(checklist_items(&content))
809    }
810
811    /// Get incomplete checklist items from a file.
812    pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
813        let content = self.note_read(path)?.unwrap_or_default();
814        Ok(incomplete_checklist_items(&content))
815    }
816
817    /// Add a checklist item to a file.
818    pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
819        let content = self.note_read(path)?.unwrap_or_default();
820        let updated = add_checklist_item(&content, item, checked);
821        self.note_write(path, &updated)
822    }
823
824    /// Complete a checklist item by hash.
825    pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
826        let content = self.note_read(path)?.unwrap_or_default();
827        let (new_content, found) = complete_checklist_item(&content, item_hash);
828        if !found.is_empty() {
829            self.note_write(path, &new_content)?;
830            Ok(true)
831        } else {
832            Ok(false)
833        }
834    }
835
836    /// Remove a checklist item by text or hash.
837    pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
838        let content = self.note_read(path)?.unwrap_or_default();
839        let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
840        if !removed.is_empty() {
841            self.note_write(path, &new_content)?;
842            Ok(true)
843        } else {
844            Ok(false)
845        }
846    }
847
848    /// Remove all completed checklist items.
849    pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
850        let content = self.note_read(path)?.unwrap_or_default();
851        let (kept, removed) = remove_completed_checklist_items(&content);
852        if !removed.is_empty() {
853            self.note_write(path, &kept)?;
854        }
855        Ok((kept, removed))
856    }
857
858    // ── Worker ────────────────────────────────────────────────────
859
860    /// Run nightly cleanup.
861    pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
862        // Read config before acquiring the write lock — config() takes
863        // a read lock and would otherwise deadlock against our write guard.
864        let config = self.config()?;
865        let fs = self.fs.write();
866        Ok(remove_completed_items(&fs, &config)?)
867    }
868
869    /// Move due scheduled tasks to Chat.
870    pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
871        // Read config first, take the write lock only for the worker pass,
872        // then release it before set_config() (which calls note_write and
873        // would re-acquire the lock).
874        let mut config = self.config()?;
875        let moved = {
876            let fs = self.fs.write();
877            move_due_tasks(&fs, &mut config)?
878        };
879        if !moved.is_empty() {
880            self.set_config(&config)?;
881        }
882        Ok(moved)
883    }
884
885    // ── Stats ────────────────────────────────────────────────────
886
887    /// Get today's completion report.
888    pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
889        let fs = self.fs.read();
890        Ok(today_report(&fs)?)
891    }
892
893    /// Get list of files completed today.
894    pub fn done_today(&self) -> Result<Vec<FileEntry>> {
895        let fs = self.fs.read();
896        Ok(done_today(&fs)?)
897    }
898
899    // ── Utilities ───────────────────────────────────────────────
900
901    /// Convert markdown to HTML.
902    pub fn markdown_to_html(&self, md: &str) -> String {
903        markdown_to_html(md)
904    }
905
906    /// Find an emoji for a keyword.
907    pub fn auto_emoji(&self, text: &str) -> String {
908        emoji_for(text)
909    }
910
911    /// Generate world clock report for given timezone names.
912    pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
913        world_clock_for_names(timezone_names)
914    }
915
916    // ── Agent Write Tracking ──────────────────────────────────────
917
918    /// Mark a file as having been written by an agent.
919    pub fn mark_agent_write(&self, path: &str) {
920        self.agent_writes.lock().insert(path.to_string());
921    }
922
923    /// Check if a file was written by an agent.
924    pub fn is_agent_write(&self, path: &str) -> bool {
925        self.agent_writes.lock().contains(path)
926    }
927
928    /// Clear the agent-write marker for a file.
929    pub fn clear_agent_write(&self, path: &str) {
930        self.agent_writes.lock().remove(path);
931    }
932
933    // ── Text extraction ──────────────────────────────────────────
934
935    /// Extract text, images, and links from markdown content.
936    pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
937        crate::tgtxt::extract_text_imgs_links(text)
938    }
939
940    // ── Headings (for tag extraction) ─────────────────────────────
941
942    /// Extract headings from content for tag generation.
943    pub fn extract_headings(&self, content: &str) -> Vec<String> {
944        extract_headings(content).into_iter().take(5).collect()
945    }
946}
947
948// ---------------------------------------------------------------------------
949// Frontmatter helpers (RFC-022)
950// ---------------------------------------------------------------------------
951
952/// Parse Oxios frontmatter from a note's content.
953///
954/// Returns `(Some(NoteMeta), body)` if the `oxios:` key is present in the
955/// frontmatter. Returns `(None, original_content)` if there is no frontmatter
956/// or the frontmatter does not contain the `oxios:` key (e.g., user-written
957/// Obsidian frontmatter). In the latter case, the full original content
958/// (including any user frontmatter) is returned as the body.
959pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
960    let trimmed = content.trim_start();
961    if !trimmed.starts_with("---") {
962        return (None, content.to_string());
963    }
964
965    // Find the closing ---
966    let after_first = &trimmed[3..];
967    let rest = after_first.trim_start_matches(['-', '\n', '\r']);
968    if let Some(end_offset) = rest.find("\n---") {
969        let yaml_block = &rest[..end_offset];
970        let body_start = end_offset + 4; // skip \n---
971        let body = rest[body_start..].trim_start().to_string();
972
973        // Parse YAML looking for the `oxios:` key
974        if !yaml_block.contains("oxios:") {
975            // User frontmatter, not ours
976            return (None, content.to_string());
977        }
978
979        #[derive(serde::Deserialize)]
980        struct FrontmatterWrapper {
981            oxios: NoteMeta,
982        }
983
984        match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
985            Ok(wrapper) => (Some(wrapper.oxios), body),
986            Err(_) => (None, content.to_string()),
987        }
988    } else {
989        (None, content.to_string())
990    }
991}
992
993// ---------------------------------------------------------------------------
994// Tests
995// ---------------------------------------------------------------------------
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    fn make_test_kb() -> KnowledgeBase {
1002        let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
1003        KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
1004    }
1005
1006    #[test]
1007    fn test_note_write_and_read() {
1008        let kb = make_test_kb();
1009        kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
1010            .unwrap();
1011        let content = kb.note_read("brain/Rust.md").unwrap().unwrap();
1012        // T12: note_write now routes through frontformat::write_note
1013        // — memo paths get synthesized id/created/updated.
1014        assert!(content.starts_with("---\n"));
1015        assert!(content.contains("# Rust"));
1016        assert!(content.contains("Hello world"));
1017    }
1018
1019    #[test]
1020    fn test_note_read_missing() {
1021        let kb = make_test_kb();
1022        assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
1023    }
1024
1025    #[test]
1026    fn test_note_delete() {
1027        let kb = make_test_kb();
1028        kb.note_write("del.md", "to delete").unwrap();
1029        kb.note_delete("del.md").unwrap();
1030        assert_eq!(kb.note_read("del.md").unwrap(), None);
1031    }
1032
1033    #[test]
1034    fn test_note_move() {
1035        let kb = make_test_kb();
1036        kb.note_write("old.md", "content").unwrap();
1037        kb.note_move("old.md", "new.md").unwrap();
1038        assert_eq!(kb.note_read("old.md").unwrap(), None);
1039        let moved = kb.note_read("new.md").unwrap().unwrap();
1040        // T12: note_move moves the file content (frontmatter included)
1041        // — the file on disk retains the synthesized frontmatter.
1042        assert!(moved.contains("content"));
1043    }
1044
1045    #[test]
1046    fn test_note_move_rewrites_inbound_links() {
1047        let kb = make_test_kb();
1048        // Two notes link to the target by its old name.
1049        kb.note_write("a.md", "See [target](target.md) and [again](target.md).")
1050            .unwrap();
1051        kb.note_write("b.md", "Ref [target](target.md).").unwrap();
1052        kb.note_write("target.md", "# Target\n\nbody").unwrap();
1053        // Re-resolve: a.md/b.md were indexed before target.md existed, so
1054        // the markdown links are exact-path matches (work regardless), but
1055        // a fresh index keeps the test self-consistent.
1056        kb.index_all().unwrap();
1057
1058        kb.note_move("target.md", "renamed.md").unwrap();
1059
1060        // Moved file content preserved.
1061        assert_eq!(kb.note_read("target.md").unwrap(), None);
1062        let renamed = kb.note_read("renamed.md").unwrap().unwrap();
1063        // T12: file retains frontmatter; body survives.
1064        assert!(renamed.contains("# Target"));
1065        assert!(renamed.contains("body"));
1066
1067        // Inbound links rewritten on disk.
1068        let a = kb.note_read("a.md").unwrap().unwrap();
1069        assert!(a.contains("See [target](renamed.md) and [again](renamed.md)."));
1070        let b = kb.note_read("b.md").unwrap().unwrap();
1071        assert!(b.contains("Ref [target](renamed.md)."));
1072
1073        // Backlink index resolves links under the new name.
1074        let bl: HashSet<String> = kb
1075            .backlinks_for("renamed.md")
1076            .into_iter()
1077            .map(|b| b.source_path)
1078            .collect();
1079        assert_eq!(bl, HashSet::from(["a.md".to_string(), "b.md".to_string()]));
1080        assert_eq!(kb.backlinks_for("target.md").len(), 0);
1081    }
1082
1083    #[test]
1084    fn test_note_move_rewrites_wikilinks() {
1085        let kb = make_test_kb();
1086        // Source references the target via every supported wikilink form.
1087        kb.note_write(
1088            "src.md",
1089            "Bare [[Target]] path [[dir/Target]] full [[dir/Target.md]] alias [[Target|T]].",
1090        )
1091        .unwrap();
1092        kb.note_write("dir/Target.md", "# Target\n\nbody").unwrap();
1093        // src.md was indexed before dir/Target.md existed; rebuild so its
1094        // wikilinks resolve against the now-complete tree.
1095        kb.index_all().unwrap();
1096
1097        kb.note_move("dir/Target.md", "dir/Renamed.md").unwrap();
1098
1099        // Every form rewrites to the new path; alias is preserved.
1100        let src = kb.note_read("src.md").unwrap().unwrap();
1101        assert!(src.contains("[[Renamed|T]]"));
1102        assert!(src.contains("[[dir/Renamed]]"));
1103        // Backlinks now resolve under the new canonical path.
1104        assert_eq!(kb.backlinks_for("dir/Renamed.md").len(), 1);
1105        assert_eq!(kb.backlinks_for("dir/Target.md").len(), 0);
1106    }
1107
1108    #[test]
1109    fn test_note_move_skips_ambiguous_bare_wikilink() {
1110        // Two files share the stem "Dup": the bare [[Dup]] in src is
1111        // ambiguous and must NOT be indexed → not rewritten when EITHER
1112        // Dup renames. The path-style [[a/Dup]] IS unambiguous and rewrites.
1113        let kb = make_test_kb();
1114        kb.note_write("src.md", "ambig [[Dup]] explicit [[a/Dup]]")
1115            .unwrap();
1116        kb.note_write("a/Dup.md", "# A").unwrap();
1117        kb.note_write("b/Dup.md", "# B").unwrap();
1118        // src.md was indexed before both Dups existed — rebuild so the
1119        // bare stem is now (correctly) ambiguous and dropped from the index.
1120        kb.index_all().unwrap();
1121
1122        kb.note_move("a/Dup.md", "a/Moved.md").unwrap();
1123
1124        let src = kb.note_read("src.md").unwrap().unwrap_or_default();
1125        // Bare link untouched (ambiguous); path-style link rewritten.
1126        assert!(
1127            src.contains("[[Dup]]"),
1128            "ambiguous bare link must be left alone: {src}"
1129        );
1130        assert!(
1131            src.contains("[[a/Moved]]"),
1132            "explicit path link must be rewritten: {src}"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_backlinks_track_wikilinks() {
1138        let kb = make_test_kb();
1139        kb.note_write("brain/Rust.md", "See [[Ownership]] and [[brain/Go]]")
1140            .unwrap();
1141        kb.note_write("brain/Ownership.md", "# Ownership").unwrap();
1142        kb.note_write("brain/Go.md", "# Go").unwrap();
1143        // Rust.md was indexed before Ownership/Go existed; rebuild so its
1144        // wikilinks resolve against the now-complete tree.
1145        kb.index_all().unwrap();
1146
1147        // Both wikilinks resolve and appear as backlinks on their targets.
1148        let owners_of_ownership: HashSet<String> = kb
1149            .backlinks_for("brain/Ownership.md")
1150            .into_iter()
1151            .map(|b| b.source_path)
1152            .collect();
1153        assert!(owners_of_ownership.contains("brain/Rust.md"));
1154        let owners_of_go: HashSet<String> = kb
1155            .backlinks_for("brain/Go.md")
1156            .into_iter()
1157            .map(|b| b.source_path)
1158            .collect();
1159        assert!(owners_of_go.contains("brain/Rust.md"));
1160    }
1161
1162    #[test]
1163    fn test_backlinks() {
1164        let kb = make_test_kb();
1165        kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
1166            .unwrap();
1167        let bl = kb.backlinks_for("brain/Ownership.md");
1168        assert_eq!(bl.len(), 1);
1169        assert_eq!(bl[0].source_path, "brain/Rust.md");
1170    }
1171
1172    #[test]
1173    fn test_note_tree() {
1174        let kb = make_test_kb();
1175        kb.note_write("brain/Rust.md", "Rust").unwrap();
1176        let entries = kb.note_tree("brain").unwrap();
1177        assert!(!entries.is_empty());
1178    }
1179
1180    #[test]
1181    fn test_search_by_name() {
1182        let kb = make_test_kb();
1183        kb.note_write("brain/Rust.md", "Rust content").unwrap();
1184        let hits = kb.search("Rust", 10).unwrap();
1185        assert!(!hits.is_empty());
1186    }
1187
1188    #[test]
1189    fn test_link_graph() {
1190        let kb = make_test_kb();
1191        kb.note_write("a.md", "[b](b.md)").unwrap();
1192        let graph = kb.link_graph();
1193        assert!(!graph.edges.is_empty());
1194    }
1195
1196    #[test]
1197    fn test_agent_write_tracking() {
1198        let kb = make_test_kb();
1199        assert!(!kb.is_agent_write("test.md"));
1200        kb.mark_agent_write("test.md");
1201        assert!(kb.is_agent_write("test.md"));
1202        kb.clear_agent_write("test.md");
1203        assert!(!kb.is_agent_write("test.md"));
1204    }
1205
1206    #[test]
1207    fn test_index_all() {
1208        let kb = make_test_kb();
1209        kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
1210            .unwrap();
1211        kb.note_write("brain/Go.md", "Go language").unwrap();
1212        kb.note_write("index.md", "Welcome").unwrap();
1213        let count = kb.index_all().unwrap();
1214        assert_eq!(count, 3);
1215        let bl = kb.backlinks_for("brain/Go.md");
1216        assert_eq!(bl.len(), 1);
1217    }
1218
1219    #[test]
1220    fn test_on_file_change_callback() {
1221        let kb = make_test_kb();
1222        let _called = std::sync::atomic::AtomicBool::new(false);
1223        let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
1224            std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1225        let flag = path_clone.clone();
1226
1227        kb.on_file_change(move |path, change| {
1228            let _ = path;
1229            let _ = change;
1230            flag.store(true, std::sync::atomic::Ordering::SeqCst);
1231        });
1232
1233        kb.note_write("test.md", "hello").unwrap();
1234        assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
1235    }
1236
1237    #[test]
1238    fn test_chat_append() {
1239        let kb = make_test_kb();
1240        kb.chat_append("Test message").unwrap();
1241        let messages = kb.chat_messages().unwrap();
1242        // The captured message must be a parseable marker block (- [ ] `HH:MM` text),
1243        // not merged into the date header. chat_append must emit the `- [ ]` prefix
1244        // that read_chat_msgs splits on.
1245        assert!(
1246            messages
1247                .iter()
1248                .any(|m| m.starts_with("- [") && m.contains("Test message")),
1249            "captured message should be a parseable marker block: {messages:?}"
1250        );
1251    }
1252
1253    #[test]
1254    fn test_config() {
1255        let kb = make_test_kb();
1256        let cfg = kb.config().unwrap();
1257        // Should return default for non-existent config
1258        let cfg2 = kb.config().unwrap();
1259        assert_eq!(cfg.language, cfg2.language);
1260    }
1261
1262    #[test]
1263    fn test_markdown_to_html() {
1264        let kb = make_test_kb();
1265        let html = kb.markdown_to_html("# Hello\n\n**world**");
1266        // markdown_to_html wraps content in a <p> tag by default, check for content
1267        assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
1268        assert!(html.contains("world"), "HTML should contain world: {html}");
1269    }
1270
1271    #[test]
1272    fn test_auto_emoji() {
1273        let kb = make_test_kb();
1274        let emoji = kb.auto_emoji("cooking pasta");
1275        assert!(!emoji.is_empty());
1276    }
1277
1278    #[test]
1279    fn test_extract_headings() {
1280        let kb = make_test_kb();
1281        let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
1282        assert!(headings.len() >= 2);
1283    }
1284
1285    #[test]
1286    fn test_frontmatter_roundtrip() {
1287        let meta = NoteMeta {
1288            author: "agent".to_string(),
1289            source: NoteSource::Hook,
1290            quality: NoteQuality::Raw,
1291            needs_review: true,
1292            session_id: Some("abc123".to_string()),
1293            message_index: Some(3),
1294            saved_at: Some("2026-06-13T00:00:00Z".to_string()),
1295        };
1296        let body = "## Test\n\nContent here.";
1297        // T12: format-aware — round-trip via frontformat::with_oxios_table,
1298        // not the bespoke serde_yaml helper that lived here before.
1299        let formatted = frontformat::with_oxios_table(body, &meta)
1300            .expect("frontformat::with_oxios_table must accept a plain body");
1301        assert!(formatted.starts_with("---\n"));
1302        let parsed_meta = frontformat::read_note_meta(&formatted)
1303            .expect("frontformat::read_note_meta must parse the round-tripped file")
1304            .expect("the round-tripped file must carry an oxios: table");
1305        assert_eq!(parsed_meta.author, "agent");
1306        assert_eq!(parsed_meta.session_id.as_deref(), Some("abc123"));
1307        assert_eq!(parsed_meta.message_index, Some(3));
1308        // Body must follow the closing fence with a blank-line separator.
1309        assert!(
1310            formatted.ends_with(body),
1311            "body must survive round-trip; got: {formatted:?}"
1312        );
1313    }
1314
1315    #[test]
1316    fn test_parse_user_frontmatter_ignored() {
1317        let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
1318        let (meta, body) = parse_note_meta(content);
1319        assert!(
1320            meta.is_none(),
1321            "User frontmatter should not be parsed as NoteMeta"
1322        );
1323        assert!(
1324            body.contains("tags: [rust, design]"),
1325            "User frontmatter preserved"
1326        );
1327    }
1328
1329    #[test]
1330    fn test_parse_no_frontmatter() {
1331        let content = "# Just a note\nSome content.";
1332        let (meta, body) = parse_note_meta(content);
1333        assert!(meta.is_none());
1334        assert_eq!(body, content);
1335    }
1336
1337    // ----------------------------------------------------------------
1338    // T12 — format-aware note writes via frontformat::write_note
1339    // ----------------------------------------------------------------
1340
1341    #[test]
1342    fn note_write_is_format_aware_and_noop_guarded() {
1343        let kb = make_test_kb();
1344        kb.note_write("docs/a.md", "hello").unwrap();
1345        let first = kb.note_read("docs/a.md").unwrap().unwrap();
1346        assert!(
1347            first.starts_with("---\n"),
1348            "memo write must synthesize frontmatter"
1349        );
1350        kb.note_write("docs/a.md", "hello").unwrap();
1351        let second = kb.note_read("docs/a.md").unwrap().unwrap();
1352        assert_eq!(first, second, "NoOp guard");
1353        kb.note_write("Chat.md", "- [ ] x\n").unwrap();
1354        let chat = kb.note_read("Chat.md").unwrap().unwrap();
1355        assert!(!chat.starts_with("---"));
1356    }
1357
1358    #[test]
1359    fn note_write_noop_skips_backlink_reindex_and_callback() {
1360        use std::sync::Arc;
1361        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
1362
1363        let kb = make_test_kb();
1364        let counter = Arc::new(AtomicUsize::new(0));
1365        let cb_counter = counter.clone();
1366        kb.on_file_change(move |_path, _change| {
1367            cb_counter.fetch_add(1, AtomicOrdering::SeqCst);
1368        });
1369
1370        kb.note_write("brain/Rust.md", "hello world").unwrap();
1371        let after_first = counter.load(AtomicOrdering::SeqCst);
1372        assert_eq!(
1373            after_first, 1,
1374            "first write must fire callback exactly once"
1375        );
1376
1377        // identical re-write must be a NoOp -> no callback
1378        kb.note_write("brain/Rust.md", "hello world").unwrap();
1379        let after_second = counter.load(AtomicOrdering::SeqCst);
1380        assert_eq!(
1381            after_second, 1,
1382            "NoOp write must NOT call notify_change; got {after_second} callbacks"
1383        );
1384    }
1385
1386    #[test]
1387    fn note_write_with_meta_merges_into_frontmatterless_file() {
1388        // Round-1 review (a): an existing BodyOnly file (no frontmatter
1389        // at all) is NOT user-authored — note_write_with_meta must
1390        // proceed, write the caller's content, and land the oxios:
1391        // table. Only frontmatter-present-WITHOUT-oxios refuses.
1392        let kb = make_test_kb();
1393        // Seed a genuine BodyOnly file (pre-migration / editor-written):
1394        // note_write itself now synthesizes frontmatter, so seeding
1395        // through it would produce a Memo without oxios: — the refusal
1396        // case, not this one.
1397        std::fs::create_dir_all(kb.root().join("brain")).unwrap();
1398        std::fs::write(kb.root().join("brain/Plain.md"), "old plain body").unwrap();
1399
1400        let meta = NoteMeta {
1401            author: "agent".to_string(),
1402            source: NoteSource::Hook,
1403            quality: NoteQuality::Raw,
1404            needs_review: true,
1405            session_id: None,
1406            message_index: None,
1407            saved_at: None,
1408        };
1409        let accepted = kb
1410            .note_write_with_meta("brain/Plain.md", "new body", &meta)
1411            .unwrap();
1412        assert!(
1413            accepted,
1414            "BodyOnly existing file must accept metadata write"
1415        );
1416        let after = kb.note_read("brain/Plain.md").unwrap().unwrap();
1417        assert!(
1418            after.contains("oxios:"),
1419            "oxios: table must land; got: {after:?}"
1420        );
1421        assert!(
1422            after.contains("new body"),
1423            "caller content must be written; got: {after:?}"
1424        );
1425        assert!(
1426            !after.contains("old plain body"),
1427            "caller content replaces the old body; got: {after:?}"
1428        );
1429    }
1430
1431    #[test]
1432    fn note_write_with_meta_refuses_user_authored_frontmatter() {
1433        let kb = make_test_kb();
1434
1435        // Pre-existing file with user-authored (foreign) frontmatter
1436        // - has frontmatter block but NO oxios: table.
1437        let user_note = "---\ntags: [rust, design]\nauthor: jane\n---\n\n# My note\n";
1438        kb.note_write("brain/User.md", user_note).unwrap();
1439
1440        let meta = NoteMeta {
1441            author: "agent".to_string(),
1442            source: NoteSource::Hook,
1443            quality: NoteQuality::Raw,
1444            needs_review: false,
1445            session_id: None,
1446            message_index: None,
1447            saved_at: None,
1448        };
1449
1450        // Must return Ok(false) - refuse to touch user-authored frontmatter
1451        let accepted = kb
1452            .note_write_with_meta("brain/User.md", "# My note\nnew body", &meta)
1453            .unwrap();
1454        assert!(
1455            !accepted,
1456            "user-authored frontmatter must refuse agent metadata write"
1457        );
1458
1459        // File must still contain the user-authored frontmatter untouched
1460        let after = kb.note_read("brain/User.md").unwrap().unwrap();
1461        assert!(
1462            after.contains("tags: [rust, design]"),
1463            "user tags must survive unchanged"
1464        );
1465        assert!(
1466            !after.contains("oxios:"),
1467            "no oxios: must be synthesized on user-authored file"
1468        );
1469    }
1470
1471    #[test]
1472    fn note_write_with_meta_refuses_system_paths() {
1473        let kb = make_test_kb();
1474        let meta = NoteMeta {
1475            author: "agent".to_string(),
1476            source: NoteSource::Hook,
1477            quality: NoteQuality::Raw,
1478            needs_review: true,
1479            session_id: None,
1480            message_index: None,
1481            saved_at: None,
1482        };
1483
1484        // System paths never carry frontmatter: note_write_with_meta
1485        // must refuse (callers fall back to raw note_write) rather
1486        // than letting an oxios: block land verbatim in Chat.md.
1487        let accepted = kb
1488            .note_write_with_meta("Chat.md", "- [ ] chat line", &meta)
1489            .unwrap();
1490        assert!(!accepted, "system path must refuse metadata write");
1491
1492        // And nothing was written by the meta path.
1493        assert_eq!(kb.note_read("Chat.md").unwrap(), None);
1494    }
1495
1496    #[test]
1497    #[cfg(unix)]
1498    fn note_write_rejects_symlink_escape() {
1499        // Round-1 review (2): frontformat's assert_safe_rel is
1500        // string-only; the F4 canonicalized containment check that
1501        // fs.write_path performed must stay in front of every
1502        // frontformat delegation. A symlinked directory component
1503        // pointing outside the root must be refused.
1504        let kb = make_test_kb();
1505        let outside =
1506            std::env::temp_dir().join(format!("test-kb-outside-{}", uuid::Uuid::new_v4()));
1507        std::fs::create_dir_all(&outside).unwrap();
1508        std::os::unix::fs::symlink(&outside, kb.root().join("brain")).unwrap();
1509
1510        // note_write must refuse...
1511        let err = kb
1512            .note_write("brain/evil.md", "escaped content")
1513            .expect_err("symlink escape must be refused");
1514        assert!(
1515            err.to_string().contains("unsafe"),
1516            "expected unsafe-path error; got: {err}"
1517        );
1518        assert!(
1519            !outside.join("evil.md").exists(),
1520            "file must NOT be created outside the root"
1521        );
1522
1523        // ...and so must note_restore.
1524        let err2 = kb
1525            .note_restore("brain/evil.md", "escaped restore")
1526            .expect_err("symlink escape must be refused on restore");
1527        assert!(
1528            err2.to_string().contains("unsafe"),
1529            "expected unsafe-path error; got: {err2}"
1530        );
1531        assert!(
1532            !outside.join("evil.md").exists(),
1533            "file must NOT be created outside the root (restore)"
1534        );
1535    }
1536
1537    #[test]
1538    fn note_write_with_meta_synthesizes_and_merges() {
1539        let kb = make_test_kb();
1540        let meta = NoteMeta {
1541            author: "agent".to_string(),
1542            source: NoteSource::Hook,
1543            quality: NoteQuality::Raw,
1544            needs_review: true,
1545            session_id: Some("sess-1".to_string()),
1546            message_index: Some(2),
1547            saved_at: Some("2026-08-21T00:00:00Z".to_string()),
1548        };
1549
1550        // Fresh file -> synthesize frontmatter with oxios:
1551        let accepted = kb
1552            .note_write_with_meta("brain/New.md", "fresh content", &meta)
1553            .unwrap();
1554        assert!(accepted, "fresh memo must accept metadata write");
1555        let after = kb.note_read("brain/New.md").unwrap().unwrap();
1556        assert!(after.starts_with("---\n"), "must carry frontmatter");
1557        assert!(after.contains("oxios:"), "must contain oxios: table");
1558
1559        // Second write merges: existing oxios: is preserved (id/created survive)
1560        let meta2 = NoteMeta {
1561            author: "agent2".to_string(),
1562            ..meta.clone()
1563        };
1564        kb.note_write_with_meta("brain/New.md", "edited body", &meta2)
1565            .unwrap();
1566        let after2 = kb.note_read("brain/New.md").unwrap().unwrap();
1567        assert!(after2.contains("id:"), "id must survive merge");
1568        assert!(
1569            after2.contains("agent2"),
1570            "author must be overwritten by new meta"
1571        );
1572        assert!(
1573            after2.contains("edited body"),
1574            "body must reflect second write"
1575        );
1576    }
1577
1578    #[test]
1579    fn restore_merges_legacy_content() {
1580        let kb = make_test_kb();
1581
1582        // Pre-migration blob: oxios: table without id/created/updated.
1583        // Restoring it must gain id/created/updated through write_document
1584        // synthesis while keeping the oxios: table.
1585        let legacy = "---\noxios:\n  author: agent\n  quality: raw\n---\nlegacy body\n";
1586        kb.note_restore("brain/Legacy.md", legacy).unwrap();
1587
1588        let after = kb.note_read("brain/Legacy.md").unwrap().unwrap();
1589        assert!(
1590            after.contains("id:"),
1591            "id must be synthesized on legacy restore"
1592        );
1593        assert!(
1594            after.contains("created:"),
1595            "created must be synthesized on legacy restore"
1596        );
1597        assert!(after.contains("oxios:"), "oxios: table must survive");
1598        assert!(after.contains("legacy body"), "body must survive");
1599
1600        // Restore must NOT fire on_file_change callbacks
1601        use std::sync::Arc;
1602        use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
1603        let counter = Arc::new(AtomicUsize::new(0));
1604        let cb_counter = counter.clone();
1605        let kb2 = make_test_kb();
1606        kb2.on_file_change(move |_p, _c| {
1607            cb_counter.fetch_add(1, AtomicOrdering::SeqCst);
1608        });
1609        kb2.note_restore("brain/Legacy2.md", legacy).unwrap();
1610        assert_eq!(
1611            counter.load(AtomicOrdering::SeqCst),
1612            0,
1613            "note_restore must suppress callbacks"
1614        );
1615    }
1616
1617    #[test]
1618    fn notes_needing_review_reads_oxios_table() {
1619        let kb = make_test_kb();
1620
1621        // Two memos, one flagged for review, one not.
1622        let flag_meta = NoteMeta {
1623            author: "agent".to_string(),
1624            source: NoteSource::Hook,
1625            quality: NoteQuality::Raw,
1626            needs_review: true,
1627            session_id: None,
1628            message_index: None,
1629            saved_at: Some("2026-08-21T00:00:00Z".to_string()),
1630        };
1631        let ok_meta = NoteMeta {
1632            needs_review: false,
1633            ..flag_meta.clone()
1634        };
1635
1636        kb.note_write_with_meta("brain/Yes.md", "needs review", &flag_meta)
1637            .unwrap();
1638        kb.note_write_with_meta("brain/No.md", "no review", &ok_meta)
1639            .unwrap();
1640
1641        let flagged = kb.notes_needing_review().unwrap();
1642        assert_eq!(flagged.len(), 1, "exactly one note flagged");
1643        let (path, _meta) = &flagged[0];
1644        assert!(
1645            path.starts_with("brain/Yes.md"),
1646            "only the flagged note must surface; got: {path}"
1647        );
1648    }
1649}