use regex::Regex;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use crate::link_index::{OutboundLink, resolve_relative_url};
use crate::path_resolver::{
PathResolverConfig, ResolvedPath, normalize_link_target, resolve_request_path,
};
use crate::relationships::{AmbiguousEndpoint, RelationshipCycle};
use crate::url_path::is_external_url;
use crate::wikilink_index::AmbiguousWikilink;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MediaKind {
Image,
Video,
Audio,
Source,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PageError {
BrokenInternalLink {
target: String,
text: String,
#[serde(skip_serializing_if = "Option::is_none")]
anchor: Option<String>,
},
BrokenMediaReference { src: String, kind: MediaKind },
UnresolvedWikilink { raw: String },
FrontmatterParseError { message: String },
RelationshipCycle {
members: Vec<String>,
rel_type: String,
},
AmbiguousRelationshipEndpoint {
raw: String,
resolved_to: String,
candidates: Vec<String>,
},
AmbiguousWikilink {
raw: String,
resolved_to: String,
candidates: Vec<String>,
},
UnplayableMedia {
src: String,
kind: MediaKind,
reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
remedy: Option<String>,
advisory: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediaReference {
pub src: String,
pub kind: MediaKind,
pub path: std::path::PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PageErrors {
pub page_url: String,
pub errors: Vec<PageError>,
}
pub fn validate_internal_links(
outbound: &[OutboundLink],
resolver_config: &PathResolverConfig,
) -> Vec<PageError> {
let mut errors = Vec::new();
for link in outbound {
if !link.internal {
continue;
}
if link.to.starts_with('#') || link.to.is_empty() {
continue;
}
if is_external_url(&link.to) {
continue;
}
let request_path = normalize_link_target(&link.to);
let resolved = resolve_request_path(resolver_config, &request_path);
if matches!(resolved, ResolvedPath::NotFound) {
errors.push(PageError::BrokenInternalLink {
target: link.to.clone(),
text: link.text.clone(),
anchor: link.anchor.clone(),
});
}
}
errors
}
pub fn validate_media_references(
html: &str,
resolver_config: &PathResolverConfig,
page_url: &str,
) -> Vec<PageError> {
scan_media_elements(html)
.into_iter()
.filter(|(src, _)| !media_reference_resolves(src, resolver_config, page_url))
.map(|(src, kind)| PageError::BrokenMediaReference { src, kind })
.collect()
}
fn scan_media_elements(html: &str) -> Vec<(String, MediaKind)> {
let doc = Html::parse_document(html);
let specs: [(&str, MediaKind); 4] = [
("img[src]", MediaKind::Image),
("video[src]", MediaKind::Video),
("audio[src]", MediaKind::Audio),
("source[src]", MediaKind::Source),
];
specs
.into_iter()
.filter_map(|(selector_str, kind)| {
Selector::parse(selector_str).ok().map(|sel| (sel, kind))
})
.flat_map(|(selector, kind)| {
doc.select(&selector)
.filter_map(|el| el.value().attr("src"))
.filter(|src| !src.is_empty() && !is_external_url(src))
.map(|src| (src.to_string(), kind.clone()))
.collect::<Vec<_>>()
})
.collect()
}
pub fn collect_media_references(
html: &str,
resolver_config: &PathResolverConfig,
page_url: &str,
) -> Vec<MediaReference> {
let mut seen = std::collections::HashSet::new();
scan_media_elements(html)
.into_iter()
.filter_map(|(src, kind)| {
let path_part = src.split(['#', '?']).next().unwrap_or_default();
if path_part.is_empty() || !seen.insert(path_part.to_string()) {
return None;
}
let absolute_url = resolve_relative_url(page_url, path_part, true);
let request_path = normalize_link_target(&absolute_url);
match resolve_request_path(resolver_config, &request_path) {
ResolvedPath::StaticFile(path) => Some(MediaReference {
src: path_part.to_string(),
kind,
path,
}),
_ => None,
}
})
.collect()
}
fn media_reference_resolves(
src: &str,
resolver_config: &PathResolverConfig,
page_url: &str,
) -> bool {
let path_part = src.split(['#', '?']).next().unwrap_or_default();
if path_part.is_empty() {
return true;
}
let absolute_url = resolve_relative_url(page_url, path_part, true);
let request_path = normalize_link_target(&absolute_url);
!matches!(
resolve_request_path(resolver_config, &request_path),
ResolvedPath::NotFound
)
}
static WIKILINK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[\[([^\[\]\n]+)\]\]").expect("static wikilink regex is valid"));
fn mask_code_blocks(html: &str) -> String {
static CODE_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)<code\b[^>]*>.*?</code>|<pre\b[^>]*>.*?</pre>")
.expect("static code-block regex is valid")
});
CODE_BLOCK_RE
.replace_all(html, |caps: ®ex::Captures| {
" ".repeat(caps[0].len())
})
.into_owned()
}
pub fn detect_unresolved_wikilinks(html: &str) -> Vec<PageError> {
let masked = mask_code_blocks(html);
let mut errors = Vec::new();
let mut seen = std::collections::HashSet::new();
for cap in WIKILINK_RE.captures_iter(&masked) {
let raw = cap
.get(0)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
if raw.is_empty() {
continue;
}
if seen.insert(raw.clone()) {
errors.push(PageError::UnresolvedWikilink { raw });
}
}
errors
}
pub fn frontmatter_parse_errors(err: &Option<String>) -> Vec<PageError> {
err.iter()
.map(|message| PageError::FrontmatterParseError {
message: message.clone(),
})
.collect()
}
pub fn relationship_cycle_errors(cycles: &[RelationshipCycle]) -> Vec<PageError> {
cycles
.iter()
.map(|cycle| PageError::RelationshipCycle {
members: cycle.members.clone(),
rel_type: cycle.rel_type.clone(),
})
.collect()
}
pub fn ambiguous_relationship_endpoint_errors(endpoints: &[AmbiguousEndpoint]) -> Vec<PageError> {
endpoints
.iter()
.map(|endpoint| PageError::AmbiguousRelationshipEndpoint {
raw: endpoint.raw.clone(),
resolved_to: endpoint.resolved_to.clone(),
candidates: endpoint.candidates.clone(),
})
.collect()
}
pub fn ambiguous_wikilink_errors(wikilinks: &[AmbiguousWikilink]) -> Vec<PageError> {
wikilinks
.iter()
.map(|wikilink| PageError::AmbiguousWikilink {
raw: wikilink.raw.clone(),
resolved_to: wikilink.resolved_to.clone(),
candidates: wikilink.candidates.clone(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
fn make_config<'a>(
base_dir: &'a Path,
exts: &'a [String],
index_file: &'a str,
tag_sources: &'a [String],
) -> PathResolverConfig<'a> {
PathResolverConfig {
base_dir,
canonical_base_dir: None,
static_folder: "static",
markdown_extensions: exts,
index_file,
tag_sources,
}
}
#[test]
fn broken_internal_link_is_reported() {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![OutboundLink {
to: "/nonexistent/".to_string(),
text: "bad".to_string(),
anchor: None,
internal: true,
}];
let errs = validate_internal_links(&outbound, &cfg);
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::BrokenInternalLink { target, .. } if target == "/nonexistent/"
));
}
#[test]
fn valid_internal_link_is_ignored() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("page.md"), "# x").unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![OutboundLink {
to: "/page/".to_string(),
text: "ok".to_string(),
anchor: None,
internal: true,
}];
let errs = validate_internal_links(&outbound, &cfg);
assert!(errs.is_empty());
}
#[test]
fn external_link_is_ignored() {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![OutboundLink {
to: "https://example.com/anything".to_string(),
text: "ext".to_string(),
anchor: None,
internal: false,
}];
let errs = validate_internal_links(&outbound, &cfg);
assert!(errs.is_empty());
}
#[test]
fn external_scheme_link_marked_internal_is_ignored() {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = [
"ftp://example.com/data.zip",
"magnet:?xt=urn:btih:abc",
"sms:+15555550123",
]
.iter()
.map(|to| OutboundLink {
to: (*to).to_string(),
text: "ext".to_string(),
anchor: None,
internal: true,
})
.collect::<Vec<_>>();
let errs = validate_internal_links(&outbound, &cfg);
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
#[test]
fn fragment_only_link_is_ignored() {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![OutboundLink {
to: "#section".to_string(),
text: "anchor".to_string(),
anchor: Some("#section".to_string()),
internal: true,
}];
let errs = validate_internal_links(&outbound, &cfg);
assert!(errs.is_empty());
}
#[test]
fn percent_encoded_link_to_existing_file_is_not_reported() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("IronCore Swag T-shirts Gifts.md"), "# x").unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![
OutboundLink {
to: "/IronCore%20Swag%20T-shirts%20Gifts".to_string(),
text: "no trailing slash".to_string(),
anchor: None,
internal: true,
},
OutboundLink {
to: "/IronCore%20Swag%20T-shirts%20Gifts/".to_string(),
text: "trailing slash".to_string(),
anchor: None,
internal: true,
},
];
let errs = validate_internal_links(&outbound, &cfg);
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
#[test]
fn percent_encoded_apostrophe_link_is_not_reported() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("World's Best.md"), "# x").unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![OutboundLink {
to: "/World%27s%20Best/".to_string(),
text: "apostrophe".to_string(),
anchor: None,
internal: true,
}];
let errs = validate_internal_links(&outbound, &cfg);
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
#[test]
fn percent_encoded_unicode_link_is_not_reported() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("café.md"), "# x").unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![OutboundLink {
to: "/caf%C3%A9/".to_string(),
text: "unicode".to_string(),
anchor: None,
internal: true,
}];
let errs = validate_internal_links(&outbound, &cfg);
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
#[test]
fn percent_encoded_link_with_anchor_and_query_is_not_reported() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("IronCore Swag T-shirts Gifts.md"), "# x").unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![OutboundLink {
to: "/IronCore%20Swag%20T-shirts%20Gifts/?x=1#top".to_string(),
text: "anchor and query".to_string(),
anchor: Some("#top".to_string()),
internal: true,
}];
let errs = validate_internal_links(&outbound, &cfg);
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
#[test]
fn missing_percent_encoded_target_is_still_reported() {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let outbound = vec![OutboundLink {
to: "/Nope%20Missing/".to_string(),
text: "gone".to_string(),
anchor: None,
internal: true,
}];
let errs = validate_internal_links(&outbound, &cfg);
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::BrokenInternalLink { target, .. } if target == "/Nope%20Missing/"
));
}
fn media_setup() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
std::fs::write(base.join("photo.png"), b"\x89PNG").unwrap();
std::fs::create_dir_all(base.join("static/images")).unwrap();
std::fs::write(base.join("static/images/ok.png"), b"\x89PNG").unwrap();
(dir, base)
}
#[test]
fn broken_img_is_reported() {
let (_guard, base) = media_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="./missing.png" alt="x"></p>"#;
let errs = validate_media_references(html, &cfg, "/");
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::BrokenMediaReference { kind: MediaKind::Image, src } if src == "./missing.png"
));
}
#[test]
fn valid_img_is_ignored() {
let (_guard, base) = media_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="photo.png" alt="x"></p>"#;
let errs = validate_media_references(html, &cfg, "/");
assert!(errs.is_empty());
}
#[test]
fn absolute_path_image_under_static_is_ignored() {
let (_guard, base) = media_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="/images/ok.png" alt="x"></p>"#;
let errs = validate_media_references(html, &cfg, "/docs/guide/");
assert!(errs.is_empty());
}
#[test]
fn external_image_is_ignored() {
let (_guard, base) = media_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="https://example.com/a.png"></p>"#;
let errs = validate_media_references(html, &cfg, "/");
assert!(errs.is_empty());
}
#[test]
fn broken_video_audio_and_source_are_reported() {
let (_guard, base) = media_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"
<video src="./gone.mp4"></video>
<audio src="./gone.mp3"></audio>
<video><source src="./gone.webm"></video>
"#;
let errs = validate_media_references(html, &cfg, "/");
assert!(
errs.iter().any(|e| matches!(
e,
PageError::BrokenMediaReference {
kind: MediaKind::Video,
..
}
)),
"{:?}",
errs
);
assert!(errs.iter().any(|e| matches!(
e,
PageError::BrokenMediaReference {
kind: MediaKind::Audio,
..
}
)));
assert!(errs.iter().any(|e| matches!(
e,
PageError::BrokenMediaReference {
kind: MediaKind::Source,
..
}
)));
}
#[test]
fn percent_encoded_relative_img_next_to_markdown_is_ignored() {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
std::fs::write(base.join("my photo.png"), b"\x89PNG").unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="./my%20photo.png" alt="x"></p>"#;
let errs = validate_media_references(html, &cfg, "/");
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
fn attachments_setup() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
let attachments = base.join("Projects/Ideas/hledger-web-gui_attachments");
std::fs::create_dir_all(&attachments).unwrap();
std::fs::write(base.join("Projects/Ideas/hledger-web-gui.md"), "# x").unwrap();
std::fs::write(attachments.join("img.png"), b"\x89PNG").unwrap();
std::fs::write(attachments.join("my photo.png"), b"\x89PNG").unwrap();
(dir, base)
}
#[test]
fn parent_relative_img_next_to_markdown_is_ignored() {
let (_guard, base) = attachments_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="../hledger-web-gui_attachments/img.png" alt="x"></p>"#;
let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
#[test]
fn parent_relative_percent_encoded_img_is_ignored() {
let (_guard, base) = attachments_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="../hledger-web-gui_attachments/my%20photo.png" alt="x"></p>"#;
let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
#[test]
fn index_page_sibling_img_is_ignored() {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(base.join("docs")).unwrap();
std::fs::write(base.join("docs/index.md"), "# x").unwrap();
std::fs::write(base.join("docs/img.png"), b"\x89PNG").unwrap();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="img.png" alt="x"></p>"#;
let errs = validate_media_references(html, &cfg, "/docs/");
assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
}
#[test]
fn parent_relative_missing_img_is_reported() {
let (_guard, base) = attachments_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<p><img src="../hledger-web-gui_attachments/nope.png" alt="x"></p>"#;
let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::BrokenMediaReference { kind: MediaKind::Image, src }
if src == "../hledger-web-gui_attachments/nope.png"
));
}
fn video_setup() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let base = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(base.join("Projects")).unwrap();
std::fs::write(base.join("Projects/note.md"), "# x").unwrap();
std::fs::write(base.join("Projects/Foo Bar.mp4"), b"\x00\x00\x00\x18ftyp").unwrap();
(dir, base)
}
#[test]
fn collect_media_references_resolves_source_to_disk_path() {
let (_guard, base) = video_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<video><source src="../Foo%20Bar.mp4" type="video/mp4"></video>"#;
let refs = collect_media_references(html, &cfg, "/Projects/note/");
assert_eq!(refs.len(), 1, "{:?}", refs);
assert_eq!(refs[0].src, "../Foo%20Bar.mp4");
assert_eq!(refs[0].kind, MediaKind::Source);
assert_eq!(refs[0].path, base.join("Projects/Foo Bar.mp4"));
}
#[test]
fn collect_media_references_strips_time_fragment() {
let (_guard, base) = video_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<video><source src="../Foo%20Bar.mp4#t=30,200"></video>"#;
let refs = collect_media_references(html, &cfg, "/Projects/note/");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].src, "../Foo%20Bar.mp4");
}
#[test]
fn collect_media_references_dedupes_repeated_embeds() {
let (_guard, base) = video_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"
<video><source src="../Foo%20Bar.mp4"></video>
<video><source src="../Foo%20Bar.mp4#t=5"></video>
"#;
let refs = collect_media_references(html, &cfg, "/Projects/note/");
assert_eq!(refs.len(), 1, "{:?}", refs);
}
#[test]
fn collect_media_references_skips_missing_and_external() {
let (_guard, base) = video_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"
<video><source src="../Gone.mp4"></video>
<video><source src="https://example.com/x.mp4"></video>
"#;
let refs = collect_media_references(html, &cfg, "/Projects/note/");
assert!(refs.is_empty(), "{:?}", refs);
}
#[test]
fn collect_media_references_finds_images_and_audio_too() {
let (_guard, base) = media_setup();
let exts = vec!["md".to_string()];
let tags: Vec<String> = vec![];
let cfg = make_config(&base, &exts, "index.md", &tags);
let html = r#"<img src="photo.png"><audio src="photo.png"></audio>"#;
let refs = collect_media_references(html, &cfg, "/");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].kind, MediaKind::Image);
}
#[test]
fn literal_wikilink_in_body_is_reported() {
let html = "<p>See [[never-a-real-page]] for more.</p>";
let errs = detect_unresolved_wikilinks(html);
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::UnresolvedWikilink { raw } if raw == "[[never-a-real-page]]"
));
}
#[test]
fn transformed_wikilink_yields_no_match() {
let html = r#"<p><a href="/tags/rust/">rust</a></p>"#;
let errs = detect_unresolved_wikilinks(html);
assert!(errs.is_empty());
}
#[test]
fn wikilink_inside_code_block_is_ignored() {
let html =
"<p>Regular</p><pre><code>This is a literal [[bracket]] inside code</code></pre>";
let errs = detect_unresolved_wikilinks(html);
assert!(
errs.is_empty(),
"expected no wikilink errors inside code/pre, got: {:?}",
errs
);
}
#[test]
fn wikilink_inside_inline_code_is_ignored() {
let html = "<p>See <code>[[foo]]</code> for the literal syntax.</p>";
let errs = detect_unresolved_wikilinks(html);
assert!(errs.is_empty());
}
#[test]
fn repeated_wikilink_is_deduped() {
let html = "<p>[[bad]] again [[bad]] and [[bad]]</p>";
let errs = detect_unresolved_wikilinks(html);
assert_eq!(errs.len(), 1);
}
#[test]
fn wikilink_with_display_text_is_reported_verbatim() {
let html = "<p>[[Target|Display Text]]</p>";
let errs = detect_unresolved_wikilinks(html);
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::UnresolvedWikilink { raw } if raw == "[[Target|Display Text]]"
));
}
#[test]
fn page_error_serializes_with_snake_case_type_tag() {
let err = PageError::BrokenInternalLink {
target: "/x/".to_string(),
text: "x".to_string(),
anchor: None,
};
let json = serde_json::to_string(&err).unwrap();
assert!(
json.contains("\"type\":\"broken_internal_link\""),
"{}",
json
);
assert!(!json.contains("\"anchor\""), "{}", json);
}
#[test]
fn media_kind_serializes_as_snake_case() {
let err = PageError::BrokenMediaReference {
src: "./x.png".to_string(),
kind: MediaKind::Image,
};
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("\"kind\":\"image\""), "{}", json);
assert!(
json.contains("\"type\":\"broken_media_reference\""),
"{}",
json
);
}
#[test]
fn unresolved_wikilink_serializes() {
let err = PageError::UnresolvedWikilink {
raw: "[[foo]]".to_string(),
};
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("\"type\":\"unresolved_wikilink\""));
assert!(json.contains("\"raw\":\"[[foo]]\""));
}
#[test]
fn frontmatter_parse_error_serializes() {
let err = PageError::FrontmatterParseError {
message: "mapping values are not allowed".to_string(),
};
let json = serde_json::to_string(&err).unwrap();
assert!(
json.contains("\"type\":\"frontmatter_parse_error\""),
"{}",
json
);
assert!(json.contains("mapping values are not allowed"), "{}", json);
}
#[test]
fn frontmatter_parse_errors_none_is_empty() {
assert!(frontmatter_parse_errors(&None).is_empty());
}
#[test]
fn frontmatter_parse_errors_some_yields_one() {
let errs = frontmatter_parse_errors(&Some("bad yaml".to_string()));
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::FrontmatterParseError { message } if message == "bad yaml"
));
}
#[test]
fn relationship_cycle_serializes_to_the_pinned_shape() {
let err = PageError::RelationshipCycle {
members: vec!["/a/".to_string(), "/b/".to_string()],
rel_type: "child".to_string(),
};
assert_eq!(
serde_json::to_string(&err).unwrap(),
r#"{"type":"relationship_cycle","members":["/a/","/b/"],"rel_type":"child"}"#
);
}
#[test]
fn ambiguous_relationship_endpoint_serializes_to_the_pinned_shape() {
let err = PageError::AmbiguousRelationshipEndpoint {
raw: "[[John Doe]]".to_string(),
resolved_to: "/people/john-jr/".to_string(),
candidates: vec!["/people/john-sr/".to_string()],
};
assert_eq!(
serde_json::to_string(&err).unwrap(),
r#"{"type":"ambiguous_relationship_endpoint","raw":"[[John Doe]]","resolved_to":"/people/john-jr/","candidates":["/people/john-sr/"]}"#
);
}
#[test]
fn ambiguous_wikilink_serializes_to_the_pinned_shape() {
let err = PageError::AmbiguousWikilink {
raw: "[[John Doe]]".to_string(),
resolved_to: "/people/john-jr/".to_string(),
candidates: vec!["/people/john-sr/".to_string()],
};
assert_eq!(
serde_json::to_string(&err).unwrap(),
r#"{"type":"ambiguous_wikilink","raw":"[[John Doe]]","resolved_to":"/people/john-jr/","candidates":["/people/john-sr/"]}"#
);
}
#[test]
fn relationship_cycle_errors_maps_every_cycle() {
let cycles = vec![
RelationshipCycle {
members: vec!["/a/".to_string(), "/b/".to_string()],
rel_type: "child".to_string(),
},
RelationshipCycle {
members: vec!["/x/".to_string(), "/y/".to_string()],
rel_type: "employee".to_string(),
},
];
let errs = relationship_cycle_errors(&cycles);
assert_eq!(errs.len(), 2);
assert!(matches!(
&errs[0],
PageError::RelationshipCycle { rel_type, members }
if rel_type == "child" && members.len() == 2
));
assert!(relationship_cycle_errors(&[]).is_empty());
}
#[test]
fn ambiguous_relationship_endpoint_errors_preserves_all_candidates() {
let endpoints = vec![AmbiguousEndpoint {
raw: "[[Sam]]".to_string(),
resolved_to: "/a/sam/".to_string(),
candidates: vec!["/m/sam/".to_string(), "/z/sam/".to_string()],
}];
let errs = ambiguous_relationship_endpoint_errors(&endpoints);
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::AmbiguousRelationshipEndpoint { raw, resolved_to, candidates }
if raw == "[[Sam]]" && resolved_to == "/a/sam/" && candidates.len() == 2
));
assert!(ambiguous_relationship_endpoint_errors(&[]).is_empty());
}
#[test]
fn ambiguous_wikilink_errors_preserves_all_candidates() {
let wikilinks = vec![AmbiguousWikilink {
raw: "[[Sam]]".to_string(),
resolved_to: "/a/sam/".to_string(),
candidates: vec!["/z/sam/".to_string()],
}];
let errs = ambiguous_wikilink_errors(&wikilinks);
assert_eq!(errs.len(), 1);
assert!(matches!(
&errs[0],
PageError::AmbiguousWikilink { raw, resolved_to, candidates }
if raw == "[[Sam]]" && resolved_to == "/a/sam/" && candidates == &["/z/sam/".to_string()]
));
assert!(ambiguous_wikilink_errors(&[]).is_empty());
}
#[test]
fn unplayable_media_serializes_to_the_frontend_contract() {
let err = PageError::UnplayableMedia {
src: "../Foo%20Bar.mp4".to_string(),
kind: MediaKind::Video,
reason: "likely cause".to_string(),
remedy: Some(
"ffmpeg -i in.mp4 -map 0 -c copy -dn -movflags +faststart out.mp4".to_string(),
),
advisory: true,
};
let json = serde_json::to_value(&err).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "unplayable_media",
"src": "../Foo%20Bar.mp4",
"kind": "video",
"reason": "likely cause",
"remedy": "ffmpeg -i in.mp4 -map 0 -c copy -dn -movflags +faststart out.mp4",
"advisory": true
})
);
}
#[test]
fn unplayable_media_omits_absent_remedy() {
let err = PageError::UnplayableMedia {
src: "../x.mp4".to_string(),
kind: MediaKind::Video,
reason: "nope".to_string(),
remedy: None,
advisory: true,
};
let json = serde_json::to_string(&err).unwrap();
assert!(!json.contains("remedy"), "{}", json);
}
#[test]
fn unplayable_media_round_trips() {
let err = PageError::UnplayableMedia {
src: "../Foo%20Bar.mp4".to_string(),
kind: MediaKind::Video,
reason: "r".to_string(),
remedy: Some("cmd".to_string()),
advisory: true,
};
let json = serde_json::to_string(&err).unwrap();
let back: PageError = serde_json::from_str(&json).unwrap();
assert_eq!(back, err);
}
#[test]
fn page_errors_empty_default_serializes() {
let pe = PageErrors {
page_url: "/x/".to_string(),
errors: vec![],
};
let json = serde_json::to_string(&pe).unwrap();
assert!(json.contains("\"page_url\":\"/x/\""));
assert!(json.contains("\"errors\":[]"));
}
}