Skip to main content

obsidian_core/
health.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::{Link, Note, common, search};
5
6/// Health report produced by [`Vault::check`](crate::Vault::check).
7pub struct VaultHealthReport {
8    /// Total number of notes scanned.
9    pub note_count: usize,
10    /// Groups of notes that share the same ID, sorted by ID.
11    pub duplicate_ids: Vec<DuplicateId>,
12    /// Groups of notes that share the same alias (case-insensitive), sorted by alias.
13    pub duplicate_aliases: Vec<DuplicateAlias>,
14    /// Broken links found across the vault, sorted by source path then line.
15    pub broken_links: Vec<BrokenLink>,
16    /// Notes with no incoming or outgoing note links, sorted by path.
17    pub stranded_notes: Vec<StrandedNote>,
18}
19
20impl VaultHealthReport {
21    /// Returns `true` if any health issues were found.
22    pub fn has_issues(&self) -> bool {
23        !self.duplicate_ids.is_empty()
24            || !self.duplicate_aliases.is_empty()
25            || !self.broken_links.is_empty()
26            || !self.stranded_notes.is_empty()
27    }
28}
29
30/// A group of notes that share the same ID.
31pub struct DuplicateId {
32    pub id: String,
33    /// Notes with this ID, sorted by path.
34    pub notes: Vec<NoteRef>,
35}
36
37/// A group of notes that share the same alias (compared case-insensitively; stored lowercase).
38pub struct DuplicateAlias {
39    pub alias: String,
40    /// Notes with this alias, sorted by path.
41    pub notes: Vec<NoteRef>,
42}
43
44/// A note path with its backlink count, used inside duplicate-detection results.
45pub struct NoteRef {
46    pub path: PathBuf,
47    pub backlink_count: usize,
48}
49
50/// A broken link found in a note.
51pub struct BrokenLink {
52    pub source_path: PathBuf,
53    /// 1-indexed line number of the link within the note.
54    pub line: usize,
55    /// Formatted link text, e.g. `[[target]]` or `[...](url.md)`.
56    pub text: String,
57}
58
59/// A note with no incoming or outgoing note links.
60pub struct StrandedNote {
61    pub path: PathBuf,
62}
63
64#[derive(Default)]
65struct NoteConnectivity {
66    backlink_count: usize,
67    has_outgoing_note_link: bool,
68}
69
70/// Scans an already-loaded note set for health issues.
71///
72/// This is the same duplicate ID, duplicate alias, broken-link, and stranded-note logic used by
73/// [`Vault::check`](crate::Vault::check), but it lets callers reuse cached note
74/// snapshots instead of walking the filesystem for every check.
75pub fn check_notes(vault_path: impl AsRef<Path>, notes: &[Note]) -> VaultHealthReport {
76    let vault_path = vault_path.as_ref();
77    let note_paths: HashSet<PathBuf> = notes.iter().map(|note| note.path.clone()).collect();
78    let connectivity = build_connectivity_by_path(notes, vault_path);
79
80    // --- Duplicate IDs ---
81    let mut id_map: HashMap<String, Vec<PathBuf>> = HashMap::new();
82    for note in notes.iter() {
83        id_map.entry(note.id.clone()).or_default().push(note.path.clone());
84    }
85    let mut duplicate_ids: Vec<DuplicateId> = id_map
86        .into_iter()
87        .filter(|(_, paths)| paths.len() > 1)
88        .map(|(id, mut paths)| {
89            paths.sort();
90            let note_refs = paths
91                .into_iter()
92                .map(|path| NoteRef {
93                    backlink_count: connectivity.get(path.as_path()).map_or(0, |info| info.backlink_count),
94                    path,
95                })
96                .collect();
97            DuplicateId { id, notes: note_refs }
98        })
99        .collect();
100    duplicate_ids.sort_by(|a, b| a.id.cmp(&b.id));
101
102    // --- Duplicate aliases ---
103    let mut alias_map: HashMap<String, HashSet<PathBuf>> = HashMap::new();
104    for note in notes.iter() {
105        for alias in &note.aliases {
106            alias_map
107                .entry(alias.to_lowercase())
108                .or_default()
109                .insert(note.path.clone());
110        }
111    }
112    let mut duplicate_aliases: Vec<DuplicateAlias> = alias_map
113        .into_iter()
114        .filter(|(_, paths)| paths.len() > 1)
115        .map(|(alias, paths)| {
116            let mut sorted_paths: Vec<PathBuf> = paths.into_iter().collect();
117            sorted_paths.sort();
118            let note_refs = sorted_paths
119                .into_iter()
120                .map(|path| NoteRef {
121                    backlink_count: connectivity.get(path.as_path()).map_or(0, |info| info.backlink_count),
122                    path,
123                })
124                .collect();
125            DuplicateAlias {
126                alias,
127                notes: note_refs,
128            }
129        })
130        .collect();
131    duplicate_aliases.sort_by(|a, b| a.alias.cmp(&b.alias));
132
133    // --- Broken links ---
134    let mut valid_wiki_targets: HashSet<String> = HashSet::new();
135    for note in notes.iter() {
136        valid_wiki_targets.insert(note.id.clone());
137        if let Some(stem) = note.path.file_stem().and_then(|s| s.to_str()) {
138            valid_wiki_targets.insert(stem.to_string());
139        }
140        for alias in &note.aliases {
141            valid_wiki_targets.insert(alias.clone());
142            valid_wiki_targets.insert(alias.to_lowercase());
143        }
144    }
145
146    let mut broken_links: Vec<BrokenLink> = Vec::new();
147    for note in notes.iter() {
148        for ll in &note.links {
149            match &ll.link {
150                Link::Wiki { target, .. } => {
151                    if !target.is_empty() && !valid_wiki_targets.contains(target.as_str()) {
152                        broken_links.push(BrokenLink {
153                            source_path: note.path.clone(),
154                            line: ll.location.line,
155                            text: format!("[[{}]]", target),
156                        });
157                    }
158                }
159                Link::Markdown { url, .. } => {
160                    // Skip external and absolute links; only check local .md links.
161                    if url.contains("://") || url.starts_with('/') {
162                        continue;
163                    }
164                    let url_path_raw = match url.find('#') {
165                        Some(i) => &url[..i],
166                        None => url.as_str(),
167                    };
168                    let url_path_decoded = common::percent_decode(url_path_raw);
169                    let url_path = url_path_decoded.as_str();
170                    if !url_path.ends_with(".md") {
171                        continue;
172                    }
173                    let source_dirs = [vault_path, note.path.parent().unwrap_or(vault_path)];
174                    if !source_dirs.iter().any(|dir| {
175                        let candidate = common::normalize_path(url_path, Some(dir));
176                        note_paths.contains(&candidate)
177                    }) {
178                        broken_links.push(BrokenLink {
179                            source_path: note.path.clone(),
180                            line: ll.location.line,
181                            text: format!("[...]({})", url),
182                        });
183                    }
184                }
185                _ => {}
186            }
187        }
188    }
189    broken_links.sort_by(|a, b| a.source_path.cmp(&b.source_path).then(a.line.cmp(&b.line)));
190
191    // --- Stranded notes ---
192    let mut stranded_notes: Vec<StrandedNote> = notes
193        .iter()
194        .filter(|note| !is_stranded_note_exempt(&note.path))
195        .filter(|note| {
196            let Some(connectivity) = connectivity.get(note.path.as_path()) else {
197                return false;
198            };
199            connectivity.backlink_count == 0 && !connectivity.has_outgoing_note_link
200        })
201        .map(|note| StrandedNote {
202            path: note.path.clone(),
203        })
204        .collect();
205    stranded_notes.sort_by(|a, b| a.path.cmp(&b.path));
206
207    VaultHealthReport {
208        note_count: notes.len(),
209        duplicate_ids,
210        duplicate_aliases,
211        broken_links,
212        stranded_notes,
213    }
214}
215
216fn build_connectivity_by_path(notes: &[Note], vault_path: &Path) -> HashMap<PathBuf, NoteConnectivity> {
217    let mut connectivity_by_path: HashMap<PathBuf, NoteConnectivity> = notes
218        .iter()
219        .map(|note| {
220            (
221                note.path.clone(),
222                NoteConnectivity {
223                    backlink_count: 0,
224                    has_outgoing_note_link: has_outgoing_note_link(note),
225                },
226            )
227        })
228        .collect();
229    let note_paths = connectivity_by_path.keys().cloned().collect::<HashSet<_>>();
230    let wiki_target_index = build_wiki_target_index(notes);
231    let markdown_target_index = build_markdown_target_index(notes, vault_path);
232
233    for source in notes {
234        let mut linked_targets = HashSet::new();
235        for located_link in &source.links {
236            match &located_link.link {
237                Link::Wiki { target, .. } => {
238                    if let Some(target_paths) = wiki_target_index.get(target.as_str()) {
239                        for target_path in target_paths {
240                            insert_linked_target(&mut linked_targets, &source.path, target_path);
241                        }
242                    }
243                }
244                Link::Markdown { url, .. } => {
245                    let Some(url_path) = local_markdown_url_path(url) else {
246                        continue;
247                    };
248                    let source_dir = source.path.parent().unwrap_or(source.path.as_path());
249                    let source_relative_candidate =
250                        common::normalize_path(source_dir.join(&url_path), Some(vault_path));
251                    if note_paths.contains(&source_relative_candidate) {
252                        insert_linked_target(&mut linked_targets, &source.path, &source_relative_candidate);
253                    }
254                    if let Some(target_path) = markdown_target_index.get(url_path.as_str()) {
255                        insert_linked_target(&mut linked_targets, &source.path, target_path);
256                    }
257                }
258                Link::Embed { .. } => {}
259            }
260        }
261
262        for target_path in linked_targets {
263            connectivity_by_path
264                .get_mut(target_path.as_path())
265                .expect("all linked note paths should have connectivity entries")
266                .backlink_count += 1;
267        }
268    }
269
270    connectivity_by_path
271}
272
273fn build_wiki_target_index(notes: &[Note]) -> HashMap<String, Vec<PathBuf>> {
274    let mut target_index: HashMap<String, Vec<PathBuf>> = HashMap::new();
275    for note in notes {
276        target_index.entry(note.id.clone()).or_default().push(note.path.clone());
277        if let Some(stem) = note.path.file_stem().and_then(|stem| stem.to_str()) {
278            target_index
279                .entry(stem.to_string())
280                .or_default()
281                .push(note.path.clone());
282        }
283        for alias in &note.aliases {
284            target_index.entry(alias.clone()).or_default().push(note.path.clone());
285        }
286    }
287    target_index
288}
289
290fn build_markdown_target_index(notes: &[Note], vault_path: &Path) -> HashMap<String, PathBuf> {
291    notes
292        .iter()
293        .map(|note| {
294            (
295                common::relative_path(vault_path, &note.path)
296                    .to_string_lossy()
297                    .to_string(),
298                note.path.clone(),
299            )
300        })
301        .collect()
302}
303
304fn insert_linked_target(linked_targets: &mut HashSet<PathBuf>, source_path: &Path, target_path: &Path) {
305    if target_path != source_path {
306        linked_targets.insert(target_path.to_path_buf());
307    }
308}
309
310fn has_outgoing_note_link(note: &Note) -> bool {
311    note.links.iter().any(|link| match &link.link {
312        Link::Wiki { target, .. } => !target.is_empty(),
313        Link::Markdown { url, .. } => is_local_markdown_note_url(url),
314        Link::Embed { .. } => false,
315    })
316}
317
318fn is_local_markdown_note_url(url: &str) -> bool {
319    local_markdown_url_path(url).is_some()
320}
321
322fn local_markdown_url_path(url: &str) -> Option<String> {
323    if url.contains("://") || url.starts_with('/') {
324        return None;
325    }
326    let url_path_raw = match url.find('#') {
327        Some(i) => &url[..i],
328        None => url,
329    };
330    let url_path = common::percent_decode(url_path_raw);
331    if !url_path.is_empty() && url_path.ends_with(".md") {
332        Some(url_path)
333    } else {
334        None
335    }
336}
337
338fn is_stranded_note_exempt(path: &Path) -> bool {
339    path.file_stem().and_then(|stem| stem.to_str()).is_some_and(|stem| {
340        stem.eq_ignore_ascii_case("README")
341            || stem.eq_ignore_ascii_case("CLAUDE")
342            || stem.eq_ignore_ascii_case("AGENTS")
343            || stem.eq_ignore_ascii_case("CHANGELOG")
344    })
345}
346
347/// Returns all notes in `notes` that link to `target`, paired with the specific
348/// links within each note that point to it.
349pub fn backlinks_from<'a>(
350    notes: &'a [Note],
351    target: &Note,
352    vault_path: &Path,
353) -> Vec<(&'a Note, Vec<crate::LocatedLink>)> {
354    notes
355        .iter()
356        .filter_map(|source| {
357            let matching = search::find_matching_links(source, target, vault_path);
358            if matching.is_empty() {
359                None
360            } else {
361                Some((source, matching))
362            }
363        })
364        .collect()
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn check_notes_reports_stranded_notes_but_ignores_readmes() {
373        let vault_dir = tempfile::tempdir().unwrap();
374        let notes = vec![
375            Note::parse(vault_dir.path().join("linked-source.md"), "See [[linked-target]].\n"),
376            Note::parse(vault_dir.path().join("linked-target.md"), "Body.\n"),
377            Note::parse(vault_dir.path().join("isolated.md"), "No note links here.\n"),
378            Note::parse(vault_dir.path().join("README.md"), "Project overview.\n"),
379        ];
380
381        let report = check_notes(vault_dir.path(), &notes);
382        let stranded_paths: Vec<_> = report
383            .stranded_notes
384            .iter()
385            .map(|note| note.path.file_name().unwrap().to_string_lossy().to_string())
386            .collect();
387
388        assert_eq!(stranded_paths, vec!["isolated.md"]);
389        assert!(report.has_issues());
390    }
391
392    #[test]
393    fn check_notes_treats_broken_and_self_links_as_outgoing_links() {
394        let vault_dir = tempfile::tempdir().unwrap();
395        let notes = vec![
396            Note::parse(vault_dir.path().join("broken.md"), "See [[missing-note]].\n"),
397            Note::parse(vault_dir.path().join("self-link.md"), "See [[self-link]].\n"),
398        ];
399
400        let report = check_notes(vault_dir.path(), &notes);
401        assert_eq!(report.broken_links.len(), 1);
402        assert!(report.stranded_notes.is_empty());
403    }
404
405    #[test]
406    fn check_notes_counts_each_source_target_backlink_once() {
407        let vault_dir = tempfile::tempdir().unwrap();
408        let notes = vec![
409            Note::parse(vault_dir.path().join("wiki-source.md"), "See [[dup]] and [[dup]].\n"),
410            Note::parse(
411                vault_dir.path().join("markdown-source.md"),
412                "See [A](dupe-a.md) and [B](dupe-b.md).\n",
413            ),
414            Note::parse(vault_dir.path().join("dupe-a.md"), "---\nid: dup\n---\n"),
415            Note::parse(vault_dir.path().join("dupe-b.md"), "---\nid: dup\n---\n"),
416        ];
417
418        let report = check_notes(vault_dir.path(), &notes);
419        let duplicate = report
420            .duplicate_ids
421            .iter()
422            .find(|duplicate| duplicate.id == "dup")
423            .expect("duplicate ID should be reported");
424        let backlink_counts: Vec<_> = duplicate
425            .notes
426            .iter()
427            .map(|note| {
428                (
429                    note.path.file_name().unwrap().to_string_lossy().to_string(),
430                    note.backlink_count,
431                )
432            })
433            .collect();
434
435        assert_eq!(
436            backlink_counts,
437            vec![("dupe-a.md".to_string(), 2), ("dupe-b.md".to_string(), 2)]
438        );
439    }
440}