1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::{Link, Note, common, search};
5
6pub struct VaultHealthReport {
8 pub note_count: usize,
10 pub duplicate_ids: Vec<DuplicateId>,
12 pub duplicate_aliases: Vec<DuplicateAlias>,
14 pub broken_links: Vec<BrokenLink>,
16 pub stranded_notes: Vec<StrandedNote>,
18}
19
20impl VaultHealthReport {
21 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
30pub struct DuplicateId {
32 pub id: String,
33 pub notes: Vec<NoteRef>,
35}
36
37pub struct DuplicateAlias {
39 pub alias: String,
40 pub notes: Vec<NoteRef>,
42}
43
44pub struct NoteRef {
46 pub path: PathBuf,
47 pub backlink_count: usize,
48}
49
50pub struct BrokenLink {
52 pub source_path: PathBuf,
53 pub line: usize,
55 pub text: String,
57}
58
59pub 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
70pub 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 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 let mut alias_map: HashMap<String, HashSet<PathBuf>> = HashMap::new();
104 for note in notes.iter() {
105 for alias in ¬e.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 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 ¬e.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 ¬e.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 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 let mut stranded_notes: Vec<StrandedNote> = notes
193 .iter()
194 .filter(|note| !is_stranded_note_exempt(¬e.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 ¬e.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, ¬e.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
347pub 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(), ¬es);
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(), ¬es);
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(), ¬es);
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}