1use anyhow::{bail, Context, Result};
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21use std::fmt;
22use std::fs;
23use std::path::{Path, PathBuf};
24use tokio::process::Command;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct VaultConfig {
31 pub vault_path: PathBuf,
33
34 #[serde(default = "default_extensions")]
36 pub extensions: Vec<String>,
37
38 #[serde(default)]
40 pub include_hidden: bool,
41
42 #[serde(default = "default_skip_dirs")]
44 pub skip_dirs: Vec<String>,
45
46 #[serde(default = "default_max_depth")]
48 pub max_depth: usize,
49
50 #[serde(default = "default_max_results")]
52 pub max_results: usize,
53}
54
55fn default_extensions() -> Vec<String> {
56 vec!["md".to_string()]
57}
58
59fn default_skip_dirs() -> Vec<String> {
60 vec![
61 ".obsidian".to_string(),
62 ".trash".to_string(),
63 ".git".to_string(),
64 "node_modules".to_string(),
65 ]
66}
67
68fn default_max_depth() -> usize {
69 10
70}
71
72fn default_max_results() -> usize {
73 200
74}
75
76impl Default for VaultConfig {
77 fn default() -> Self {
78 Self {
79 vault_path: PathBuf::from("."),
80 extensions: default_extensions(),
81 include_hidden: false,
82 skip_dirs: default_skip_dirs(),
83 max_depth: default_max_depth(),
84 max_results: default_max_results(),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Note {
94 pub path: String,
96 pub title: String,
98 pub content: String,
100 #[serde(default)]
102 pub tags: BTreeSet<String>,
103 #[serde(default)]
105 pub forward_links: BTreeSet<String>,
106 pub size_bytes: u64,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub modified: Option<u64>,
111}
112
113impl Note {
114 pub fn preview(&self, max_chars: usize) -> &str {
116 let content = if let Some(yaml_end) = self.content.find("\n---\n") {
117 &self.content[yaml_end + 5..]
119 } else {
120 &self.content
121 };
122
123 let text = content.trim_start();
124 if text.len() <= max_chars {
125 text
126 } else {
127 match text[..max_chars].rfind(' ') {
129 Some(pos) if pos > max_chars / 2 => &text[..pos],
130 _ => &text[..max_chars],
131 }
132 }
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum SearchMode {
142 Fuzzy,
144 Exact,
146 Regex,
148}
149
150impl Default for SearchMode {
151 fn default() -> Self {
152 SearchMode::Fuzzy
153 }
154}
155
156impl fmt::Display for SearchMode {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 match self {
159 SearchMode::Fuzzy => write!(f, "fuzzy"),
160 SearchMode::Exact => write!(f, "exact"),
161 SearchMode::Regex => write!(f, "regex"),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum SearchScope {
170 Title,
172 Content,
174 All,
176}
177
178impl Default for SearchScope {
179 fn default() -> Self {
180 SearchScope::All
181 }
182}
183
184impl fmt::Display for SearchScope {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 match self {
187 SearchScope::Title => write!(f, "title"),
188 SearchScope::Content => write!(f, "content"),
189 SearchScope::All => write!(f, "all"),
190 }
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SearchResult {
197 pub notes: Vec<NoteMatch>,
199 pub total_matches: usize,
201 pub truncated: bool,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct NoteMatch {
208 pub path: String,
210 pub title: String,
212 pub matched_field: MatchField,
214 #[serde(skip_serializing_if = "Option::is_none")]
216 pub snippet: Option<String>,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum MatchField {
223 Title,
224 Content,
225 Tag,
226 Path,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct BacklinkInfo {
234 pub note_title: String,
236 pub backlinks: Vec<LinkRef>,
238 pub forward_links: Vec<LinkRef>,
240 pub is_orphan: bool,
242 pub backlink_count: usize,
244 pub forward_link_count: usize,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct LinkRef {
251 pub source_title: String,
253 pub source_path: String,
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub display_text: Option<String>,
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub line_number: Option<usize>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct VaultGraph {
266 pub note_count: usize,
268 pub link_count: usize,
270 pub orphan_count: usize,
272 pub most_linked: Vec<(String, usize)>,
274 pub most_linking: Vec<(String, usize)>,
276 #[serde(default)]
278 pub tag_clusters: BTreeMap<String, BTreeSet<String>>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct TagAnalysis {
286 pub tags: BTreeMap<String, TagInfo>,
288 pub tag_count: usize,
290 pub top_tags: Vec<(String, usize)>,
292 pub singleton_tags: Vec<String>,
294 #[serde(default)]
296 pub hierarchy: BTreeMap<String, BTreeSet<String>>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct TagInfo {
302 pub tag: String,
304 pub count: usize,
306 pub notes: Vec<String>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct GitStatus {
315 pub is_repo: bool,
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub branch: Option<String>,
320 #[serde(default)]
322 pub uncommitted_changes: usize,
323 #[serde(default)]
325 pub staged: Vec<String>,
326 #[serde(default)]
328 pub modified: Vec<String>,
329 #[serde(default)]
331 pub untracked: Vec<String>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct GitLogEntry {
337 pub hash: String,
339 pub message: String,
341 pub author: String,
343 pub date: String,
345 #[serde(default)]
347 pub files_changed: Vec<String>,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct GitCommitResult {
353 pub success: bool,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub hash: Option<String>,
358 pub files_committed: usize,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub error: Option<String>,
363}
364
365pub struct ObsidianVault {
388 config: VaultConfig,
389 index: once_cell::sync::OnceCell<HashMap<String, String>>,
391}
392
393impl ObsidianVault {
394 pub fn new(config: VaultConfig) -> Self {
396 Self {
397 config,
398 index: once_cell::sync::OnceCell::new(),
399 }
400 }
401
402 pub fn config(&self) -> &VaultConfig {
404 &self.config
405 }
406
407 fn vault_root(&self) -> &Path {
409 &self.config.vault_path
410 }
411
412 pub fn read_note(&self, path_or_title: &str) -> Result<Note> {
418 let vault_root = self.vault_root();
419
420 let full_path = vault_root.join(path_or_title);
422 let full_path = if full_path.exists() {
423 full_path
424 } else {
425 let with_ext = vault_root.join(format!("{}.md", path_or_title));
427 if with_ext.exists() {
428 with_ext
429 } else {
430 let idx = self.get_or_build_index()?;
432 match idx.get(&path_or_title.to_lowercase()) {
433 Some(rel_path) => vault_root.join(rel_path),
434 None => bail!("Note not found: {}", path_or_title),
435 }
436 }
437 };
438
439 self.parse_note(&full_path, vault_root)
440 }
441
442 pub fn read_notes(&self, paths: &[&str]) -> Result<Vec<Note>> {
444 let mut notes = Vec::with_capacity(paths.len());
445 for path in paths {
446 notes.push(self.read_note(path)?);
447 }
448 Ok(notes)
449 }
450
451 pub fn list_notes(&self) -> Result<Vec<Note>> {
453 let vault_root = self.vault_root();
454 let mut notes = Vec::new();
455 self.walk_vault(vault_root, vault_root, 0, &mut notes)?;
456 Ok(notes)
457 }
458
459 pub fn search(&self, query: &str) -> Result<SearchResult> {
463 self.search_with_options(query, SearchMode::Fuzzy, SearchScope::All)
464 }
465
466 pub fn search_with_options(
468 &self,
469 query: &str,
470 mode: SearchMode,
471 scope: SearchScope,
472 ) -> Result<SearchResult> {
473 let notes = self.list_notes()?;
474 let query_lower = query.to_lowercase();
475 let max = self.config.max_results;
476
477 let regex = if mode == SearchMode::Regex {
478 Some(regex::Regex::new(&format!("(?i){}", query)).context("Invalid regex pattern")?)
479 } else {
480 None
481 };
482
483 let mut matches: Vec<NoteMatch> = Vec::new();
484 let mut total = 0;
485
486 for note in ¬es {
487 if total >= max {
488 let has_match = self.note_matches(note, &query_lower, mode, scope, regex.as_ref());
490 if has_match {
491 total += 1;
492 }
493 continue;
494 }
495
496 if scope == SearchScope::Title || scope == SearchScope::All {
498 if self.text_matches(¬e.title, &query_lower, mode, regex.as_ref()) {
499 total += 1;
500 matches.push(NoteMatch {
501 path: note.path.clone(),
502 title: note.title.clone(),
503 matched_field: MatchField::Title,
504 snippet: None,
505 });
506 continue;
507 }
508 }
509
510 if scope == SearchScope::All {
512 if self.text_matches(¬e.path, &query_lower, mode, regex.as_ref()) {
513 total += 1;
514 matches.push(NoteMatch {
515 path: note.path.clone(),
516 title: note.title.clone(),
517 matched_field: MatchField::Path,
518 snippet: None,
519 });
520 continue;
521 }
522 }
523
524 if scope == SearchScope::All {
526 for tag in ¬e.tags {
527 if self.text_matches(tag, &query_lower, mode, regex.as_ref()) {
528 total += 1;
529 matches.push(NoteMatch {
530 path: note.path.clone(),
531 title: note.title.clone(),
532 matched_field: MatchField::Tag,
533 snippet: Some(format!("#{}", tag)),
534 });
535 break;
536 }
537 }
538 if matches.len() > 0 && matches.last().unwrap().matched_field == MatchField::Tag {
539 continue;
540 }
541 }
542
543 if scope == SearchScope::Content || scope == SearchScope::All {
545 if self.text_matches(¬e.content, &query_lower, mode, regex.as_ref()) {
546 total += 1;
547 let snippet = self.extract_snippet(¬e.content, &query_lower, 200);
548 matches.push(NoteMatch {
549 path: note.path.clone(),
550 title: note.title.clone(),
551 matched_field: MatchField::Content,
552 snippet,
553 });
554 }
555 }
556 }
557
558 let truncated = matches.len() < total;
559
560 Ok(SearchResult {
561 notes: matches,
562 total_matches: total,
563 truncated,
564 })
565 }
566
567 pub fn search_by_tag(&self, tag: &str) -> Result<Vec<Note>> {
569 let tag_lower = tag.trim_start_matches('#').to_lowercase();
570 let notes = self.list_notes()?;
571 Ok(notes
572 .into_iter()
573 .filter(|n| n.tags.iter().any(|t| t.to_lowercase() == tag_lower))
574 .collect())
575 }
576
577 pub fn analyze_backlinks(&self, note_title: &str) -> Result<BacklinkInfo> {
581 let notes = self.list_notes()?;
582 let title_lower = note_title.to_lowercase();
583
584 let target = notes
586 .iter()
587 .find(|n| n.title.to_lowercase() == title_lower)
588 .context(format!("Note '{}' not found", note_title))?;
589
590 let forward_links: Vec<LinkRef> = target
592 .forward_links
593 .iter()
594 .map(|_link| LinkRef {
595 source_title: target.title.clone(),
596 source_path: target.path.clone(),
597 display_text: None,
598 line_number: None,
599 })
600 .collect();
601
602 let mut backlinks = Vec::new();
604 for note in ¬es {
605 if note.path == target.path {
606 continue;
607 }
608 let link_refs = self.find_links_to(note, &target.title);
610 backlinks.extend(link_refs);
611 }
612
613 let backlink_count = backlinks.len();
614 let forward_link_count = forward_links.len();
615 let is_orphan = backlink_count == 0 && forward_link_count == 0;
616
617 Ok(BacklinkInfo {
618 note_title: target.title.clone(),
619 backlinks,
620 forward_links,
621 is_orphan,
622 backlink_count,
623 forward_link_count,
624 })
625 }
626
627 pub fn analyze_vault_graph(&self) -> Result<VaultGraph> {
629 let notes = self.list_notes()?;
630 let note_count = notes.len();
631
632 let mut backlink_counts: HashMap<String, usize> = HashMap::new();
634 let mut forward_link_counts: HashMap<String, usize> = HashMap::new();
635 let mut link_total: usize = 0;
636 let mut linked_notes: HashSet<String> = HashSet::new();
637
638 for note in ¬es {
639 let fc = note.forward_links.len();
640 forward_link_counts.insert(note.title.clone(), fc);
641 link_total += fc;
642
643 if fc > 0 {
644 linked_notes.insert(note.title.clone());
645 }
646
647 for target in ¬e.forward_links {
648 *backlink_counts.entry(target.clone()).or_insert(0) += 1;
649 linked_notes.insert(target.clone());
650 }
651 }
652
653 let orphan_count = notes
655 .iter()
656 .filter(|n| {
657 *backlink_counts.get(&n.title).unwrap_or(&0) == 0
658 && *forward_link_counts.get(&n.title).unwrap_or(&0) == 0
659 })
660 .count();
661
662 let mut most_linked: Vec<(String, usize)> = backlink_counts.into_iter().collect();
664 most_linked.sort_by(|a, b| b.1.cmp(&a.1));
665 most_linked.truncate(20);
666
667 let mut most_linking: Vec<(String, usize)> = forward_link_counts.into_iter().collect();
669 most_linking.sort_by(|a, b| b.1.cmp(&a.1));
670 most_linking.truncate(20);
671
672 let mut tag_clusters: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
674 for note in ¬es {
675 let tags: Vec<&String> = note.tags.iter().collect();
676 for i in 0..tags.len() {
677 for j in (i + 1)..tags.len() {
678 tag_clusters
679 .entry(tags[i].clone())
680 .or_default()
681 .insert(tags[j].clone());
682 tag_clusters
683 .entry(tags[j].clone())
684 .or_default()
685 .insert(tags[i].clone());
686 }
687 }
688 }
689
690 Ok(VaultGraph {
691 note_count,
692 link_count: link_total,
693 orphan_count,
694 most_linked,
695 most_linking,
696 tag_clusters,
697 })
698 }
699
700 pub fn analyze_tags(&self) -> Result<TagAnalysis> {
704 let notes = self.list_notes()?;
705 let mut tag_map: BTreeMap<String, Vec<String>> = BTreeMap::new();
706
707 for note in ¬es {
708 for tag in ¬e.tags {
709 tag_map
710 .entry(tag.clone())
711 .or_default()
712 .push(note.title.clone());
713 }
714 }
715
716 let tag_count = tag_map.len();
717
718 let tags: BTreeMap<String, TagInfo> = tag_map
720 .iter()
721 .map(|(tag, note_titles)| {
722 (
723 tag.clone(),
724 TagInfo {
725 tag: tag.clone(),
726 count: note_titles.len(),
727 notes: note_titles.clone(),
728 },
729 )
730 })
731 .collect();
732
733 let mut top_tags: Vec<(String, usize)> = tag_map
735 .iter()
736 .map(|(tag, notes)| (tag.clone(), notes.len()))
737 .collect();
738 top_tags.sort_by(|a, b| b.1.cmp(&a.1));
739
740 let singleton_tags: Vec<String> = tag_map
742 .iter()
743 .filter(|(_, notes)| notes.len() == 1)
744 .map(|(tag, _)| tag.clone())
745 .collect();
746
747 let mut hierarchy: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
749 for tag in tag_map.keys() {
750 if let Some(slash_pos) = tag.find('/') {
751 let parent = &tag[..slash_pos];
752 hierarchy
753 .entry(parent.to_string())
754 .or_default()
755 .insert(tag.clone());
756 }
757 }
758
759 Ok(TagAnalysis {
760 tags,
761 tag_count,
762 top_tags,
763 singleton_tags,
764 hierarchy,
765 })
766 }
767
768 pub async fn git_status(&self) -> Result<GitStatus> {
772 let output = Command::new("git")
773 .args(["status", "--porcelain"])
774 .current_dir(self.vault_root())
775 .output()
776 .await
777 .context("Failed to run git status. Is git installed?")?;
778
779 if !output.status.success() {
780 return Ok(GitStatus {
781 is_repo: false,
782 branch: None,
783 uncommitted_changes: 0,
784 staged: vec![],
785 modified: vec![],
786 untracked: vec![],
787 });
788 }
789
790 let stdout = String::from_utf8_lossy(&output.stdout);
791 let mut staged = Vec::new();
792 let mut modified = Vec::new();
793 let mut untracked = Vec::new();
794
795 for line in stdout.lines() {
796 if line.len() < 4 {
797 continue;
798 }
799 let status = &line[..2];
800 let file = line[3..].to_string();
801
802 match status {
803 "?? " => untracked.push(file),
804 "A " | "M " | "R " => staged.push(file),
805 _ if status.starts_with(' ') => modified.push(file),
806 _ => {
807 modified.push(file);
809 }
810 }
811 }
812
813 let uncommitted = staged.len() + modified.len() + untracked.len();
814
815 let branch_output = Command::new("git")
817 .args(["rev-parse", "--abbrev-ref", "HEAD"])
818 .current_dir(self.vault_root())
819 .output()
820 .await
821 .context("Failed to get git branch")?;
822
823 let branch = String::from_utf8_lossy(&branch_output.stdout)
824 .trim()
825 .to_string();
826 let branch = if branch.is_empty() || branch == "HEAD" {
827 None
828 } else {
829 Some(branch)
830 };
831
832 Ok(GitStatus {
833 is_repo: true,
834 branch,
835 uncommitted_changes: uncommitted,
836 staged,
837 modified,
838 untracked,
839 })
840 }
841
842 pub async fn git_log(&self, max_entries: usize) -> Result<Vec<GitLogEntry>> {
844 let output = Command::new("git")
845 .args([
846 "log",
847 &format!("-{}", max_entries),
848 "--pretty=format:%h|%s|%an|%ai",
849 "--name-only",
850 ])
851 .current_dir(self.vault_root())
852 .output()
853 .await
854 .context("Failed to run git log")?;
855
856 if !output.status.success() {
857 bail!(
858 "git log failed: {}",
859 String::from_utf8_lossy(&output.stderr)
860 );
861 }
862
863 let stdout = String::from_utf8_lossy(&output.stdout);
864 let mut entries = Vec::new();
865 let mut current: Option<GitLogEntry> = None;
866
867 for line in stdout.lines() {
868 if line.is_empty() {
869 if let Some(entry) = current.take() {
870 entries.push(entry);
871 }
872 continue;
873 }
874
875 if let Some(_pipe_pos) = line.find('|') {
876 if let Some(entry) = current.take() {
878 entries.push(entry);
879 }
880
881 let parts: Vec<&str> = line.splitn(4, '|').collect();
882 if parts.len() >= 4 {
883 current = Some(GitLogEntry {
884 hash: parts[0].to_string(),
885 message: parts[1].to_string(),
886 author: parts[2].to_string(),
887 date: parts[3].to_string(),
888 files_changed: Vec::new(),
889 });
890 }
891 } else if let Some(ref mut entry) = current {
892 entry.files_changed.push(line.to_string());
894 }
895 }
896
897 if let Some(entry) = current.take() {
898 entries.push(entry);
899 }
900
901 Ok(entries)
902 }
903
904 pub async fn git_commit_all(&self, message: &str) -> Result<GitCommitResult> {
906 let add_output = Command::new("git")
908 .args(["add", "-A"])
909 .current_dir(self.vault_root())
910 .output()
911 .await
912 .context("Failed to run git add")?;
913
914 if !add_output.status.success() {
915 return Ok(GitCommitResult {
916 success: false,
917 hash: None,
918 files_committed: 0,
919 error: Some(String::from_utf8_lossy(&add_output.stderr).to_string()),
920 });
921 }
922
923 let diff_output = Command::new("git")
925 .args(["diff", "--cached", "--stat"])
926 .current_dir(self.vault_root())
927 .output()
928 .await
929 .context("Failed to check staged changes")?;
930
931 let diff_stdout = String::from_utf8_lossy(&diff_output.stdout);
932 if diff_stdout.trim().is_empty() {
933 return Ok(GitCommitResult {
934 success: true,
935 hash: None,
936 files_committed: 0,
937 error: Some("No changes to commit".to_string()),
938 });
939 }
940
941 let file_count = diff_stdout.lines().count().saturating_sub(1); let commit_output = Command::new("git")
946 .args(["commit", "-m", message])
947 .current_dir(self.vault_root())
948 .output()
949 .await
950 .context("Failed to run git commit")?;
951
952 if !commit_output.status.success() {
953 return Ok(GitCommitResult {
954 success: false,
955 hash: None,
956 files_committed: 0,
957 error: Some(String::from_utf8_lossy(&commit_output.stderr).to_string()),
958 });
959 }
960
961 let hash_output = Command::new("git")
963 .args(["rev-parse", "--short", "HEAD"])
964 .current_dir(self.vault_root())
965 .output()
966 .await
967 .context("Failed to get commit hash")?;
968
969 let hash = String::from_utf8_lossy(&hash_output.stdout).trim().to_string();
970
971 Ok(GitCommitResult {
972 success: true,
973 hash: Some(hash),
974 files_committed: file_count,
975 error: None,
976 })
977 }
978
979 pub async fn git_init(&self) -> Result<bool> {
981 let git_dir = self.vault_root().join(".git");
982 if git_dir.exists() {
983 return Ok(false);
984 }
985
986 let output = Command::new("git")
987 .args(["init"])
988 .current_dir(self.vault_root())
989 .output()
990 .await
991 .context("Failed to run git init")?;
992
993 if !output.status.success() {
994 bail!(
995 "git init failed: {}",
996 String::from_utf8_lossy(&output.stderr)
997 );
998 }
999
1000 tracing::info!("Initialized git repository in {}", self.vault_root().display());
1001 Ok(true)
1002 }
1003
1004 pub async fn create_gitignore(&self) -> Result<PathBuf> {
1006 let gitignore_path = self.vault_root().join(".gitignore");
1007
1008 let content = r#".obsidian/
1009.trash/
1010.DS_Store
1011*.swp
1012*.swo
1013*~
1014"#;
1015
1016 fs::write(&gitignore_path, content).context("Failed to write .gitignore")?;
1017 Ok(gitignore_path)
1018 }
1019
1020 fn build_index(&self) -> Result<HashMap<String, String>> {
1024 let notes = self.list_notes()?;
1025 let mut index = HashMap::with_capacity(notes.len());
1026
1027 for note in notes {
1028 index.insert(note.title.to_lowercase(), note.path.clone());
1030 }
1031
1032 Ok(index)
1033 }
1034
1035 fn get_or_build_index(&self) -> Result<&HashMap<String, String>> {
1037 self.index.get_or_try_init(|| self.build_index())
1038 }
1039
1040 fn walk_vault(
1042 &self,
1043 dir: &Path,
1044 vault_root: &Path,
1045 depth: usize,
1046 notes: &mut Vec<Note>,
1047 ) -> Result<()> {
1048 if depth > self.config.max_depth {
1049 return Ok(());
1050 }
1051
1052 let entries = fs::read_dir(dir)
1053 .with_context(|| format!("Failed to read directory: {}", dir.display()))?;
1054
1055 for entry in entries {
1056 let entry = entry?;
1057 let name = entry.file_name().to_string_lossy().to_string();
1058 let path = entry.path();
1059
1060 if !self.config.include_hidden && name.starts_with('.') {
1062 continue;
1063 }
1064
1065 if self.config.skip_dirs.iter().any(|d| *d == name) {
1067 continue;
1068 }
1069
1070 if path.is_dir() {
1071 self.walk_vault(&path, vault_root, depth + 1, notes)?;
1072 } else {
1073 let ext = path
1075 .extension()
1076 .and_then(|e| e.to_str())
1077 .unwrap_or("")
1078 .to_lowercase();
1079
1080 if self.config.extensions.contains(&ext) {
1081 match self.parse_note(&path, vault_root) {
1082 Ok(note) => notes.push(note),
1083 Err(e) => {
1084 tracing::debug!("Failed to parse {}: {}", path.display(), e);
1085 }
1086 }
1087 }
1088 }
1089 }
1090
1091 Ok(())
1092 }
1093
1094 fn parse_note(&self, path: &Path, vault_root: &Path) -> Result<Note> {
1096 let content = fs::read_to_string(path)
1097 .with_context(|| format!("Failed to read {}", path.display()))?;
1098
1099 let relative = path
1100 .strip_prefix(vault_root)
1101 .unwrap_or(path)
1102 .to_string_lossy()
1103 .to_string();
1104
1105 let title = path
1106 .file_stem()
1107 .and_then(|s| s.to_str())
1108 .unwrap_or("untitled")
1109 .to_string();
1110
1111 let tags = Self::extract_tags(&content);
1112 let forward_links = Self::extract_wikilinks(&content);
1113
1114 let metadata = fs::metadata(path).ok();
1115 let size_bytes = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
1116 let modified = metadata
1117 .and_then(|m| m.modified().ok())
1118 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1119 .map(|d| d.as_secs());
1120
1121 Ok(Note {
1122 path: relative,
1123 title,
1124 content,
1125 tags,
1126 forward_links,
1127 size_bytes,
1128 modified,
1129 })
1130 }
1131
1132 fn extract_tags(content: &str) -> BTreeSet<String> {
1140 let mut tags = BTreeSet::new();
1141
1142 for line in content.lines() {
1143 if line.trim().starts_with("```") {
1145 continue;
1146 }
1147
1148 let bytes = line.as_bytes();
1150 let mut pos = 0;
1151
1152 while pos < bytes.len() {
1153 if bytes[pos] != b'#' {
1154 pos += 1;
1155 continue;
1156 }
1157
1158 let hash_pos = pos;
1159 pos += 1;
1160
1161 if pos >= bytes.len() {
1164 continue;
1165 }
1166
1167 let next_ch = bytes[pos];
1168
1169 let prefix = &line[..hash_pos];
1172 if prefix.trim().is_empty() && (next_ch == b'#' || next_ch == b' ' || next_ch == b'\t') {
1173 continue;
1174 }
1175
1176 if next_ch == b' ' || next_ch == b'\t' || next_ch == b'\n' {
1178 continue;
1179 }
1180
1181 let tag: String = line[pos..]
1183 .chars()
1184 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c == '/')
1185 .collect();
1186
1187 let is_hex_color = (tag.len() == 3 || tag.len() == 6)
1190 && tag.chars().all(|c| c.is_ascii_hexdigit());
1191
1192 if tag.len() >= 2 && !is_hex_color {
1193 tags.insert(tag.to_lowercase());
1194 }
1195 }
1196 }
1197
1198 tags
1199 }
1200
1201 fn extract_wikilinks(content: &str) -> BTreeSet<String> {
1208 let mut links = BTreeSet::new();
1209 let mut remaining = content;
1210
1211 while let Some(start) = remaining.find("[[") {
1212 remaining = &remaining[start + 2..];
1213 if let Some(end) = remaining.find("]]") {
1214 let link_text = &remaining[..end];
1215
1216 let target = if let Some(pipe_pos) = link_text.find('|') {
1218 &link_text[..pipe_pos]
1219 } else {
1220 link_text
1221 };
1222
1223 let target = target.trim();
1224 if !target.is_empty() {
1225 links.insert(target.to_string());
1226 }
1227
1228 remaining = &remaining[end + 2..];
1229 } else {
1230 break;
1231 }
1232 }
1233
1234 links
1235 }
1236
1237 fn find_links_to(&self, note: &Note, target_title: &str) -> Vec<LinkRef> {
1239 let target_lower = target_title.to_lowercase();
1240 let mut refs = Vec::new();
1241
1242 for (line_num, line) in note.content.lines().enumerate() {
1243 let mut remaining = line;
1244 while let Some(start) = remaining.find("[[") {
1245 remaining = &remaining[start + 2..];
1246 if let Some(end) = remaining.find("]]") {
1247 let link_text = &remaining[..end];
1248 let target = if let Some(pipe_pos) = link_text.find('|') {
1249 &link_text[..pipe_pos]
1250 } else {
1251 link_text
1252 };
1253
1254 if target.trim().to_lowercase() == target_lower {
1255 let display = if link_text.contains('|') {
1256 let pipe_pos = link_text.find('|').unwrap();
1257 Some(link_text[pipe_pos + 1..].trim().to_string())
1258 } else {
1259 None
1260 };
1261
1262 refs.push(LinkRef {
1263 source_title: note.title.clone(),
1264 source_path: note.path.clone(),
1265 display_text: display,
1266 line_number: Some(line_num + 1),
1267 });
1268 }
1269
1270 remaining = &remaining[end + 2..];
1271 } else {
1272 break;
1273 }
1274 }
1275 }
1276
1277 refs
1278 }
1279
1280 fn text_matches(
1282 &self,
1283 text: &str,
1284 query_lower: &str,
1285 mode: SearchMode,
1286 regex: Option<®ex::Regex>,
1287 ) -> bool {
1288 match mode {
1289 SearchMode::Fuzzy => text.to_lowercase().contains(query_lower),
1290 SearchMode::Exact => text.to_lowercase() == *query_lower,
1291 SearchMode::Regex => regex.map(|r: ®ex::Regex| r.is_match(text)).unwrap_or(false),
1292 }
1293 }
1294
1295 fn note_matches(
1297 &self,
1298 note: &Note,
1299 query_lower: &str,
1300 mode: SearchMode,
1301 scope: SearchScope,
1302 regex: Option<®ex::Regex>,
1303 ) -> bool {
1304 match scope {
1305 SearchScope::Title => self.text_matches(¬e.title, query_lower, mode, regex),
1306 SearchScope::Content => self.text_matches(¬e.content, query_lower, mode, regex),
1307 SearchScope::All => {
1308 self.text_matches(¬e.title, query_lower, mode, regex)
1309 || self.text_matches(¬e.path, query_lower, mode, regex)
1310 || self.text_matches(¬e.content, query_lower, mode, regex)
1311 || note.tags.iter().any(|t| self.text_matches(t, query_lower, mode, regex))
1312 }
1313 }
1314 }
1315
1316 fn extract_snippet(&self, content: &str, query: &str, max_chars: usize) -> Option<String> {
1318 let content_lower = content.to_lowercase();
1319 let pos = content_lower.find(query)?;
1320
1321 let start = if pos > max_chars / 2 {
1322 let candidate = pos - max_chars / 2;
1324 content[..candidate]
1325 .rfind(' ')
1326 .map(|p| p + 1)
1327 .unwrap_or(candidate)
1328 } else {
1329 0
1330 };
1331
1332 let end = (pos + query.len() + max_chars / 2).min(content.len());
1333
1334 let mut snippet = String::new();
1335 if start > 0 {
1336 snippet.push_str("...");
1337 }
1338 snippet.push_str(&content[start..end]);
1339 if end < content.len() {
1340 snippet.push_str("...");
1341 }
1342
1343 Some(snippet)
1344 }
1345}
1346
1347impl fmt::Debug for ObsidianVault {
1348 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1349 f.debug_struct("ObsidianVault")
1350 .field("vault_path", &self.config.vault_path)
1351 .field("extensions", &self.config.extensions)
1352 .finish()
1353 }
1354}
1355
1356pub fn skill_instructions() -> String {
1364 let prompt = r#"# Obsidian Vault Skill
1365
1366You are running the **obsidian** skill. Your job is to help the user
1367manage, search, and analyze an Obsidian vault.
1368
1369## Capabilities
1370
1371### 1. Note Search and Reading
1372- Search notes by title, content, tag, or path (fuzzy, exact, or regex)
1373- Read individual notes or batches of notes
1374- List all notes in the vault
1375- Extract snippets around search matches
1376
1377### 2. Backlink and Link Analysis
1378- Analyze backlinks (which notes link TO a target note)
1379- Analyze forward links (which notes a source links TO)
1380- Detect orphan notes (no backlinks, no forward links)
1381- Generate a full vault graph with most-linked and most-linking notes
1382- Identify tag co-occurrence clusters
1383
1384### 3. Tag Analysis
1385- Extract all tags from the vault
1386- Show tag frequency and distribution
1387- Find singleton tags (used only once)
1388- Analyze hierarchical tags (e.g., `project/alpha` under `project`)
1389- Search notes by tag
1390
1391### 4. Git Version Control
1392- Check git status of the vault
1393- View git history for the vault
1394- Commit all changes with a message
1395- Initialize a git repository
1396- Create a .gitignore for Obsidian metadata
1397
1398## Workflow
1399
1400### For Searching Notes
14011. Determine the vault path (ask the user or infer from context)
14022. Use the search API or grep/ripgrep to find matching notes
14033. Read the matching notes
14044. Present results with titles, paths, and relevant snippets
1405
1406### For Backlink Analysis
14071. Identify the target note
14082. Use grep for `[[target]]` patterns across all notes
14093. Present the backlinks with source note, line number, and context
14104. Highlight orphan notes that might need linking
1411
1412### For Tag Analysis
14131. Scan all markdown files for `#tag` patterns
14142. Aggregate and count tag usage
14153. Present tag frequency, hierarchy, and co-occurrence
14164. Suggest tag cleanup if there are many singletons
1417
1418### For Git Operations
14191. Check if the vault is a git repo
14202. If not, offer to initialize one
14213. Show status, diff, or log as requested
14224. Commit changes with descriptive messages
1423
1424## Guidelines
1425
1426- **Respect vault structure** — don't modify `.obsidian/` configuration
1427- **Preserve wikilinks** — when editing notes, maintain `[[link]]` syntax
1428- **Tag consistency** — prefer lowercase tags, suggest normalizing mixed case
1429- **Git safety** — always show status before committing, never force push
1430- **Large vaults** — for vaults with 1000+ notes, use streaming/limited results
1431
1432## Common Commands
1433
1434### Search with ripgrep
1435```bash
1436rg -i "query" --type md /path/to/vault
1437```
1438
1439### Find backlinks to a note
1440```bash
1441rg '\[\[Note Title\]\]' --type md /path/to/vault
1442```
1443
1444### Find all tags
1445```bash
1446rg -o '#[a-zA-Z][a-zA-Z0-9_/-]+' --type md /path/to/vault | sort | uniq -c | sort -rn
1447```
1448
1449### Git status
1450```bash
1451cd /path/to/vault && git status --short
1452```
1453
1454### Commit all changes
1455```bash
1456cd /path/to/vault && git add -A && git commit -m "vault: update notes"
1457```
1458"#;
1459 prompt.to_string()
1460}
1461
1462#[cfg(test)]
1465mod tests {
1466 use super::*;
1467 use std::fs;
1468
1469 fn setup_test_vault() -> tempfile::TempDir {
1471 let tmp = tempfile::tempdir().unwrap();
1472 let root = tmp.path();
1473
1474 fs::write(
1476 root.join("index.md"),
1477 "# Welcome\n\nThis is the vault index.\n\n[[Project Alpha]] [[meeting-notes]]\n\n#status/active",
1478 )
1479 .unwrap();
1480
1481 fs::write(
1482 root.join("Project Alpha.md"),
1483 "# Project Alpha\n\nA major project.\n\nLinks: [[index]] [[Team]]\n\n#project #status/active",
1484 )
1485 .unwrap();
1486
1487 fs::write(
1488 root.join("meeting-notes.md"),
1489 "# Meeting Notes\n\nNotes from meetings.\n\n[[Project Alpha]] was discussed.\n\n#meeting #project",
1490 )
1491 .unwrap();
1492
1493 fs::create_dir_all(root.join("archive")).unwrap();
1494 fs::write(
1495 root.join("archive").join("old-note.md"),
1496 "# Old Note\n\nAn archived note.\n\n#archive #status/inactive",
1497 )
1498 .unwrap();
1499
1500 fs::create_dir_all(root.join(".obsidian")).unwrap();
1502 fs::write(root.join(".obsidian").join("app.json"), "{}").unwrap();
1503
1504 tmp
1505 }
1506
1507 fn make_vault(path: &Path) -> ObsidianVault {
1508 ObsidianVault::new(VaultConfig {
1509 vault_path: path.to_path_buf(),
1510 ..Default::default()
1511 })
1512 }
1513
1514 #[test]
1517 fn test_vault_config_default() {
1518 let config = VaultConfig::default();
1519 assert_eq!(config.extensions, vec!["md"]);
1520 assert!(!config.include_hidden);
1521 assert!(config.skip_dirs.contains(&".obsidian".to_string()));
1522 assert_eq!(config.max_depth, 10);
1523 assert_eq!(config.max_results, 200);
1524 }
1525
1526 #[test]
1527 fn test_vault_config_serde_roundtrip() {
1528 let config = VaultConfig {
1529 vault_path: PathBuf::from("/my/vault"),
1530 extensions: vec!["md".to_string(), "txt".to_string()],
1531 include_hidden: true,
1532 skip_dirs: vec![".git".to_string()],
1533 max_depth: 5,
1534 max_results: 100,
1535 };
1536
1537 let json = serde_json::to_string(&config).unwrap();
1538 let parsed: VaultConfig = serde_json::from_str(&json).unwrap();
1539 assert_eq!(parsed.vault_path, PathBuf::from("/my/vault"));
1540 assert_eq!(parsed.extensions.len(), 2);
1541 assert!(parsed.include_hidden);
1542 assert_eq!(parsed.max_depth, 5);
1543 }
1544
1545 #[test]
1548 fn test_list_notes() {
1549 let tmp = setup_test_vault();
1550 let vault = make_vault(tmp.path());
1551 let notes = vault.list_notes().unwrap();
1552
1553 assert_eq!(notes.len(), 4);
1554 let titles: Vec<&str> = notes.iter().map(|n| n.title.as_str()).collect();
1555 assert!(titles.contains(&"index"));
1556 assert!(titles.contains(&"Project Alpha"));
1557 assert!(titles.contains(&"meeting-notes"));
1558 assert!(titles.contains(&"old-note"));
1559 }
1560
1561 #[test]
1562 fn test_list_notes_skips_obsidian_dir() {
1563 let tmp = setup_test_vault();
1564 let vault = make_vault(tmp.path());
1565 let notes = vault.list_notes().unwrap();
1566
1567 for note in ¬es {
1569 assert!(!note.path.contains(".obsidian"), "Should skip .obsidian: {}", note.path);
1570 }
1571 }
1572
1573 #[test]
1574 fn test_read_note_by_path() {
1575 let tmp = setup_test_vault();
1576 let vault = make_vault(tmp.path());
1577
1578 let note = vault.read_note("index.md").unwrap();
1579 assert_eq!(note.title, "index");
1580 assert!(note.content.contains("Welcome"));
1581 }
1582
1583 #[test]
1584 fn test_read_note_by_title() {
1585 let tmp = setup_test_vault();
1586 let vault = make_vault(tmp.path());
1587
1588 let note = vault.read_note("Project Alpha").unwrap();
1589 assert_eq!(note.title, "Project Alpha");
1590 assert!(note.content.contains("major project"));
1591 }
1592
1593 #[test]
1594 fn test_read_note_not_found() {
1595 let tmp = setup_test_vault();
1596 let vault = make_vault(tmp.path());
1597
1598 assert!(vault.read_note("nonexistent").is_err());
1599 }
1600
1601 #[test]
1602 fn test_read_note_subdirectory() {
1603 let tmp = setup_test_vault();
1604 let vault = make_vault(tmp.path());
1605
1606 let note = vault.read_note("archive/old-note.md").unwrap();
1607 assert_eq!(note.title, "old-note");
1608 }
1609
1610 #[test]
1613 fn test_extract_tags_basic() {
1614 let content = "Some text #project and #status/active";
1615 let tags = ObsidianVault::extract_tags(content);
1616 assert!(tags.contains("project"));
1617 assert!(tags.contains("status/active"));
1618 }
1619
1620 #[test]
1621 fn test_extract_tags_ignores_headings() {
1622 let content = "# Heading One\n\n## Heading Two\n\nSome #tag here";
1623 let tags = ObsidianVault::extract_tags(content);
1624 assert!(!tags.contains("heading"));
1625 assert!(!tags.contains("heading-one"));
1626 assert!(tags.contains("tag"));
1627 }
1628
1629 #[test]
1630 fn test_extract_tags_ignores_hex_colors() {
1631 let content = "Color #fff and #aabbcc but #real-tag";
1632 let tags = ObsidianVault::extract_tags(content);
1633 assert!(!tags.contains("fff"));
1634 assert!(!tags.contains("aabbcc"));
1635 assert!(tags.contains("real-tag"));
1636 }
1637
1638 #[test]
1639 fn test_extract_tags_minimum_length() {
1640 let content = "#a #ab #my-tag";
1641 let tags = ObsidianVault::extract_tags(content);
1642 assert!(!tags.contains("a")); assert!(tags.contains("ab"));
1644 assert!(tags.contains("my-tag"));
1645 }
1646
1647 #[test]
1648 fn test_extract_tags_from_note() {
1649 let tmp = setup_test_vault();
1650 let vault = make_vault(tmp.path());
1651 let note = vault.read_note("index.md").unwrap();
1652
1653 assert!(note.tags.contains("status/active"));
1654 }
1655
1656 #[test]
1659 fn test_extract_wikilinks_basic() {
1660 let content = "See [[Target]] for details.";
1661 let links = ObsidianVault::extract_wikilinks(content);
1662 assert!(links.contains("Target"));
1663 }
1664
1665 #[test]
1666 fn test_extract_wikilinks_with_alias() {
1667 let content = "See [[Target|display text]] for details.";
1668 let links = ObsidianVault::extract_wikilinks(content);
1669 assert!(links.contains("Target"));
1670 assert!(!links.contains("display text"));
1671 }
1672
1673 #[test]
1674 fn test_extract_wikilinks_multiple() {
1675 let content = "[[Alpha]] and [[Beta]] and [[Gamma]]";
1676 let links = ObsidianVault::extract_wikilinks(content);
1677 assert_eq!(links.len(), 3);
1678 assert!(links.contains("Alpha"));
1679 assert!(links.contains("Beta"));
1680 assert!(links.contains("Gamma"));
1681 }
1682
1683 #[test]
1684 fn test_extract_wikilinks_empty() {
1685 let content = "No links here.";
1686 let links = ObsidianVault::extract_wikilinks(content);
1687 assert!(links.is_empty());
1688 }
1689
1690 #[test]
1691 fn test_forward_links_from_note() {
1692 let tmp = setup_test_vault();
1693 let vault = make_vault(tmp.path());
1694 let note = vault.read_note("index.md").unwrap();
1695
1696 assert!(note.forward_links.contains("Project Alpha"));
1697 assert!(note.forward_links.contains("meeting-notes"));
1698 }
1699
1700 #[test]
1703 fn test_search_fuzzy() {
1704 let tmp = setup_test_vault();
1705 let vault = make_vault(tmp.path());
1706
1707 let results = vault.search("project").unwrap();
1708 assert!(results.total_matches >= 2); }
1710
1711 #[test]
1712 fn test_search_by_tag() {
1713 let tmp = setup_test_vault();
1714 let vault = make_vault(tmp.path());
1715
1716 let notes = vault.search_by_tag("project").unwrap();
1717 assert!(notes.len() >= 1);
1718 assert!(notes.iter().any(|n| n.title == "Project Alpha"));
1719 }
1720
1721 #[test]
1722 fn test_search_by_tag_with_hash() {
1723 let tmp = setup_test_vault();
1724 let vault = make_vault(tmp.path());
1725
1726 let notes = vault.search_by_tag("#project").unwrap();
1727 assert!(notes.len() >= 1);
1728 }
1729
1730 #[test]
1731 fn test_search_title_only() {
1732 let tmp = setup_test_vault();
1733 let vault = make_vault(tmp.path());
1734
1735 let results = vault
1736 .search_with_options("alpha", SearchMode::Fuzzy, SearchScope::Title)
1737 .unwrap();
1738 assert!(results.total_matches >= 1);
1739 assert!(results.notes.iter().any(|m| m.matched_field == MatchField::Title));
1740 }
1741
1742 #[test]
1743 fn test_search_no_results() {
1744 let tmp = setup_test_vault();
1745 let vault = make_vault(tmp.path());
1746
1747 let results = vault.search("zzzznonexistent").unwrap();
1748 assert_eq!(results.total_matches, 0);
1749 }
1750
1751 #[test]
1752 fn test_search_truncation() {
1753 let tmp = tempfile::tempdir().unwrap();
1754 let root = tmp.path();
1755
1756 for i in 0..5 {
1758 fs::write(root.join(format!("note{}.md", i)), format!("Find me matchtest {}", i)).unwrap();
1759 }
1760
1761 let vault = ObsidianVault::new(VaultConfig {
1762 vault_path: root.to_path_buf(),
1763 max_results: 3,
1764 ..Default::default()
1765 });
1766
1767 let results = vault.search("matchtest").unwrap();
1768 assert_eq!(results.notes.len(), 3);
1769 assert!(results.truncated);
1770 assert_eq!(results.total_matches, 5);
1771 }
1772
1773 #[test]
1776 fn test_analyze_backlinks() {
1777 let tmp = setup_test_vault();
1778 let vault = make_vault(tmp.path());
1779
1780 let info = vault.analyze_backlinks("Project Alpha").unwrap();
1781 assert_eq!(info.note_title, "Project Alpha");
1782 assert!(info.backlink_count >= 1); assert!(info.forward_link_count >= 2); assert!(!info.is_orphan);
1785 }
1786
1787 #[test]
1788 fn test_analyze_backlinks_orphan() {
1789 let tmp = setup_test_vault();
1790 let vault = make_vault(tmp.path());
1791
1792 let info = vault.analyze_backlinks("old-note").unwrap();
1794 assert!(info.is_orphan);
1795 assert_eq!(info.backlink_count, 0);
1796 }
1797
1798 #[test]
1799 fn test_analyze_backlinks_not_found() {
1800 let tmp = setup_test_vault();
1801 let vault = make_vault(tmp.path());
1802
1803 assert!(vault.analyze_backlinks("nonexistent").is_err());
1804 }
1805
1806 #[test]
1807 fn test_find_links_to_with_line_numbers() {
1808 let tmp = setup_test_vault();
1809 let vault = make_vault(tmp.path());
1810
1811 let info = vault.analyze_backlinks("Project Alpha").unwrap();
1812 for bl in &info.backlinks {
1814 assert!(bl.line_number.is_some());
1815 assert!(bl.line_number.unwrap() > 0);
1816 }
1817 }
1818
1819 #[test]
1822 fn test_analyze_vault_graph() {
1823 let tmp = setup_test_vault();
1824 let vault = make_vault(tmp.path());
1825
1826 let graph = vault.analyze_vault_graph().unwrap();
1827 assert_eq!(graph.note_count, 4);
1828 assert!(graph.link_count > 0);
1829 assert!(graph.orphan_count >= 1); assert!(!graph.most_linked.is_empty());
1831 assert!(!graph.most_linking.is_empty());
1832 }
1833
1834 #[test]
1835 fn test_vault_graph_most_linked() {
1836 let tmp = setup_test_vault();
1837 let vault = make_vault(tmp.path());
1838
1839 let graph = vault.analyze_vault_graph().unwrap();
1840 assert!(graph
1842 .most_linked
1843 .iter()
1844 .any(|(title, _)| title == "Project Alpha"));
1845 }
1846
1847 #[test]
1850 fn test_analyze_tags() {
1851 let tmp = setup_test_vault();
1852 let vault = make_vault(tmp.path());
1853
1854 let analysis = vault.analyze_tags().unwrap();
1855 assert!(analysis.tag_count >= 4);
1856 assert!(!analysis.top_tags.is_empty());
1857 assert!(analysis.tags.contains_key(&"project".to_string()));
1858 assert!(analysis.tags.contains_key(&"meeting".to_string()));
1859 assert!(analysis.tags.contains_key(&"status/active".to_string()));
1860 }
1861
1862 #[test]
1863 fn test_tag_hierarchy() {
1864 let tmp = setup_test_vault();
1865 let vault = make_vault(tmp.path());
1866
1867 let analysis = vault.analyze_tags().unwrap();
1868 assert!(analysis.hierarchy.contains_key(&"status".to_string()));
1869 let children = &analysis.hierarchy["status"];
1870 assert!(children.contains(&"status/active".to_string()));
1871 assert!(children.contains(&"status/inactive".to_string()));
1872 }
1873
1874 #[test]
1875 fn test_singleton_tags() {
1876 let tmp = setup_test_vault();
1877 let vault = make_vault(tmp.path());
1878
1879 let analysis = vault.analyze_tags().unwrap();
1880 assert!(analysis.singleton_tags.contains(&"archive".to_string()));
1882 }
1883
1884 #[test]
1887 fn test_note_preview() {
1888 let note = Note {
1889 path: "test.md".to_string(),
1890 title: "test".to_string(),
1891 content: "Some content that is long enough to need truncation at some point".to_string(),
1892 tags: BTreeSet::new(),
1893 forward_links: BTreeSet::new(),
1894 size_bytes: 100,
1895 modified: None,
1896 };
1897
1898 let preview = note.preview(20);
1899 assert!(preview.len() <= 20);
1900 }
1901
1902 #[test]
1903 fn test_note_preview_skips_frontmatter() {
1904 let note = Note {
1905 path: "test.md".to_string(),
1906 title: "test".to_string(),
1907 content: "---\ntitle: Test\ndate: 2024-01-01\n---\nActual content here".to_string(),
1908 tags: BTreeSet::new(),
1909 forward_links: BTreeSet::new(),
1910 size_bytes: 100,
1911 modified: None,
1912 };
1913
1914 let preview = note.preview(200);
1915 assert!(preview.starts_with("Actual content"));
1916 }
1917
1918 #[test]
1921 fn test_search_mode_display() {
1922 assert_eq!(format!("{}", SearchMode::Fuzzy), "fuzzy");
1923 assert_eq!(format!("{}", SearchMode::Exact), "exact");
1924 assert_eq!(format!("{}", SearchMode::Regex), "regex");
1925 }
1926
1927 #[test]
1928 fn test_search_scope_display() {
1929 assert_eq!(format!("{}", SearchScope::Title), "title");
1930 assert_eq!(format!("{}", SearchScope::Content), "content");
1931 assert_eq!(format!("{}", SearchScope::All), "all");
1932 }
1933
1934 #[test]
1937 fn test_search_regex() {
1938 let tmp = setup_test_vault();
1939 let vault = make_vault(tmp.path());
1940
1941 let results = vault
1942 .search_with_options(
1943 "project|meeting",
1944 SearchMode::Regex,
1945 SearchScope::Title,
1946 )
1947 .unwrap();
1948 assert!(results.total_matches >= 2);
1949 }
1950
1951 #[test]
1954 fn test_skill_instructions() {
1955 let instructions = skill_instructions();
1956 assert!(instructions.contains("Obsidian Vault Skill"));
1957 assert!(instructions.contains("Note Search"));
1958 assert!(instructions.contains("Backlink"));
1959 assert!(instructions.contains("Git Version Control"));
1960 }
1961
1962 #[test]
1965 fn test_empty_vault() {
1966 let tmp = tempfile::tempdir().unwrap();
1967 let vault = make_vault(tmp.path());
1968
1969 let notes = vault.list_notes().unwrap();
1970 assert!(notes.is_empty());
1971
1972 let results = vault.search("anything").unwrap();
1973 assert_eq!(results.total_matches, 0);
1974
1975 let analysis = vault.analyze_tags().unwrap();
1976 assert_eq!(analysis.tag_count, 0);
1977 }
1978
1979 #[test]
1982 fn test_debug_format() {
1983 let tmp = setup_test_vault();
1984 let vault = make_vault(tmp.path());
1985 let debug = format!("{:?}", vault);
1986 assert!(debug.contains("ObsidianVault"));
1987 }
1988
1989 #[test]
1992 fn test_extract_snippet() {
1993 let tmp = setup_test_vault();
1994 let vault = make_vault(tmp.path());
1995
1996 let note = vault.read_note("meeting-notes.md").unwrap();
1997 let snippet = vault.extract_snippet(¬e.content, "discussed", 50);
1998 assert!(snippet.is_some());
1999 let s = snippet.unwrap();
2000 assert!(s.contains("discussed"));
2001 }
2002
2003 #[test]
2006 fn test_note_serde_roundtrip() {
2007 let note = Note {
2008 path: "test/note.md".to_string(),
2009 title: "note".to_string(),
2010 content: "Content with [[link]] and #tag".to_string(),
2011 tags: {
2012 let mut s = BTreeSet::new();
2013 s.insert("tag".to_string());
2014 s
2015 },
2016 forward_links: {
2017 let mut s = BTreeSet::new();
2018 s.insert("link".to_string());
2019 s
2020 },
2021 size_bytes: 30,
2022 modified: Some(1700000000),
2023 };
2024
2025 let json = serde_json::to_string(¬e).unwrap();
2026 let parsed: Note = serde_json::from_str(&json).unwrap();
2027 assert_eq!(parsed.title, note.title);
2028 assert_eq!(parsed.tags, note.tags);
2029 assert_eq!(parsed.forward_links, note.forward_links);
2030 }
2031
2032 #[test]
2033 fn test_backlink_info_serde_roundtrip() {
2034 let info = BacklinkInfo {
2035 note_title: "Target".to_string(),
2036 backlinks: vec![LinkRef {
2037 source_title: "Source".to_string(),
2038 source_path: "source.md".to_string(),
2039 display_text: Some("click here".to_string()),
2040 line_number: Some(5),
2041 }],
2042 forward_links: vec![],
2043 is_orphan: false,
2044 backlink_count: 1,
2045 forward_link_count: 0,
2046 };
2047
2048 let json = serde_json::to_string(&info).unwrap();
2049 let parsed: BacklinkInfo = serde_json::from_str(&json).unwrap();
2050 assert_eq!(parsed.note_title, "Target");
2051 assert_eq!(parsed.backlinks.len(), 1);
2052 assert_eq!(parsed.backlinks[0].line_number, Some(5));
2053 }
2054
2055 #[test]
2056 fn test_vault_graph_serde_roundtrip() {
2057 let graph = VaultGraph {
2058 note_count: 10,
2059 link_count: 25,
2060 orphan_count: 3,
2061 most_linked: vec![("Alpha".to_string(), 5), ("Beta".to_string(), 3)],
2062 most_linking: vec![("Index".to_string(), 10)],
2063 tag_clusters: BTreeMap::new(),
2064 };
2065
2066 let json = serde_json::to_string(&graph).unwrap();
2067 let parsed: VaultGraph = serde_json::from_str(&json).unwrap();
2068 assert_eq!(parsed.note_count, 10);
2069 assert_eq!(parsed.most_linked.len(), 2);
2070 }
2071
2072 #[test]
2073 fn test_tag_analysis_serde_roundtrip() {
2074 let analysis = TagAnalysis {
2075 tags: BTreeMap::new(),
2076 tag_count: 5,
2077 top_tags: vec![("rust".to_string(), 10)],
2078 singleton_tags: vec!["unique".to_string()],
2079 hierarchy: BTreeMap::new(),
2080 };
2081
2082 let json = serde_json::to_string(&analysis).unwrap();
2083 let parsed: TagAnalysis = serde_json::from_str(&json).unwrap();
2084 assert_eq!(parsed.tag_count, 5);
2085 assert_eq!(parsed.top_tags.len(), 1);
2086 }
2087
2088 #[test]
2089 fn test_git_status_serde_roundtrip() {
2090 let status = GitStatus {
2091 is_repo: true,
2092 branch: Some("main".to_string()),
2093 uncommitted_changes: 3,
2094 staged: vec!["a.md".to_string()],
2095 modified: vec!["b.md".to_string()],
2096 untracked: vec!["c.md".to_string()],
2097 };
2098
2099 let json = serde_json::to_string(&status).unwrap();
2100 let parsed: GitStatus = serde_json::from_str(&json).unwrap();
2101 assert!(parsed.is_repo);
2102 assert_eq!(parsed.branch, Some("main".to_string()));
2103 assert_eq!(parsed.staged.len(), 1);
2104 }
2105
2106 #[test]
2107 fn test_git_commit_result_serde_roundtrip() {
2108 let result = GitCommitResult {
2109 success: true,
2110 hash: Some("abc1234".to_string()),
2111 files_committed: 5,
2112 error: None,
2113 };
2114
2115 let json = serde_json::to_string(&result).unwrap();
2116 let parsed: GitCommitResult = serde_json::from_str(&json).unwrap();
2117 assert!(parsed.success);
2118 assert_eq!(parsed.hash, Some("abc1234".to_string()));
2119 }
2120}