Skip to main content

moss_core/resolve/
link_class.rs

1//! Pure link classification against an injected URL index.
2//! The editor implements `UrlIndex` over the inverted ArticleMap; the build
3//! may implement it over page_map for the parity test. moss-core does NO I/O.
4
5/// Classification of one link target against the deployed URL space.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum LinkClass {
8    /// Matches a canonical deployed URL exactly (case-sensitive).
9    Resolved { url: String },
10    /// A page exists, but this link won't hit its canonical URL (case/slug).
11    Mismatch { canonical: String },
12    /// Internal reference/absolute path with no deployed page (best-effort).
13    Broken,
14    /// http(s)/protocol-relative/mailto/tel/data — not checked.
15    External,
16    /// Same-page #fragment.
17    Anchor,
18}
19
20pub fn classify_link(target: &str, from_source: &str, index: &dyn UrlIndex) -> LinkClass {
21    // 1. Author-facing short-circuits.
22    if target.starts_with("http://") || target.starts_with("https://")
23        || target.starts_with("//") || target.starts_with("mailto:")
24        || target.starts_with("tel:") || target.starts_with("data:")
25    {
26        return LinkClass::External;
27    }
28    if target.starts_with('#') {
29        return LinkClass::Anchor;
30    }
31
32    let path = crate::resolve::fuzzy_path::split_url_path(target).0;
33    if path.is_empty() {
34        return LinkClass::Anchor; // pure ?query/#frag on current page
35    }
36
37    // 2. Asset-shaped (has a file extension) and not a known page → stay silent.
38    let last = path.rsplit('/').next().unwrap_or(path);
39    let asset_shaped = last.contains('.') && !last.ends_with('.');
40
41    // 3. Absolute path: look up against the deployed URL space directly.
42    // Note: trailing-slash differences (/research vs /research/) both Resolve — they redirect on hosts, they don't 404.
43    if path.starts_with('/') {
44        if index.lookup_exact(path.trim_start_matches('/')) || index.lookup_exact(path) {
45            return LinkClass::Resolved { url: path.to_string() };
46        }
47        if let Some(canonical) = index.lookup_normalized(path) {
48            return LinkClass::Mismatch { canonical };
49        }
50        if asset_shaped {
51            return LinkClass::External; // silent
52        }
53        return LinkClass::Broken;
54    }
55
56    // 4. Reference (relative/bare): resolve to a canonical URL.
57    if let Some(url) = index.resolve_reference_to_url(path, from_source) {
58        return LinkClass::Resolved { url };
59    }
60    if asset_shaped {
61        return LinkClass::External; // silent (relative asset the map doesn't index)
62    }
63    LinkClass::Broken
64}
65
66/// Backing data for classification, injected by the caller (zero-I/O in core).
67pub trait UrlIndex {
68    /// Case-sensitive presence of a URL path in the deployed space (host-accurate).
69    /// Implementors MUST normalize `url_path` by stripping leading and trailing slashes before comparison; callers MAY pass paths with either or both.
70    fn lookup_exact(&self, url_path: &str) -> bool;
71    /// Case/slug-normalized match → canonical URL. MUST return Some only when the
72    /// normalized bucket has exactly one member (else None — ambiguous).
73    fn lookup_normalized(&self, url_path: &str) -> Option<String>;
74    /// Resolve a wikilink/relative reference to its canonical URL path.
75    fn resolve_reference_to_url(&self, reference: &str, from_source: &str) -> Option<String>;
76}
77
78/// Cross-module test fake for `UrlIndex`. `new()` returns the empty/negative
79/// result for every method; `resolving(&[..])` makes `resolve_reference_to_url`
80/// return mapped URLs for the listed references (the other methods stay
81/// negative). Module-level so `reference.rs` tests can import it.
82#[cfg(test)]
83pub(crate) struct FakeUrlIndex {
84    refs: std::collections::HashMap<String, String>,
85}
86
87#[cfg(test)]
88impl FakeUrlIndex {
89    pub fn new() -> Self { FakeUrlIndex { refs: std::collections::HashMap::new() } }
90    /// Map `reference → url` so `classify_link` returns `Resolved { url }` for
91    /// each listed reference. `lookup_exact`/`lookup_normalized` stay negative.
92    pub fn resolving(pairs: &[(&str, &str)]) -> Self {
93        FakeUrlIndex {
94            refs: pairs.iter().map(|(r, u)| (r.to_string(), u.to_string())).collect(),
95        }
96    }
97}
98
99#[cfg(test)]
100impl UrlIndex for FakeUrlIndex {
101    fn lookup_exact(&self, _url_path: &str) -> bool { false }
102    fn lookup_normalized(&self, _url_path: &str) -> Option<String> { None }
103    fn resolve_reference_to_url(&self, reference: &str, _from_source: &str) -> Option<String> {
104        self.refs.get(reference).cloned()
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn linkclass_constructs() {
114        assert_eq!(LinkClass::Broken, LinkClass::Broken);
115    }
116
117
118    // A tiny in-memory UrlIndex fixture:
119    struct FakeIndex {
120        exact: std::collections::HashSet<String>,
121        normalized: std::collections::HashMap<String, Option<String>>, // norm_key -> Some(canonical)|None(ambiguous)
122        refs: std::collections::HashMap<String, String>,               // reference -> url
123    }
124    impl UrlIndex for FakeIndex {
125        fn lookup_exact(&self, u: &str) -> bool { self.exact.contains(u.trim_matches('/')) }
126        fn lookup_normalized(&self, u: &str) -> Option<String> {
127            self.normalized.get(&norm(u)).cloned().flatten()
128        }
129        fn resolve_reference_to_url(&self, r: &str, _from: &str) -> Option<String> {
130            self.refs.get(r).cloned()
131        }
132    }
133    fn norm(u: &str) -> String { u.trim_matches('/').to_lowercase() }
134
135    fn idx() -> FakeIndex {
136        let mut exact = std::collections::HashSet::new();
137        exact.insert("research".to_string());
138        let mut normalized = std::collections::HashMap::new();
139        normalized.insert("research".to_string(), Some("/research/".to_string()));
140        let mut refs = std::collections::HashMap::new();
141        refs.insert("Research".to_string(), "/research/".to_string());
142        FakeIndex { exact, normalized, refs }
143    }
144
145    #[test] fn external_passthrough() {
146        assert_eq!(classify_link("https://x.com", "a.md", &idx()), LinkClass::External);
147        assert_eq!(classify_link("mailto:a@b.c", "a.md", &idx()), LinkClass::External);
148    }
149    #[test] fn anchor_only() {
150        assert_eq!(classify_link("#sec", "a.md", &idx()), LinkClass::Anchor);
151    }
152    #[test] fn absolute_exact_resolved() {
153        assert_eq!(classify_link("/research/", "a.md", &idx()),
154                   LinkClass::Resolved { url: "/research/".into() });
155    }
156    #[test] fn absolute_case_mismatch() { // the yinlab bug
157        assert_eq!(classify_link("/Research/", "a.md", &idx()),
158                   LinkClass::Mismatch { canonical: "/research/".into() });
159    }
160    #[test] fn absolute_mismatch_keeps_fragment_out_of_lookup() {
161        assert_eq!(classify_link("/Research/#theme-1", "a.md", &idx()),
162                   LinkClass::Mismatch { canonical: "/research/".into() });
163    }
164    #[test] fn reference_resolved() {
165        assert_eq!(classify_link("Research", "a.md", &idx()),
166                   LinkClass::Resolved { url: "/research/".into() });
167    }
168    #[test] fn asset_shaped_unknown_is_silent_not_broken() {
169        // has an extension, not in index → treat as External (silent), never Broken
170        assert_eq!(classify_link("/img/Logo.PNG", "a.md", &idx()), LinkClass::External);
171    }
172    #[test] fn unknown_reference_is_broken() {
173        assert_eq!(classify_link("nope-no-page", "a.md", &idx()), LinkClass::Broken);
174    }
175}