Skip to main content

llmwiki_tooling/
resolve.rs

1use crate::error::WikiError;
2use crate::page::{WikilinkFragment, WikilinkOccurrence};
3use crate::wiki::Wiki;
4
5/// Why a wikilink could not be resolved.
6#[derive(Debug)]
7pub enum BrokenReason {
8    PageNotFound,
9    HeadingNotFound { heading: String },
10    BlockNotFound { block_id: String },
11}
12
13impl std::fmt::Display for BrokenReason {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        match self {
16            Self::PageNotFound => write!(f, "page not found"),
17            Self::HeadingNotFound { heading } => write!(f, "heading not found: '{heading}'"),
18            Self::BlockNotFound { block_id } => write!(f, "block not found: '^{block_id}'"),
19        }
20    }
21}
22
23/// Resolve a wikilink occurrence against the wiki.
24pub fn resolve_wikilink(wikilink: &WikilinkOccurrence, wiki: &Wiki) -> Result<(), ResolveError> {
25    let (_, entry) = wiki
26        .find(wikilink.page.as_str())
27        .ok_or(ResolveError::Broken(BrokenReason::PageNotFound))?;
28
29    if let Some(fragment) = &wikilink.fragment {
30        let target_path = wiki.root().path().join(&entry.rel_path);
31
32        match fragment {
33            WikilinkFragment::Heading(heading) => {
34                let headings = wiki.headings(&target_path).map_err(ResolveError::Wiki)?;
35                let found = headings
36                    .iter()
37                    .any(|h| h.text.eq_ignore_ascii_case(heading));
38                if !found {
39                    return Err(ResolveError::Broken(BrokenReason::HeadingNotFound {
40                        heading: heading.clone(),
41                    }));
42                }
43            }
44            WikilinkFragment::Block(block_id) => {
45                let block_ids = wiki.block_ids(&target_path).map_err(ResolveError::Wiki)?;
46                let found = block_ids.iter().any(|b| b.as_str() == block_id.as_str());
47                if !found {
48                    return Err(ResolveError::Broken(BrokenReason::BlockNotFound {
49                        block_id: block_id.as_str().to_owned(),
50                    }));
51                }
52            }
53        }
54    }
55
56    Ok(())
57}
58
59#[derive(Debug, thiserror::Error)]
60pub enum ResolveError {
61    #[error("{0}")]
62    Broken(BrokenReason),
63    #[error(transparent)]
64    Wiki(WikiError),
65}
66
67/// Check all wikilinks in a file and return the broken ones.
68pub fn find_broken_links(
69    wiki: &Wiki,
70    file_path: &std::path::Path,
71) -> Result<Vec<(WikilinkOccurrence, BrokenReason)>, WikiError> {
72    let wikilinks = wiki.wikilinks(file_path)?;
73    let mut broken = Vec::new();
74
75    for wl in wikilinks {
76        if wl.page.as_str().is_empty() {
77            continue;
78        }
79        match resolve_wikilink(wl, wiki) {
80            Ok(()) => {}
81            Err(ResolveError::Broken(reason)) => broken.push((wl.clone(), reason)),
82            Err(ResolveError::Wiki(e)) => return Err(e),
83        }
84    }
85
86    Ok(broken)
87}