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 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#[derive(Debug, Clone)]
51pub enum FileChange {
52 Created(String),
54 Updated(String),
56 Deleted(String),
58 Moved {
60 old: String,
62 new: String,
64 },
65}
66
67#[derive(Debug, Clone)]
69pub struct NoteHit {
70 pub path: String,
72 pub name: String,
74 pub snippet: String,
76 pub backlink_count: usize,
78 pub name_similarity: i32,
80}
81
82pub struct KnowledgeBase {
90 fs: RwLock<VirtualFs>,
92 backlinks: RwLock<BacklinkIndex>,
94 agent_writes: ParkingMutex<HashSet<String>>,
96 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 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 pub fn for_space(space_dir: &std::path::Path) -> Result<Self> {
123 Self::new(space_dir.join("knowledge"))
124 }
125
126 pub fn root(&self) -> PathBuf {
128 self.fs.read().root().to_path_buf()
129 }
130
131 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 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 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 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 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 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 pub fn note_write(&self, path: &str, content: &str) -> Result<()> {
225 self.assert_within_root(path)?;
232
233 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 return Ok(());
248 }
249
250 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 pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
285 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 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 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 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 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 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 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 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 pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
400 self.assert_within_root(path)?;
402
403 let root = self.fs.read().root().to_path_buf();
404 let now = OffsetDateTime::now_utc();
405 let outcome = frontformat::write_note(&root, path, content, now)
411 .map_err(|e| anyhow::anyhow!("frontformat::write_note({path}) failed: {e}"))?;
412
413 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 Ok(())
424 }
425 pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
436 let pre_stem_index = self.build_stem_index();
442
443 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 let sources: HashSet<String> = {
455 let backlinks = self.backlinks.read();
456 backlinks.sources_for(old_path)
457 };
458
459 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 let _ = self.fs.write().write_path(new_path, &wiki_done);
470 }
471 wiki_done
472 }
473 None => String::new(),
474 };
475
476 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 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 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 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 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 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 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 pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
586 self.backlinks.read().backlinks_for(path)
587 }
588
589 pub fn link_graph(&self) -> LinkGraph {
591 self.backlinks.read().link_graph()
592 }
593
594 pub fn index_all(&self) -> Result<usize> {
601 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 pub fn reindex_one(&self, path: &str) -> Result<()> {
651 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 pub fn forget_file(&self, path: &str) {
667 self.backlinks.write().remove_file(path);
668 }
669
670 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 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 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 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 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 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 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 pub fn journal_today_path(&self) -> String {
756 let tz = chrono::Local::now().offset().to_owned();
757 today_journal_filename(tz)
758 }
759
760 pub fn habits(&self, year: i32) -> Result<Habits> {
764 let fs = self.fs.read();
765 Ok(habits(&fs, year)?)
766 }
767
768 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 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 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 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 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 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 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 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 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 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 pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
862 let config = self.config()?;
865 let fs = self.fs.write();
866 Ok(remove_completed_items(&fs, &config)?)
867 }
868
869 pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
871 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 pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
889 let fs = self.fs.read();
890 Ok(today_report(&fs)?)
891 }
892
893 pub fn done_today(&self) -> Result<Vec<FileEntry>> {
895 let fs = self.fs.read();
896 Ok(done_today(&fs)?)
897 }
898
899 pub fn markdown_to_html(&self, md: &str) -> String {
903 markdown_to_html(md)
904 }
905
906 pub fn auto_emoji(&self, text: &str) -> String {
908 emoji_for(text)
909 }
910
911 pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
913 world_clock_for_names(timezone_names)
914 }
915
916 pub fn mark_agent_write(&self, path: &str) {
920 self.agent_writes.lock().insert(path.to_string());
921 }
922
923 pub fn is_agent_write(&self, path: &str) -> bool {
925 self.agent_writes.lock().contains(path)
926 }
927
928 pub fn clear_agent_write(&self, path: &str) {
930 self.agent_writes.lock().remove(path);
931 }
932
933 pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
937 crate::tgtxt::extract_text_imgs_links(text)
938 }
939
940 pub fn extract_headings(&self, content: &str) -> Vec<String> {
944 extract_headings(content).into_iter().take(5).collect()
945 }
946}
947
948pub 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 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; let body = rest[body_start..].trim_start().to_string();
972
973 if !yaml_block.contains("oxios:") {
975 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#[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 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 assert!(moved.contains("content"));
1043 }
1044
1045 #[test]
1046 fn test_note_move_rewrites_inbound_links() {
1047 let kb = make_test_kb();
1048 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 kb.index_all().unwrap();
1057
1058 kb.note_move("target.md", "renamed.md").unwrap();
1059
1060 assert_eq!(kb.note_read("target.md").unwrap(), None);
1062 let renamed = kb.note_read("renamed.md").unwrap().unwrap();
1063 assert!(renamed.contains("# Target"));
1065 assert!(renamed.contains("body"));
1066
1067 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 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 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 kb.index_all().unwrap();
1096
1097 kb.note_move("dir/Target.md", "dir/Renamed.md").unwrap();
1098
1099 let src = kb.note_read("src.md").unwrap().unwrap();
1101 assert!(src.contains("[[Renamed|T]]"));
1102 assert!(src.contains("[[dir/Renamed]]"));
1103 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 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 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 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 kb.index_all().unwrap();
1146
1147 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 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 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 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 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 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 #[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 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 let kb = make_test_kb();
1393 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 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 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 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 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 assert_eq!(kb.note_read("Chat.md").unwrap(), None);
1494 }
1495
1496 #[test]
1497 #[cfg(unix)]
1498 fn note_write_rejects_symlink_escape() {
1499 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 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 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 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 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 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 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 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}