#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkClass {
Resolved { url: String },
Mismatch { canonical: String },
Broken,
External,
Anchor,
}
pub fn classify_link(target: &str, from_source: &str, index: &dyn UrlIndex) -> LinkClass {
if target.starts_with("http://") || target.starts_with("https://")
|| target.starts_with("//") || target.starts_with("mailto:")
|| target.starts_with("tel:") || target.starts_with("data:")
{
return LinkClass::External;
}
if target.starts_with('#') {
return LinkClass::Anchor;
}
let path = crate::resolve::fuzzy_path::split_url_path(target).0;
if path.is_empty() {
return LinkClass::Anchor; }
let last = path.rsplit('/').next().unwrap_or(path);
let asset_shaped = last.contains('.') && !last.ends_with('.');
if path.starts_with('/') {
if index.lookup_exact(path.trim_start_matches('/')) || index.lookup_exact(path) {
return LinkClass::Resolved { url: path.to_string() };
}
if let Some(canonical) = index.lookup_normalized(path) {
return LinkClass::Mismatch { canonical };
}
if asset_shaped {
return LinkClass::External; }
return LinkClass::Broken;
}
if let Some(url) = index.resolve_reference_to_url(path, from_source) {
return LinkClass::Resolved { url };
}
if asset_shaped {
return LinkClass::External; }
LinkClass::Broken
}
pub trait UrlIndex {
fn lookup_exact(&self, url_path: &str) -> bool;
fn lookup_normalized(&self, url_path: &str) -> Option<String>;
fn resolve_reference_to_url(&self, reference: &str, from_source: &str) -> Option<String>;
}
#[cfg(test)]
pub(crate) struct FakeUrlIndex {
refs: std::collections::HashMap<String, String>,
}
#[cfg(test)]
impl FakeUrlIndex {
pub fn new() -> Self { FakeUrlIndex { refs: std::collections::HashMap::new() } }
pub fn resolving(pairs: &[(&str, &str)]) -> Self {
FakeUrlIndex {
refs: pairs.iter().map(|(r, u)| (r.to_string(), u.to_string())).collect(),
}
}
}
#[cfg(test)]
impl UrlIndex for FakeUrlIndex {
fn lookup_exact(&self, _url_path: &str) -> bool { false }
fn lookup_normalized(&self, _url_path: &str) -> Option<String> { None }
fn resolve_reference_to_url(&self, reference: &str, _from_source: &str) -> Option<String> {
self.refs.get(reference).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linkclass_constructs() {
assert_eq!(LinkClass::Broken, LinkClass::Broken);
}
struct FakeIndex {
exact: std::collections::HashSet<String>,
normalized: std::collections::HashMap<String, Option<String>>, refs: std::collections::HashMap<String, String>, }
impl UrlIndex for FakeIndex {
fn lookup_exact(&self, u: &str) -> bool { self.exact.contains(u.trim_matches('/')) }
fn lookup_normalized(&self, u: &str) -> Option<String> {
self.normalized.get(&norm(u)).cloned().flatten()
}
fn resolve_reference_to_url(&self, r: &str, _from: &str) -> Option<String> {
self.refs.get(r).cloned()
}
}
fn norm(u: &str) -> String { u.trim_matches('/').to_lowercase() }
fn idx() -> FakeIndex {
let mut exact = std::collections::HashSet::new();
exact.insert("research".to_string());
let mut normalized = std::collections::HashMap::new();
normalized.insert("research".to_string(), Some("/research/".to_string()));
let mut refs = std::collections::HashMap::new();
refs.insert("Research".to_string(), "/research/".to_string());
FakeIndex { exact, normalized, refs }
}
#[test] fn external_passthrough() {
assert_eq!(classify_link("https://x.com", "a.md", &idx()), LinkClass::External);
assert_eq!(classify_link("mailto:a@b.c", "a.md", &idx()), LinkClass::External);
}
#[test] fn anchor_only() {
assert_eq!(classify_link("#sec", "a.md", &idx()), LinkClass::Anchor);
}
#[test] fn absolute_exact_resolved() {
assert_eq!(classify_link("/research/", "a.md", &idx()),
LinkClass::Resolved { url: "/research/".into() });
}
#[test] fn absolute_case_mismatch() { assert_eq!(classify_link("/Research/", "a.md", &idx()),
LinkClass::Mismatch { canonical: "/research/".into() });
}
#[test] fn absolute_mismatch_keeps_fragment_out_of_lookup() {
assert_eq!(classify_link("/Research/#theme-1", "a.md", &idx()),
LinkClass::Mismatch { canonical: "/research/".into() });
}
#[test] fn reference_resolved() {
assert_eq!(classify_link("Research", "a.md", &idx()),
LinkClass::Resolved { url: "/research/".into() });
}
#[test] fn asset_shaped_unknown_is_silent_not_broken() {
assert_eq!(classify_link("/img/Logo.PNG", "a.md", &idx()), LinkClass::External);
}
#[test] fn unknown_reference_is_broken() {
assert_eq!(classify_link("nope-no-page", "a.md", &idx()), LinkClass::Broken);
}
}