1use std::collections::HashSet;
11use std::path::PathBuf;
12
13use anyhow::Result;
14use parking_lot::{Mutex as ParkingMutex, RwLock};
15
16pub 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::{
32 StemIndex, extract_headings, rewrite_link_targets, rewrite_wikilink_targets, similar,
33};
34use crate::plugins::world_clock_for_names;
35use crate::stats::{done_today, today_report};
36use crate::types::NoteMeta;
37use crate::types::{CHAT_FILENAME, DIR_USER_ROOT, FileEntry, Habits, KnowledgeConfig};
38#[cfg(test)]
39use crate::types::{NoteQuality, NoteSource};
40use crate::worker::{move_due_tasks, remove_completed_items};
41use crate::{today_chat_header, today_journal_filename};
42
43#[derive(Debug, Clone)]
45pub enum FileChange {
46 Created(String),
48 Updated(String),
50 Deleted(String),
52 Moved {
54 old: String,
56 new: String,
58 },
59}
60
61#[derive(Debug, Clone)]
63pub struct NoteHit {
64 pub path: String,
66 pub name: String,
68 pub snippet: String,
70 pub backlink_count: usize,
72 pub name_similarity: i32,
74}
75
76pub struct KnowledgeBase {
84 fs: RwLock<VirtualFs>,
86 backlinks: RwLock<BacklinkIndex>,
88 agent_writes: ParkingMutex<HashSet<String>>,
90 on_change: RwLock<Vec<FileChangeCallback>>,
93}
94
95impl std::fmt::Debug for KnowledgeBase {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("KnowledgeBase")
98 .field("root", &self.fs.read().root())
99 .finish()
100 }
101}
102
103impl KnowledgeBase {
104 pub fn new(root: PathBuf) -> Result<Self> {
106 let fs = VirtualFs::new(root)?;
107 Ok(Self {
108 fs: RwLock::new(fs),
109 backlinks: RwLock::new(BacklinkIndex::new()),
110 agent_writes: ParkingMutex::new(HashSet::new()),
111 on_change: RwLock::new(Vec::new()),
112 })
113 }
114
115 pub fn for_space(space_dir: &std::path::Path) -> Result<Self> {
117 Self::new(space_dir.join("knowledge"))
118 }
119
120 pub fn root(&self) -> PathBuf {
122 self.fs.read().root().to_path_buf()
123 }
124
125 pub fn on_file_change<F>(&self, f: F)
130 where
131 F: Fn(&str, FileChange) + Send + Sync + 'static,
132 {
133 self.on_change.write().push(Box::new(f));
134 }
135
136 fn notify_change(&self, path: &str, change: FileChange) {
138 for cb in self.on_change.read().iter() {
139 cb(path, change.clone());
140 }
141 }
142
143 pub fn note_read(&self, path: &str) -> Result<Option<String>> {
147 let fs = self.fs.read();
148 match fs.read_path(path) {
149 Ok(content) => Ok(Some(content)),
150 Err(_) => Ok(None),
151 }
152 }
153
154 pub fn note_read_bytes(&self, path: &str) -> Result<Option<Vec<u8>>> {
157 let fs = self.fs.read();
158 match fs.read_path_bytes(path) {
159 Ok(bytes) => Ok(Some(bytes)),
160 Err(_) => Ok(None),
161 }
162 }
163
164 fn build_stem_index(&self) -> StemIndex {
174 let mut index: StemIndex = StemIndex::new();
175 let files = match self.list_all_md_files() {
176 Ok(f) => f,
177 Err(e) => {
178 tracing::warn!(error = %e, "stem_index walk failed; wikilinks stay unresolved");
179 return index;
180 }
181 };
182 for (path, _size) in files {
183 let stem = match path.rsplit('/').next() {
184 Some(b) => b.trim_end_matches(".md"),
185 None => path.as_str().trim_end_matches(".md"),
186 }
187 .to_lowercase();
188 index.entry(stem).or_default().push(path);
189 }
190 index
191 }
192 pub fn note_write(&self, path: &str, content: &str) -> Result<()> {
197 let is_new = {
201 let fs = self.fs.write();
202 let is_new = fs.read_path(path).is_err();
203 fs.write_path(path, content)?;
204 is_new
205 };
206 let stem_index = self.build_stem_index();
209 {
210 let mut backlinks = self.backlinks.write();
211 backlinks.remove_file(path);
212 backlinks.index_file_with(path, content, &stem_index);
213 }
214
215 self.notify_change(
216 path,
217 if is_new {
218 FileChange::Created(path.to_string())
219 } else {
220 FileChange::Updated(path.to_string())
221 },
222 );
223 Ok(())
224 }
225
226 pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
235 let existing = self.note_read(path).ok().flatten();
237 let final_content = match existing {
238 Some(ref existing_content) => {
239 let (existing_meta, body) = parse_note_meta(existing_content);
240 match existing_meta {
241 Some(old_meta) => {
243 let merged = NoteMeta {
244 saved_at: old_meta.saved_at.or(meta.saved_at.clone()),
245 ..meta.clone()
246 };
247 format_frontmatter(&merged, if body.is_empty() { content } else { &body })
248 }
249 None => {
252 tracing::debug!(
253 path,
254 "Skipping note_write_with_meta on user-authored note"
255 );
256 return Ok(false);
257 }
258 }
259 }
260 None => format_frontmatter(meta, content),
261 };
262 self.note_write(path, &final_content).map(|_| true)
263 }
264
265 pub fn notes_needing_review(&self) -> Result<Vec<(String, NoteMeta)>> {
271 let fs = self.fs.read();
272 let mut result = Vec::new();
273
274 let files = fs.all_md_files()?;
275 for (path, _size) in &files {
276 if let Ok(content) = fs.read_path(path) {
277 let (meta, _body) = parse_note_meta(&content);
278 if let Some(m) = meta
279 && m.needs_review
280 {
281 result.push((path.clone(), m));
282 }
283 }
284 }
285
286 result.sort_by(|a, b| {
288 a.1.saved_at
289 .as_deref()
290 .unwrap_or("")
291 .cmp(b.1.saved_at.as_deref().unwrap_or(""))
292 });
293
294 Ok(result)
295 }
296 pub fn note_delete(&self, path: &str) -> Result<()> {
299 {
300 let fs = self.fs.write();
301 fs.delete_path(path)?;
302 }
303 self.backlinks.write().remove_file(path);
304 self.notify_change(path, FileChange::Deleted(path.to_string()));
305 Ok(())
306 }
307
308 pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
315 {
316 let fs = self.fs.write();
317 fs.write_path(path, content)?;
318 }
319 let stem_index = self.build_stem_index();
320 let mut backlinks = self.backlinks.write();
321 backlinks.remove_file(path);
322 backlinks.index_file_with(path, content, &stem_index);
323 Ok(())
325 }
326 pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
337 let pre_stem_index = self.build_stem_index();
343
344 let new_content = {
346 let fs = self.fs.write();
347 fs.rename_path(old_path, new_path)?;
348 fs.read_path(new_path).ok()
349 };
350
351 let sources: HashSet<String> = {
356 let backlinks = self.backlinks.read();
357 backlinks.sources_for(old_path)
358 };
359
360 let indexed_content = match &new_content {
364 Some(c) => {
365 let (md_done, _) = rewrite_link_targets(c, old_path, new_path);
366 let (wiki_done, _) =
367 rewrite_wikilink_targets(&md_done, old_path, new_path, Some(&pre_stem_index));
368 if &wiki_done != c {
369 let _ = self.fs.write().write_path(new_path, &wiki_done);
371 }
372 wiki_done
373 }
374 None => String::new(),
375 };
376
377 let mut touched: Vec<(String, String)> = Vec::with_capacity(sources.len());
380 for src in &sources {
381 if src == old_path || src == new_path {
382 continue;
384 }
385 if let Ok(content) = self.fs.read().read_path(src) {
386 let (md_done, n_md) = rewrite_link_targets(&content, old_path, new_path);
387 let (final_done, n_wiki) =
388 rewrite_wikilink_targets(&md_done, old_path, new_path, Some(&pre_stem_index));
389 if (n_md > 0 || n_wiki > 0) && final_done != content {
390 touched.push((src.clone(), final_done));
391 }
392 }
393 }
394
395 let post_stem_index = self.build_stem_index();
400 {
401 let mut backlinks = self.backlinks.write();
402 backlinks.remove_file(old_path);
403 if !indexed_content.is_empty() {
404 backlinks.index_file_with(new_path, &indexed_content, &post_stem_index);
405 }
406 for (src, content) in &touched {
407 backlinks.index_file_with(src, content, &post_stem_index);
408 }
409 }
410
411 if !touched.is_empty() {
415 let fs = self.fs.write();
416 for (src, content) in &touched {
417 let _ = fs.write_path(src, content);
418 }
419 }
420
421 self.notify_change(
422 old_path,
423 FileChange::Moved {
424 old: old_path.to_string(),
425 new: new_path.to_string(),
426 },
427 );
428 Ok(())
429 }
430
431 pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
433 let fs = self.fs.read();
434 let dir = if dir.is_empty() || dir == "/" {
435 DIR_USER_ROOT
436 } else {
437 dir
438 };
439 Ok(fs.files_and_dirs(dir)?)
440 }
441
442 pub fn list_all_md_files(&self) -> Result<Vec<(String, i64)>> {
445 let fs = self.fs.read();
446 Ok(fs.all_md_files()?)
447 }
448
449 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
456 let fs = self.fs.read();
457 let files = fs.search_files_by_name(query)?;
458
459 let hits: Vec<NoteHit> = files
460 .into_iter()
461 .take(limit)
462 .map(|f| {
463 let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
464 f.name.clone()
465 } else {
466 format!("{}/{}", f.parent_dir, f.name)
467 };
468 let name_sim = similar(&f.display_name, query) as i32;
469 let bl_count = self.backlinks.read().backlink_count(&path);
470 NoteHit {
471 path,
472 name: f.display_name,
473 snippet: String::new(),
474 backlink_count: bl_count,
475 name_similarity: name_sim,
476 }
477 })
478 .collect();
479
480 Ok(hits)
481 }
482
483 pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
487 self.backlinks.read().backlinks_for(path)
488 }
489
490 pub fn link_graph(&self) -> LinkGraph {
492 self.backlinks.read().link_graph()
493 }
494
495 pub fn index_all(&self) -> Result<usize> {
502 let (paths_contents, stem_index) = {
505 let fs = self.fs.read();
506 let all = fs.all_md_files()?;
507 let stem_index = {
508 let mut idx: StemIndex = StemIndex::new();
509 for (path, _size) in &all {
510 let stem = path
511 .rsplit('/')
512 .next()
513 .unwrap_or(path.as_str())
514 .trim_end_matches(".md")
515 .to_lowercase();
516 idx.entry(stem).or_default().push(path.clone());
517 }
518 idx
519 };
520 let mut paths_contents: Vec<(String, String)> = Vec::with_capacity(all.len());
521 for (path, _size) in &all {
522 if let Ok(content) = fs.read_path(path) {
523 paths_contents.push((path.clone(), content));
524 }
525 }
526 (paths_contents, stem_index)
527 };
528
529 let mut count = 0;
530 {
531 let mut backlinks = self.backlinks.write();
532 backlinks.clear();
533 for (path, content) in &paths_contents {
534 backlinks.index_file_with(path, content, &stem_index);
535 count += 1;
536 }
537 }
538
539 tracing::info!(files = count, "Knowledge base indexed");
540 Ok(count)
541 }
542
543 pub fn chat_append(&self, message: &str) -> Result<()> {
547 let header = today_chat_header();
548 let timestamp = chrono::Local::now().format("`15:04`").to_string();
549 let entry = format!("- [ ] {timestamp} {message}");
550
551 let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
552 if !content.contains(&header) {
553 if !content.trim_end().ends_with('\n') {
554 content.push('\n');
555 }
556 content.push_str(&header);
557 content.push('\n');
558 }
559 content.push_str(&entry);
560 content.push('\n');
561 self.note_write(CHAT_FILENAME, &content)?;
562 Ok(())
563 }
564
565 pub fn chat_messages(&self) -> Result<Vec<String>> {
567 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
568 Ok(read_chat_msgs(&content))
569 }
570
571 pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
573 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
574 match delete_chat_msg(&content, msg_hash) {
575 Ok(new_content) => {
576 self.note_write(CHAT_FILENAME, &new_content)?;
577 Ok(true)
578 }
579 Err(_) => Ok(false),
580 }
581 }
582
583 pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
585 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
586 match rename_chat_msg(&content, msg_hash, new_body) {
587 Ok(new_content) => {
588 self.note_write(CHAT_FILENAME, &new_content)?;
589 Ok(true)
590 }
591 Err(_) => Ok(false),
592 }
593 }
594
595 pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
597 let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
598 let target_content = self.note_read(target_path)?.unwrap_or_default();
599 let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
600 if new_chat != chat_content {
601 self.note_write(CHAT_FILENAME, &new_chat)?;
602 self.note_write(target_path, &new_target)?;
603 Ok(true)
604 } else {
605 Ok(false)
606 }
607 }
608
609 pub fn journal_add_record(&self, record: &str) -> Result<()> {
613 let fs = self.fs.write();
614 let tz = chrono::Local::now().offset().to_owned();
615 journal_add_record(&fs, record, tz)?;
616 Ok(())
617 }
618
619 pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
621 let fs = self.fs.write();
622 let tz = chrono::Local::now().offset().to_owned();
623 journal_add_emoji(&fs, emoji, tz)?;
624 Ok(())
625 }
626
627 pub fn journal_today_path(&self) -> String {
629 let tz = chrono::Local::now().offset().to_owned();
630 today_journal_filename(tz)
631 }
632
633 pub fn habits(&self, year: i32) -> Result<Habits> {
637 let fs = self.fs.read();
638 Ok(habits(&fs, year)?)
639 }
640
641 pub fn habits_last_week(&self) -> Result<Habits> {
643 let fs = self.fs.read();
644 let tz = chrono::Local::now().offset().to_owned();
645 Ok(last_week_habits(&fs, tz)?)
646 }
647
648 pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
650 let fs = self.fs.write();
651 write_habits(&fs, year, habits)?;
652 Ok(())
653 }
654
655 pub fn config(&self) -> Result<KnowledgeConfig> {
659 let fs = self.fs.read();
660 match fs.read_path("config.json") {
661 Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
662 Err(_) => Ok(KnowledgeConfig::default()),
663 }
664 }
665
666 pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
668 let json = serde_json::to_string_pretty(config)?;
669 self.note_write("config.json", &json)?;
670 Ok(())
671 }
672
673 pub fn checklist_items(
677 &self,
678 path: &str,
679 ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
680 let content = self.note_read(path)?.unwrap_or_default();
681 Ok(checklist_items(&content))
682 }
683
684 pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
686 let content = self.note_read(path)?.unwrap_or_default();
687 Ok(incomplete_checklist_items(&content))
688 }
689
690 pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
692 let content = self.note_read(path)?.unwrap_or_default();
693 let updated = add_checklist_item(&content, item, checked);
694 self.note_write(path, &updated)
695 }
696
697 pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
699 let content = self.note_read(path)?.unwrap_or_default();
700 let (new_content, found) = complete_checklist_item(&content, item_hash);
701 if !found.is_empty() {
702 self.note_write(path, &new_content)?;
703 Ok(true)
704 } else {
705 Ok(false)
706 }
707 }
708
709 pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
711 let content = self.note_read(path)?.unwrap_or_default();
712 let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
713 if !removed.is_empty() {
714 self.note_write(path, &new_content)?;
715 Ok(true)
716 } else {
717 Ok(false)
718 }
719 }
720
721 pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
723 let content = self.note_read(path)?.unwrap_or_default();
724 let (kept, removed) = remove_completed_checklist_items(&content);
725 if !removed.is_empty() {
726 self.note_write(path, &kept)?;
727 }
728 Ok((kept, removed))
729 }
730
731 pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
735 let config = self.config()?;
738 let fs = self.fs.write();
739 Ok(remove_completed_items(&fs, &config)?)
740 }
741
742 pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
744 let mut config = self.config()?;
748 let moved = {
749 let fs = self.fs.write();
750 move_due_tasks(&fs, &mut config)?
751 };
752 if !moved.is_empty() {
753 self.set_config(&config)?;
754 }
755 Ok(moved)
756 }
757
758 pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
762 let fs = self.fs.read();
763 Ok(today_report(&fs)?)
764 }
765
766 pub fn done_today(&self) -> Result<Vec<FileEntry>> {
768 let fs = self.fs.read();
769 Ok(done_today(&fs)?)
770 }
771
772 pub fn markdown_to_html(&self, md: &str) -> String {
776 markdown_to_html(md)
777 }
778
779 pub fn auto_emoji(&self, text: &str) -> String {
781 emoji_for(text)
782 }
783
784 pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
786 world_clock_for_names(timezone_names)
787 }
788
789 pub fn mark_agent_write(&self, path: &str) {
793 self.agent_writes.lock().insert(path.to_string());
794 }
795
796 pub fn is_agent_write(&self, path: &str) -> bool {
798 self.agent_writes.lock().contains(path)
799 }
800
801 pub fn clear_agent_write(&self, path: &str) {
803 self.agent_writes.lock().remove(path);
804 }
805
806 pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
810 crate::tgtxt::extract_text_imgs_links(text)
811 }
812
813 pub fn extract_headings(&self, content: &str) -> Vec<String> {
817 extract_headings(content).into_iter().take(5).collect()
818 }
819}
820
821pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
833 let trimmed = content.trim_start();
834 if !trimmed.starts_with("---") {
835 return (None, content.to_string());
836 }
837
838 let after_first = &trimmed[3..];
840 let rest = after_first.trim_start_matches(['-', '\n', '\r']);
841 if let Some(end_offset) = rest.find("\n---") {
842 let yaml_block = &rest[..end_offset];
843 let body_start = end_offset + 4; let body = rest[body_start..].trim_start().to_string();
845
846 if !yaml_block.contains("oxios:") {
848 return (None, content.to_string());
850 }
851
852 #[derive(serde::Deserialize)]
853 struct FrontmatterWrapper {
854 oxios: NoteMeta,
855 }
856
857 match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
858 Ok(wrapper) => (Some(wrapper.oxios), body),
859 Err(_) => (None, content.to_string()),
860 }
861 } else {
862 (None, content.to_string())
863 }
864}
865
866fn format_frontmatter(meta: &NoteMeta, body: &str) -> String {
872 let yaml = serde_yaml::to_string(meta).unwrap_or_default();
873 let indented: String = yaml
874 .lines()
875 .filter(|l| !l.is_empty())
876 .map(|l| format!(" {l}"))
877 .collect::<Vec<_>>()
878 .join("\n");
879 format!("---\noxios:\n{}\n---\n\n{}", indented, body)
880}
881
882#[cfg(test)]
887mod tests {
888 use super::*;
889
890 fn make_test_kb() -> KnowledgeBase {
891 let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
892 KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
893 }
894
895 #[test]
896 fn test_note_write_and_read() {
897 let kb = make_test_kb();
898 kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
899 .unwrap();
900 let content = kb.note_read("brain/Rust.md").unwrap();
901 assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
902 }
903
904 #[test]
905 fn test_note_read_missing() {
906 let kb = make_test_kb();
907 assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
908 }
909
910 #[test]
911 fn test_note_delete() {
912 let kb = make_test_kb();
913 kb.note_write("del.md", "to delete").unwrap();
914 kb.note_delete("del.md").unwrap();
915 assert_eq!(kb.note_read("del.md").unwrap(), None);
916 }
917
918 #[test]
919 fn test_note_move() {
920 let kb = make_test_kb();
921 kb.note_write("old.md", "content").unwrap();
922 kb.note_move("old.md", "new.md").unwrap();
923 assert_eq!(kb.note_read("old.md").unwrap(), None);
924 assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
925 }
926
927 #[test]
928 fn test_note_move_rewrites_inbound_links() {
929 let kb = make_test_kb();
930 kb.note_write("a.md", "See [target](target.md) and [again](target.md).")
932 .unwrap();
933 kb.note_write("b.md", "Ref [target](target.md).").unwrap();
934 kb.note_write("target.md", "# Target\n\nbody").unwrap();
935 kb.index_all().unwrap();
939
940 kb.note_move("target.md", "renamed.md").unwrap();
941
942 assert_eq!(kb.note_read("target.md").unwrap(), None);
944 assert_eq!(
945 kb.note_read("renamed.md").unwrap(),
946 Some("# Target\n\nbody".to_string())
947 );
948
949 assert_eq!(
951 kb.note_read("a.md").unwrap().as_deref(),
952 Some("See [target](renamed.md) and [again](renamed.md).")
953 );
954 assert_eq!(
955 kb.note_read("b.md").unwrap().as_deref(),
956 Some("Ref [target](renamed.md).")
957 );
958
959 let bl: HashSet<String> = kb
961 .backlinks_for("renamed.md")
962 .into_iter()
963 .map(|b| b.source_path)
964 .collect();
965 assert_eq!(bl, HashSet::from(["a.md".to_string(), "b.md".to_string()]));
966 assert_eq!(kb.backlinks_for("target.md").len(), 0);
967 }
968
969 #[test]
970 fn test_note_move_rewrites_wikilinks() {
971 let kb = make_test_kb();
972 kb.note_write(
974 "src.md",
975 "Bare [[Target]] path [[dir/Target]] full [[dir/Target.md]] alias [[Target|T]].",
976 )
977 .unwrap();
978 kb.note_write("dir/Target.md", "# Target\n\nbody").unwrap();
979 kb.index_all().unwrap();
982
983 kb.note_move("dir/Target.md", "dir/Renamed.md").unwrap();
984
985 assert_eq!(
987 kb.note_read("src.md").unwrap().as_deref(),
988 Some(
989 "Bare [[Renamed]] path [[dir/Renamed]] full [[dir/Renamed.md]] alias [[Renamed|T]]."
990 ),
991 );
992 assert_eq!(kb.backlinks_for("dir/Renamed.md").len(), 1);
994 assert_eq!(kb.backlinks_for("dir/Target.md").len(), 0);
995 }
996
997 #[test]
998 fn test_note_move_skips_ambiguous_bare_wikilink() {
999 let kb = make_test_kb();
1003 kb.note_write("src.md", "ambig [[Dup]] explicit [[a/Dup]]")
1004 .unwrap();
1005 kb.note_write("a/Dup.md", "# A").unwrap();
1006 kb.note_write("b/Dup.md", "# B").unwrap();
1007 kb.index_all().unwrap();
1010
1011 kb.note_move("a/Dup.md", "a/Moved.md").unwrap();
1012
1013 let src = kb.note_read("src.md").unwrap().unwrap_or_default();
1014 assert!(
1016 src.contains("[[Dup]]"),
1017 "ambiguous bare link must be left alone: {src}"
1018 );
1019 assert!(
1020 src.contains("[[a/Moved]]"),
1021 "explicit path link must be rewritten: {src}"
1022 );
1023 }
1024
1025 #[test]
1026 fn test_backlinks_track_wikilinks() {
1027 let kb = make_test_kb();
1028 kb.note_write("brain/Rust.md", "See [[Ownership]] and [[brain/Go]]")
1029 .unwrap();
1030 kb.note_write("brain/Ownership.md", "# Ownership").unwrap();
1031 kb.note_write("brain/Go.md", "# Go").unwrap();
1032 kb.index_all().unwrap();
1035
1036 let owners_of_ownership: HashSet<String> = kb
1038 .backlinks_for("brain/Ownership.md")
1039 .into_iter()
1040 .map(|b| b.source_path)
1041 .collect();
1042 assert!(owners_of_ownership.contains("brain/Rust.md"));
1043 let owners_of_go: HashSet<String> = kb
1044 .backlinks_for("brain/Go.md")
1045 .into_iter()
1046 .map(|b| b.source_path)
1047 .collect();
1048 assert!(owners_of_go.contains("brain/Rust.md"));
1049 }
1050
1051 #[test]
1052 fn test_backlinks() {
1053 let kb = make_test_kb();
1054 kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
1055 .unwrap();
1056 let bl = kb.backlinks_for("brain/Ownership.md");
1057 assert_eq!(bl.len(), 1);
1058 assert_eq!(bl[0].source_path, "brain/Rust.md");
1059 }
1060
1061 #[test]
1062 fn test_note_tree() {
1063 let kb = make_test_kb();
1064 kb.note_write("brain/Rust.md", "Rust").unwrap();
1065 let entries = kb.note_tree("brain").unwrap();
1066 assert!(!entries.is_empty());
1067 }
1068
1069 #[test]
1070 fn test_search_by_name() {
1071 let kb = make_test_kb();
1072 kb.note_write("brain/Rust.md", "Rust content").unwrap();
1073 let hits = kb.search("Rust", 10).unwrap();
1074 assert!(!hits.is_empty());
1075 }
1076
1077 #[test]
1078 fn test_link_graph() {
1079 let kb = make_test_kb();
1080 kb.note_write("a.md", "[b](b.md)").unwrap();
1081 let graph = kb.link_graph();
1082 assert!(!graph.edges.is_empty());
1083 }
1084
1085 #[test]
1086 fn test_agent_write_tracking() {
1087 let kb = make_test_kb();
1088 assert!(!kb.is_agent_write("test.md"));
1089 kb.mark_agent_write("test.md");
1090 assert!(kb.is_agent_write("test.md"));
1091 kb.clear_agent_write("test.md");
1092 assert!(!kb.is_agent_write("test.md"));
1093 }
1094
1095 #[test]
1096 fn test_index_all() {
1097 let kb = make_test_kb();
1098 kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
1099 .unwrap();
1100 kb.note_write("brain/Go.md", "Go language").unwrap();
1101 kb.note_write("index.md", "Welcome").unwrap();
1102 let count = kb.index_all().unwrap();
1103 assert_eq!(count, 3);
1104 let bl = kb.backlinks_for("brain/Go.md");
1105 assert_eq!(bl.len(), 1);
1106 }
1107
1108 #[test]
1109 fn test_on_file_change_callback() {
1110 let kb = make_test_kb();
1111 let _called = std::sync::atomic::AtomicBool::new(false);
1112 let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
1113 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1114 let flag = path_clone.clone();
1115
1116 kb.on_file_change(move |path, change| {
1117 let _ = path;
1118 let _ = change;
1119 flag.store(true, std::sync::atomic::Ordering::SeqCst);
1120 });
1121
1122 kb.note_write("test.md", "hello").unwrap();
1123 assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
1124 }
1125
1126 #[test]
1127 fn test_chat_append() {
1128 let kb = make_test_kb();
1129 kb.chat_append("Test message").unwrap();
1130 let messages = kb.chat_messages().unwrap();
1131 assert!(
1135 messages
1136 .iter()
1137 .any(|m| m.starts_with("- [") && m.contains("Test message")),
1138 "captured message should be a parseable marker block: {messages:?}"
1139 );
1140 }
1141
1142 #[test]
1143 fn test_config() {
1144 let kb = make_test_kb();
1145 let cfg = kb.config().unwrap();
1146 let cfg2 = kb.config().unwrap();
1148 assert_eq!(cfg.language, cfg2.language);
1149 }
1150
1151 #[test]
1152 fn test_markdown_to_html() {
1153 let kb = make_test_kb();
1154 let html = kb.markdown_to_html("# Hello\n\n**world**");
1155 assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
1157 assert!(html.contains("world"), "HTML should contain world: {html}");
1158 }
1159
1160 #[test]
1161 fn test_auto_emoji() {
1162 let kb = make_test_kb();
1163 let emoji = kb.auto_emoji("cooking pasta");
1164 assert!(!emoji.is_empty());
1165 }
1166
1167 #[test]
1168 fn test_extract_headings() {
1169 let kb = make_test_kb();
1170 let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
1171 assert!(headings.len() >= 2);
1172 }
1173
1174 #[test]
1175 fn test_frontmatter_roundtrip() {
1176 let meta = NoteMeta {
1177 author: "agent".to_string(),
1178 source: NoteSource::Hook,
1179 quality: NoteQuality::Raw,
1180 needs_review: true,
1181 session_id: Some("abc123".to_string()),
1182 message_index: Some(3),
1183 saved_at: Some("2026-06-13T00:00:00Z".to_string()),
1184 };
1185 let body = "## Test\n\nContent here.";
1186 let formatted = format_frontmatter(&meta, body);
1187 assert!(formatted.starts_with("---\noxios:\n"));
1188 let (parsed_meta, parsed_body) = parse_note_meta(&formatted);
1189 assert!(
1190 parsed_meta.is_some(),
1191 "Failed to parse round-tripped frontmatter"
1192 );
1193 let pm = parsed_meta.unwrap();
1194 assert_eq!(pm.author, "agent");
1195 assert_eq!(pm.session_id.as_deref(), Some("abc123"));
1196 assert_eq!(pm.message_index, Some(3));
1197 assert_eq!(parsed_body.trim(), body.trim());
1198 }
1199
1200 #[test]
1201 fn test_parse_user_frontmatter_ignored() {
1202 let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
1203 let (meta, body) = parse_note_meta(content);
1204 assert!(
1205 meta.is_none(),
1206 "User frontmatter should not be parsed as NoteMeta"
1207 );
1208 assert!(
1209 body.contains("tags: [rust, design]"),
1210 "User frontmatter preserved"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_parse_no_frontmatter() {
1216 let content = "# Just a note\nSome content.";
1217 let (meta, body) = parse_note_meta(content);
1218 assert!(meta.is_none());
1219 assert_eq!(body, content);
1220 }
1221}