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    /// Move/rename a note.
185    pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
186        self.fs.read().rename_path(old_path, new_path)?;
187        self.backlinks.write().remove_file(old_path);
188        if let Some(content) = self.note_read(new_path)? {
189            self.backlinks.write().index_file(new_path, &content);
190        }
191        self.notify_change(
192            old_path,
193            FileChange::Moved {
194                old: old_path.to_string(),
195                new: new_path.to_string(),
196            },
197        );
198        Ok(())
199    }
200
201    /// List notes in a directory.
202    pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
203        let fs = self.fs.read();
204        let dir = if dir.is_empty() || dir == "/" {
205            DIR_USER_ROOT
206        } else {
207            dir
208        };
209        Ok(fs.files_and_dirs(dir)?)
210    }
211
212    // ── Search (file-name based only) ────────────────────────────
213
214    /// Search notes by file name fuzzy matching.
215    ///
216    /// **Note:** Semantic search is handled by `KnowledgeLens`,
217    /// not by this method.
218    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
219        let fs = self.fs.read();
220        let files = fs.search_files_by_name(query)?;
221
222        let hits: Vec<NoteHit> = files
223            .into_iter()
224            .take(limit)
225            .map(|f| {
226                let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
227                    f.name.clone()
228                } else {
229                    format!("{}/{}", f.parent_dir, f.name)
230                };
231                let name_sim = similar(&f.display_name, query) as i32;
232                let bl_count = self.backlinks.read().backlink_count(&path);
233                NoteHit {
234                    path,
235                    name: f.display_name,
236                    snippet: String::new(),
237                    backlink_count: bl_count,
238                    name_similarity: name_sim,
239                }
240            })
241            .collect();
242
243        Ok(hits)
244    }
245
246    // ── Backlinks & Graph ─────────────────────────────────────────
247
248    /// Get backlinks for a note.
249    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
250        self.backlinks.read().backlinks_for(path)
251    }
252
253    /// Get the full link graph for visualization.
254    pub fn link_graph(&self) -> LinkGraph {
255        self.backlinks.read().link_graph()
256    }
257
258    /// Index all markdown files in the knowledge base.
259    ///
260    /// Walks the entire directory tree and builds the backlink index.
261    /// Returns the number of files indexed.
262    pub fn index_all(&self) -> Result<usize> {
263        let fs = self.fs.read();
264        let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
265        let mut count = 0;
266
267        for entry in &entries {
268            if entry.is_dir {
269                let sub = fs.files_and_dirs(&entry.name)?;
270                for sub_entry in &sub {
271                    if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
272                        let path = format!("{}/{}", entry.name, sub_entry.name);
273                        if let Ok(content) = fs.read_path(&path) {
274                            self.backlinks.write().index_file(&path, &content);
275                            count += 1;
276                        }
277                    }
278                }
279            } else if entry.name.ends_with(".md") {
280                if let Ok(content) = fs.read_path(&entry.name) {
281                    self.backlinks.write().index_file(&entry.name, &content);
282                    count += 1;
283                }
284            }
285        }
286
287        tracing::info!(files = count, "Knowledge base indexed");
288        Ok(count)
289    }
290
291    // ── Chat / Inbox ───────────────────────────────────────────────
292
293    /// Append a timestamped message to Chat.md.
294    pub fn chat_append(&self, message: &str) -> Result<()> {
295        let header = today_chat_header();
296        let timestamp = chrono::Local::now().format("`15:04`").to_string();
297        let entry = format!("{} {}", timestamp, message);
298
299        let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
300        if !content.contains(&header) {
301            if !content.trim_end().ends_with('\n') {
302                content.push('\n');
303            }
304            content.push_str(&header);
305            content.push('\n');
306        }
307        content.push_str(&entry);
308        content.push('\n');
309        self.note_write(CHAT_FILENAME, &content)?;
310        Ok(())
311    }
312
313    /// Parse Chat.md into structured message blocks.
314    pub fn chat_messages(&self) -> Result<Vec<String>> {
315        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
316        Ok(read_chat_msgs(&content))
317    }
318
319    /// Delete a specific chat message by its content hash.
320    pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
321        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
322        match delete_chat_msg(&content, msg_hash) {
323            Ok(new_content) => {
324                self.note_write(CHAT_FILENAME, &new_content)?;
325                Ok(true)
326            }
327            Err(_) => Ok(false),
328        }
329    }
330
331    /// Rename a specific chat message by its content hash.
332    pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
333        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
334        match rename_chat_msg(&content, msg_hash, new_body) {
335            Ok(new_content) => {
336                self.note_write(CHAT_FILENAME, &new_content)?;
337                Ok(true)
338            }
339            Err(_) => Ok(false),
340        }
341    }
342
343    /// Move a chat message to a target file as a checklist item.
344    pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
345        let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
346        let target_content = self.note_read(target_path)?.unwrap_or_default();
347        let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
348        if new_chat != chat_content {
349            self.note_write(CHAT_FILENAME, &new_chat)?;
350            self.note_write(target_path, &new_target)?;
351            Ok(true)
352        } else {
353            Ok(false)
354        }
355    }
356
357    // ── Journal ───────────────────────────────────────────────────
358
359    /// Add a timestamped record to today's journal entry.
360    pub fn journal_add_record(&self, record: &str) -> Result<()> {
361        let fs = self.fs.read();
362        let tz = chrono::Local::now().offset().to_owned();
363        journal_add_record(&fs, record, tz)?;
364        Ok(())
365    }
366
367    /// Add an emoji to today's journal header.
368    pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
369        let fs = self.fs.read();
370        let tz = chrono::Local::now().offset().to_owned();
371        journal_add_emoji(&fs, emoji, tz)?;
372        Ok(())
373    }
374
375    /// Get today's journal file path (e.g., "journal/2026.05 May.md").
376    pub fn journal_today_path(&self) -> String {
377        let tz = chrono::Local::now().offset().to_owned();
378        today_journal_filename(tz)
379    }
380
381    // ── Habits ───────────────────────────────────────────────────
382
383    /// Read habit tracking data for a given year.
384    pub fn habits(&self, year: i32) -> Result<Habits> {
385        let fs = self.fs.read();
386        Ok(habits(&fs, year)?)
387    }
388
389    /// Get last week's habit data.
390    pub fn habits_last_week(&self) -> Result<Habits> {
391        let fs = self.fs.read();
392        let tz = chrono::Local::now().offset().to_owned();
393        Ok(last_week_habits(&fs, tz)?)
394    }
395
396    /// Write habit data for a year.
397    pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
398        let fs = self.fs.read();
399        write_habits(&fs, year, habits)?;
400        Ok(())
401    }
402
403    // ── Config ────────────────────────────────────────────────────
404
405    /// Read the knowledge base config (config.json).
406    pub fn config(&self) -> Result<KnowledgeConfig> {
407        let fs = self.fs.read();
408        match fs.read_path("config.json") {
409            Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
410            Err(_) => Ok(KnowledgeConfig::default()),
411        }
412    }
413
414    /// Write the knowledge base config.
415    pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
416        let json = serde_json::to_string_pretty(config)?;
417        self.note_write("config.json", &json)?;
418        Ok(())
419    }
420
421    // ── Checklist ────────────────────────────────────────────────
422
423    /// Parse checklist items from a file.
424    pub fn checklist_items(
425        &self,
426        path: &str,
427    ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
428        let content = self.note_read(path)?.unwrap_or_default();
429        Ok(checklist_items(&content))
430    }
431
432    /// Get incomplete checklist items from a file.
433    pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
434        let content = self.note_read(path)?.unwrap_or_default();
435        Ok(incomplete_checklist_items(&content))
436    }
437
438    /// Add a checklist item to a file.
439    pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
440        let content = self.note_read(path)?.unwrap_or_default();
441        let updated = add_checklist_item(&content, item, checked);
442        self.note_write(path, &updated)
443    }
444
445    /// Complete a checklist item by hash.
446    pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
447        let content = self.note_read(path)?.unwrap_or_default();
448        let (new_content, found) = complete_checklist_item(&content, item_hash);
449        if !found.is_empty() {
450            self.note_write(path, &new_content)?;
451            Ok(true)
452        } else {
453            Ok(false)
454        }
455    }
456
457    /// Remove a checklist item by text or hash.
458    pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
459        let content = self.note_read(path)?.unwrap_or_default();
460        let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
461        if !removed.is_empty() {
462            self.note_write(path, &new_content)?;
463            Ok(true)
464        } else {
465            Ok(false)
466        }
467    }
468
469    /// Remove all completed checklist items.
470    pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
471        let content = self.note_read(path)?.unwrap_or_default();
472        let (kept, removed) = remove_completed_checklist_items(&content);
473        if !removed.is_empty() {
474            self.note_write(path, &kept)?;
475        }
476        Ok((kept, removed))
477    }
478
479    // ── Worker ────────────────────────────────────────────────────
480
481    /// Run nightly cleanup.
482    pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
483        let fs = self.fs.read();
484        let config = self.config()?;
485        Ok(remove_completed_items(&fs, &config)?)
486    }
487
488    /// Move due scheduled tasks to Chat.
489    pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
490        let fs = self.fs.read();
491        let mut config = self.config()?;
492        let moved = move_due_tasks(&fs, &mut config)?;
493        if !moved.is_empty() {
494            self.set_config(&config)?;
495        }
496        Ok(moved)
497    }
498
499    // ── Stats ────────────────────────────────────────────────────
500
501    /// Get today's completion report.
502    pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
503        let fs = self.fs.read();
504        Ok(today_report(&fs)?)
505    }
506
507    /// Get list of files completed today.
508    pub fn done_today(&self) -> Result<Vec<FileEntry>> {
509        let fs = self.fs.read();
510        Ok(done_today(&fs)?)
511    }
512
513    // ── Utilities ───────────────────────────────────────────────
514
515    /// Convert markdown to HTML.
516    pub fn markdown_to_html(&self, md: &str) -> String {
517        markdown_to_html(md)
518    }
519
520    /// Find an emoji for a keyword.
521    pub fn auto_emoji(&self, text: &str) -> String {
522        emoji_for(text)
523    }
524
525    /// Generate world clock report for given timezone names.
526    pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
527        world_clock_for_names(timezone_names)
528    }
529
530    // ── Agent Write Tracking ──────────────────────────────────────
531
532    /// Mark a file as having been written by an agent.
533    pub fn mark_agent_write(&self, path: &str) {
534        self.agent_writes.lock().insert(path.to_string());
535    }
536
537    /// Check if a file was written by an agent.
538    pub fn is_agent_write(&self, path: &str) -> bool {
539        self.agent_writes.lock().contains(path)
540    }
541
542    /// Clear the agent-write marker for a file.
543    pub fn clear_agent_write(&self, path: &str) {
544        self.agent_writes.lock().remove(path);
545    }
546
547    // ── Text extraction ──────────────────────────────────────────
548
549    /// Extract text, images, and links from markdown content.
550    pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
551        crate::tgtxt::extract_text_imgs_links(text)
552    }
553
554    // ── Headings (for tag extraction) ─────────────────────────────
555
556    /// Extract headings from content for tag generation.
557    pub fn extract_headings(&self, content: &str) -> Vec<String> {
558        extract_headings(content).into_iter().take(5).collect()
559    }
560}
561
562// ---------------------------------------------------------------------------
563// Tests
564// ---------------------------------------------------------------------------
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    fn make_test_kb() -> KnowledgeBase {
571        let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
572        KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
573    }
574
575    #[test]
576    fn test_note_write_and_read() {
577        let kb = make_test_kb();
578        kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
579            .unwrap();
580        let content = kb.note_read("brain/Rust.md").unwrap();
581        assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
582    }
583
584    #[test]
585    fn test_note_read_missing() {
586        let kb = make_test_kb();
587        assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
588    }
589
590    #[test]
591    fn test_note_delete() {
592        let kb = make_test_kb();
593        kb.note_write("del.md", "to delete").unwrap();
594        kb.note_delete("del.md").unwrap();
595        assert_eq!(kb.note_read("del.md").unwrap(), None);
596    }
597
598    #[test]
599    fn test_note_move() {
600        let kb = make_test_kb();
601        kb.note_write("old.md", "content").unwrap();
602        kb.note_move("old.md", "new.md").unwrap();
603        assert_eq!(kb.note_read("old.md").unwrap(), None);
604        assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
605    }
606
607    #[test]
608    fn test_backlinks() {
609        let kb = make_test_kb();
610        kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
611            .unwrap();
612        let bl = kb.backlinks_for("brain/Ownership.md");
613        assert_eq!(bl.len(), 1);
614        assert_eq!(bl[0].source_path, "brain/Rust.md");
615    }
616
617    #[test]
618    fn test_note_tree() {
619        let kb = make_test_kb();
620        kb.note_write("brain/Rust.md", "Rust").unwrap();
621        let entries = kb.note_tree("brain").unwrap();
622        assert!(!entries.is_empty());
623    }
624
625    #[test]
626    fn test_search_by_name() {
627        let kb = make_test_kb();
628        kb.note_write("brain/Rust.md", "Rust content").unwrap();
629        let hits = kb.search("Rust", 10).unwrap();
630        assert!(!hits.is_empty());
631    }
632
633    #[test]
634    fn test_link_graph() {
635        let kb = make_test_kb();
636        kb.note_write("a.md", "[b](b.md)").unwrap();
637        let graph = kb.link_graph();
638        assert!(!graph.edges.is_empty());
639    }
640
641    #[test]
642    fn test_agent_write_tracking() {
643        let kb = make_test_kb();
644        assert!(!kb.is_agent_write("test.md"));
645        kb.mark_agent_write("test.md");
646        assert!(kb.is_agent_write("test.md"));
647        kb.clear_agent_write("test.md");
648        assert!(!kb.is_agent_write("test.md"));
649    }
650
651    #[test]
652    fn test_index_all() {
653        let kb = make_test_kb();
654        kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
655            .unwrap();
656        kb.note_write("brain/Go.md", "Go language").unwrap();
657        kb.note_write("index.md", "Welcome").unwrap();
658        let count = kb.index_all().unwrap();
659        assert_eq!(count, 3);
660        let bl = kb.backlinks_for("brain/Go.md");
661        assert_eq!(bl.len(), 1);
662    }
663
664    #[test]
665    fn test_on_file_change_callback() {
666        let kb = make_test_kb();
667        let _called = std::sync::atomic::AtomicBool::new(false);
668        let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
669            std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
670        let flag = path_clone.clone();
671
672        kb.on_file_change(move |path, change| {
673            let _ = path;
674            let _ = change;
675            flag.store(true, std::sync::atomic::Ordering::SeqCst);
676        });
677
678        kb.note_write("test.md", "hello").unwrap();
679        assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
680    }
681
682    #[test]
683    fn test_chat_append() {
684        let kb = make_test_kb();
685        kb.chat_append("Test message").unwrap();
686        let messages = kb.chat_messages().unwrap();
687        assert!(!messages.is_empty());
688    }
689
690    #[test]
691    fn test_config() {
692        let kb = make_test_kb();
693        let cfg = kb.config().unwrap();
694        // Should return default for non-existent config
695        let cfg2 = kb.config().unwrap();
696        assert_eq!(cfg.language, cfg2.language);
697    }
698
699    #[test]
700    fn test_markdown_to_html() {
701        let kb = make_test_kb();
702        let html = kb.markdown_to_html("# Hello\n\n**world**");
703        // markdown_to_html wraps content in a <p> tag by default, check for content
704        assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
705        assert!(html.contains("world"), "HTML should contain world: {html}");
706    }
707
708    #[test]
709    fn test_auto_emoji() {
710        let kb = make_test_kb();
711        let emoji = kb.auto_emoji("cooking pasta");
712        assert!(!emoji.is_empty());
713    }
714
715    #[test]
716    fn test_extract_headings() {
717        let kb = make_test_kb();
718        let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
719        assert!(headings.len() >= 2);
720    }
721}