1use regex::Regex;
30use scraper::{Html, Selector};
31use serde::{Deserialize, Serialize};
32use std::sync::LazyLock;
33
34use crate::link_index::{OutboundLink, resolve_relative_url, resolve_relative_url_checked};
35use crate::path_resolver::{
36 PathResolverConfig, ResolvedPath, normalize_link_target, resolve_request_path,
37};
38use crate::relationships::{AmbiguousEndpoint, RelationshipCycle};
39use crate::url_path::is_external_url;
40use crate::wikilink_index::AmbiguousWikilink;
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum MediaKind {
46 Image,
47 Video,
48 Audio,
49 Source,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(tag = "type", rename_all = "snake_case")]
55pub enum PageError {
56 BrokenInternalLink {
58 target: String,
59 text: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 anchor: Option<String>,
62 },
63 BrokenMediaReference { src: String, kind: MediaKind },
66 UnresolvedWikilink { raw: String },
68 FrontmatterParseError { message: String },
71 ConfigParseError { message: String },
76 RelationshipCycle {
79 members: Vec<String>,
81 rel_type: String,
83 },
84 AmbiguousRelationshipEndpoint {
87 raw: String,
89 resolved_to: String,
91 candidates: Vec<String>,
93 },
94 ContactDataProblem {
98 field: String,
100 message: String,
102 },
103 AmbiguousWikilink {
106 raw: String,
108 resolved_to: String,
110 candidates: Vec<String>,
112 },
113 UnplayableMedia {
130 src: String,
134 kind: MediaKind,
135 reason: String,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 remedy: Option<String>,
140 advisory: bool,
144 },
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct MediaReference {
154 pub src: String,
157 pub kind: MediaKind,
159 pub path: std::path::PathBuf,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, Default)]
165pub struct PageErrors {
166 pub page_url: String,
168 pub errors: Vec<PageError>,
171 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
176 pub not_downloaded: bool,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188enum LinkDefect {
189 Missing,
191 EscapesRoot,
195 NonCanonical,
211}
212
213const MBR_URL_PREFIX: &str = "/.mbr/";
215
216fn is_mbr_namespace_url(absolute_url: &str) -> bool {
240 absolute_url.starts_with(MBR_URL_PREFIX)
243}
244
245fn classify_emitted_href(
283 href: &str,
284 resolver_config: &PathResolverConfig,
285 page_url: &str,
286) -> Option<LinkDefect> {
287 if href.is_empty() || href.starts_with('#') {
290 return None;
291 }
292
293 if is_external_url(href) {
297 return None;
298 }
299
300 let path_part = href.split(['#', '?']).next().unwrap_or_default();
303 if path_part.is_empty() {
304 return None;
305 }
306
307 let Some(absolute_url) = resolve_relative_url_checked(page_url, path_part, true) else {
311 return Some(LinkDefect::EscapesRoot);
312 };
313
314 if is_mbr_namespace_url(&absolute_url) {
317 return None;
318 }
319
320 let request_path = normalize_link_target(&absolute_url);
325 match resolve_request_path(resolver_config, &request_path) {
326 ResolvedPath::NotFound => Some(LinkDefect::Missing),
327 ResolvedPath::MarkdownFile(_) => {
333 (!path_part.ends_with('/')).then_some(LinkDefect::NonCanonical)
334 }
335 _ => None,
340 }
341}
342
343pub fn validate_rendered_links(
350 html: &str,
351 resolver_config: &PathResolverConfig,
352 page_url: &str,
353) -> Vec<PageError> {
354 let Ok(selector) = Selector::parse("a[href]") else {
355 return Vec::new();
356 };
357 let doc = Html::parse_document(html);
358 let mut seen = std::collections::HashSet::new();
359
360 doc.select(&selector)
361 .filter_map(|element| {
362 let href = element.value().attr("href")?;
363 if !seen.insert(href.to_string()) {
364 return None;
365 }
366 classify_emitted_href(href, resolver_config, page_url)?;
367 let text = element.text().collect::<String>().trim().to_string();
368 let (_, anchor) = crate::link_index::split_url_anchor(href);
369 Some(PageError::BrokenInternalLink {
370 target: href.to_string(),
371 text,
372 anchor,
373 })
374 })
375 .collect()
376}
377
378pub fn validate_internal_links(
385 outbound: &[OutboundLink],
386 resolver_config: &PathResolverConfig,
387) -> Vec<PageError> {
388 outbound
389 .iter()
390 .filter(|link| link.internal)
391 .filter(|link| {
392 classify_emitted_href(&link.to, resolver_config, "/").is_some_and(|defect| {
395 matches!(defect, LinkDefect::Missing | LinkDefect::EscapesRoot)
401 })
402 })
403 .map(|link| PageError::BrokenInternalLink {
404 target: link.to.clone(),
405 text: link.text.clone(),
406 anchor: link.anchor.clone(),
407 })
408 .collect()
409}
410
411pub fn validate_media_references(
421 html: &str,
422 resolver_config: &PathResolverConfig,
423 page_url: &str,
424) -> Vec<PageError> {
425 scan_media_elements(html, SectionBackgrounds::Include)
426 .into_iter()
427 .filter(|(src, _)| !media_reference_resolves(src, resolver_config, page_url))
428 .map(|(src, kind)| PageError::BrokenMediaReference { src, kind })
429 .collect()
430}
431
432fn scan_media_elements(html: &str, backgrounds: SectionBackgrounds) -> Vec<(String, MediaKind)> {
440 let doc = Html::parse_document(html);
441 let section_backgrounds = match backgrounds {
442 SectionBackgrounds::Include => scan_section_backgrounds(&doc),
443 SectionBackgrounds::Skip => Vec::new(),
444 };
445
446 let specs: [(&str, MediaKind); 4] = [
447 ("img[src]", MediaKind::Image),
448 ("video[src]", MediaKind::Video),
449 ("audio[src]", MediaKind::Audio),
450 ("source[src]", MediaKind::Source),
451 ];
452
453 specs
454 .into_iter()
455 .filter_map(|(selector_str, kind)| {
456 Selector::parse(selector_str).ok().map(|sel| (sel, kind))
457 })
458 .flat_map(|(selector, kind)| {
459 doc.select(&selector)
460 .filter_map(|el| el.value().attr("src"))
461 .filter(|src| !src.is_empty() && !is_external_url(src))
462 .map(|src| (src.to_string(), kind.clone()))
463 .collect::<Vec<_>>()
464 })
465 .chain(section_backgrounds)
466 .collect()
467}
468
469#[derive(Clone, Copy)]
475enum SectionBackgrounds {
476 Include,
477 Skip,
478}
479
480fn scan_section_backgrounds(doc: &Html) -> Vec<(String, MediaKind)> {
490 let Ok(selector) = Selector::parse(
491 "section[data-background-image], section[data-background], \
492 section[data-background-video]",
493 ) else {
494 return Vec::new();
495 };
496 let internal = |url: &str| !url.is_empty() && !is_external_url(url);
497
498 doc.select(&selector)
499 .flat_map(|el| {
500 let attr = |name| el.value().attr(name);
501 let images = attr("data-background-image")
502 .into_iter()
503 .chain(
504 attr("data-background")
505 .filter(|v| crate::markdown::is_reveal_background_url(v)),
506 )
507 .map(str::trim)
508 .filter(|url| internal(url))
509 .map(|url| (url.to_string(), MediaKind::Image));
510 let videos = attr("data-background-video")
511 .filter(|v| internal(v.trim()))
512 .into_iter()
513 .flat_map(|v| v.split(','))
514 .map(str::trim)
515 .filter(|url| internal(url))
516 .map(|url| (url.to_string(), MediaKind::Video));
517 images.chain(videos).collect::<Vec<_>>()
518 })
519 .collect()
520}
521
522pub fn collect_media_references(
533 html: &str,
534 resolver_config: &PathResolverConfig,
535 page_url: &str,
536) -> Vec<MediaReference> {
537 let mut seen = std::collections::HashSet::new();
538
539 scan_media_elements(html, SectionBackgrounds::Skip)
540 .into_iter()
541 .filter_map(|(src, kind)| {
542 let path_part = src.split(['#', '?']).next().unwrap_or_default();
543 if path_part.is_empty() || !seen.insert(path_part.to_string()) {
544 return None;
545 }
546 let absolute_url = resolve_relative_url(page_url, path_part, true);
547 let request_path = normalize_link_target(&absolute_url);
548 match resolve_request_path(resolver_config, &request_path) {
549 ResolvedPath::StaticFile(path) => Some(MediaReference {
550 src: path_part.to_string(),
551 kind,
552 path,
553 }),
554 _ => None,
555 }
556 })
557 .collect()
558}
559
560fn media_reference_resolves(
576 src: &str,
577 resolver_config: &PathResolverConfig,
578 page_url: &str,
579) -> bool {
580 let path_part = src.split(['#', '?']).next().unwrap_or_default();
583
584 if path_part.is_empty() {
586 return true;
587 }
588
589 let absolute_url = resolve_relative_url(page_url, path_part, true);
590 let request_path = normalize_link_target(&absolute_url);
591
592 !matches!(
594 resolve_request_path(resolver_config, &request_path),
595 ResolvedPath::NotFound
596 )
597}
598
599static WIKILINK_RE: LazyLock<Regex> =
603 LazyLock::new(|| Regex::new(r"\[\[([^\[\]\n]+)\]\]").expect("static wikilink regex is valid"));
604
605fn mask_code_blocks(html: &str) -> String {
610 static CODE_BLOCK_RE: LazyLock<Regex> = LazyLock::new(|| {
611 Regex::new(r"(?is)<code\b[^>]*>.*?</code>|<pre\b[^>]*>.*?</pre>")
612 .expect("static code-block regex is valid")
613 });
614
615 CODE_BLOCK_RE
616 .replace_all(html, |caps: ®ex::Captures| {
617 " ".repeat(caps[0].len())
620 })
621 .into_owned()
622}
623
624pub fn detect_unresolved_wikilinks(html: &str) -> Vec<PageError> {
627 let masked = mask_code_blocks(html);
628 let mut errors = Vec::new();
629 let mut seen = std::collections::HashSet::new();
630
631 for cap in WIKILINK_RE.captures_iter(&masked) {
632 let raw = cap
633 .get(0)
634 .map(|m| m.as_str().to_string())
635 .unwrap_or_default();
636 if raw.is_empty() {
637 continue;
638 }
639 if seen.insert(raw.clone()) {
642 errors.push(PageError::UnresolvedWikilink { raw });
643 }
644 }
645
646 errors
647}
648
649pub fn frontmatter_parse_errors(err: &Option<String>) -> Vec<PageError> {
653 err.iter()
654 .map(|message| PageError::FrontmatterParseError {
655 message: message.clone(),
656 })
657 .collect()
658}
659
660pub fn relationship_cycle_errors(cycles: &[RelationshipCycle]) -> Vec<PageError> {
668 cycles
669 .iter()
670 .map(|cycle| PageError::RelationshipCycle {
671 members: cycle.members.clone(),
672 rel_type: cycle.rel_type.clone(),
673 })
674 .collect()
675}
676
677pub fn ambiguous_relationship_endpoint_errors(endpoints: &[AmbiguousEndpoint]) -> Vec<PageError> {
681 endpoints
682 .iter()
683 .map(|endpoint| PageError::AmbiguousRelationshipEndpoint {
684 raw: endpoint.raw.clone(),
685 resolved_to: endpoint.resolved_to.clone(),
686 candidates: endpoint.candidates.clone(),
687 })
688 .collect()
689}
690
691pub fn contact_problem_errors(problems: &[crate::contact::ContactProblem]) -> Vec<PageError> {
695 problems
696 .iter()
697 .map(|p| PageError::ContactDataProblem {
698 field: p.field.clone(),
699 message: p.message.clone(),
700 })
701 .collect()
702}
703
704pub fn ambiguous_wikilink_errors(wikilinks: &[AmbiguousWikilink]) -> Vec<PageError> {
708 wikilinks
709 .iter()
710 .map(|wikilink| PageError::AmbiguousWikilink {
711 raw: wikilink.raw.clone(),
712 resolved_to: wikilink.resolved_to.clone(),
713 candidates: wikilink.candidates.clone(),
714 })
715 .collect()
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use std::path::{Path, PathBuf};
722 use tempfile::TempDir;
723
724 fn make_config<'a>(
725 base_dir: &'a Path,
726 exts: &'a [String],
727 index_file: &'a str,
728 tag_sources: &'a [String],
729 ) -> PathResolverConfig<'a> {
730 let vault: &'a crate::vault::LocalVault =
733 Box::leak(Box::new(crate::vault::LocalVault::new(base_dir)));
734 PathResolverConfig {
735 vault,
736 static_vault: None,
737 static_folder: "static",
738 markdown_extensions: exts,
739 index_file,
740 tag_sources,
741 exempt_hidden_dirs: &[],
742 }
743 }
744
745 #[test]
748 fn broken_internal_link_is_reported() {
749 let dir = TempDir::new().unwrap();
750 let base = dir.path().canonicalize().unwrap();
751 let exts = vec!["md".to_string()];
752 let tags: Vec<String> = vec![];
753 let cfg = make_config(&base, &exts, "index.md", &tags);
754
755 let outbound = vec![OutboundLink {
756 to: "/nonexistent/".to_string(),
757 text: "bad".to_string(),
758 anchor: None,
759 internal: true,
760 }];
761
762 let errs = validate_internal_links(&outbound, &cfg);
763 assert_eq!(errs.len(), 1);
764 assert!(matches!(
765 &errs[0],
766 PageError::BrokenInternalLink { target, .. } if target == "/nonexistent/"
767 ));
768 }
769
770 #[test]
771 fn valid_internal_link_is_ignored() {
772 let dir = TempDir::new().unwrap();
773 std::fs::write(dir.path().join("page.md"), "# x").unwrap();
774 let base = dir.path().canonicalize().unwrap();
775 let exts = vec!["md".to_string()];
776 let tags: Vec<String> = vec![];
777 let cfg = make_config(&base, &exts, "index.md", &tags);
778
779 let outbound = vec![OutboundLink {
780 to: "/page/".to_string(),
781 text: "ok".to_string(),
782 anchor: None,
783 internal: true,
784 }];
785
786 let errs = validate_internal_links(&outbound, &cfg);
787 assert!(errs.is_empty());
788 }
789
790 #[test]
791 fn external_link_is_ignored() {
792 let dir = TempDir::new().unwrap();
793 let base = dir.path().canonicalize().unwrap();
794 let exts = vec!["md".to_string()];
795 let tags: Vec<String> = vec![];
796 let cfg = make_config(&base, &exts, "index.md", &tags);
797
798 let outbound = vec![OutboundLink {
799 to: "https://example.com/anything".to_string(),
800 text: "ext".to_string(),
801 anchor: None,
802 internal: false,
803 }];
804
805 let errs = validate_internal_links(&outbound, &cfg);
806 assert!(errs.is_empty());
807 }
808
809 #[test]
810 fn external_scheme_link_marked_internal_is_ignored() {
811 let dir = TempDir::new().unwrap();
816 let base = dir.path().canonicalize().unwrap();
817 let exts = vec!["md".to_string()];
818 let tags: Vec<String> = vec![];
819 let cfg = make_config(&base, &exts, "index.md", &tags);
820
821 let outbound = [
822 "ftp://example.com/data.zip",
823 "magnet:?xt=urn:btih:abc",
824 "sms:+15555550123",
825 ]
826 .iter()
827 .map(|to| OutboundLink {
828 to: (*to).to_string(),
829 text: "ext".to_string(),
830 anchor: None,
831 internal: true,
833 })
834 .collect::<Vec<_>>();
835
836 let errs = validate_internal_links(&outbound, &cfg);
837 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
838 }
839
840 #[test]
841 fn fragment_only_link_is_ignored() {
842 let dir = TempDir::new().unwrap();
843 let base = dir.path().canonicalize().unwrap();
844 let exts = vec!["md".to_string()];
845 let tags: Vec<String> = vec![];
846 let cfg = make_config(&base, &exts, "index.md", &tags);
847
848 let outbound = vec![OutboundLink {
849 to: "#section".to_string(),
850 text: "anchor".to_string(),
851 anchor: Some("#section".to_string()),
852 internal: true,
853 }];
854
855 let errs = validate_internal_links(&outbound, &cfg);
856 assert!(errs.is_empty());
857 }
858
859 #[test]
860 fn percent_encoded_link_to_existing_file_is_not_reported() {
861 let dir = TempDir::new().unwrap();
865 std::fs::write(dir.path().join("IronCore Swag T-shirts Gifts.md"), "# x").unwrap();
866 let base = dir.path().canonicalize().unwrap();
867 let exts = vec!["md".to_string()];
868 let tags: Vec<String> = vec![];
869 let cfg = make_config(&base, &exts, "index.md", &tags);
870
871 let outbound = vec![
872 OutboundLink {
873 to: "/IronCore%20Swag%20T-shirts%20Gifts".to_string(),
874 text: "no trailing slash".to_string(),
875 anchor: None,
876 internal: true,
877 },
878 OutboundLink {
879 to: "/IronCore%20Swag%20T-shirts%20Gifts/".to_string(),
880 text: "trailing slash".to_string(),
881 anchor: None,
882 internal: true,
883 },
884 ];
885
886 let errs = validate_internal_links(&outbound, &cfg);
887 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
888 }
889
890 #[test]
891 fn percent_encoded_apostrophe_link_is_not_reported() {
892 let dir = TempDir::new().unwrap();
893 std::fs::write(dir.path().join("World's Best.md"), "# x").unwrap();
894 let base = dir.path().canonicalize().unwrap();
895 let exts = vec!["md".to_string()];
896 let tags: Vec<String> = vec![];
897 let cfg = make_config(&base, &exts, "index.md", &tags);
898
899 let outbound = vec![OutboundLink {
900 to: "/World%27s%20Best/".to_string(),
901 text: "apostrophe".to_string(),
902 anchor: None,
903 internal: true,
904 }];
905
906 let errs = validate_internal_links(&outbound, &cfg);
907 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
908 }
909
910 #[test]
911 fn percent_encoded_unicode_link_is_not_reported() {
912 let dir = TempDir::new().unwrap();
913 std::fs::write(dir.path().join("café.md"), "# x").unwrap();
914 let base = dir.path().canonicalize().unwrap();
915 let exts = vec!["md".to_string()];
916 let tags: Vec<String> = vec![];
917 let cfg = make_config(&base, &exts, "index.md", &tags);
918
919 let outbound = vec![OutboundLink {
920 to: "/caf%C3%A9/".to_string(),
921 text: "unicode".to_string(),
922 anchor: None,
923 internal: true,
924 }];
925
926 let errs = validate_internal_links(&outbound, &cfg);
927 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
928 }
929
930 #[test]
931 fn percent_encoded_link_with_anchor_and_query_is_not_reported() {
932 let dir = TempDir::new().unwrap();
933 std::fs::write(dir.path().join("IronCore Swag T-shirts Gifts.md"), "# x").unwrap();
934 let base = dir.path().canonicalize().unwrap();
935 let exts = vec!["md".to_string()];
936 let tags: Vec<String> = vec![];
937 let cfg = make_config(&base, &exts, "index.md", &tags);
938
939 let outbound = vec![OutboundLink {
940 to: "/IronCore%20Swag%20T-shirts%20Gifts/?x=1#top".to_string(),
941 text: "anchor and query".to_string(),
942 anchor: Some("#top".to_string()),
943 internal: true,
944 }];
945
946 let errs = validate_internal_links(&outbound, &cfg);
947 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
948 }
949
950 #[test]
951 fn missing_percent_encoded_target_is_still_reported() {
952 let dir = TempDir::new().unwrap();
953 let base = dir.path().canonicalize().unwrap();
954 let exts = vec!["md".to_string()];
955 let tags: Vec<String> = vec![];
956 let cfg = make_config(&base, &exts, "index.md", &tags);
957
958 let outbound = vec![OutboundLink {
959 to: "/Nope%20Missing/".to_string(),
960 text: "gone".to_string(),
961 anchor: None,
962 internal: true,
963 }];
964
965 let errs = validate_internal_links(&outbound, &cfg);
966 assert_eq!(errs.len(), 1);
967 assert!(matches!(
969 &errs[0],
970 PageError::BrokenInternalLink { target, .. } if target == "/Nope%20Missing/"
971 ));
972 }
973
974 fn rendered_link_setup() -> (TempDir, PathBuf) {
979 let dir = TempDir::new().unwrap();
980 let base = dir.path().canonicalize().unwrap();
981 std::fs::create_dir_all(base.join("docs")).unwrap();
982 std::fs::create_dir_all(base.join("folder")).unwrap();
983 std::fs::write(base.join("docs/guide.md"), "# guide").unwrap();
984 std::fs::write(base.join("folder/file.md"), "# file").unwrap();
985 std::fs::write(base.join("folder/sibling.md"), "# sibling").unwrap();
986 std::fs::write(base.join("LICENSE"), "MIT").unwrap();
987 (dir, base)
988 }
989
990 fn rendered_link_errors(html: &str, base: &Path, page_url: &str) -> Vec<PageError> {
991 let exts = vec!["md".to_string()];
992 let tags: Vec<String> = vec![];
993 let cfg = make_config(base, &exts, "index.md", &tags);
994 validate_rendered_links(html, &cfg, page_url)
995 }
996
997 #[test]
1002 fn page_link_without_trailing_slash_is_reported() {
1003 let (_guard, base) = rendered_link_setup();
1004 let html = r#"<a href="../../folder/file">file</a>"#;
1005 let errs = rendered_link_errors(html, &base, "/docs/guide/");
1006
1007 assert_eq!(errs.len(), 1, "{errs:?}");
1008 assert!(matches!(
1009 &errs[0],
1010 PageError::BrokenInternalLink { target, text, .. }
1011 if target == "../../folder/file" && text == "file"
1012 ));
1013 }
1014
1015 #[test]
1016 fn page_link_with_trailing_slash_is_ignored() {
1017 let (_guard, base) = rendered_link_setup();
1018 let html = r#"<a href="../../folder/file/">file</a>"#;
1019 assert!(
1020 rendered_link_errors(html, &base, "/docs/guide/").is_empty(),
1021 "the canonical spelling must not be flagged"
1022 );
1023 }
1024
1025 #[test]
1028 fn extensionless_static_file_link_is_ignored() {
1029 let (_guard, base) = rendered_link_setup();
1030 let html = r#"<a href="../../LICENSE">License</a>"#;
1031 assert!(
1032 rendered_link_errors(html, &base, "/docs/guide/").is_empty(),
1033 "an extension-less static file must not be asked for a trailing slash"
1034 );
1035 }
1036
1037 #[test]
1041 fn resolving_alone_cannot_tell_the_two_spellings_apart() {
1042 assert_eq!(
1043 normalize_link_target("/folder/file"),
1044 normalize_link_target("/folder/file/"),
1045 "if this ever differs, the trailing-slash check can move earlier"
1046 );
1047 }
1048
1049 #[test]
1053 fn above_root_link_is_reported() {
1054 let (_guard, base) = rendered_link_setup();
1055 let html = r#"<a href="../../../../escape/target/">escape</a>"#;
1056 let errs = rendered_link_errors(html, &base, "/docs/guide/");
1057
1058 assert_eq!(errs.len(), 1, "{errs:?}");
1059 assert!(matches!(
1060 &errs[0],
1061 PageError::BrokenInternalLink { target, .. } if target == "../../../../escape/target/"
1062 ));
1063 }
1064
1065 #[test]
1069 fn above_root_link_to_an_existing_clamped_target_is_still_reported() {
1070 let (_guard, base) = rendered_link_setup();
1071 let html = r#"<a href="../../../folder/file/">file</a>"#;
1074 let errs = rendered_link_errors(html, &base, "/docs/guide/");
1075 assert_eq!(
1076 errs.len(),
1077 1,
1078 "a clamped above-root link must still be reported: {errs:?}"
1079 );
1080 }
1081
1082 #[test]
1083 fn missing_target_is_reported() {
1084 let (_guard, base) = rendered_link_setup();
1085 let html = r#"<a href="../../folder/gone/">gone</a>"#;
1086 assert_eq!(rendered_link_errors(html, &base, "/docs/guide/").len(), 1);
1087 }
1088
1089 #[test]
1090 fn external_fragment_and_empty_hrefs_are_ignored() {
1091 let (_guard, base) = rendered_link_setup();
1092 let html = r##"
1093 <a href="https://example.com/x">ext</a>
1094 <a href="mailto:a@b.c">mail</a>
1095 <a href="#section">anchor</a>
1096 <a href="">empty</a>
1097 <a href="?q=1">query only</a>
1098 "##;
1099 let errs = rendered_link_errors(html, &base, "/docs/guide/");
1100 assert!(errs.is_empty(), "{errs:?}");
1101 }
1102
1103 #[test]
1104 fn anchor_and_query_do_not_hide_a_page_link_defect() {
1105 let (_guard, base) = rendered_link_setup();
1106 let bad = r#"<a href="../../folder/file#top">f</a>"#;
1109 assert_eq!(rendered_link_errors(bad, &base, "/docs/guide/").len(), 1);
1110
1111 let good = r#"<a href="../../folder/file/?x=1#top">f</a>"#;
1112 assert!(rendered_link_errors(good, &base, "/docs/guide/").is_empty());
1113 }
1114
1115 #[test]
1116 fn percent_encoded_href_resolves_before_it_is_judged() {
1117 let dir = TempDir::new().unwrap();
1118 let base = dir.path().canonicalize().unwrap();
1119 std::fs::create_dir_all(base.join("docs")).unwrap();
1120 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1121 std::fs::write(base.join("My Page.md"), "# m").unwrap();
1122
1123 let html = r#"<a href="../../My%20Page/">My Page</a>"#;
1124 assert!(rendered_link_errors(html, &base, "/docs/guide/").is_empty());
1125 }
1126
1127 #[test]
1128 fn repeated_identical_hrefs_are_deduped() {
1129 let (_guard, base) = rendered_link_setup();
1130 let html = r#"
1131 <a href="../../folder/gone/">a</a>
1132 <a href="../../folder/gone/">b</a>
1133 "#;
1134 assert_eq!(rendered_link_errors(html, &base, "/docs/guide/").len(), 1);
1135 }
1136
1137 #[test]
1147 fn directory_listing_link_without_trailing_slash_is_ignored() {
1148 let (_guard, base) = rendered_link_setup();
1149 let html = r#"<a href="../../folder">folder</a>"#;
1152 assert!(
1153 rendered_link_errors(html, &base, "/docs/guide/").is_empty(),
1154 "a directory listing is served in place and must not be flagged"
1155 );
1156 }
1157
1158 #[test]
1161 fn absolute_directory_link_without_trailing_slash_is_ignored() {
1162 let (_guard, base) = rendered_link_setup();
1163 let html = r#"<a href="/folder">folder</a>"#;
1164 assert!(
1165 rendered_link_errors(html, &base, "/docs/guide/").is_empty(),
1166 "an absolute directory link must not be flagged"
1167 );
1168 }
1169
1170 #[test]
1174 fn tag_urls_without_trailing_slash_are_ignored() {
1175 let dir = TempDir::new().unwrap();
1176 let base = dir.path().canonicalize().unwrap();
1177 std::fs::create_dir_all(base.join("docs")).unwrap();
1178 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1179 let exts = vec!["md".to_string()];
1180 let tags = vec!["tags".to_string(), "people".to_string()];
1181 let cfg = make_config(&base, &exts, "index.md", &tags);
1182
1183 let html = r#"
1184 <a href="/tags/rust">tag page</a>
1185 <a href="/tags">tag index</a>
1186 <a href="/people/jane_doe">person</a>
1187 "#;
1188 let errs = validate_rendered_links(html, &cfg, "/docs/guide/");
1189 assert!(errs.is_empty(), "expected no errors, got: {errs:?}");
1190 }
1191
1192 #[test]
1196 fn tag_and_directory_urls_with_trailing_slash_are_ignored() {
1197 let dir = TempDir::new().unwrap();
1198 let base = dir.path().canonicalize().unwrap();
1199 std::fs::create_dir_all(base.join("docs")).unwrap();
1200 std::fs::create_dir_all(base.join("folder")).unwrap();
1201 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1202 std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1203 let exts = vec!["md".to_string()];
1204 let tags = vec!["tags".to_string()];
1205 let cfg = make_config(&base, &exts, "index.md", &tags);
1206
1207 let html = r#"
1208 <a href="/tags/rust/">tag page</a>
1209 <a href="/tags/">tag index</a>
1210 <a href="/folder/">folder</a>
1211 "#;
1212 let errs = validate_rendered_links(html, &cfg, "/docs/guide/");
1213 assert!(errs.is_empty(), "expected no errors, got: {errs:?}");
1214 }
1215
1216 #[test]
1222 fn media_viewer_links_are_ignored() {
1223 let (_guard, base) = rendered_link_setup();
1224 let html = r#"
1225 <a href="/.mbr/videos/?path=%2Fvideos%2Fdemo.mp4">demo.mp4</a>
1226 <a href="/.mbr/pdfs/?path=%2Fdocs%2Fpaper.pdf">paper.pdf</a>
1227 <a href="/.mbr/audio/?path=%2Fmusic%2Fsong.mp3">song.mp3</a>
1228 <a href="/.mbr/images/?path=%2Fimages%2Fphoto.jpg">photo.jpg</a>
1229 "#;
1230 let errs = rendered_link_errors(html, &base, "/docs/guide/");
1231 assert!(
1232 errs.is_empty(),
1233 "media viewer endpoints are routes, not files: {errs:?}"
1234 );
1235 }
1236
1237 #[test]
1240 fn mbr_namespace_links_are_ignored() {
1241 let (_guard, base) = rendered_link_setup();
1242 let html = r#"
1245 <a href="/.mbr/site.json">site index</a>
1246 <a href="/.mbr/theme.css">theme</a>
1247 <a href="../../.mbr/videos/?path=%2Fvideos%2Fdemo.mp4">relative viewer</a>
1248 "#;
1249 let errs = rendered_link_errors(html, &base, "/docs/guide/");
1250 assert!(errs.is_empty(), "expected no errors, got: {errs:?}");
1251 }
1252
1253 #[test]
1257 fn lookalike_paths_are_still_checked() {
1258 let (_guard, base) = rendered_link_setup();
1259 let html = r#"
1260 <a href="/.mbrx/gone/">not the namespace</a>
1261 <a href="/docs/.mbr/gone/">not at the root</a>
1262 "#;
1263 assert_eq!(
1264 rendered_link_errors(html, &base, "/docs/guide/").len(),
1265 2,
1266 "only the site-root `/.mbr/` prefix is mbr's"
1267 );
1268 }
1269
1270 #[test]
1275 fn narrowing_keeps_reporting_markdown_page_links() {
1276 let dir = TempDir::new().unwrap();
1277 let base = dir.path().canonicalize().unwrap();
1278 std::fs::create_dir_all(base.join("docs")).unwrap();
1279 std::fs::create_dir_all(base.join("folder")).unwrap();
1280 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1281 std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1282 let exts = vec!["md".to_string()];
1283 let tags = vec!["tags".to_string()];
1284 let cfg = make_config(&base, &exts, "index.md", &tags);
1285
1286 let html = r#"<a href="/folder/file">file</a>"#;
1287 assert_eq!(
1288 validate_rendered_links(html, &cfg, "/docs/guide/").len(),
1289 1,
1290 "a markdown page link without its trailing slash is still a defect"
1291 );
1292 }
1293
1294 #[test]
1313 fn a_markdown_href_keeping_its_md_extension_is_not_canonical() {
1314 let dir = TempDir::new().unwrap();
1315 let base = dir.path().canonicalize().unwrap();
1316 std::fs::create_dir_all(base.join("docs")).unwrap();
1317 std::fs::create_dir_all(base.join("folder")).unwrap();
1318 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1319 std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1320 let exts = vec!["md".to_string()];
1321 let tags = vec!["tags".to_string()];
1322 let cfg = make_config(&base, &exts, "index.md", &tags);
1323
1324 for href in [
1327 "/folder/file.md", "../../folder/file.md", "/folder/file.md#top", "/folder/file.md?v=2", ] {
1332 assert_eq!(
1333 classify_emitted_href(href, &cfg, "/docs/guide/"),
1334 Some(LinkDefect::NonCanonical),
1335 "`{href}` still names the file, so it needs the trailing slash"
1336 );
1337 }
1338
1339 assert_eq!(
1342 classify_emitted_href("/folder/file", &cfg, "/docs/guide/"),
1343 Some(LinkDefect::NonCanonical)
1344 );
1345 }
1346
1347 #[test]
1353 fn a_markdown_href_with_its_trailing_slash_is_silent() {
1354 let dir = TempDir::new().unwrap();
1355 let base = dir.path().canonicalize().unwrap();
1356 std::fs::create_dir_all(base.join("docs")).unwrap();
1357 std::fs::create_dir_all(base.join("folder")).unwrap();
1358 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1359 std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1360 let exts = vec!["md".to_string()];
1361 let tags = vec!["tags".to_string()];
1362 let cfg = make_config(&base, &exts, "index.md", &tags);
1363
1364 for href in [
1365 "/folder/file/",
1366 "../../folder/file/",
1367 "/folder/file/#top",
1368 "/folder/file/?v=2",
1369 ] {
1370 assert_eq!(
1371 classify_emitted_href(href, &cfg, "/docs/guide/"),
1372 None,
1373 "`{href}` is exactly what the renderer emits and must be silent"
1374 );
1375 }
1376 }
1377
1378 #[test]
1382 fn a_relative_md_href_that_resolves_nowhere_is_missing_not_non_canonical() {
1383 let dir = TempDir::new().unwrap();
1384 let base = dir.path().canonicalize().unwrap();
1385 std::fs::create_dir_all(base.join("docs")).unwrap();
1386 std::fs::create_dir_all(base.join("folder")).unwrap();
1387 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1388 std::fs::write(base.join("folder/file.md"), "# f").unwrap();
1389 let exts = vec!["md".to_string()];
1390 let tags = vec!["tags".to_string()];
1391 let cfg = make_config(&base, &exts, "index.md", &tags);
1392
1393 assert_eq!(
1395 classify_emitted_href("../folder/file.md", &cfg, "/docs/guide/"),
1396 Some(LinkDefect::Missing)
1397 );
1398 }
1399
1400 #[test]
1403 fn narrowing_does_not_hide_missing_targets() {
1404 let dir = TempDir::new().unwrap();
1405 let base = dir.path().canonicalize().unwrap();
1406 std::fs::create_dir_all(base.join("docs")).unwrap();
1407 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1408 let exts = vec!["md".to_string()];
1409 let tags = vec!["tags".to_string()];
1410 let cfg = make_config(&base, &exts, "index.md", &tags);
1411
1412 let html = r#"<a href="/nope">gone</a><a href="/nope/">gone too</a>"#;
1414 assert_eq!(validate_rendered_links(html, &cfg, "/docs/guide/").len(), 2);
1415 }
1416
1417 #[test]
1420 fn index_page_sibling_link_is_ignored() {
1421 let dir = TempDir::new().unwrap();
1422 let base = dir.path().canonicalize().unwrap();
1423 std::fs::create_dir_all(base.join("docs")).unwrap();
1424 std::fs::write(base.join("docs/index.md"), "# i").unwrap();
1425 std::fs::write(base.join("docs/guide.md"), "# g").unwrap();
1426
1427 let html = r#"<a href="guide/">Guide</a>"#;
1428 assert!(rendered_link_errors(html, &base, "/docs/").is_empty());
1429 }
1430
1431 fn media_setup() -> (TempDir, PathBuf) {
1434 let dir = TempDir::new().unwrap();
1435 let base = dir.path().canonicalize().unwrap();
1436 std::fs::write(base.join("photo.png"), b"\x89PNG").unwrap();
1438 std::fs::create_dir_all(base.join("static/images")).unwrap();
1439 std::fs::write(base.join("static/images/ok.png"), b"\x89PNG").unwrap();
1440 (dir, base)
1441 }
1442
1443 #[test]
1444 fn broken_img_is_reported() {
1445 let (_guard, base) = media_setup();
1446 let exts = vec!["md".to_string()];
1447 let tags: Vec<String> = vec![];
1448 let cfg = make_config(&base, &exts, "index.md", &tags);
1449
1450 let html = r#"<p><img src="./missing.png" alt="x"></p>"#;
1452 let errs = validate_media_references(html, &cfg, "/");
1453 assert_eq!(errs.len(), 1);
1454 assert!(matches!(
1455 &errs[0],
1456 PageError::BrokenMediaReference { kind: MediaKind::Image, src } if src == "./missing.png"
1457 ));
1458 }
1459
1460 #[test]
1461 fn broken_section_backgrounds_are_reported() {
1462 let (_guard, base) = media_setup();
1463 let exts = vec!["md".to_string()];
1464 let tags: Vec<String> = vec![];
1465 let cfg = make_config(&base, &exts, "index.md", &tags);
1466
1467 let html = r##"
1469 <section data-background-image="../photo.png"></section>
1470 <section data-background-image="../gone.png"></section>
1471 <section data-background="#336"></section>
1472 <section data-background="../also-gone.jpg"></section>
1473 <section data-background-video="../photo.png, ../missing.mp4"></section>
1474 <section data-background-image="https://example.com/x.png"></section>
1475 <section data-background-video="data:video/mp4;base64,AAAA"></section>
1476 <section data-background-iframe="../nowhere.html"></section>
1477 "##;
1478 let errs = validate_media_references(html, &cfg, "/deck/");
1479 let mut found: Vec<(String, MediaKind)> = errs
1480 .into_iter()
1481 .map(|e| match e {
1482 PageError::BrokenMediaReference { src, kind } => (src, kind),
1483 other => panic!("unexpected {other:?}"),
1484 })
1485 .collect();
1486 found.sort_by(|a, b| a.0.cmp(&b.0));
1487 assert_eq!(
1488 found,
1489 vec![
1490 ("../also-gone.jpg".to_string(), MediaKind::Image),
1491 ("../gone.png".to_string(), MediaKind::Image),
1492 ("../missing.mp4".to_string(), MediaKind::Video),
1493 ]
1494 );
1495 }
1496
1497 #[test]
1498 fn section_backgrounds_stay_out_of_collected_media() {
1499 let (_guard, base) = media_setup();
1500 let exts = vec!["md".to_string()];
1501 let tags: Vec<String> = vec![];
1502 let cfg = make_config(&base, &exts, "index.md", &tags);
1503
1504 let html = r#"<section data-background-image="../photo.png"></section>"#;
1505 assert!(collect_media_references(html, &cfg, "/deck/").is_empty());
1506 }
1507
1508 #[test]
1509 fn valid_img_is_ignored() {
1510 let (_guard, base) = media_setup();
1511 let exts = vec!["md".to_string()];
1512 let tags: Vec<String> = vec![];
1513 let cfg = make_config(&base, &exts, "index.md", &tags);
1514
1515 let html = r#"<p><img src="photo.png" alt="x"></p>"#;
1516 let errs = validate_media_references(html, &cfg, "/");
1517 assert!(errs.is_empty());
1518 }
1519
1520 #[test]
1521 fn absolute_path_image_under_static_is_ignored() {
1522 let (_guard, base) = media_setup();
1523 let exts = vec!["md".to_string()];
1524 let tags: Vec<String> = vec![];
1525 let cfg = make_config(&base, &exts, "index.md", &tags);
1526
1527 let html = r#"<p><img src="/images/ok.png" alt="x"></p>"#;
1530 let errs = validate_media_references(html, &cfg, "/docs/guide/");
1531 assert!(errs.is_empty());
1532 }
1533
1534 #[test]
1535 fn external_image_is_ignored() {
1536 let (_guard, base) = media_setup();
1537 let exts = vec!["md".to_string()];
1538 let tags: Vec<String> = vec![];
1539 let cfg = make_config(&base, &exts, "index.md", &tags);
1540
1541 let html = r#"<p><img src="https://example.com/a.png"></p>"#;
1542 let errs = validate_media_references(html, &cfg, "/");
1543 assert!(errs.is_empty());
1544 }
1545
1546 #[test]
1547 fn broken_video_audio_and_source_are_reported() {
1548 let (_guard, base) = media_setup();
1549 let exts = vec!["md".to_string()];
1550 let tags: Vec<String> = vec![];
1551 let cfg = make_config(&base, &exts, "index.md", &tags);
1552
1553 let html = r#"
1554 <video src="./gone.mp4"></video>
1555 <audio src="./gone.mp3"></audio>
1556 <video><source src="./gone.webm"></video>
1557 "#;
1558 let errs = validate_media_references(html, &cfg, "/");
1559
1560 assert!(
1561 errs.iter().any(|e| matches!(
1562 e,
1563 PageError::BrokenMediaReference {
1564 kind: MediaKind::Video,
1565 ..
1566 }
1567 )),
1568 "{:?}",
1569 errs
1570 );
1571 assert!(errs.iter().any(|e| matches!(
1572 e,
1573 PageError::BrokenMediaReference {
1574 kind: MediaKind::Audio,
1575 ..
1576 }
1577 )));
1578 assert!(errs.iter().any(|e| matches!(
1579 e,
1580 PageError::BrokenMediaReference {
1581 kind: MediaKind::Source,
1582 ..
1583 }
1584 )));
1585 }
1586
1587 #[test]
1588 fn percent_encoded_relative_img_next_to_markdown_is_ignored() {
1589 let dir = TempDir::new().unwrap();
1592 let base = dir.path().canonicalize().unwrap();
1593 std::fs::write(base.join("my photo.png"), b"\x89PNG").unwrap();
1594 let exts = vec!["md".to_string()];
1595 let tags: Vec<String> = vec![];
1596 let cfg = make_config(&base, &exts, "index.md", &tags);
1597
1598 let html = r#"<p><img src="./my%20photo.png" alt="x"></p>"#;
1599 let errs = validate_media_references(html, &cfg, "/");
1600 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
1601 }
1602
1603 fn attachments_setup() -> (TempDir, PathBuf) {
1608 let dir = TempDir::new().unwrap();
1609 let base = dir.path().canonicalize().unwrap();
1610 let attachments = base.join("Projects/Ideas/hledger-web-gui_attachments");
1611 std::fs::create_dir_all(&attachments).unwrap();
1612 std::fs::write(base.join("Projects/Ideas/hledger-web-gui.md"), "# x").unwrap();
1613 std::fs::write(attachments.join("img.png"), b"\x89PNG").unwrap();
1614 std::fs::write(attachments.join("my photo.png"), b"\x89PNG").unwrap();
1615 (dir, base)
1616 }
1617
1618 #[test]
1619 fn parent_relative_img_next_to_markdown_is_ignored() {
1620 let (_guard, base) = attachments_setup();
1624 let exts = vec!["md".to_string()];
1625 let tags: Vec<String> = vec![];
1626 let cfg = make_config(&base, &exts, "index.md", &tags);
1627
1628 let html = r#"<p><img src="../hledger-web-gui_attachments/img.png" alt="x"></p>"#;
1629 let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
1630 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
1631 }
1632
1633 #[test]
1634 fn parent_relative_percent_encoded_img_is_ignored() {
1635 let (_guard, base) = attachments_setup();
1636 let exts = vec!["md".to_string()];
1637 let tags: Vec<String> = vec![];
1638 let cfg = make_config(&base, &exts, "index.md", &tags);
1639
1640 let html = r#"<p><img src="../hledger-web-gui_attachments/my%20photo.png" alt="x"></p>"#;
1641 let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
1642 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
1643 }
1644
1645 #[test]
1646 fn index_page_sibling_img_is_ignored() {
1647 let dir = TempDir::new().unwrap();
1650 let base = dir.path().canonicalize().unwrap();
1651 std::fs::create_dir_all(base.join("docs")).unwrap();
1652 std::fs::write(base.join("docs/index.md"), "# x").unwrap();
1653 std::fs::write(base.join("docs/img.png"), b"\x89PNG").unwrap();
1654 let exts = vec!["md".to_string()];
1655 let tags: Vec<String> = vec![];
1656 let cfg = make_config(&base, &exts, "index.md", &tags);
1657
1658 let html = r#"<p><img src="img.png" alt="x"></p>"#;
1659 let errs = validate_media_references(html, &cfg, "/docs/");
1660 assert!(errs.is_empty(), "expected no errors, got: {:?}", errs);
1661 }
1662
1663 #[test]
1664 fn parent_relative_missing_img_is_reported() {
1665 let (_guard, base) = attachments_setup();
1667 let exts = vec!["md".to_string()];
1668 let tags: Vec<String> = vec![];
1669 let cfg = make_config(&base, &exts, "index.md", &tags);
1670
1671 let html = r#"<p><img src="../hledger-web-gui_attachments/nope.png" alt="x"></p>"#;
1672 let errs = validate_media_references(html, &cfg, "/Projects/Ideas/hledger-web-gui/");
1673 assert_eq!(errs.len(), 1);
1674 assert!(matches!(
1675 &errs[0],
1676 PageError::BrokenMediaReference { kind: MediaKind::Image, src }
1677 if src == "../hledger-web-gui_attachments/nope.png"
1678 ));
1679 }
1680
1681 fn video_setup() -> (TempDir, PathBuf) {
1684 let dir = TempDir::new().unwrap();
1685 let base = dir.path().canonicalize().unwrap();
1686 std::fs::create_dir_all(base.join("Projects")).unwrap();
1687 std::fs::write(base.join("Projects/note.md"), "# x").unwrap();
1688 std::fs::write(base.join("Projects/Foo Bar.mp4"), b"\x00\x00\x00\x18ftyp").unwrap();
1689 (dir, base)
1690 }
1691
1692 #[test]
1693 fn collect_media_references_resolves_source_to_disk_path() {
1694 let (_guard, base) = video_setup();
1695 let exts = vec!["md".to_string()];
1696 let tags: Vec<String> = vec![];
1697 let cfg = make_config(&base, &exts, "index.md", &tags);
1698
1699 let html = r#"<video><source src="../Foo%20Bar.mp4" type="video/mp4"></video>"#;
1700 let refs = collect_media_references(html, &cfg, "/Projects/note/");
1701
1702 assert_eq!(refs.len(), 1, "{:?}", refs);
1703 assert_eq!(refs[0].src, "../Foo%20Bar.mp4");
1704 assert_eq!(refs[0].kind, MediaKind::Source);
1705 assert_eq!(refs[0].path, base.join("Projects/Foo Bar.mp4"));
1706 }
1707
1708 #[test]
1709 fn collect_media_references_strips_time_fragment() {
1710 let (_guard, base) = video_setup();
1714 let exts = vec!["md".to_string()];
1715 let tags: Vec<String> = vec![];
1716 let cfg = make_config(&base, &exts, "index.md", &tags);
1717
1718 let html = r#"<video><source src="../Foo%20Bar.mp4#t=30,200"></video>"#;
1719 let refs = collect_media_references(html, &cfg, "/Projects/note/");
1720
1721 assert_eq!(refs.len(), 1);
1722 assert_eq!(refs[0].src, "../Foo%20Bar.mp4");
1723 }
1724
1725 #[test]
1726 fn collect_media_references_dedupes_repeated_embeds() {
1727 let (_guard, base) = video_setup();
1730 let exts = vec!["md".to_string()];
1731 let tags: Vec<String> = vec![];
1732 let cfg = make_config(&base, &exts, "index.md", &tags);
1733
1734 let html = r#"
1735 <video><source src="../Foo%20Bar.mp4"></video>
1736 <video><source src="../Foo%20Bar.mp4#t=5"></video>
1737 "#;
1738 let refs = collect_media_references(html, &cfg, "/Projects/note/");
1739 assert_eq!(refs.len(), 1, "{:?}", refs);
1740 }
1741
1742 #[test]
1743 fn collect_media_references_skips_missing_and_external() {
1744 let (_guard, base) = video_setup();
1745 let exts = vec!["md".to_string()];
1746 let tags: Vec<String> = vec![];
1747 let cfg = make_config(&base, &exts, "index.md", &tags);
1748
1749 let html = r#"
1750 <video><source src="../Gone.mp4"></video>
1751 <video><source src="https://example.com/x.mp4"></video>
1752 "#;
1753 let refs = collect_media_references(html, &cfg, "/Projects/note/");
1754 assert!(refs.is_empty(), "{:?}", refs);
1755 }
1756
1757 #[test]
1758 fn collect_media_references_finds_images_and_audio_too() {
1759 let (_guard, base) = media_setup();
1761 let exts = vec!["md".to_string()];
1762 let tags: Vec<String> = vec![];
1763 let cfg = make_config(&base, &exts, "index.md", &tags);
1764
1765 let html = r#"<img src="photo.png"><audio src="photo.png"></audio>"#;
1766 let refs = collect_media_references(html, &cfg, "/");
1767 assert_eq!(refs.len(), 1);
1769 assert_eq!(refs[0].kind, MediaKind::Image);
1770 }
1771
1772 #[test]
1775 fn literal_wikilink_in_body_is_reported() {
1776 let html = "<p>See [[never-a-real-page]] for more.</p>";
1777 let errs = detect_unresolved_wikilinks(html);
1778 assert_eq!(errs.len(), 1);
1779 assert!(matches!(
1780 &errs[0],
1781 PageError::UnresolvedWikilink { raw } if raw == "[[never-a-real-page]]"
1782 ));
1783 }
1784
1785 #[test]
1786 fn transformed_wikilink_yields_no_match() {
1787 let html = r#"<p><a href="/tags/rust/">rust</a></p>"#;
1790 let errs = detect_unresolved_wikilinks(html);
1791 assert!(errs.is_empty());
1792 }
1793
1794 #[test]
1795 fn wikilink_inside_code_block_is_ignored() {
1796 let html =
1797 "<p>Regular</p><pre><code>This is a literal [[bracket]] inside code</code></pre>";
1798 let errs = detect_unresolved_wikilinks(html);
1799 assert!(
1800 errs.is_empty(),
1801 "expected no wikilink errors inside code/pre, got: {:?}",
1802 errs
1803 );
1804 }
1805
1806 #[test]
1807 fn wikilink_inside_inline_code_is_ignored() {
1808 let html = "<p>See <code>[[foo]]</code> for the literal syntax.</p>";
1809 let errs = detect_unresolved_wikilinks(html);
1810 assert!(errs.is_empty());
1811 }
1812
1813 #[test]
1814 fn repeated_wikilink_is_deduped() {
1815 let html = "<p>[[bad]] again [[bad]] and [[bad]]</p>";
1816 let errs = detect_unresolved_wikilinks(html);
1817 assert_eq!(errs.len(), 1);
1818 }
1819
1820 #[test]
1821 fn wikilink_with_display_text_is_reported_verbatim() {
1822 let html = "<p>[[Target|Display Text]]</p>";
1823 let errs = detect_unresolved_wikilinks(html);
1824 assert_eq!(errs.len(), 1);
1825 assert!(matches!(
1826 &errs[0],
1827 PageError::UnresolvedWikilink { raw } if raw == "[[Target|Display Text]]"
1828 ));
1829 }
1830
1831 #[test]
1834 fn page_error_serializes_with_snake_case_type_tag() {
1835 let err = PageError::BrokenInternalLink {
1836 target: "/x/".to_string(),
1837 text: "x".to_string(),
1838 anchor: None,
1839 };
1840 let json = serde_json::to_string(&err).unwrap();
1841 assert!(
1842 json.contains("\"type\":\"broken_internal_link\""),
1843 "{}",
1844 json
1845 );
1846 assert!(!json.contains("\"anchor\""), "{}", json);
1848 }
1849
1850 #[test]
1851 fn media_kind_serializes_as_snake_case() {
1852 let err = PageError::BrokenMediaReference {
1853 src: "./x.png".to_string(),
1854 kind: MediaKind::Image,
1855 };
1856 let json = serde_json::to_string(&err).unwrap();
1857 assert!(json.contains("\"kind\":\"image\""), "{}", json);
1858 assert!(
1859 json.contains("\"type\":\"broken_media_reference\""),
1860 "{}",
1861 json
1862 );
1863 }
1864
1865 #[test]
1866 fn unresolved_wikilink_serializes() {
1867 let err = PageError::UnresolvedWikilink {
1868 raw: "[[foo]]".to_string(),
1869 };
1870 let json = serde_json::to_string(&err).unwrap();
1871 assert!(json.contains("\"type\":\"unresolved_wikilink\""));
1872 assert!(json.contains("\"raw\":\"[[foo]]\""));
1873 }
1874
1875 #[test]
1876 fn frontmatter_parse_error_serializes() {
1877 let err = PageError::FrontmatterParseError {
1878 message: "mapping values are not allowed".to_string(),
1879 };
1880 let json = serde_json::to_string(&err).unwrap();
1881 assert!(
1882 json.contains("\"type\":\"frontmatter_parse_error\""),
1883 "{}",
1884 json
1885 );
1886 assert!(json.contains("mapping values are not allowed"), "{}", json);
1887 }
1888
1889 #[test]
1890 fn frontmatter_parse_errors_none_is_empty() {
1891 assert!(frontmatter_parse_errors(&None).is_empty());
1892 }
1893
1894 #[test]
1895 fn frontmatter_parse_errors_some_yields_one() {
1896 let errs = frontmatter_parse_errors(&Some("bad yaml".to_string()));
1897 assert_eq!(errs.len(), 1);
1898 assert!(matches!(
1899 &errs[0],
1900 PageError::FrontmatterParseError { message } if message == "bad yaml"
1901 ));
1902 }
1903
1904 #[test]
1910 fn config_parse_error_serializes_to_the_pinned_shape() {
1911 let err = PageError::ConfigParseError {
1912 message: "bad toml".to_string(),
1913 };
1914 assert_eq!(
1915 serde_json::to_string(&err).unwrap(),
1916 r#"{"type":"config_parse_error","message":"bad toml"}"#
1917 );
1918 }
1919
1920 #[test]
1921 fn relationship_cycle_serializes_to_the_pinned_shape() {
1922 let err = PageError::RelationshipCycle {
1923 members: vec!["/a/".to_string(), "/b/".to_string()],
1924 rel_type: "child".to_string(),
1925 };
1926 assert_eq!(
1927 serde_json::to_string(&err).unwrap(),
1928 r#"{"type":"relationship_cycle","members":["/a/","/b/"],"rel_type":"child"}"#
1929 );
1930 }
1931
1932 #[test]
1933 fn ambiguous_relationship_endpoint_serializes_to_the_pinned_shape() {
1934 let err = PageError::AmbiguousRelationshipEndpoint {
1935 raw: "[[John Doe]]".to_string(),
1936 resolved_to: "/people/john-jr/".to_string(),
1937 candidates: vec!["/people/john-sr/".to_string()],
1938 };
1939 assert_eq!(
1940 serde_json::to_string(&err).unwrap(),
1941 r#"{"type":"ambiguous_relationship_endpoint","raw":"[[John Doe]]","resolved_to":"/people/john-jr/","candidates":["/people/john-sr/"]}"#
1942 );
1943 }
1944
1945 #[test]
1946 fn ambiguous_wikilink_serializes_to_the_pinned_shape() {
1947 let err = PageError::AmbiguousWikilink {
1948 raw: "[[John Doe]]".to_string(),
1949 resolved_to: "/people/john-jr/".to_string(),
1950 candidates: vec!["/people/john-sr/".to_string()],
1951 };
1952 assert_eq!(
1953 serde_json::to_string(&err).unwrap(),
1954 r#"{"type":"ambiguous_wikilink","raw":"[[John Doe]]","resolved_to":"/people/john-jr/","candidates":["/people/john-sr/"]}"#
1955 );
1956 }
1957
1958 #[test]
1959 fn relationship_cycle_errors_maps_every_cycle() {
1960 let cycles = vec![
1961 RelationshipCycle {
1962 members: vec!["/a/".to_string(), "/b/".to_string()],
1963 rel_type: "child".to_string(),
1964 },
1965 RelationshipCycle {
1966 members: vec!["/x/".to_string(), "/y/".to_string()],
1967 rel_type: "employee".to_string(),
1968 },
1969 ];
1970 let errs = relationship_cycle_errors(&cycles);
1971 assert_eq!(errs.len(), 2);
1972 assert!(matches!(
1973 &errs[0],
1974 PageError::RelationshipCycle { rel_type, members }
1975 if rel_type == "child" && members.len() == 2
1976 ));
1977 assert!(relationship_cycle_errors(&[]).is_empty());
1978 }
1979
1980 #[test]
1981 fn ambiguous_relationship_endpoint_errors_preserves_all_candidates() {
1982 let endpoints = vec![AmbiguousEndpoint {
1983 raw: "[[Sam]]".to_string(),
1984 resolved_to: "/a/sam/".to_string(),
1985 candidates: vec!["/m/sam/".to_string(), "/z/sam/".to_string()],
1986 }];
1987 let errs = ambiguous_relationship_endpoint_errors(&endpoints);
1988 assert_eq!(errs.len(), 1);
1989 assert!(matches!(
1990 &errs[0],
1991 PageError::AmbiguousRelationshipEndpoint { raw, resolved_to, candidates }
1992 if raw == "[[Sam]]" && resolved_to == "/a/sam/" && candidates.len() == 2
1993 ));
1994 assert!(ambiguous_relationship_endpoint_errors(&[]).is_empty());
1995 }
1996
1997 #[test]
1998 fn ambiguous_wikilink_errors_preserves_all_candidates() {
1999 let wikilinks = vec![AmbiguousWikilink {
2000 raw: "[[Sam]]".to_string(),
2001 resolved_to: "/a/sam/".to_string(),
2002 candidates: vec!["/z/sam/".to_string()],
2003 }];
2004 let errs = ambiguous_wikilink_errors(&wikilinks);
2005 assert_eq!(errs.len(), 1);
2006 assert!(matches!(
2007 &errs[0],
2008 PageError::AmbiguousWikilink { raw, resolved_to, candidates }
2009 if raw == "[[Sam]]" && resolved_to == "/a/sam/" && candidates == &["/z/sam/".to_string()]
2010 ));
2011 assert!(ambiguous_wikilink_errors(&[]).is_empty());
2012 }
2013
2014 #[test]
2015 fn unplayable_media_serializes_to_the_frontend_contract() {
2016 let err = PageError::UnplayableMedia {
2019 src: "../Foo%20Bar.mp4".to_string(),
2020 kind: MediaKind::Video,
2021 reason: "likely cause".to_string(),
2022 remedy: Some(
2023 "ffmpeg -i in.mp4 -map 0 -c copy -dn -movflags +faststart out.mp4".to_string(),
2024 ),
2025 advisory: true,
2026 };
2027
2028 let json = serde_json::to_value(&err).unwrap();
2029 assert_eq!(
2030 json,
2031 serde_json::json!({
2032 "type": "unplayable_media",
2033 "src": "../Foo%20Bar.mp4",
2034 "kind": "video",
2035 "reason": "likely cause",
2036 "remedy": "ffmpeg -i in.mp4 -map 0 -c copy -dn -movflags +faststart out.mp4",
2037 "advisory": true
2038 })
2039 );
2040 }
2041
2042 #[test]
2043 fn unplayable_media_omits_absent_remedy() {
2044 let err = PageError::UnplayableMedia {
2045 src: "../x.mp4".to_string(),
2046 kind: MediaKind::Video,
2047 reason: "nope".to_string(),
2048 remedy: None,
2049 advisory: true,
2050 };
2051 let json = serde_json::to_string(&err).unwrap();
2052 assert!(!json.contains("remedy"), "{}", json);
2053 }
2054
2055 #[test]
2056 fn unplayable_media_round_trips() {
2057 let err = PageError::UnplayableMedia {
2058 src: "../Foo%20Bar.mp4".to_string(),
2059 kind: MediaKind::Video,
2060 reason: "r".to_string(),
2061 remedy: Some("cmd".to_string()),
2062 advisory: true,
2063 };
2064 let json = serde_json::to_string(&err).unwrap();
2065 let back: PageError = serde_json::from_str(&json).unwrap();
2066 assert_eq!(back, err);
2067 }
2068
2069 #[test]
2070 fn page_errors_empty_default_serializes() {
2071 let pe = PageErrors {
2072 page_url: "/x/".to_string(),
2073 errors: vec![],
2074 not_downloaded: false,
2075 };
2076 let json = serde_json::to_string(&pe).unwrap();
2077 assert!(json.contains("\"page_url\":\"/x/\""));
2078 assert!(json.contains("\"errors\":[]"));
2079 assert!(
2080 !json.contains("not_downloaded"),
2081 "an ordinary payload is unchanged: {json}"
2082 );
2083 }
2084
2085 #[test]
2086 fn page_errors_mark_a_not_downloaded_note() {
2087 let pe = PageErrors {
2088 page_url: "/cloud/".to_string(),
2089 errors: vec![],
2090 not_downloaded: true,
2091 };
2092 let json = serde_json::to_string(&pe).unwrap();
2093 assert_eq!(
2094 json,
2095 r#"{"page_url":"/cloud/","errors":[],"not_downloaded":true}"#
2096 );
2097 let back: PageErrors = serde_json::from_str(r#"{"page_url":"/x/","errors":[]}"#).unwrap();
2098 assert!(!back.not_downloaded, "absent means downloaded");
2099 }
2100}