moss_core/resolve/
link_class.rs1#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum LinkClass {
8 Resolved { url: String },
10 Mismatch { canonical: String },
12 Broken,
14 External,
16 Anchor,
18}
19
20pub fn classify_link(target: &str, from_source: &str, index: &dyn UrlIndex) -> LinkClass {
21 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; }
36
37 let last = path.rsplit('/').next().unwrap_or(path);
39 let asset_shaped = last.contains('.') && !last.ends_with('.');
40
41 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; }
53 return LinkClass::Broken;
54 }
55
56 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; }
63 LinkClass::Broken
64}
65
66pub trait UrlIndex {
68 fn lookup_exact(&self, url_path: &str) -> bool;
71 fn lookup_normalized(&self, url_path: &str) -> Option<String>;
74 fn resolve_reference_to_url(&self, reference: &str, from_source: &str) -> Option<String>;
76}
77
78#[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 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 struct FakeIndex {
120 exact: std::collections::HashSet<String>,
121 normalized: std::collections::HashMap<String, Option<String>>, refs: std::collections::HashMap<String, String>, }
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() { 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 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}