Skip to main content

roboticus_agent/
obsidian.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, LazyLock};
4
5use async_trait::async_trait;
6use regex::Regex;
7
8static TAG_RE: LazyLock<Regex> =
9    LazyLock::new(|| Regex::new(r"(?:^|\s)#([a-zA-Z][\w/-]*)").expect("valid regex"));
10static WIKILINK_RE: LazyLock<Regex> =
11    LazyLock::new(|| Regex::new(r"\[\[([^\]]+)\]\]").expect("valid regex"));
12use serde::{Deserialize, Serialize};
13use tokio::sync::RwLock;
14use tracing;
15
16use roboticus_core::config::ObsidianConfig;
17use roboticus_core::{Result, RoboticusError};
18
19use crate::knowledge::{KnowledgeChunk, KnowledgeSource};
20
21// ---------------------------------------------------------------------------
22// Types
23// ---------------------------------------------------------------------------
24
25/// A parsed `[[target|display]]` wikilink with optional heading anchor.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct WikiLink {
28    pub target: String,
29    pub display: Option<String>,
30    pub heading: Option<String>,
31}
32
33/// A parsed Obsidian note with metadata extracted from frontmatter and content.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ObsidianNote {
36    pub path: PathBuf,
37    pub title: String,
38    pub content: String,
39    pub frontmatter: Option<serde_yaml::Value>,
40    pub tags: Vec<String>,
41    #[serde(skip)]
42    pub outgoing_links: Vec<String>,
43    pub created_at: Option<String>,
44    pub modified_at: Option<String>,
45}
46
47/// The vault manager — handles scanning, indexing, wikilink resolution,
48/// backlink tracking, template rendering, and note I/O.
49pub struct ObsidianVault {
50    pub root: PathBuf,
51    pub vault_name: String,
52    config: ObsidianConfig,
53    notes: HashMap<String, ObsidianNote>,
54    /// Lowercase note title -> relative path (for case-insensitive resolution).
55    name_index: HashMap<String, PathBuf>,
56    /// Normalized target -> list of source note relative paths.
57    backlink_index: HashMap<String, Vec<String>>,
58}
59
60impl ObsidianVault {
61    /// Construct a vault from config. Discovers the vault root (explicit or auto-detect)
62    /// and optionally scans on creation.
63    pub fn from_config(config: &ObsidianConfig) -> Result<Self> {
64        let root = if let Some(ref explicit) = config.vault_path {
65            explicit.clone()
66        } else if config.auto_detect {
67            auto_detect_vault(&config.auto_detect_paths)?
68        } else {
69            return Err(RoboticusError::Config(
70                "obsidian.vault_path must be set, or enable auto_detect with auto_detect_paths"
71                    .into(),
72            ));
73        };
74
75        if !root.exists() {
76            return Err(RoboticusError::Config(format!(
77                "obsidian vault path does not exist: {}",
78                root.display()
79            )));
80        }
81
82        let vault_name = root
83            .file_name()
84            .and_then(|n| n.to_str())
85            .unwrap_or("vault")
86            .to_string();
87
88        let mut vault = Self {
89            root,
90            vault_name,
91            config: config.clone(),
92            notes: HashMap::new(),
93            name_index: HashMap::new(),
94            backlink_index: HashMap::new(),
95        };
96
97        if config.index_on_start {
98            vault.scan()?;
99        }
100
101        Ok(vault)
102    }
103
104    /// Recursively scan the vault, respecting `ignored_folders`.
105    pub fn scan(&mut self) -> Result<()> {
106        self.notes.clear();
107        self.name_index.clear();
108        self.backlink_index.clear();
109
110        let mut files = Vec::new();
111        self.collect_markdown_files(&self.root.clone(), &mut files);
112
113        for path in files {
114            if let Ok(note) = self.parse_note(&path) {
115                let rel = self.relative_path(&path);
116                let key = rel.to_string_lossy().to_string();
117
118                let title_lower = note.title.to_lowercase();
119                let existing = self.name_index.get(&title_lower);
120                if existing.is_none()
121                    || existing.is_some_and(|e| key.len() < e.to_string_lossy().len())
122                {
123                    self.name_index.insert(title_lower, PathBuf::from(&key));
124                }
125
126                self.notes.insert(key, note);
127            }
128        }
129
130        self.rebuild_backlinks();
131
132        tracing::info!(
133            vault = %self.vault_name,
134            notes = self.notes.len(),
135            "Obsidian vault scanned"
136        );
137
138        Ok(())
139    }
140
141    fn collect_markdown_files(&self, dir: &Path, out: &mut Vec<PathBuf>) {
142        let entries = match std::fs::read_dir(dir) {
143            Ok(e) => e,
144            Err(_) => return,
145        };
146
147        for entry in entries.flatten() {
148            let path = entry.path();
149            if path.is_dir() {
150                let dir_name = path
151                    .file_name()
152                    .and_then(|n| n.to_str())
153                    .unwrap_or_default();
154                if !self.config.ignored_folders.iter().any(|f| f == dir_name) {
155                    self.collect_markdown_files(&path, out);
156                }
157            } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
158                out.push(path);
159            }
160        }
161    }
162
163    fn parse_note(&self, path: &Path) -> Result<ObsidianNote> {
164        // Cap note reads at 5 MB to prevent OOM during vault scan.
165        const MAX_NOTE_BYTES: u64 = 5 * 1024 * 1024;
166        let raw = {
167            use std::io::Read;
168            let file = std::fs::File::open(path).map_err(|e| {
169                RoboticusError::Config(format!("failed to open {}: {e}", path.display()))
170            })?;
171            if file.metadata().map(|m| m.len()).unwrap_or(0) > MAX_NOTE_BYTES {
172                return Err(RoboticusError::Config(format!(
173                    "note too large (>{} bytes): {}",
174                    MAX_NOTE_BYTES,
175                    path.display()
176                )));
177            }
178            let mut buf = String::new();
179            file.take(MAX_NOTE_BYTES)
180                .read_to_string(&mut buf)
181                .map_err(|e| {
182                    RoboticusError::Config(format!("failed to read {}: {e}", path.display()))
183                })?;
184            buf
185        };
186
187        let (frontmatter, content) = parse_frontmatter(&raw);
188        let tags = extract_tags(&frontmatter, content);
189        let outgoing = parse_wikilink_targets(content);
190
191        let title = path
192            .file_stem()
193            .and_then(|s| s.to_str())
194            .unwrap_or_default()
195            .to_string();
196
197        let meta = std::fs::metadata(path).ok();
198        let modified_at = meta.as_ref().and_then(|m| m.modified().ok()).map(|t| {
199            chrono::DateTime::<chrono::Utc>::from(t)
200                .format("%Y-%m-%dT%H:%M:%S")
201                .to_string()
202        });
203        let created_at = meta.as_ref().and_then(|m| m.created().ok()).map(|t| {
204            chrono::DateTime::<chrono::Utc>::from(t)
205                .format("%Y-%m-%dT%H:%M:%S")
206                .to_string()
207        });
208
209        Ok(ObsidianNote {
210            path: path.to_path_buf(),
211            title,
212            content: content.to_string(),
213            frontmatter,
214            tags,
215            outgoing_links: outgoing,
216            created_at,
217            modified_at,
218        })
219    }
220
221    fn rebuild_backlinks(&mut self) {
222        self.backlink_index.clear();
223        for (source_key, note) in &self.notes {
224            for target in &note.outgoing_links {
225                let normalized = target.to_lowercase();
226                self.backlink_index
227                    .entry(normalized)
228                    .or_default()
229                    .push(source_key.clone());
230            }
231        }
232    }
233
234    fn relative_path(&self, path: &Path) -> PathBuf {
235        path.strip_prefix(&self.root).unwrap_or(path).to_path_buf()
236    }
237
238    // -- Public API --
239
240    pub fn get_note(&self, rel_path: &str) -> Option<&ObsidianNote> {
241        self.notes.get(rel_path)
242    }
243
244    pub fn search_by_tag(&self, tag: &str) -> Vec<&ObsidianNote> {
245        let tag_lower = tag.to_lowercase();
246        self.notes
247            .values()
248            .filter(|n| n.tags.iter().any(|t| t.to_lowercase() == tag_lower))
249            .collect()
250    }
251
252    pub fn search_by_content(
253        &self,
254        query: &str,
255        max_results: usize,
256    ) -> Vec<(&str, &ObsidianNote, f64)> {
257        let query_lower = query.to_lowercase();
258        let mut results: Vec<(&str, &ObsidianNote, f64)> = self
259            .notes
260            .iter()
261            .filter_map(|(key, note)| {
262                let content_lower = note.content.to_lowercase();
263                let title_lower = note.title.to_lowercase();
264
265                let content_hits = content_lower.matches(&query_lower).count();
266                let title_hit = if title_lower.contains(&query_lower) {
267                    1.0
268                } else {
269                    0.0
270                };
271
272                if content_hits == 0 && title_hit == 0.0 {
273                    return None;
274                }
275
276                let content_score = content_hits as f64 / note.content.len().max(1) as f64;
277
278                let tag_boost = if note
279                    .tags
280                    .iter()
281                    .any(|t| t.to_lowercase().contains(&query_lower))
282                {
283                    self.config.tag_boost
284                } else {
285                    0.0
286                };
287
288                let backlink_count = self.backlinks_for_key(key).len() as f64;
289                let backlink_boost = (backlink_count / 10.0).min(0.2);
290
291                let score = content_score + title_hit * 0.5 + tag_boost + backlink_boost;
292
293                Some((key.as_str(), note, score))
294            })
295            .collect();
296
297        results.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
298        results.truncate(max_results);
299        results
300    }
301
302    /// Resolve a wikilink target to a relative path (case-insensitive).
303    pub fn resolve_wikilink(&self, target: &str) -> Option<PathBuf> {
304        let normalized = target.split('#').next().unwrap_or(target);
305        let normalized = normalized.split('|').next().unwrap_or(normalized);
306        let lower = normalized.to_lowercase().trim().to_string();
307
308        if let Some(path) = self.name_index.get(&lower) {
309            return Some(path.clone());
310        }
311
312        if lower.contains('/')
313            && let path @ Some(_) = self.notes.get(&lower).map(|_| PathBuf::from(&lower))
314        {
315            return path;
316        }
317
318        None
319    }
320
321    pub fn backlinks_for(&self, note_path: &str) -> Vec<&ObsidianNote> {
322        self.backlinks_for_key(note_path)
323            .into_iter()
324            .filter_map(|k| self.notes.get(k))
325            .collect()
326    }
327
328    fn backlinks_for_key(&self, key: &str) -> Vec<&str> {
329        let title = Path::new(key)
330            .file_stem()
331            .and_then(|s| s.to_str())
332            .unwrap_or(key)
333            .to_lowercase();
334
335        self.backlink_index
336            .get(&title)
337            .map(|v| v.iter().map(|s| s.as_str()).collect())
338            .unwrap_or_default()
339    }
340
341    /// Write a note to the vault. Creates parent directories and prepends YAML frontmatter.
342    pub fn write_note(
343        &mut self,
344        rel_path: &str,
345        content: &str,
346        frontmatter: Option<serde_json::Value>,
347    ) -> Result<PathBuf> {
348        let input_path = Path::new(rel_path);
349        if input_path.is_absolute()
350            || input_path
351                .components()
352                .any(|c| matches!(c, std::path::Component::ParentDir))
353        {
354            return Err(RoboticusError::Config(
355                "note path must be relative and must not contain '..'".into(),
356            ));
357        }
358
359        let path = if rel_path.contains('/') || rel_path.contains('\\') {
360            self.root.join(rel_path)
361        } else {
362            self.root.join(&self.config.default_folder).join(rel_path)
363        };
364
365        let path = if path.extension().is_none() {
366            path.with_extension("md")
367        } else {
368            path
369        };
370
371        let canonical_root = std::fs::canonicalize(&self.root).map_err(|e| {
372            RoboticusError::Config(format!(
373                "failed to resolve vault root '{}': {e}",
374                self.root.display()
375            ))
376        })?;
377        let parent = path.parent().ok_or_else(|| {
378            RoboticusError::Config(format!("invalid target path: {}", path.display()))
379        })?;
380        let mut existing_ancestor = parent;
381        while !existing_ancestor.exists() {
382            existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
383                RoboticusError::Config("unable to resolve note parent directory".into())
384            })?;
385        }
386        let canonical_ancestor = std::fs::canonicalize(existing_ancestor).map_err(|e| {
387            RoboticusError::Config(format!(
388                "failed to resolve note parent '{}': {e}",
389                existing_ancestor.display()
390            ))
391        })?;
392        if !canonical_ancestor.starts_with(&canonical_root) {
393            return Err(RoboticusError::Config(
394                "note path escapes vault root".into(),
395            ));
396        }
397
398        if let Some(parent) = path.parent() {
399            std::fs::create_dir_all(parent)
400                .map_err(|e| RoboticusError::Config(format!("failed to create dirs: {e}")))?;
401        }
402
403        let mut file_content = String::new();
404
405        let fm = if let Some(extra) = frontmatter {
406            let mut map = match extra {
407                serde_json::Value::Object(m) => m,
408                _ => serde_json::Map::new(),
409            };
410            map.entry("created_by")
411                .or_insert(serde_json::Value::String("roboticus".into()));
412            map.entry("created_at").or_insert(serde_json::Value::String(
413                chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
414            ));
415            Some(serde_json::Value::Object(map))
416        } else {
417            Some(serde_json::json!({
418                "created_by": "roboticus",
419                "created_at": chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
420            }))
421        };
422
423        if let Some(ref fm_val) = fm
424            && let Ok(yaml) = serde_yaml::to_string(fm_val)
425        {
426            file_content.push_str("---\n");
427            file_content.push_str(&yaml);
428            file_content.push_str("---\n\n");
429        }
430
431        file_content.push_str(content);
432
433        std::fs::write(&path, &file_content)
434            .map_err(|e| RoboticusError::Config(format!("failed to write note: {e}")))?;
435
436        // Re-parse and index the new note
437        if let Ok(note) = self.parse_note(&path) {
438            let rel = self.relative_path(&path);
439            let key = rel.to_string_lossy().to_string();
440            let title_lower = note.title.to_lowercase();
441            self.name_index.insert(title_lower, PathBuf::from(&key));
442            self.notes.insert(key, note);
443            self.rebuild_backlinks();
444        }
445
446        Ok(path)
447    }
448
449    /// Apply a template by substituting `{{variable}}` placeholders.
450    pub fn apply_template(
451        &self,
452        template_name: &str,
453        vars: &HashMap<String, String>,
454    ) -> Result<String> {
455        let template_dir = self.root.join(&self.config.template_folder);
456        let template_path = template_dir.join(template_name);
457        let template_path = if template_path.extension().is_none() {
458            template_path.with_extension("md")
459        } else {
460            template_path
461        };
462
463        if !template_path.exists() {
464            return Err(RoboticusError::Config(format!(
465                "template not found: {}",
466                template_path.display()
467            )));
468        }
469
470        let raw = std::fs::read_to_string(&template_path)
471            .map_err(|e| RoboticusError::Config(format!("failed to read template: {e}")))?;
472
473        let mut result = raw;
474        for (key, value) in vars {
475            let placeholder = format!("{{{{{key}}}}}");
476            result = result.replace(&placeholder, value);
477        }
478
479        // Built-in variables
480        result = result.replace(
481            "{{date}}",
482            &chrono::Utc::now().format("%Y-%m-%d").to_string(),
483        );
484        result = result.replace(
485            "{{time}}",
486            &chrono::Utc::now().format("%H:%M:%S").to_string(),
487        );
488
489        Ok(result)
490    }
491
492    /// Generate an `obsidian://` URI for a note.
493    pub fn obsidian_uri(&self, note_rel_path: &str) -> String {
494        let vault_encoded = urlencoding::encode(&self.vault_name);
495        let file = note_rel_path.strip_suffix(".md").unwrap_or(note_rel_path);
496        let file_encoded = urlencoding::encode(file);
497        format!("obsidian://open?vault={vault_encoded}&file={file_encoded}")
498    }
499
500    pub fn note_count(&self) -> usize {
501        self.notes.len()
502    }
503
504    pub fn all_tags(&self) -> Vec<String> {
505        let mut tags: Vec<String> = self
506            .notes
507            .values()
508            .flat_map(|n| n.tags.iter().cloned())
509            .collect();
510        tags.sort();
511        tags.dedup();
512        tags
513    }
514
515    pub fn notes_in_folder(&self, folder: &str) -> Vec<(&str, &ObsidianNote)> {
516        self.notes
517            .iter()
518            .filter(|(k, _)| k.starts_with(folder))
519            .map(|(k, v)| (k.as_str(), v))
520            .collect()
521    }
522}
523
524// ---------------------------------------------------------------------------
525// Auto-detect
526// ---------------------------------------------------------------------------
527
528fn auto_detect_vault(search_paths: &[PathBuf]) -> Result<PathBuf> {
529    for base in search_paths {
530        if let Some(found) = find_obsidian_dir(base) {
531            tracing::info!(vault = %found.display(), "Auto-detected Obsidian vault");
532            return Ok(found);
533        }
534    }
535    Err(RoboticusError::Config(
536        "auto_detect enabled but no .obsidian directory found in specified paths".into(),
537    ))
538}
539
540fn find_obsidian_dir(base: &Path) -> Option<PathBuf> {
541    if !base.is_dir() {
542        return None;
543    }
544
545    if base.join(".obsidian").is_dir() {
546        return Some(base.to_path_buf());
547    }
548
549    let entries = std::fs::read_dir(base).ok()?;
550    let mut candidates = Vec::new();
551    for entry in entries.flatten() {
552        let path = entry.path();
553        if path.is_dir() && path.join(".obsidian").is_dir() {
554            candidates.push(path);
555        }
556    }
557
558    if candidates.len() > 1 {
559        tracing::warn!(
560            count = candidates.len(),
561            "Multiple Obsidian vaults found, using shortest path"
562        );
563        candidates.sort_by_key(|p| p.to_string_lossy().len());
564    }
565
566    candidates.into_iter().next()
567}
568
569// ---------------------------------------------------------------------------
570// Frontmatter / tag / wikilink parsing
571// ---------------------------------------------------------------------------
572
573fn parse_frontmatter(raw: &str) -> (Option<serde_yaml::Value>, &str) {
574    if !raw.starts_with("---") {
575        return (None, raw);
576    }
577
578    if let Some(end) = raw[3..].find("\n---") {
579        let yaml_str = &raw[3..3 + end];
580        let rest_start = 3 + end + 4; // skip past "\n---"
581        let rest = if rest_start < raw.len() {
582            raw[rest_start..].trim_start_matches('\n')
583        } else {
584            ""
585        };
586
587        match serde_yaml::from_str(yaml_str) {
588            Ok(val) => (Some(val), rest),
589            Err(_) => (None, raw),
590        }
591    } else {
592        (None, raw)
593    }
594}
595
596fn extract_tags(frontmatter: &Option<serde_yaml::Value>, content: &str) -> Vec<String> {
597    let mut tags = Vec::new();
598
599    // Tags from frontmatter
600    if let Some(fm) = frontmatter
601        && let Some(fm_tags) = fm.get("tags")
602    {
603        match fm_tags {
604            serde_yaml::Value::Sequence(seq) => {
605                for item in seq {
606                    if let Some(s) = item.as_str() {
607                        tags.push(s.to_string());
608                    }
609                }
610            }
611            serde_yaml::Value::String(s) => {
612                for tag in s.split(',') {
613                    let trimmed = tag.trim();
614                    if !trimmed.is_empty() {
615                        tags.push(trimmed.to_string());
616                    }
617                }
618            }
619            _ => {}
620        }
621    }
622
623    // Inline #tags from content
624    for cap in TAG_RE.captures_iter(content) {
625        if let Some(m) = cap.get(1) {
626            let tag = m.as_str().to_string();
627            if !tags.contains(&tag) {
628                tags.push(tag);
629            }
630        }
631    }
632
633    tags
634}
635
636/// Parse all wikilink targets from content (just the target names, not display text).
637fn parse_wikilink_targets(content: &str) -> Vec<String> {
638    let mut targets = Vec::new();
639
640    for cap in WIKILINK_RE.captures_iter(content) {
641        if let Some(inner) = cap.get(1) {
642            let raw = inner.as_str();
643            let target = raw.split('|').next().unwrap_or(raw);
644            let target = target.split('#').next().unwrap_or(target);
645            let target = target.trim().to_string();
646            if !target.is_empty() && !targets.contains(&target) {
647                targets.push(target);
648            }
649        }
650    }
651
652    targets
653}
654
655/// Parse a wikilink string into a structured `WikiLink`.
656pub fn parse_wikilink(raw: &str) -> WikiLink {
657    let inner = raw.trim_start_matches("[[").trim_end_matches("]]");
658    let (target_part, display) = if let Some(idx) = inner.find('|') {
659        (&inner[..idx], Some(inner[idx + 1..].to_string()))
660    } else {
661        (inner, None)
662    };
663
664    let (target, heading) = if let Some(idx) = target_part.find('#') {
665        (
666            target_part[..idx].to_string(),
667            Some(target_part[idx + 1..].to_string()),
668        )
669    } else {
670        (target_part.to_string(), None)
671    };
672
673    WikiLink {
674        target,
675        display,
676        heading,
677    }
678}
679
680fn truncate(s: &str, max: usize) -> String {
681    if s.len() <= max {
682        s.to_string()
683    } else {
684        let boundary = s.floor_char_boundary(max);
685        format!("{}...", &s[..boundary])
686    }
687}
688
689// ---------------------------------------------------------------------------
690// KnowledgeSource implementation
691// ---------------------------------------------------------------------------
692
693pub struct ObsidianSource {
694    vault: Arc<RwLock<ObsidianVault>>,
695}
696
697impl ObsidianSource {
698    pub fn new(vault: Arc<RwLock<ObsidianVault>>) -> Self {
699        Self { vault }
700    }
701}
702
703#[async_trait]
704impl KnowledgeSource for ObsidianSource {
705    fn name(&self) -> &str {
706        "obsidian"
707    }
708
709    fn source_type(&self) -> &str {
710        "obsidian"
711    }
712
713    async fn query(&self, query: &str, max_results: usize) -> Result<Vec<KnowledgeChunk>> {
714        let vault = self.vault.read().await;
715        let results = vault.search_by_content(query, max_results);
716
717        Ok(results
718            .into_iter()
719            .map(|(key, note, score)| {
720                let mut metadata = serde_json::json!({
721                    "path": key,
722                    "title": note.title,
723                    "tags": note.tags,
724                });
725
726                if let Some(ref fm) = note.frontmatter {
727                    metadata["frontmatter"] = serde_json::to_value(fm)
728                        .inspect_err(|e| tracing::warn!(error = %e, "failed to serialize obsidian frontmatter"))
729                        .unwrap_or_default();
730                }
731
732                let backlink_count = vault.backlinks_for(key).len();
733                metadata["backlink_count"] = serde_json::json!(backlink_count);
734
735                let obsidian_uri = vault.obsidian_uri(key);
736                metadata["obsidian_uri"] = serde_json::json!(obsidian_uri);
737
738                KnowledgeChunk {
739                    content: truncate(&note.content, 2000),
740                    source: format!("obsidian://{}", key),
741                    relevance: score,
742                    metadata: Some(metadata),
743                }
744            })
745            .collect())
746    }
747
748    async fn ingest(&self, content: &str, source: &str) -> Result<()> {
749        let mut vault = self.vault.write().await;
750        let path = source.strip_prefix("obsidian://").unwrap_or(source);
751        vault.write_note(path, content, None)?;
752        Ok(())
753    }
754
755    fn is_available(&self) -> bool {
756        true
757    }
758}
759
760// ---------------------------------------------------------------------------
761// File watcher (feature-gated behind "vault-watcher")
762// ---------------------------------------------------------------------------
763
764#[cfg(feature = "vault-watcher")]
765pub mod watcher {
766    use std::sync::Arc;
767    use std::time::Duration;
768
769    use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
770    use tokio::sync::RwLock;
771    use tokio::sync::mpsc;
772
773    use super::ObsidianVault;
774
775    pub struct VaultWatcher {
776        _watcher: RecommendedWatcher,
777    }
778
779    impl VaultWatcher {
780        /// Spawn a file watcher that re-scans the vault on changes.
781        /// Uses a 500ms debounce to avoid thrashing during bulk edits.
782        pub async fn start(vault: Arc<RwLock<ObsidianVault>>) -> Result<Self, notify::Error> {
783            let (tx, mut rx) = mpsc::channel::<()>(16);
784
785            let vault_root = {
786                let v = vault.read().await;
787                v.root.clone()
788            };
789
790            let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
791                if let Ok(event) = res {
792                    match event.kind {
793                        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
794                            let _ = tx.try_send(());
795                        }
796                        _ => {}
797                    }
798                }
799            })?;
800
801            watcher.watch(&vault_root, RecursiveMode::Recursive)?;
802
803            let debounce_vault = Arc::clone(&vault);
804            tokio::spawn(async move {
805                let debounce = Duration::from_millis(500);
806                loop {
807                    if rx.recv().await.is_none() {
808                        break;
809                    }
810                    // Drain any buffered events during debounce window
811                    tokio::time::sleep(debounce).await;
812                    while rx.try_recv().is_ok() {}
813
814                    let mut v = debounce_vault.write().await;
815                    if let Err(e) = v.scan() {
816                        tracing::warn!(error = %e, "Vault re-scan after file change failed");
817                    } else {
818                        tracing::debug!(
819                            notes = v.note_count(),
820                            "Vault re-scanned after file change"
821                        );
822                    }
823                }
824            });
825
826            tracing::info!("Obsidian vault file watcher started");
827
828            Ok(Self { _watcher: watcher })
829        }
830    }
831}
832
833// ---------------------------------------------------------------------------
834// Tests
835// ---------------------------------------------------------------------------
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use std::fs;
841    use tempfile::TempDir;
842
843    fn create_test_vault() -> (TempDir, ObsidianConfig) {
844        let dir = TempDir::new().unwrap();
845        fs::create_dir(dir.path().join(".obsidian")).unwrap();
846        fs::create_dir(dir.path().join("templates")).unwrap();
847        fs::create_dir(dir.path().join("roboticus")).unwrap();
848
849        let config = ObsidianConfig {
850            enabled: true,
851            vault_path: Some(dir.path().to_path_buf()),
852            index_on_start: false,
853            ..Default::default()
854        };
855
856        (dir, config)
857    }
858
859    #[test]
860    fn parse_frontmatter_with_tags() {
861        let raw = "---\ntags:\n  - rust\n  - coding\ntitle: Test\n---\n\nHello world";
862        let (fm, content) = parse_frontmatter(raw);
863        assert!(fm.is_some());
864        assert_eq!(content, "Hello world");
865        let tags = extract_tags(&fm, content);
866        assert!(tags.contains(&"rust".to_string()));
867        assert!(tags.contains(&"coding".to_string()));
868    }
869
870    #[test]
871    fn parse_frontmatter_none_without_dashes() {
872        let raw = "No frontmatter here";
873        let (fm, content) = parse_frontmatter(raw);
874        assert!(fm.is_none());
875        assert_eq!(content, "No frontmatter here");
876    }
877
878    #[test]
879    fn extract_inline_tags() {
880        let content = "Hello #rust and #coding are great. Not# a tag.";
881        let tags = extract_tags(&None, content);
882        assert!(tags.contains(&"rust".to_string()));
883        assert!(tags.contains(&"coding".to_string()));
884        assert_eq!(tags.len(), 2);
885    }
886
887    #[test]
888    fn parse_wikilink_simple() {
889        let link = parse_wikilink("[[My Note]]");
890        assert_eq!(link.target, "My Note");
891        assert!(link.display.is_none());
892        assert!(link.heading.is_none());
893    }
894
895    #[test]
896    fn parse_wikilink_with_display() {
897        let link = parse_wikilink("[[Target|Display Text]]");
898        assert_eq!(link.target, "Target");
899        assert_eq!(link.display.as_deref(), Some("Display Text"));
900    }
901
902    #[test]
903    fn parse_wikilink_with_heading() {
904        let link = parse_wikilink("[[Note#Section]]");
905        assert_eq!(link.target, "Note");
906        assert_eq!(link.heading.as_deref(), Some("Section"));
907    }
908
909    #[test]
910    fn parse_wikilink_targets_from_content() {
911        let content = "See [[Note A]] and [[Note B|alias]] and [[Note A]] again.";
912        let targets = parse_wikilink_targets(content);
913        assert_eq!(targets, vec!["Note A", "Note B"]);
914    }
915
916    #[test]
917    fn vault_scan_and_search() {
918        let (dir, config) = create_test_vault();
919        fs::write(
920            dir.path().join("alpha.md"),
921            "---\ntags:\n  - rust\n---\n\nRust programming notes",
922        )
923        .unwrap();
924        fs::write(dir.path().join("beta.md"), "Python programming notes").unwrap();
925        fs::write(
926            dir.path().join("gamma.md"),
927            "See [[alpha]] for Rust details",
928        )
929        .unwrap();
930
931        let mut vault = ObsidianVault::from_config(&config).unwrap();
932        vault.scan().unwrap();
933
934        assert_eq!(vault.note_count(), 3);
935
936        let results = vault.search_by_content("Rust", 10);
937        assert!(!results.is_empty());
938        assert!(results[0].1.content.contains("Rust"));
939
940        let by_tag = vault.search_by_tag("rust");
941        assert_eq!(by_tag.len(), 1);
942        assert_eq!(by_tag[0].title, "alpha");
943    }
944
945    #[test]
946    fn wikilink_resolution() {
947        let (dir, config) = create_test_vault();
948        fs::write(dir.path().join("My Note.md"), "Content here").unwrap();
949
950        let mut vault = ObsidianVault::from_config(&config).unwrap();
951        vault.scan().unwrap();
952
953        assert!(vault.resolve_wikilink("My Note").is_some());
954        assert!(vault.resolve_wikilink("my note").is_some());
955        assert!(vault.resolve_wikilink("Nonexistent").is_none());
956    }
957
958    #[test]
959    fn backlink_index_built() {
960        let (dir, config) = create_test_vault();
961        fs::write(dir.path().join("target.md"), "I am the target").unwrap();
962        fs::write(dir.path().join("source.md"), "Linking to [[target]] here").unwrap();
963
964        let mut vault = ObsidianVault::from_config(&config).unwrap();
965        vault.scan().unwrap();
966
967        let backlinks = vault.backlinks_for("target.md");
968        assert_eq!(backlinks.len(), 1);
969        assert_eq!(backlinks[0].title, "source");
970    }
971
972    #[test]
973    fn write_note_creates_file() {
974        let (_dir, config) = create_test_vault();
975        let mut vault = ObsidianVault::from_config(&config).unwrap();
976
977        let result = vault.write_note("test-note", "Hello from Roboticus", None);
978        assert!(result.is_ok());
979
980        let path = result.unwrap();
981        assert!(path.exists());
982        let content = fs::read_to_string(&path).unwrap();
983        assert!(content.contains("Hello from Roboticus"));
984        assert!(content.contains("created_by: roboticus"));
985    }
986
987    #[test]
988    fn write_note_with_frontmatter() {
989        let (_dir, config) = create_test_vault();
990        let mut vault = ObsidianVault::from_config(&config).unwrap();
991
992        let fm = serde_json::json!({
993            "tags": ["test", "demo"],
994            "status": "draft"
995        });
996
997        let path = vault
998            .write_note("custom.md", "Custom content", Some(fm))
999            .unwrap();
1000
1001        let content = fs::read_to_string(&path).unwrap();
1002        assert!(content.contains("custom content") || content.contains("Custom content"));
1003        assert!(content.contains("created_by"));
1004    }
1005
1006    #[test]
1007    fn write_note_rejects_path_traversal() {
1008        let (_dir, config) = create_test_vault();
1009        let mut vault = ObsidianVault::from_config(&config).unwrap();
1010        let err = vault.write_note("../escape.md", "bad", None).unwrap_err();
1011        assert!(err.to_string().contains("must be relative"));
1012    }
1013
1014    #[test]
1015    fn template_application() {
1016        let (dir, config) = create_test_vault();
1017        fs::write(
1018            dir.path().join("templates/daily.md"),
1019            "# {{title}}\n\nDate: {{date}}\n\n## Notes\n",
1020        )
1021        .unwrap();
1022
1023        let vault = ObsidianVault::from_config(&config).unwrap();
1024        let mut vars = HashMap::new();
1025        vars.insert("title".into(), "My Daily Note".into());
1026
1027        let result = vault.apply_template("daily", &vars).unwrap();
1028        assert!(result.contains("# My Daily Note"));
1029        assert!(result.contains("Date:"));
1030        assert!(!result.contains("{{title}}"));
1031        assert!(!result.contains("{{date}}"));
1032    }
1033
1034    #[test]
1035    fn template_missing_error() {
1036        let (_dir, config) = create_test_vault();
1037        let vault = ObsidianVault::from_config(&config).unwrap();
1038        let result = vault.apply_template("nonexistent", &HashMap::new());
1039        assert!(result.is_err());
1040    }
1041
1042    #[test]
1043    fn obsidian_uri_generation() {
1044        let (_dir, config) = create_test_vault();
1045        let vault = ObsidianVault::from_config(&config).unwrap();
1046
1047        let uri = vault.obsidian_uri("folder/My Note.md");
1048        assert!(uri.starts_with("obsidian://open?vault="));
1049        assert!(uri.contains("file="));
1050        assert!(!uri.contains(".md"));
1051    }
1052
1053    #[test]
1054    fn auto_detect_finds_vault() {
1055        let dir = TempDir::new().unwrap();
1056        let vault_dir = dir.path().join("MyVault");
1057        fs::create_dir(&vault_dir).unwrap();
1058        fs::create_dir(vault_dir.join(".obsidian")).unwrap();
1059
1060        let result = auto_detect_vault(&[dir.path().to_path_buf()]);
1061        assert!(result.is_ok());
1062        assert_eq!(result.unwrap(), vault_dir);
1063    }
1064
1065    #[test]
1066    fn auto_detect_no_vault_errors() {
1067        let dir = TempDir::new().unwrap();
1068        let result = auto_detect_vault(&[dir.path().to_path_buf()]);
1069        assert!(result.is_err());
1070    }
1071
1072    #[test]
1073    fn ignored_folders_respected() {
1074        let (dir, config) = create_test_vault();
1075        fs::create_dir(dir.path().join(".trash")).unwrap();
1076        fs::write(dir.path().join(".trash/deleted.md"), "deleted note").unwrap();
1077        fs::write(dir.path().join("visible.md"), "visible note").unwrap();
1078
1079        let mut vault = ObsidianVault::from_config(&config).unwrap();
1080        vault.scan().unwrap();
1081
1082        assert_eq!(vault.note_count(), 1);
1083        assert!(vault.get_note("visible.md").is_some());
1084    }
1085
1086    #[test]
1087    fn all_tags_deduped() {
1088        let (dir, config) = create_test_vault();
1089        fs::write(
1090            dir.path().join("a.md"),
1091            "---\ntags:\n  - rust\n  - coding\n---\nContent",
1092        )
1093        .unwrap();
1094        fs::write(
1095            dir.path().join("b.md"),
1096            "---\ntags:\n  - rust\n  - docs\n---\nMore content",
1097        )
1098        .unwrap();
1099
1100        let mut vault = ObsidianVault::from_config(&config).unwrap();
1101        vault.scan().unwrap();
1102
1103        let tags = vault.all_tags();
1104        assert!(tags.contains(&"rust".to_string()));
1105        assert!(tags.contains(&"coding".to_string()));
1106        assert!(tags.contains(&"docs".to_string()));
1107        assert_eq!(tags.iter().filter(|t| *t == "rust").count(), 1);
1108    }
1109
1110    #[tokio::test]
1111    async fn obsidian_source_query() {
1112        let (dir, config) = create_test_vault();
1113        fs::write(
1114            dir.path().join("knowledge.md"),
1115            "Important Rust knowledge about ownership",
1116        )
1117        .unwrap();
1118
1119        let mut vault = ObsidianVault::from_config(&config).unwrap();
1120        vault.scan().unwrap();
1121
1122        let vault = Arc::new(RwLock::new(vault));
1123        let source = ObsidianSource::new(vault);
1124
1125        let chunks = source.query("Rust", 5).await.unwrap();
1126        assert_eq!(chunks.len(), 1);
1127        assert!(chunks[0].content.contains("Rust"));
1128        assert!(chunks[0].source.starts_with("obsidian://"));
1129    }
1130
1131    #[tokio::test]
1132    async fn obsidian_source_ingest() {
1133        let (dir, config) = create_test_vault();
1134        let mut vault = ObsidianVault::from_config(&config).unwrap();
1135        vault.scan().unwrap();
1136        let vault = Arc::new(RwLock::new(vault));
1137        let source = ObsidianSource::new(vault);
1138
1139        source
1140            .ingest("New note content", "obsidian://ingested-note")
1141            .await
1142            .unwrap();
1143
1144        let written = dir.path().join("roboticus/ingested-note.md");
1145        assert!(written.exists());
1146    }
1147}