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::{FileEntry, Habits, KnowledgeConfig, CHAT_FILENAME, DIR_USER_ROOT};
35use crate::worker::{move_due_tasks, remove_completed_items};
36use crate::{today_chat_header, today_journal_filename};
37
38/// File change event emitted via `on_file_change` callbacks.
39#[derive(Debug, Clone)]
40pub enum FileChange {
41    /// A new file was created.
42    Created(String),
43    /// An existing file was updated.
44    Updated(String),
45    /// A file was deleted.
46    Deleted(String),
47    /// A file was moved or renamed.
48    Moved {
49        /// Original path before the move.
50        old: String,
51        /// New path after the move.
52        new: String,
53    },
54}
55
56/// Knowledge search hit (file-name based).
57#[derive(Debug, Clone)]
58pub struct NoteHit {
59    /// File path relative to knowledge root.
60    pub path: String,
61    /// Display name of the file.
62    pub name: String,
63    /// Content snippet.
64    pub snippet: String,
65    /// Number of backlinks pointing to this note.
66    pub backlink_count: usize,
67    /// Name similarity score (0–100).
68    pub name_similarity: i32,
69}
70
71/// Markdown knowledge base application layer.
72///
73/// Wraps [`VirtualFs`] for sandboxed file I/O, [`BacklinkIndex`] for
74/// link tracking, and provides all app-layer features (chat, journal,
75/// habits, checklist, etc.).
76///
77/// **No kernel dependencies.** Can be used standalone by any channel.
78pub struct KnowledgeBase {
79    /// Sandboxed filesystem.
80    fs: RwLock<VirtualFs>,
81    /// Bidirectional link index.
82    backlinks: RwLock<BacklinkIndex>,
83    /// Files written by agents (not by the user).
84    agent_writes: ParkingMutex<HashSet<String>>,
85    /// Callbacks invoked on file changes.
86    /// Used by [`KnowledgeLens`] to keep semantic index in sync.
87    on_change: RwLock<Vec<FileChangeCallback>>,
88}
89
90impl std::fmt::Debug for KnowledgeBase {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("KnowledgeBase")
93            .field("root", &self.fs.read().root())
94            .finish()
95    }
96}
97
98impl KnowledgeBase {
99    /// Create a new KnowledgeBase for the given root directory.
100    pub fn new(root: PathBuf) -> Result<Self> {
101        let fs = VirtualFs::new(root)?;
102        Ok(Self {
103            fs: RwLock::new(fs),
104            backlinks: RwLock::new(BacklinkIndex::new()),
105            agent_writes: ParkingMutex::new(HashSet::new()),
106            on_change: RwLock::new(Vec::new()),
107        })
108    }
109
110    /// Create a new KnowledgeBase scoped to a Space's subdirectory.
111    pub fn for_space(space_dir: &std::path::Path) -> Result<Self> {
112        Self::new(space_dir.join("knowledge"))
113    }
114
115    /// Get the root path of the knowledge base.
116    pub fn root(&self) -> PathBuf {
117        self.fs.read().root().to_path_buf()
118    }
119
120    /// Register a callback to be invoked on every file change.
121    ///
122    /// The callback receives `(path, FileChange)`.
123    /// Multiple callbacks can be registered.
124    pub fn on_file_change<F>(&self, f: F)
125    where
126        F: Fn(&str, FileChange) + Send + Sync + 'static,
127    {
128        self.on_change.write().push(Box::new(f));
129    }
130
131    /// Emit file change notifications to all registered callbacks.
132    fn notify_change(&self, path: &str, change: FileChange) {
133        for cb in self.on_change.read().iter() {
134            cb(path, change.clone());
135        }
136    }
137
138    // ── File I/O ───────────────────────────────────────────────────
139
140    /// Read a note's content.
141    pub fn note_read(&self, path: &str) -> Result<Option<String>> {
142        let fs = self.fs.read();
143        match fs.read_path(path) {
144            Ok(content) => Ok(Some(content)),
145            Err(_) => Ok(None),
146        }
147    }
148
149    /// Write a note — creates or overwrites.
150    ///
151    /// Writes the `.md` file via VirtualFs, updates the backlink index,
152    /// and notifies registered `on_file_change` callbacks.
153    pub fn note_write(&self, path: &str, content: &str) -> Result<()> {
154        let fs = self.fs.read();
155        let is_new = fs.read_path(path).is_err();
156
157        fs.write_path(path, content)?;
158
159        {
160            let mut backlinks = self.backlinks.write();
161            backlinks.remove_file(path);
162            backlinks.index_file(path, content);
163        }
164
165        self.notify_change(
166            path,
167            if is_new {
168                FileChange::Created(path.to_string())
169            } else {
170                FileChange::Updated(path.to_string())
171            },
172        );
173        Ok(())
174    }
175
176    /// Delete a note.
177    pub fn note_delete(&self, path: &str) -> Result<()> {
178        self.fs.read().delete_path(path)?;
179        self.backlinks.write().remove_file(path);
180        self.notify_change(path, FileChange::Deleted(path.to_string()));
181        Ok(())
182    }
183
184    /// Restore a note's content without triggering file-change callbacks.
185    ///
186    /// Used when reverting to a previous git version — writes the file
187    /// and updates the backlink index, but does **not** fire `on_file_change`
188    /// callbacks. This prevents an infinite loop where restore → write →
189    /// callback → git commit → ... repeats.
190    pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
191        self.fs.read().write_path(path, content)?;
192        let mut backlinks = self.backlinks.write();
193        backlinks.remove_file(path);
194        backlinks.index_file(path, content);
195        // Intentionally skip notify_change()
196        Ok(())
197    }
198
199    /// Move/rename a note.
200    pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
201        self.fs.read().rename_path(old_path, new_path)?;
202        self.backlinks.write().remove_file(old_path);
203        if let Some(content) = self.note_read(new_path)? {
204            self.backlinks.write().index_file(new_path, &content);
205        }
206        self.notify_change(
207            old_path,
208            FileChange::Moved {
209                old: old_path.to_string(),
210                new: new_path.to_string(),
211            },
212        );
213        Ok(())
214    }
215
216    /// List notes in a directory.
217    pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
218        let fs = self.fs.read();
219        let dir = if dir.is_empty() || dir == "/" {
220            DIR_USER_ROOT
221        } else {
222            dir
223        };
224        Ok(fs.files_and_dirs(dir)?)
225    }
226
227    // ── Search (file-name based only) ────────────────────────────
228
229    /// Search notes by file name fuzzy matching.
230    ///
231    /// **Note:** Semantic search is handled by `KnowledgeLens`,
232    /// not by this method.
233    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
234        let fs = self.fs.read();
235        let files = fs.search_files_by_name(query)?;
236
237        let hits: Vec<NoteHit> = files
238            .into_iter()
239            .take(limit)
240            .map(|f| {
241                let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
242                    f.name.clone()
243                } else {
244                    format!("{}/{}", f.parent_dir, f.name)
245                };
246                let name_sim = similar(&f.display_name, query) as i32;
247                let bl_count = self.backlinks.read().backlink_count(&path);
248                NoteHit {
249                    path,
250                    name: f.display_name,
251                    snippet: String::new(),
252                    backlink_count: bl_count,
253                    name_similarity: name_sim,
254                }
255            })
256            .collect();
257
258        Ok(hits)
259    }
260
261    // ── Backlinks & Graph ─────────────────────────────────────────
262
263    /// Get backlinks for a note.
264    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
265        self.backlinks.read().backlinks_for(path)
266    }
267
268    /// Get the full link graph for visualization.
269    pub fn link_graph(&self) -> LinkGraph {
270        self.backlinks.read().link_graph()
271    }
272
273    /// Index all markdown files in the knowledge base.
274    ///
275    /// Walks the entire directory tree and builds the backlink index.
276    /// Returns the number of files indexed.
277    pub fn index_all(&self) -> Result<usize> {
278        let fs = self.fs.read();
279        let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
280        let mut count = 0;
281
282        for entry in &entries {
283            if entry.is_dir {
284                let sub = fs.files_and_dirs(&entry.name)?;
285                for sub_entry in &sub {
286                    if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
287                        let path = format!("{}/{}", entry.name, sub_entry.name);
288                        if let Ok(content) = fs.read_path(&path) {
289                            self.backlinks.write().index_file(&path, &content);
290                            count += 1;
291                        }
292                    }
293                }
294            } else if entry.name.ends_with(".md") {
295                if let Ok(content) = fs.read_path(&entry.name) {
296                    self.backlinks.write().index_file(&entry.name, &content);
297                    count += 1;
298                }
299            }
300        }
301
302        tracing::info!(files = count, "Knowledge base indexed");
303        Ok(count)
304    }
305
306    // ── Chat / Inbox ───────────────────────────────────────────────
307
308    /// Append a timestamped message to Chat.md.
309    pub fn chat_append(&self, message: &str) -> Result<()> {
310        let header = today_chat_header();
311        let timestamp = chrono::Local::now().format("`15:04`").to_string();
312        let entry = format!("{timestamp} {message}");
313
314        let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
315        if !content.contains(&header) {
316            if !content.trim_end().ends_with('\n') {
317                content.push('\n');
318            }
319            content.push_str(&header);
320            content.push('\n');
321        }
322        content.push_str(&entry);
323        content.push('\n');
324        self.note_write(CHAT_FILENAME, &content)?;
325        Ok(())
326    }
327
328    /// Parse Chat.md into structured message blocks.
329    pub fn chat_messages(&self) -> Result<Vec<String>> {
330        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
331        Ok(read_chat_msgs(&content))
332    }
333
334    /// Delete a specific chat message by its content hash.
335    pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
336        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
337        match delete_chat_msg(&content, msg_hash) {
338            Ok(new_content) => {
339                self.note_write(CHAT_FILENAME, &new_content)?;
340                Ok(true)
341            }
342            Err(_) => Ok(false),
343        }
344    }
345
346    /// Rename a specific chat message by its content hash.
347    pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
348        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
349        match rename_chat_msg(&content, msg_hash, new_body) {
350            Ok(new_content) => {
351                self.note_write(CHAT_FILENAME, &new_content)?;
352                Ok(true)
353            }
354            Err(_) => Ok(false),
355        }
356    }
357
358    /// Move a chat message to a target file as a checklist item.
359    pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
360        let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
361        let target_content = self.note_read(target_path)?.unwrap_or_default();
362        let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
363        if new_chat != chat_content {
364            self.note_write(CHAT_FILENAME, &new_chat)?;
365            self.note_write(target_path, &new_target)?;
366            Ok(true)
367        } else {
368            Ok(false)
369        }
370    }
371
372    // ── Journal ───────────────────────────────────────────────────
373
374    /// Add a timestamped record to today's journal entry.
375    pub fn journal_add_record(&self, record: &str) -> Result<()> {
376        let fs = self.fs.read();
377        let tz = chrono::Local::now().offset().to_owned();
378        journal_add_record(&fs, record, tz)?;
379        Ok(())
380    }
381
382    /// Add an emoji to today's journal header.
383    pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
384        let fs = self.fs.read();
385        let tz = chrono::Local::now().offset().to_owned();
386        journal_add_emoji(&fs, emoji, tz)?;
387        Ok(())
388    }
389
390    /// Get today's journal file path (e.g., "journal/2026.05 May.md").
391    pub fn journal_today_path(&self) -> String {
392        let tz = chrono::Local::now().offset().to_owned();
393        today_journal_filename(tz)
394    }
395
396    // ── Habits ───────────────────────────────────────────────────
397
398    /// Read habit tracking data for a given year.
399    pub fn habits(&self, year: i32) -> Result<Habits> {
400        let fs = self.fs.read();
401        Ok(habits(&fs, year)?)
402    }
403
404    /// Get last week's habit data.
405    pub fn habits_last_week(&self) -> Result<Habits> {
406        let fs = self.fs.read();
407        let tz = chrono::Local::now().offset().to_owned();
408        Ok(last_week_habits(&fs, tz)?)
409    }
410
411    /// Write habit data for a year.
412    pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
413        let fs = self.fs.read();
414        write_habits(&fs, year, habits)?;
415        Ok(())
416    }
417
418    // ── Config ────────────────────────────────────────────────────
419
420    /// Read the knowledge base config (config.json).
421    pub fn config(&self) -> Result<KnowledgeConfig> {
422        let fs = self.fs.read();
423        match fs.read_path("config.json") {
424            Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
425            Err(_) => Ok(KnowledgeConfig::default()),
426        }
427    }
428
429    /// Write the knowledge base config.
430    pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
431        let json = serde_json::to_string_pretty(config)?;
432        self.note_write("config.json", &json)?;
433        Ok(())
434    }
435
436    // ── Checklist ────────────────────────────────────────────────
437
438    /// Parse checklist items from a file.
439    pub fn checklist_items(
440        &self,
441        path: &str,
442    ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
443        let content = self.note_read(path)?.unwrap_or_default();
444        Ok(checklist_items(&content))
445    }
446
447    /// Get incomplete checklist items from a file.
448    pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
449        let content = self.note_read(path)?.unwrap_or_default();
450        Ok(incomplete_checklist_items(&content))
451    }
452
453    /// Add a checklist item to a file.
454    pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
455        let content = self.note_read(path)?.unwrap_or_default();
456        let updated = add_checklist_item(&content, item, checked);
457        self.note_write(path, &updated)
458    }
459
460    /// Complete a checklist item by hash.
461    pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
462        let content = self.note_read(path)?.unwrap_or_default();
463        let (new_content, found) = complete_checklist_item(&content, item_hash);
464        if !found.is_empty() {
465            self.note_write(path, &new_content)?;
466            Ok(true)
467        } else {
468            Ok(false)
469        }
470    }
471
472    /// Remove a checklist item by text or hash.
473    pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
474        let content = self.note_read(path)?.unwrap_or_default();
475        let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
476        if !removed.is_empty() {
477            self.note_write(path, &new_content)?;
478            Ok(true)
479        } else {
480            Ok(false)
481        }
482    }
483
484    /// Remove all completed checklist items.
485    pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
486        let content = self.note_read(path)?.unwrap_or_default();
487        let (kept, removed) = remove_completed_checklist_items(&content);
488        if !removed.is_empty() {
489            self.note_write(path, &kept)?;
490        }
491        Ok((kept, removed))
492    }
493
494    // ── Worker ────────────────────────────────────────────────────
495
496    /// Run nightly cleanup.
497    pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
498        let fs = self.fs.read();
499        let config = self.config()?;
500        Ok(remove_completed_items(&fs, &config)?)
501    }
502
503    /// Move due scheduled tasks to Chat.
504    pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
505        let fs = self.fs.read();
506        let mut config = self.config()?;
507        let moved = move_due_tasks(&fs, &mut config)?;
508        if !moved.is_empty() {
509            self.set_config(&config)?;
510        }
511        Ok(moved)
512    }
513
514    // ── Stats ────────────────────────────────────────────────────
515
516    /// Get today's completion report.
517    pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
518        let fs = self.fs.read();
519        Ok(today_report(&fs)?)
520    }
521
522    /// Get list of files completed today.
523    pub fn done_today(&self) -> Result<Vec<FileEntry>> {
524        let fs = self.fs.read();
525        Ok(done_today(&fs)?)
526    }
527
528    // ── Utilities ───────────────────────────────────────────────
529
530    /// Convert markdown to HTML.
531    pub fn markdown_to_html(&self, md: &str) -> String {
532        markdown_to_html(md)
533    }
534
535    /// Find an emoji for a keyword.
536    pub fn auto_emoji(&self, text: &str) -> String {
537        emoji_for(text)
538    }
539
540    /// Generate world clock report for given timezone names.
541    pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
542        world_clock_for_names(timezone_names)
543    }
544
545    // ── Agent Write Tracking ──────────────────────────────────────
546
547    /// Mark a file as having been written by an agent.
548    pub fn mark_agent_write(&self, path: &str) {
549        self.agent_writes.lock().insert(path.to_string());
550    }
551
552    /// Check if a file was written by an agent.
553    pub fn is_agent_write(&self, path: &str) -> bool {
554        self.agent_writes.lock().contains(path)
555    }
556
557    /// Clear the agent-write marker for a file.
558    pub fn clear_agent_write(&self, path: &str) {
559        self.agent_writes.lock().remove(path);
560    }
561
562    // ── Text extraction ──────────────────────────────────────────
563
564    /// Extract text, images, and links from markdown content.
565    pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
566        crate::tgtxt::extract_text_imgs_links(text)
567    }
568
569    // ── Headings (for tag extraction) ─────────────────────────────
570
571    /// Extract headings from content for tag generation.
572    pub fn extract_headings(&self, content: &str) -> Vec<String> {
573        extract_headings(content).into_iter().take(5).collect()
574    }
575}
576
577// ---------------------------------------------------------------------------
578// Tests
579// ---------------------------------------------------------------------------
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    fn make_test_kb() -> KnowledgeBase {
586        let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
587        KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
588    }
589
590    #[test]
591    fn test_note_write_and_read() {
592        let kb = make_test_kb();
593        kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
594            .unwrap();
595        let content = kb.note_read("brain/Rust.md").unwrap();
596        assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
597    }
598
599    #[test]
600    fn test_note_read_missing() {
601        let kb = make_test_kb();
602        assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
603    }
604
605    #[test]
606    fn test_note_delete() {
607        let kb = make_test_kb();
608        kb.note_write("del.md", "to delete").unwrap();
609        kb.note_delete("del.md").unwrap();
610        assert_eq!(kb.note_read("del.md").unwrap(), None);
611    }
612
613    #[test]
614    fn test_note_move() {
615        let kb = make_test_kb();
616        kb.note_write("old.md", "content").unwrap();
617        kb.note_move("old.md", "new.md").unwrap();
618        assert_eq!(kb.note_read("old.md").unwrap(), None);
619        assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
620    }
621
622    #[test]
623    fn test_backlinks() {
624        let kb = make_test_kb();
625        kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
626            .unwrap();
627        let bl = kb.backlinks_for("brain/Ownership.md");
628        assert_eq!(bl.len(), 1);
629        assert_eq!(bl[0].source_path, "brain/Rust.md");
630    }
631
632    #[test]
633    fn test_note_tree() {
634        let kb = make_test_kb();
635        kb.note_write("brain/Rust.md", "Rust").unwrap();
636        let entries = kb.note_tree("brain").unwrap();
637        assert!(!entries.is_empty());
638    }
639
640    #[test]
641    fn test_search_by_name() {
642        let kb = make_test_kb();
643        kb.note_write("brain/Rust.md", "Rust content").unwrap();
644        let hits = kb.search("Rust", 10).unwrap();
645        assert!(!hits.is_empty());
646    }
647
648    #[test]
649    fn test_link_graph() {
650        let kb = make_test_kb();
651        kb.note_write("a.md", "[b](b.md)").unwrap();
652        let graph = kb.link_graph();
653        assert!(!graph.edges.is_empty());
654    }
655
656    #[test]
657    fn test_agent_write_tracking() {
658        let kb = make_test_kb();
659        assert!(!kb.is_agent_write("test.md"));
660        kb.mark_agent_write("test.md");
661        assert!(kb.is_agent_write("test.md"));
662        kb.clear_agent_write("test.md");
663        assert!(!kb.is_agent_write("test.md"));
664    }
665
666    #[test]
667    fn test_index_all() {
668        let kb = make_test_kb();
669        kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
670            .unwrap();
671        kb.note_write("brain/Go.md", "Go language").unwrap();
672        kb.note_write("index.md", "Welcome").unwrap();
673        let count = kb.index_all().unwrap();
674        assert_eq!(count, 3);
675        let bl = kb.backlinks_for("brain/Go.md");
676        assert_eq!(bl.len(), 1);
677    }
678
679    #[test]
680    fn test_on_file_change_callback() {
681        let kb = make_test_kb();
682        let _called = std::sync::atomic::AtomicBool::new(false);
683        let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
684            std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
685        let flag = path_clone.clone();
686
687        kb.on_file_change(move |path, change| {
688            let _ = path;
689            let _ = change;
690            flag.store(true, std::sync::atomic::Ordering::SeqCst);
691        });
692
693        kb.note_write("test.md", "hello").unwrap();
694        assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
695    }
696
697    #[test]
698    fn test_chat_append() {
699        let kb = make_test_kb();
700        kb.chat_append("Test message").unwrap();
701        let messages = kb.chat_messages().unwrap();
702        assert!(!messages.is_empty());
703    }
704
705    #[test]
706    fn test_config() {
707        let kb = make_test_kb();
708        let cfg = kb.config().unwrap();
709        // Should return default for non-existent config
710        let cfg2 = kb.config().unwrap();
711        assert_eq!(cfg.language, cfg2.language);
712    }
713
714    #[test]
715    fn test_markdown_to_html() {
716        let kb = make_test_kb();
717        let html = kb.markdown_to_html("# Hello\n\n**world**");
718        // markdown_to_html wraps content in a <p> tag by default, check for content
719        assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
720        assert!(html.contains("world"), "HTML should contain world: {html}");
721    }
722
723    #[test]
724    fn test_auto_emoji() {
725        let kb = make_test_kb();
726        let emoji = kb.auto_emoji("cooking pasta");
727        assert!(!emoji.is_empty());
728    }
729
730    #[test]
731    fn test_extract_headings() {
732        let kb = make_test_kb();
733        let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
734        assert!(headings.len() >= 2);
735    }
736}