Skip to main content

oxi/skills/
obsidian.rs

1//! Obsidian vault management skill for oxi
2//!
3//! Provides tools for working with Obsidian vaults programmatically:
4//! - Search and read notes (by title, tag, path, or full-text)
5//! - Analyze backlinks and forward links between notes
6//! - Extract and query tags across the vault
7//! - Git version control integration for vault backups and history
8//!
9//! This module does NOT depend on the Obsidian API or any external service.
10//! It operates directly on the filesystem, reading Markdown files and
11//! parsing wikilinks (`[[note-name]]`) and tags (`#tag`) from content.
12//!
13//! The module provides both:
14//! - An [`ObsidianVault`] struct for programmatic vault access
15//! - A [`skill_instructions`] function that produces system-prompt content
16//!   for the LLM-driven Obsidian workflow
17
18use 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// ── Configuration ────────────────────────────────────────────────────
27
28/// Configuration for connecting to an Obsidian vault.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct VaultConfig {
31    /// Path to the Obsidian vault root directory.
32    pub vault_path: PathBuf,
33
34    /// File extensions to treat as notes (default: `["md"]`).
35    #[serde(default = "default_extensions")]
36    pub extensions: Vec<String>,
37
38    /// Whether to include hidden directories (starting with `.`).
39    #[serde(default)]
40    pub include_hidden: bool,
41
42    /// Directories to skip when scanning the vault.
43    #[serde(default = "default_skip_dirs")]
44    pub skip_dirs: Vec<String>,
45
46    /// Maximum depth for directory traversal (default: 10).
47    #[serde(default = "default_max_depth")]
48    pub max_depth: usize,
49
50    /// Maximum number of notes to return in a single query (default: 200).
51    #[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// ── Note types ───────────────────────────────────────────────────────
90
91/// A single Obsidian note.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Note {
94    /// Relative path from the vault root.
95    pub path: String,
96    /// Note title (filename without extension).
97    pub title: String,
98    /// Full text content.
99    pub content: String,
100    /// Tags extracted from the note body (`#tag`).
101    #[serde(default)]
102    pub tags: BTreeSet<String>,
103    /// Forward links — notes this note links TO (`[[target]]`).
104    #[serde(default)]
105    pub forward_links: BTreeSet<String>,
106    /// File size in bytes.
107    pub size_bytes: u64,
108    /// Last modified timestamp (seconds since epoch).
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub modified: Option<u64>,
111}
112
113impl Note {
114    /// First non-empty, non-frontmatter line as a preview.
115    pub fn preview(&self, max_chars: usize) -> &str {
116        let content = if let Some(yaml_end) = self.content.find("\n---\n") {
117            // Skip YAML frontmatter
118            &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            // Try to cut at a word boundary
128            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// ── Search types ─────────────────────────────────────────────────────
137
138/// How to match search queries.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum SearchMode {
142    /// Case-insensitive substring match.
143    Fuzzy,
144    /// Exact match (case-insensitive).
145    Exact,
146    /// Regular expression match.
147    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/// What part of a note to search.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum SearchScope {
170    /// Search note titles only.
171    Title,
172    /// Search note content only.
173    Content,
174    /// Search both title and content.
175    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/// Result of a vault search.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct SearchResult {
197    /// Notes matching the query.
198    pub notes: Vec<NoteMatch>,
199    /// Total number of matches (may exceed notes.len() if capped).
200    pub total_matches: usize,
201    /// Whether results were truncated by `max_results`.
202    pub truncated: bool,
203}
204
205/// A single note match.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct NoteMatch {
208    /// Relative path of the matching note.
209    pub path: String,
210    /// Note title.
211    pub title: String,
212    /// Which field matched.
213    pub matched_field: MatchField,
214    /// Matching text snippet (up to 200 chars around the match).
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub snippet: Option<String>,
217}
218
219/// Which field matched.
220#[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// ── Link analysis types ──────────────────────────────────────────────
230
231/// Result of backlink analysis for a single note.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct BacklinkInfo {
234    /// The target note being analyzed.
235    pub note_title: String,
236    /// Notes that link TO this note.
237    pub backlinks: Vec<LinkRef>,
238    /// Notes this note links TO.
239    pub forward_links: Vec<LinkRef>,
240    /// Orphan status — this note has no backlinks.
241    pub is_orphan: bool,
242    /// Number of backlinks.
243    pub backlink_count: usize,
244    /// Number of forward links.
245    pub forward_link_count: usize,
246}
247
248/// A reference from one note to another.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct LinkRef {
251    /// Title of the source note.
252    pub source_title: String,
253    /// Relative path of the source note.
254    pub source_path: String,
255    /// Display text of the link (if different from target).
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub display_text: Option<String>,
258    /// Line number where the link appears (1-based, if known).
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub line_number: Option<usize>,
261}
262
263/// Full vault graph.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct VaultGraph {
266    /// Number of notes in the vault.
267    pub note_count: usize,
268    /// Number of unique links between notes.
269    pub link_count: usize,
270    /// Number of orphan notes (no backlinks, no forward links).
271    pub orphan_count: usize,
272    /// Notes sorted by backlink count (most linked first).
273    pub most_linked: Vec<(String, usize)>,
274    /// Notes sorted by forward link count (most outgoing links).
275    pub most_linking: Vec<(String, usize)>,
276    /// Tag co-occurrence: tags that appear together in notes.
277    #[serde(default)]
278    pub tag_clusters: BTreeMap<String, BTreeSet<String>>,
279}
280
281// ── Tag analysis types ───────────────────────────────────────────────
282
283/// Result of tag analysis across the vault.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct TagAnalysis {
286    /// All unique tags found in the vault.
287    pub tags: BTreeMap<String, TagInfo>,
288    /// Total number of unique tags.
289    pub tag_count: usize,
290    /// Tags sorted by frequency (most used first).
291    pub top_tags: Vec<(String, usize)>,
292    /// Tags only used once.
293    pub singleton_tags: Vec<String>,
294    /// Hierarchical tag breakdown (e.g., "project/alpha" under "project").
295    #[serde(default)]
296    pub hierarchy: BTreeMap<String, BTreeSet<String>>,
297}
298
299/// Information about a single tag.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct TagInfo {
302    /// The tag string (without `#`).
303    pub tag: String,
304    /// Number of notes using this tag.
305    pub count: usize,
306    /// Notes that have this tag.
307    pub notes: Vec<String>,
308}
309
310// ── Git integration types ────────────────────────────────────────────
311
312/// Result of a git status check on the vault.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct GitStatus {
315    /// Whether the vault is a git repository.
316    pub is_repo: bool,
317    /// Current branch name.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub branch: Option<String>,
320    /// Number of uncommitted changes.
321    #[serde(default)]
322    pub uncommitted_changes: usize,
323    /// Staged files.
324    #[serde(default)]
325    pub staged: Vec<String>,
326    /// Modified (unstaged) files.
327    #[serde(default)]
328    pub modified: Vec<String>,
329    /// Untracked files.
330    #[serde(default)]
331    pub untracked: Vec<String>,
332}
333
334/// Result of a git log query.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct GitLogEntry {
337    /// Commit hash (short).
338    pub hash: String,
339    /// Commit message.
340    pub message: String,
341    /// Author.
342    pub author: String,
343    /// Date string.
344    pub date: String,
345    /// Files changed in this commit.
346    #[serde(default)]
347    pub files_changed: Vec<String>,
348}
349
350/// Result of a git commit operation.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct GitCommitResult {
353    /// Whether the commit succeeded.
354    pub success: bool,
355    /// Commit hash (short).
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub hash: Option<String>,
358    /// Number of files committed.
359    pub files_committed: usize,
360    /// Error message if failed.
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub error: Option<String>,
363}
364
365// ── Main vault struct ────────────────────────────────────────────────
366
367/// Provides access to an Obsidian vault on the filesystem.
368///
369/// # Example
370///
371/// ```rust,ignore
372/// use oxi::skills::obsidian::{ObsidianVault, VaultConfig};
373/// use std::path::PathBuf;
374///
375/// let config = VaultConfig {
376///     vault_path: PathBuf::from("/path/to/vault"),
377///     ..Default::default()
378/// };
379/// let vault = ObsidianVault::new(config);
380///
381/// // Search notes
382/// let results = vault.search("meeting notes").unwrap();
383///
384/// // Analyze backlinks
385/// let backlinks = vault.analyze_backlinks("Project Alpha").unwrap();
386/// ```
387pub struct ObsidianVault {
388    config: VaultConfig,
389    /// Lazily loaded note index: title → relative path.
390    index: once_cell::sync::OnceCell<HashMap<String, String>>,
391}
392
393impl ObsidianVault {
394    /// Create a new vault accessor with the given configuration.
395    pub fn new(config: VaultConfig) -> Self {
396        Self {
397            config,
398            index: once_cell::sync::OnceCell::new(),
399        }
400    }
401
402    /// Access the vault configuration.
403    pub fn config(&self) -> &VaultConfig {
404        &self.config
405    }
406
407    /// Resolve the vault root as an absolute path.
408    fn vault_root(&self) -> &Path {
409        &self.config.vault_path
410    }
411
412    // ── Note reading ────────────────────────────────────────────────
413
414    /// Read a single note by its relative path or title.
415    ///
416    /// Tries path first, then falls back to title lookup.
417    pub fn read_note(&self, path_or_title: &str) -> Result<Note> {
418        let vault_root = self.vault_root();
419
420        // Try as path first
421        let full_path = vault_root.join(path_or_title);
422        let full_path = if full_path.exists() {
423            full_path
424        } else {
425            // Try adding .md extension
426            let with_ext = vault_root.join(format!("{}.md", path_or_title));
427            if with_ext.exists() {
428                with_ext
429            } else {
430                // Try title lookup
431                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    /// Read multiple notes by their relative paths.
443    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    /// List all notes in the vault.
452    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    // ── Search ──────────────────────────────────────────────────────
460
461    /// Search notes by query string.
462    pub fn search(&self, query: &str) -> Result<SearchResult> {
463        self.search_with_options(query, SearchMode::Fuzzy, SearchScope::All)
464    }
465
466    /// Search notes with explicit mode and scope.
467    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 &notes {
487            if total >= max {
488                // Keep counting but don't collect more
489                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            // Check title
497            if scope == SearchScope::Title || scope == SearchScope::All {
498                if self.text_matches(&note.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            // Check path
511            if scope == SearchScope::All {
512                if self.text_matches(&note.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            // Check tags
525            if scope == SearchScope::All {
526                for tag in &note.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            // Check content
544            if scope == SearchScope::Content || scope == SearchScope::All {
545                if self.text_matches(&note.content, &query_lower, mode, regex.as_ref()) {
546                    total += 1;
547                    let snippet = self.extract_snippet(&note.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    /// Search notes by tag.
568    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    // ── Backlink analysis ───────────────────────────────────────────
578
579    /// Analyze backlinks for a specific note.
580    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        // Find the target note
585        let target = notes
586            .iter()
587            .find(|n| n.title.to_lowercase() == title_lower)
588            .context(format!("Note '{}' not found", note_title))?;
589
590        // Collect forward links (from target to others)
591        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        // Collect backlinks (from other notes to target)
603        let mut backlinks = Vec::new();
604        for note in &notes {
605            if note.path == target.path {
606                continue;
607            }
608            // Check if this note links to the target
609            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    /// Analyze backlinks for all notes in the vault (full graph).
628    pub fn analyze_vault_graph(&self) -> Result<VaultGraph> {
629        let notes = self.list_notes()?;
630        let note_count = notes.len();
631
632        // Build title → backlink count map
633        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 &notes {
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 &note.forward_links {
648                *backlink_counts.entry(target.clone()).or_insert(0) += 1;
649                linked_notes.insert(target.clone());
650            }
651        }
652
653        // Orphans: notes with no backlinks AND no forward links
654        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        // Most linked (by backlink count)
663        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        // Most linking (by forward link count)
668        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        // Tag clusters: co-occurring tags
673        let mut tag_clusters: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
674        for note in &notes {
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    // ── Tag analysis ────────────────────────────────────────────────
701
702    /// Analyze all tags in the vault.
703    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 &notes {
708            for tag in &note.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        // Build TagInfo map
719        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        // Top tags sorted by frequency
734        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        // Singleton tags
741        let singleton_tags: Vec<String> = tag_map
742            .iter()
743            .filter(|(_, notes)| notes.len() == 1)
744            .map(|(tag, _)| tag.clone())
745            .collect();
746
747        // Hierarchical tags (e.g., "project/alpha" → "project" parent)
748        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    // ── Git integration ─────────────────────────────────────────────
769
770    /// Check the git status of the vault.
771    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                    // Both staged and unstaged changes
808                    modified.push(file);
809                }
810            }
811        }
812
813        let uncommitted = staged.len() + modified.len() + untracked.len();
814
815        // Get branch name
816        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    /// Get recent git log entries for the vault.
843    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                // New commit line
877                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                // File name line
893                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    /// Stage all changes and create a commit.
905    pub async fn git_commit_all(&self, message: &str) -> Result<GitCommitResult> {
906        // Stage everything
907        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        // Check if there are changes to commit
924        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        // Count files
942        let file_count = diff_stdout.lines().count().saturating_sub(1); // Last line is summary
943
944        // Commit
945        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        // Get the commit hash
962        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    /// Initialize a git repository in the vault if one doesn't exist.
980    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    /// Create a .gitignore for the vault (ignoring Obsidian metadata).
1005    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    // ── Internal helpers ────────────────────────────────────────────
1021
1022    /// Build the note index (title → relative path).
1023    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            // Lowercase title for case-insensitive lookup
1029            index.insert(note.title.to_lowercase(), note.path.clone());
1030        }
1031
1032        Ok(index)
1033    }
1034
1035    /// Get or build the note index.
1036    fn get_or_build_index(&self) -> Result<&HashMap<String, String>> {
1037        self.index.get_or_try_init(|| self.build_index())
1038    }
1039
1040    /// Recursively walk the vault directory and collect notes.
1041    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            // Skip hidden files/dirs
1061            if !self.config.include_hidden && name.starts_with('.') {
1062                continue;
1063            }
1064
1065            // Skip configured directories
1066            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                // Check extension
1074                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    /// Parse a single note file into a [`Note`].
1095    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    /// Extract `#tag` occurrences from note content.
1133    ///
1134    /// Handles:
1135    /// - `#tag`
1136    /// - `#nested/tag`
1137    /// - Ignores headings (`# Heading`) by requiring no space before text
1138    /// - Ignores hex colors (`#fff`, `#aabbcc`)
1139    fn extract_tags(content: &str) -> BTreeSet<String> {
1140        let mut tags = BTreeSet::new();
1141
1142        for line in content.lines() {
1143            // Skip code blocks
1144            if line.trim().starts_with("```") {
1145                continue;
1146            }
1147
1148            // Scan for '#' characters that represent tags
1149            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                // Check if this is a heading: '#' at start of line (or after whitespace)
1162                // followed by a space or another '#'
1163                if pos >= bytes.len() {
1164                    continue;
1165                }
1166
1167                let next_ch = bytes[pos];
1168
1169                // Heading detection: if all chars before '#' are whitespace AND
1170                // next char is '#' or whitespace, it's a heading marker
1171                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                // Also skip if followed by whitespace (not a tag)
1177                if next_ch == b' ' || next_ch == b'\t' || next_ch == b'\n' {
1178                    continue;
1179                }
1180
1181                // Extract the tag text after '#'
1182                let tag: String = line[pos..]
1183                    .chars()
1184                    .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c == '/')
1185                    .collect();
1186
1187                // Tags need at least 2 chars and shouldn't be hex colors
1188                // Hex colors are exactly 3 or 6 hex chars (e.g., #fff, #aabbcc)
1189                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    /// Extract `[[wikilink]]` references from note content.
1202    ///
1203    /// Handles:
1204    /// - `[[Note Name]]`
1205    /// - `[[Note Name|Display Text]]`
1206    /// - `[[note-name]]`
1207    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                // Handle alias syntax: [[target|display]]
1217                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    /// Find all links from one note to a target.
1238    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    /// Check if text matches the query using the given mode.
1281    fn text_matches(
1282        &self,
1283        text: &str,
1284        query_lower: &str,
1285        mode: SearchMode,
1286        regex: Option<&regex::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: &regex::Regex| r.is_match(text)).unwrap_or(false),
1292        }
1293    }
1294
1295    /// Check if a note matches the query.
1296    fn note_matches(
1297        &self,
1298        note: &Note,
1299        query_lower: &str,
1300        mode: SearchMode,
1301        scope: SearchScope,
1302        regex: Option<&regex::Regex>,
1303    ) -> bool {
1304        match scope {
1305            SearchScope::Title => self.text_matches(&note.title, query_lower, mode, regex),
1306            SearchScope::Content => self.text_matches(&note.content, query_lower, mode, regex),
1307            SearchScope::All => {
1308                self.text_matches(&note.title, query_lower, mode, regex)
1309                    || self.text_matches(&note.path, query_lower, mode, regex)
1310                    || self.text_matches(&note.content, query_lower, mode, regex)
1311                    || note.tags.iter().any(|t| self.text_matches(t, query_lower, mode, regex))
1312            }
1313        }
1314    }
1315
1316    /// Extract a text snippet around the first match in content.
1317    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            // Find a word boundary
1323            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
1356// ── Skill instructions ───────────────────────────────────────────────
1357
1358/// Generate the skill instructions to be injected into the system prompt
1359/// when the Obsidian skill is active.
1360///
1361/// This tells the LLM how to interact with Obsidian vaults using the
1362/// tools available to it (bash, read, write).
1363pub 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// ── Tests ────────────────────────────────────────────────────────────
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467    use std::fs;
1468
1469    /// Create a temporary vault with a known structure for testing.
1470    fn setup_test_vault() -> tempfile::TempDir {
1471        let tmp = tempfile::tempdir().unwrap();
1472        let root = tmp.path();
1473
1474        // Create some notes
1475        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        // Create a file that should be skipped
1501        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    // ── Configuration tests ─────────────────────────────────────────
1515
1516    #[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    // ── Note reading tests ──────────────────────────────────────────
1546
1547    #[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        // Should not include files from .obsidian/
1568        for note in &notes {
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    // ── Tag extraction tests ────────────────────────────────────────
1611
1612    #[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")); // too short
1643        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    // ── Wikilink extraction tests ───────────────────────────────────
1657
1658    #[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    // ── Search tests ────────────────────────────────────────────────
1701
1702    #[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); // "Project Alpha" and "meeting-notes" (contains "project")
1709    }
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        // Create more notes than max_results allows
1757        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    // ── Backlink tests ──────────────────────────────────────────────
1774
1775    #[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); // index links to it
1783        assert!(info.forward_link_count >= 2); // links to index and Team
1784        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        // "old-note" has no incoming or outgoing links
1793        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        // Backlinks should have line numbers
1813        for bl in &info.backlinks {
1814            assert!(bl.line_number.is_some());
1815            assert!(bl.line_number.unwrap() > 0);
1816        }
1817    }
1818
1819    // ── Vault graph tests ───────────────────────────────────────────
1820
1821    #[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); // old-note is an orphan
1830        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        // "Project Alpha" should be one of the most linked
1841        assert!(graph
1842            .most_linked
1843            .iter()
1844            .any(|(title, _)| title == "Project Alpha"));
1845    }
1846
1847    // ── Tag analysis tests ──────────────────────────────────────────
1848
1849    #[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        // "archive" is only used in old-note
1881        assert!(analysis.singleton_tags.contains(&"archive".to_string()));
1882    }
1883
1884    // ── Note preview tests ──────────────────────────────────────────
1885
1886    #[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    // ── Search mode tests ───────────────────────────────────────────
1919
1920    #[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    // ── Search with regex ───────────────────────────────────────────
1935
1936    #[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    // ── Skill instructions test ─────────────────────────────────────
1952
1953    #[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    // ── Empty vault tests ───────────────────────────────────────────
1963
1964    #[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    // ── Debug implementation test ───────────────────────────────────
1980
1981    #[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    // ── Snippet extraction test ─────────────────────────────────────
1990
1991    #[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(&note.content, "discussed", 50);
1998        assert!(snippet.is_some());
1999        let s = snippet.unwrap();
2000        assert!(s.contains("discussed"));
2001    }
2002
2003    // ── Serialization roundtrip tests ───────────────────────────────
2004
2005    #[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(&note).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}