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#[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#[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
47pub struct ObsidianVault {
50 pub root: PathBuf,
51 pub vault_name: String,
52 config: ObsidianConfig,
53 notes: HashMap<String, ObsidianNote>,
54 name_index: HashMap<String, PathBuf>,
56 backlink_index: HashMap<String, Vec<String>>,
58}
59
60impl ObsidianVault {
61 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 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 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 ¬e.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 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 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 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 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 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 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 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
524fn 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
569fn 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; 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 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 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
636fn 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
655pub 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
689pub 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(¬e.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#[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 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 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#[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}