Skip to main content

mbr_core/
link_transform.rs

1//! Link transformation for trailing-slash URL convention.
2//!
3//! When markdown files are served with trailing-slash URLs (e.g., `docs/guide.md` → `/docs/guide/`),
4//! relative links in the markdown need to be adjusted so they resolve correctly from the browser's
5//! perspective.
6//!
7//! ## Problem
8//!
9//! A link `[other](other.md)` in `docs/guide.md`:
10//! - Filesystem: refers to `docs/other.md` (sibling file)
11//! - From URL `/docs/guide/`: browser resolves `other.md` as `/docs/guide/other.md` (WRONG)
12//! - Correct URL: `/docs/other/`
13//!
14//! ## Solution
15//!
16//! Transform relative links by:
17//! 1. Adding `../` prefix for regular markdown files (not index files)
18//! 2. Replacing markdown extensions with trailing slash
19//! 3. Collapsing index file references to their directory
20
21use std::sync::Arc;
22
23/// Answers "does this absolute, percent-decoded site URL address a markdown
24/// page?" for an authored link target that carries no markdown extension.
25///
26/// See [`LinkTransformConfig::markdown_page_probe`] for why this is a
27/// caller-supplied predicate rather than a guess.
28pub type MarkdownPageProbe = Arc<dyn Fn(&str) -> bool + Send + Sync>;
29
30/// Builds the probe used in server and build mode: ask the *path resolver* —
31/// the very code a live HTTP request goes through — whether the URL resolves to
32/// a markdown file.
33///
34/// Reusing the resolver rather than re-deriving candidate filenames is the
35/// point: this cannot disagree with what the server actually serves, which is
36/// the class of bug that produced the trailing-slash defect in the first place.
37///
38/// The cost is paid only for *extension-less relative* link targets — `.md`
39/// links, root-relative URLs, anchors and external URLs never reach it — and is
40/// a handful of `stat` calls.
41pub fn filesystem_markdown_page_probe(
42    resolver: crate::path_resolver::OwnedPathResolverConfig,
43) -> MarkdownPageProbe {
44    Arc::new(move |absolute_url: &str| {
45        let request_path = crate::path_resolver::normalize_link_target(absolute_url);
46        matches!(
47            crate::path_resolver::resolve_request_path(&resolver.as_config(), &request_path),
48            crate::path_resolver::ResolvedPath::MarkdownFile(_)
49        )
50    })
51}
52
53/// Configuration for link transformation.
54#[derive(Clone)]
55pub struct LinkTransformConfig {
56    /// Markdown file extensions (e.g., ["md", "markdown"])
57    pub markdown_extensions: Vec<String>,
58    /// Index filename (e.g., "index.md")
59    pub index_file: String,
60    /// Whether the current file is an index file (affects ../ prefix)
61    pub is_index_file: bool,
62    /// Page depth for converting root-relative URLs to relative (build mode).
63    /// None = leave root-relative URLs unchanged (server mode).
64    pub url_depth: Option<usize>,
65    /// The canonical URL of the page being rendered (e.g. `/docs/guide/`).
66    ///
67    /// Used together with `is_index_file` for Obsidian-style body-wikilink
68    /// resolution (current folder first, else global). Empty when there is no
69    /// current page context (e.g. CLI/QuickLook paths, which never resolve
70    /// wikilinks globally).
71    pub current_page_url: String,
72    /// Decides whether an **extension-less** relative target names a markdown
73    /// page (`[x](../folder/file)`) or a genuinely extension-less static file
74    /// (`[license](../LICENSE)`).
75    ///
76    /// Markdown pages are served at directory-style URLs, so a link to one must
77    /// end in `/`; a static file must not. Nothing in the href distinguishes
78    /// the two and guessing either way corrupts the other, so the repository
79    /// has to answer. `None` means "no repository context" — CLI/QuickLook
80    /// rendering, link-grep and unit tests — and preserves the historical
81    /// behaviour of treating an extension-less target as a static file.
82    ///
83    /// Called with the target's **absolute** site URL, already resolved against
84    /// [`Self::current_page_url`], so a probe never reimplements relative-path
85    /// arithmetic.
86    pub markdown_page_probe: Option<MarkdownPageProbe>,
87}
88
89/// Hand-written because [`MarkdownPageProbe`] is a closure and cannot derive
90/// `Debug`; only its presence is worth printing.
91impl std::fmt::Debug for LinkTransformConfig {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("LinkTransformConfig")
94            .field("markdown_extensions", &self.markdown_extensions)
95            .field("index_file", &self.index_file)
96            .field("is_index_file", &self.is_index_file)
97            .field("url_depth", &self.url_depth)
98            .field("current_page_url", &self.current_page_url)
99            .field("markdown_page_probe", &self.markdown_page_probe.is_some())
100            .finish()
101    }
102}
103
104impl Default for LinkTransformConfig {
105    fn default() -> Self {
106        Self {
107            markdown_extensions: vec!["md".to_string()],
108            index_file: "index.md".to_string(),
109            is_index_file: false,
110            url_depth: None,
111            current_page_url: String::new(),
112            markdown_page_probe: None,
113        }
114    }
115}
116
117/// Transform a relative link URL for the trailing-slash URL convention.
118///
119/// # Rules
120///
121/// 1. Anchor-only links (`#...`) → unchanged
122/// 2. External URLs — anything with a scheme (`https:`, `mailto:`, `magnet:`,
123///    `blob:`, …) or protocol-relative (`//host`) → unchanged, as decided by
124///    [`crate::url_path::is_external_url`]
125/// 3. Root-relative URLs (starts with `/`) → unchanged
126/// 4. Relative markdown links → prepend `../` (if not index file), replace extension with `/`
127/// 5. Relative **extension-less** targets → prepend `../` (if not index file),
128///    and append `/` when [`LinkTransformConfig::markdown_page_probe`] says the
129///    target is a markdown page
130/// 6. Relative static files → prepend `../` (if not index file)
131///
132/// # Examples
133///
134/// ```
135/// use mbr_core::link_transform::{transform_link, LinkTransformConfig};
136///
137/// let config = LinkTransformConfig {
138///     markdown_extensions: vec!["md".to_string()],
139///     index_file: "index.md".to_string(),
140///     is_index_file: false,
141///     url_depth: None,
142///     current_page_url: String::new(),
143///     markdown_page_probe: None,
144/// };
145///
146/// // Regular markdown file: add ../ and trailing slash
147/// assert_eq!(transform_link("other.md", &config), "../other/");
148///
149/// // Index file config: no ../ prefix
150/// let index_config = LinkTransformConfig { is_index_file: true, ..config.clone() };
151/// assert_eq!(transform_link("other.md", &index_config), "other/");
152///
153/// // Absolute URLs unchanged
154/// assert_eq!(transform_link("https://example.com", &config), "https://example.com");
155/// ```
156pub fn transform_link(url: &str, config: &LinkTransformConfig) -> String {
157    // Empty or whitespace-only
158    if url.is_empty() || url.trim().is_empty() {
159        return url.to_string();
160    }
161
162    // Anchor-only links
163    if url.starts_with('#') {
164        return url.to_string();
165    }
166
167    // Anything that leaves the site is opaque to us: absolute and
168    // protocol-relative URLs, but equally `mailto:`, `tel:`, `sms:`,
169    // `magnet:`, `data:`, `javascript:` and `blob:` (what the Crepe editor
170    // mints for a freshly pasted image before upload — a bogus `../` prefix
171    // would break the in-editor preview). One predicate covers them all, so a
172    // scheme can no longer be handled here but missed by link tracking.
173    if crate::url_path::is_external_url(url) {
174        return url.to_string();
175    }
176
177    // Root-relative URLs — convert to relative in build mode
178    if url.starts_with('/') {
179        return match config.url_depth {
180            Some(depth) => make_relative_url(url, depth),
181            None => url.to_string(),
182        };
183    }
184
185    // Split into path and suffix (anchor/query)
186    let (path, suffix) = split_url_parts(url);
187
188    // Empty path after splitting (e.g., just "?query" or malformed)
189    if path.is_empty() {
190        return url.to_string();
191    }
192
193    // Normalize: strip leading "./"
194    let path = path.strip_prefix("./").unwrap_or(&path);
195
196    // Count and strip existing "../" prefixes
197    let (parent_count, remaining_path) = count_parent_traversals(path);
198
199    // If nothing remains after stripping ../, just return with adjusted parents
200    if remaining_path.is_empty() {
201        let prefix = if config.is_index_file {
202            "../".repeat(parent_count)
203        } else {
204            "../".repeat(parent_count + 1)
205        };
206        return format!("{}{}", prefix, suffix);
207    }
208
209    // Check if it's a markdown file
210    if let Some(base_path) = strip_markdown_extension(remaining_path, &config.markdown_extensions) {
211        // Check if it ends with index file (without extension)
212        let index_stem = config
213            .index_file
214            .strip_suffix(".md")
215            .or_else(|| config.index_file.strip_suffix(".markdown"))
216            .unwrap_or(&config.index_file);
217
218        // The link targets an index file only when the *final path segment* is
219        // exactly the index stem — mirroring `repo::build_markdown_url_path`.
220        // A plain `ends_with` also matched "site-index", "myindex" and
221        // "subindex", rewriting them to "../site-/", "../my/" and "../sub/":
222        // dead links, or worse, a live link to a different page.
223        let is_index_target =
224            base_path == index_stem || base_path.ends_with(&format!("/{}", index_stem));
225
226        let final_path = if is_index_target {
227            // Collapse index file to directory
228            let stripped = base_path
229                .strip_suffix(index_stem)
230                .unwrap_or(base_path)
231                .trim_end_matches('/');
232            if stripped.is_empty() {
233                // Just "index.md" -> "./" for index files, "../" for regular
234                "".to_string()
235            } else {
236                format!("{}/", stripped)
237            }
238        } else {
239            format!("{}/", base_path)
240        };
241
242        // Build prefix based on parent count and whether current file is index
243        let prefix = if config.is_index_file {
244            "../".repeat(parent_count)
245        } else {
246            "../".repeat(parent_count + 1)
247        };
248
249        // Handle edge case: if final_path is empty and we have no prefix, use "./"
250        if final_path.is_empty() && prefix.is_empty() {
251            return format!("./{}", suffix);
252        }
253
254        return format!("{}{}{}", prefix, final_path, suffix);
255    }
256
257    // Extension-less target, or a static file. `strip_markdown_extension` is
258    // the *only* markdown detector above, so an extension-less link to a
259    // markdown page (`[x](../folder/file)` — how Obsidian and zk write links)
260    // lands here and used to be emitted verbatim, without the trailing slash
261    // the URL convention requires. The page still resolved, but at a
262    // non-canonical URL, and every relative href on *that* page then resolved
263    // one directory too high. Ask the repository which kind of target this is;
264    // when nobody can answer, keep the historical static-file behaviour.
265    let prefix = if config.is_index_file {
266        "../".repeat(parent_count)
267    } else {
268        "../".repeat(parent_count + 1)
269    };
270
271    if !remaining_path.ends_with('/') && resolves_to_markdown_page(path, config) {
272        return format!("{}{}/{}", prefix, remaining_path, suffix);
273    }
274
275    format!("{}{}{}", prefix, remaining_path, suffix)
276}
277
278/// Asks [`LinkTransformConfig::markdown_page_probe`] whether `authored_path`
279/// (relative, `./` already stripped, no anchor/query) names a markdown page.
280///
281/// The authored path is resolved against the page URL with *markdown*
282/// semantics — `is_index_file` decides whether the page URL's last segment is a
283/// file stem or a real directory — because that is what the author wrote it
284/// relative to. The transform's own `../` compensation happens afterwards and
285/// must not be applied twice.
286fn resolves_to_markdown_page(authored_path: &str, config: &LinkTransformConfig) -> bool {
287    let Some(probe) = &config.markdown_page_probe else {
288        return false;
289    };
290    // Without a page URL there is nothing to resolve against, and guessing
291    // would append slashes to root-level static files.
292    if config.current_page_url.is_empty() {
293        return false;
294    }
295    match crate::link_index::resolve_relative_url_checked(
296        &config.current_page_url,
297        authored_path,
298        config.is_index_file,
299    ) {
300        // A target outside the repository is nobody's markdown page; leaving it
301        // untouched keeps the defect visible to the link validators.
302        None => false,
303        Some(absolute) => probe(&absolute),
304    }
305}
306
307/// Split a URL into path and suffix (anchor # or query ?).
308/// Returns (path, suffix) where suffix includes the delimiter.
309fn split_url_parts(url: &str) -> (String, String) {
310    // Find first occurrence of # or ?
311    let anchor_pos = url.find('#');
312    let query_pos = url.find('?');
313
314    let split_pos = match (anchor_pos, query_pos) {
315        (Some(a), Some(q)) => Some(a.min(q)),
316        (Some(a), None) => Some(a),
317        (None, Some(q)) => Some(q),
318        (None, None) => None,
319    };
320
321    match split_pos {
322        Some(pos) => (url[..pos].to_string(), url[pos..].to_string()),
323        None => (url.to_string(), String::new()),
324    }
325}
326
327/// Count leading "../" sequences and return (count, remaining_path).
328fn count_parent_traversals(path: &str) -> (usize, &str) {
329    let mut count = 0;
330    let mut remaining = path;
331
332    while let Some(rest) = remaining.strip_prefix("../") {
333        count += 1;
334        remaining = rest;
335    }
336
337    (count, remaining)
338}
339
340/// Strip markdown extension if present, returning the base path.
341fn strip_markdown_extension<'a>(path: &'a str, extensions: &[String]) -> Option<&'a str> {
342    for ext in extensions {
343        let suffix = format!(".{}", ext);
344        if path.ends_with(&suffix) {
345            return Some(&path[..path.len() - suffix.len()]);
346        }
347    }
348    None
349}
350
351/// Convert an absolute URL path to a relative URL from the given depth.
352///
353/// Examples (from depth 2):
354/// - "/" → "../../"
355/// - "/docs/" → "../../docs/"
356/// - "/docs/guide/" → "../../docs/guide/"
357pub fn make_relative_url(absolute_url: &str, depth: usize) -> String {
358    let target = absolute_url.trim_start_matches('/');
359    if target.is_empty() {
360        // Link to root
361        if depth == 0 {
362            "./".to_string()
363        } else {
364            "../".repeat(depth)
365        }
366    } else {
367        // Go up to root, then down to target
368        if depth == 0 {
369            target.to_string()
370        } else {
371            format!("{}{}", "../".repeat(depth), target)
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn regular_config() -> LinkTransformConfig {
381        LinkTransformConfig {
382            markdown_extensions: vec!["md".to_string(), "markdown".to_string()],
383            index_file: "index.md".to_string(),
384            is_index_file: false,
385            url_depth: None,
386            current_page_url: String::new(),
387            markdown_page_probe: None,
388        }
389    }
390
391    fn index_config() -> LinkTransformConfig {
392        LinkTransformConfig {
393            is_index_file: true,
394            ..regular_config()
395        }
396    }
397
398    // =========================================================================
399    // Regular markdown files (is_index_file: false)
400    // =========================================================================
401
402    #[test]
403    fn test_simple_relative_md() {
404        assert_eq!(transform_link("other.md", &regular_config()), "../other/");
405    }
406
407    #[test]
408    fn test_subdirectory_md() {
409        assert_eq!(
410            transform_link("sub/doc.md", &regular_config()),
411            "../sub/doc/"
412        );
413    }
414
415    #[test]
416    fn test_parent_traversal() {
417        assert_eq!(
418            transform_link("../other.md", &regular_config()),
419            "../../other/"
420        );
421    }
422
423    #[test]
424    fn test_double_parent() {
425        assert_eq!(
426            transform_link("../../root.md", &regular_config()),
427            "../../../root/"
428        );
429    }
430
431    #[test]
432    fn test_index_collapse() {
433        assert_eq!(
434            transform_link("folder/index.md", &regular_config()),
435            "../folder/"
436        );
437    }
438
439    #[test]
440    fn test_nested_index_collapse() {
441        assert_eq!(transform_link("a/b/index.md", &regular_config()), "../a/b/");
442    }
443
444    #[test]
445    fn test_just_index_md() {
446        // Link to index.md in same directory
447        assert_eq!(transform_link("index.md", &regular_config()), "../");
448    }
449
450    /// Regression (mirrors `repo::test_build_markdown_url_path_myindex_not_
451    /// treated_as_index`): only a *whole* final segment equal to the index
452    /// stem collapses to a directory. `ends_with` used to mangle these into
453    /// `../site-/`, `../my/`, `../re/` and `../sub/` — the last of which can
454    /// silently point at a real but different page.
455    #[test]
456    fn test_index_lookalike_stems_keep_their_own_url() {
457        let cases = [
458            ("site-index.md", "../site-index/"),
459            ("myindex.md", "../myindex/"),
460            ("reindex.md", "../reindex/"),
461            ("subindex.md", "../subindex/"),
462            ("docs/site-index.md", "../docs/site-index/"),
463        ];
464
465        for (input, expected) in cases {
466            assert_eq!(transform_link(input, &regular_config()), expected);
467        }
468
469        // Index files themselves still collapse to the folder URL.
470        assert_eq!(
471            transform_link("docs/index.md", &regular_config()),
472            "../docs/"
473        );
474        assert_eq!(transform_link("index.md", &regular_config()), "../");
475    }
476
477    #[test]
478    fn test_index_lookalike_stems_from_index_page() {
479        assert_eq!(transform_link("subindex.md", &index_config()), "subindex/");
480        assert_eq!(transform_link("docs/index.md", &index_config()), "docs/");
481    }
482
483    #[test]
484    fn test_static_file() {
485        assert_eq!(
486            transform_link("image.png", &regular_config()),
487            "../image.png"
488        );
489    }
490
491    #[test]
492    fn test_nested_static() {
493        assert_eq!(
494            transform_link("assets/img.png", &regular_config()),
495            "../assets/img.png"
496        );
497    }
498
499    #[test]
500    fn test_md_with_anchor() {
501        assert_eq!(
502            transform_link("other.md#section", &regular_config()),
503            "../other/#section"
504        );
505    }
506
507    #[test]
508    fn test_md_with_query() {
509        assert_eq!(
510            transform_link("other.md?foo=bar", &regular_config()),
511            "../other/?foo=bar"
512        );
513    }
514
515    #[test]
516    fn test_md_with_query_and_anchor() {
517        assert_eq!(
518            transform_link("other.md?foo=bar#section", &regular_config()),
519            "../other/?foo=bar#section"
520        );
521    }
522
523    #[test]
524    fn test_explicit_current_dir() {
525        assert_eq!(transform_link("./other.md", &regular_config()), "../other/");
526    }
527
528    #[test]
529    fn test_alternate_extension() {
530        assert_eq!(
531            transform_link("other.markdown", &regular_config()),
532            "../other/"
533        );
534    }
535
536    #[test]
537    fn test_parent_static_file() {
538        assert_eq!(
539            transform_link("../image.png", &regular_config()),
540            "../../image.png"
541        );
542    }
543
544    // =========================================================================
545    // Index files (is_index_file: true)
546    // =========================================================================
547
548    #[test]
549    fn test_index_simple_relative_md() {
550        assert_eq!(transform_link("other.md", &index_config()), "other/");
551    }
552
553    #[test]
554    fn test_index_subdirectory_md() {
555        assert_eq!(transform_link("sub/doc.md", &index_config()), "sub/doc/");
556    }
557
558    #[test]
559    fn test_index_parent_traversal() {
560        assert_eq!(transform_link("../other.md", &index_config()), "../other/");
561    }
562
563    #[test]
564    fn test_index_double_parent() {
565        assert_eq!(
566            transform_link("../../root.md", &index_config()),
567            "../../root/"
568        );
569    }
570
571    #[test]
572    fn test_index_static_file() {
573        // Index files don't need ../ for siblings
574        assert_eq!(transform_link("image.png", &index_config()), "image.png");
575    }
576
577    #[test]
578    fn test_index_nested_static() {
579        assert_eq!(
580            transform_link("assets/img.png", &index_config()),
581            "assets/img.png"
582        );
583    }
584
585    #[test]
586    fn test_index_md_with_anchor() {
587        assert_eq!(
588            transform_link("other.md#section", &index_config()),
589            "other/#section"
590        );
591    }
592
593    #[test]
594    fn test_index_parent_static() {
595        assert_eq!(
596            transform_link("../image.png", &index_config()),
597            "../image.png"
598        );
599    }
600
601    #[test]
602    fn test_index_to_index_collapse() {
603        assert_eq!(
604            transform_link("folder/index.md", &index_config()),
605            "folder/"
606        );
607    }
608
609    // =========================================================================
610    // URLs that should be unchanged (both modes)
611    // =========================================================================
612
613    #[test]
614    fn test_absolute_https() {
615        let url = "https://example.com/path";
616        assert_eq!(transform_link(url, &regular_config()), url);
617        assert_eq!(transform_link(url, &index_config()), url);
618    }
619
620    #[test]
621    fn test_absolute_http() {
622        let url = "http://example.com/path";
623        assert_eq!(transform_link(url, &regular_config()), url);
624        assert_eq!(transform_link(url, &index_config()), url);
625    }
626
627    #[test]
628    fn test_protocol_relative() {
629        let url = "//cdn.example.com/file.js";
630        assert_eq!(transform_link(url, &regular_config()), url);
631        assert_eq!(transform_link(url, &index_config()), url);
632    }
633
634    #[test]
635    fn test_root_relative() {
636        let url = "/docs/guide/";
637        assert_eq!(transform_link(url, &regular_config()), url);
638        assert_eq!(transform_link(url, &index_config()), url);
639    }
640
641    #[test]
642    fn test_anchor_only() {
643        let url = "#section";
644        assert_eq!(transform_link(url, &regular_config()), url);
645        assert_eq!(transform_link(url, &index_config()), url);
646    }
647
648    #[test]
649    fn test_empty_link() {
650        assert_eq!(transform_link("", &regular_config()), "");
651        assert_eq!(transform_link("", &index_config()), "");
652    }
653
654    #[test]
655    fn test_data_url() {
656        let url = "data:image/png;base64,abc123";
657        assert_eq!(transform_link(url, &regular_config()), url);
658    }
659
660    #[test]
661    fn test_data_image_url_unchanged() {
662        // A realistic data: image URL must pass through untouched (no ../).
663        let url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ";
664        assert_eq!(transform_link(url, &regular_config()), url);
665        assert_eq!(transform_link(url, &index_config()), url);
666    }
667
668    #[test]
669    fn test_blob_url_unchanged() {
670        // The Crepe editor mints blob: URLs for pasted images before upload;
671        // rewriting one to `../blob:...` would break the in-editor preview.
672        let url = "blob:http://localhost:5220/550e8400-e29b-41d4-a716-446655440000";
673        assert_eq!(transform_link(url, &regular_config()), url);
674        assert_eq!(transform_link(url, &index_config()), url);
675    }
676
677    #[test]
678    fn test_javascript_url() {
679        let url = "javascript:void(0)";
680        assert_eq!(transform_link(url, &regular_config()), url);
681    }
682
683    #[test]
684    fn test_mailto_url() {
685        let url = "mailto:test@example.com";
686        assert_eq!(transform_link(url, &regular_config()), url);
687    }
688
689    #[test]
690    fn test_ftp_url() {
691        let url = "ftp://ftp.example.com/file.txt";
692        assert_eq!(transform_link(url, &regular_config()), url);
693    }
694
695    #[test]
696    fn test_scheme_urls_unchanged() {
697        // Regression: `magnet:` was rewritten to `../magnet:?…` and `ftps:`
698        // to `../ftps:/…` because each scheme had to be enumerated by hand.
699        for url in [
700            "ftps://ftp.example.com/file.txt",
701            "magnet:?xt=urn:btih:c12fe1c06bba254a9dc9",
702            "sms:+15555550123",
703            "callto:+15555550123",
704            "ssh://git@example.com/repo.git",
705        ] {
706            assert_eq!(transform_link(url, &regular_config()), url);
707            assert_eq!(transform_link(url, &index_config()), url);
708        }
709    }
710
711    #[test]
712    fn test_colon_in_relative_path_is_still_transformed() {
713        // A colon after a slash is not a scheme, so the link must still be
714        // rewritten for the trailing-slash URL convention.
715        assert_eq!(
716            transform_link("docs/a:b.md", &regular_config()),
717            "../docs/a:b/"
718        );
719    }
720
721    // =========================================================================
722    // Edge cases
723    // =========================================================================
724
725    #[test]
726    fn test_file_with_dots_in_name() {
727        // my.file.md should only strip the final .md
728        assert_eq!(
729            transform_link("my.file.md", &regular_config()),
730            "../my.file/"
731        );
732    }
733
734    #[test]
735    fn test_non_md_extension() {
736        assert_eq!(
737            transform_link("readme.txt", &regular_config()),
738            "../readme.txt"
739        );
740    }
741
742    #[test]
743    fn test_just_query() {
744        // Edge case: just a query string
745        assert_eq!(transform_link("?foo=bar", &regular_config()), "?foo=bar");
746    }
747
748    #[test]
749    fn test_deeply_nested_path() {
750        assert_eq!(
751            transform_link("a/b/c/d/file.md", &regular_config()),
752            "../a/b/c/d/file/"
753        );
754    }
755
756    #[test]
757    fn test_mixed_traversal_and_descent() {
758        assert_eq!(
759            transform_link("../sibling/doc.md", &regular_config()),
760            "../../sibling/doc/"
761        );
762    }
763
764    // =========================================================================
765    // Root-relative URL transformation with url_depth (build mode)
766    // =========================================================================
767
768    fn build_config(depth: usize) -> LinkTransformConfig {
769        LinkTransformConfig {
770            url_depth: Some(depth),
771            ..regular_config()
772        }
773    }
774
775    #[test]
776    fn test_root_relative_with_depth_0() {
777        assert_eq!(
778            transform_link("/videos/demo.mp4", &build_config(0)),
779            "videos/demo.mp4"
780        );
781    }
782
783    #[test]
784    fn test_root_relative_with_depth_1() {
785        assert_eq!(
786            transform_link("/videos/demo.mp4", &build_config(1)),
787            "../videos/demo.mp4"
788        );
789    }
790
791    #[test]
792    fn test_root_relative_with_depth_2() {
793        assert_eq!(
794            transform_link("/videos/demo.mp4", &build_config(2)),
795            "../../videos/demo.mp4"
796        );
797    }
798
799    #[test]
800    fn test_root_relative_to_root_with_depth() {
801        assert_eq!(transform_link("/", &build_config(0)), "./");
802        assert_eq!(transform_link("/", &build_config(1)), "../");
803        assert_eq!(transform_link("/", &build_config(2)), "../../");
804    }
805
806    #[test]
807    fn test_root_relative_tag_link_with_depth() {
808        assert_eq!(
809            transform_link("/tags/rust/", &build_config(2)),
810            "../../tags/rust/"
811        );
812    }
813
814    #[test]
815    fn test_root_relative_unchanged_without_depth() {
816        // Server mode (url_depth: None) leaves root-relative unchanged
817        assert_eq!(
818            transform_link("/videos/demo.mp4", &regular_config()),
819            "/videos/demo.mp4"
820        );
821        assert_eq!(
822            transform_link("/tags/rust/", &regular_config()),
823            "/tags/rust/"
824        );
825    }
826
827    // =========================================================================
828    // make_relative_url
829    // =========================================================================
830
831    #[test]
832    fn test_make_relative_url_to_root() {
833        assert_eq!(make_relative_url("/", 0), "./");
834        assert_eq!(make_relative_url("/", 1), "../");
835        assert_eq!(make_relative_url("/", 2), "../../");
836    }
837
838    #[test]
839    fn test_make_relative_url_to_path() {
840        assert_eq!(make_relative_url("/docs/", 0), "docs/");
841        assert_eq!(make_relative_url("/docs/guide/", 0), "docs/guide/");
842        assert_eq!(make_relative_url("/docs/", 1), "../docs/");
843        assert_eq!(make_relative_url("/other/", 1), "../other/");
844        assert_eq!(make_relative_url("/docs/", 2), "../../docs/");
845        assert_eq!(make_relative_url("/docs/guide/", 2), "../../docs/guide/");
846    }
847}
848
849#[cfg(test)]
850mod proptests {
851    use super::*;
852    use proptest::prelude::*;
853
854    fn regular_config() -> LinkTransformConfig {
855        LinkTransformConfig {
856            markdown_extensions: vec!["md".to_string(), "markdown".to_string()],
857            index_file: "index.md".to_string(),
858            is_index_file: false,
859            url_depth: None,
860            current_page_url: String::new(),
861            markdown_page_probe: None,
862        }
863    }
864
865    fn index_config() -> LinkTransformConfig {
866        LinkTransformConfig {
867            is_index_file: true,
868            ..regular_config()
869        }
870    }
871
872    proptest! {
873        /// Transformation is deterministic
874        #[test]
875        fn prop_deterministic(url in ".*") {
876            let config = regular_config();
877            let r1 = transform_link(&url, &config);
878            let r2 = transform_link(&url, &config);
879            prop_assert_eq!(r1, r2);
880        }
881
882        /// Absolute HTTPS URLs are never modified
883        #[test]
884        fn prop_https_unchanged(path in "[a-zA-Z0-9./_-]*") {
885            let url = format!("https://example.com/{}", path);
886            let config = regular_config();
887            prop_assert_eq!(transform_link(&url, &config), url);
888        }
889
890        /// Absolute HTTP URLs are never modified
891        #[test]
892        fn prop_http_unchanged(path in "[a-zA-Z0-9./_-]*") {
893            let url = format!("http://example.com/{}", path);
894            let config = regular_config();
895            prop_assert_eq!(transform_link(&url, &config), url);
896        }
897
898        /// Protocol-relative URLs are never modified
899        #[test]
900        fn prop_protocol_relative_unchanged(path in "[a-zA-Z0-9./_-]*") {
901            let url = format!("//cdn.example.com/{}", path);
902            let config = regular_config();
903            prop_assert_eq!(transform_link(&url, &config), url);
904        }
905
906        /// Root-relative URLs are unchanged when url_depth is None (server mode)
907        #[test]
908        fn prop_root_relative_unchanged(path in "/[a-zA-Z0-9./_-]*") {
909            let config = regular_config();
910            prop_assert_eq!(transform_link(&path, &config), path);
911        }
912
913        /// Root-relative URLs are relativized when url_depth is Some (build mode)
914        #[test]
915        fn prop_root_relative_relativized(
916            path in "[a-zA-Z][a-zA-Z0-9/_-]{0,20}",
917            depth in 0usize..5
918        ) {
919            let url = format!("/{}", path);
920            let mut config = regular_config();
921            config.url_depth = Some(depth);
922            let result = transform_link(&url, &config);
923            // Result should NOT start with /
924            prop_assert!(!result.starts_with('/'), "Should be relative: {}", result);
925            // Result should contain the original path (without leading /)
926            prop_assert!(result.ends_with(&path), "Should end with path {}: {}", path, result);
927        }
928
929        /// Anchor-only links are never modified
930        #[test]
931        fn prop_anchor_only_unchanged(anchor in "#[a-zA-Z0-9_-]*") {
932            let config = regular_config();
933            prop_assert_eq!(transform_link(&anchor, &config), anchor);
934        }
935
936        /// Empty links are never modified
937        #[test]
938        fn prop_empty_unchanged(_dummy in 0..1i32) {
939            let config = regular_config();
940            prop_assert_eq!(transform_link("", &config), "");
941        }
942
943        /// Regular markdown links always get ../ prepended
944        #[test]
945        fn prop_regular_md_gets_parent(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
946            let url = format!("{}.md", name);
947            let config = regular_config();
948            let result = transform_link(&url, &config);
949            prop_assert!(result.starts_with("../"), "Expected ../ prefix: {}", result);
950        }
951
952        /// Index file markdown links don't get extra ../
953        #[test]
954        fn prop_index_md_no_extra_parent(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
955            let url = format!("{}.md", name);
956            let config = index_config();
957            let result = transform_link(&url, &config);
958            prop_assert!(!result.starts_with("../"), "Should not have ../ prefix: {}", result);
959        }
960
961        /// Transformed markdown links end with /
962        #[test]
963        fn prop_md_ends_with_slash(name in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
964            let url = format!("{}.md", name);
965            let config = regular_config();
966            let result = transform_link(&url, &config);
967            // Strip any anchor/query to check the path
968            let base = result.split(&['?', '#'][..]).next().unwrap();
969            prop_assert!(base.ends_with('/'), "Path should end with /: {}", base);
970        }
971
972        /// Anchors are preserved through transformation
973        #[test]
974        fn prop_anchor_preserved(
975            name in "[a-zA-Z][a-zA-Z0-9_-]{0,10}",
976            anchor in "[a-zA-Z][a-zA-Z0-9_-]{0,10}"
977        ) {
978            let url = format!("{}.md#{}", name, anchor);
979            let config = regular_config();
980            let result = transform_link(&url, &config);
981            prop_assert!(result.contains(&format!("#{}", anchor)), "Anchor not preserved: {}", result);
982        }
983
984        /// Query strings are preserved through transformation
985        #[test]
986        fn prop_query_preserved(
987            name in "[a-zA-Z][a-zA-Z0-9_-]{0,10}",
988            query in "[a-zA-Z][a-zA-Z0-9_=-]{0,10}"
989        ) {
990            let url = format!("{}.md?{}", name, query);
991            let config = regular_config();
992            let result = transform_link(&url, &config);
993            prop_assert!(result.contains(&format!("?{}", query)), "Query not preserved: {}", result);
994        }
995    }
996}
997
998/// End-to-end link resolution: author an href, run the **real** transform, then
999/// resolve the emitted href the way a browser would against the page's real
1000/// served URL, and assert the final absolute URL.
1001///
1002/// This is the layer whose absence let the reported bug ship. The unit tests
1003/// above are string-in/string-out with no page URL at all, so they cannot tell
1004/// `../other/` emitted from `/docs/guide/` (correct) from the same string
1005/// emitted from `/docs/` (one level too high), and nothing anywhere authored a
1006/// `../` href against a real repository. Every case here therefore states the
1007/// *destination*, which is the only thing a reader experiences.
1008#[cfg(test)]
1009mod browser_resolution_tests {
1010    use super::*;
1011    use crate::path_resolver::OwnedPathResolverConfig;
1012    use std::path::Path;
1013    use tempfile::TempDir;
1014
1015    // ---------------------------------------------------------------------
1016    // RFC 3986 §5.2 relative resolution — a browser, in ~40 lines.
1017    // ---------------------------------------------------------------------
1018
1019    /// RFC 3986 §5.2.4 `remove_dot_segments`, verbatim.
1020    ///
1021    /// Written out rather than approximated because the whole point of these
1022    /// tests is that `.`/`..` handling matches a browser exactly — including
1023    /// the clamp on an above-root `..`, which is precisely the behaviour that
1024    /// let a defective link look like a working one.
1025    fn remove_dot_segments(path: &str) -> String {
1026        let mut input = path.to_string();
1027        let mut output = String::new();
1028
1029        fn pop_last_segment(output: &mut String) {
1030            match output.rfind('/') {
1031                Some(index) => output.truncate(index),
1032                None => output.clear(),
1033            }
1034        }
1035
1036        while !input.is_empty() {
1037            if let Some(rest) = input.strip_prefix("../") {
1038                input = rest.to_string();
1039            } else if let Some(rest) = input.strip_prefix("./") {
1040                input = rest.to_string();
1041            } else if let Some(rest) = input.strip_prefix("/./") {
1042                input = format!("/{rest}");
1043            } else if input == "/." {
1044                input = "/".to_string();
1045            } else if let Some(rest) = input.strip_prefix("/../") {
1046                input = format!("/{rest}");
1047                pop_last_segment(&mut output);
1048            } else if input == "/.." {
1049                input = "/".to_string();
1050                pop_last_segment(&mut output);
1051            } else if input == "." || input == ".." {
1052                input.clear();
1053            } else {
1054                // Move the first path segment (with its leading "/") to output.
1055                let end = if let Some(rest) = input.strip_prefix('/') {
1056                    rest.find('/').map(|i| i + 1).unwrap_or(input.len())
1057                } else {
1058                    input.find('/').unwrap_or(input.len())
1059                };
1060                output.push_str(&input[..end]);
1061                input = input[end..].to_string();
1062            }
1063        }
1064
1065        output
1066    }
1067
1068    /// RFC 3986 §5.2.3 `merge`, for a base that has an authority (a site URL
1069    /// always does): everything up to and including the base's last `/`.
1070    fn merge(base_path: &str, reference_path: &str) -> String {
1071        match base_path.rfind('/') {
1072            Some(index) => format!("{}{}", &base_path[..=index], reference_path),
1073            None => format!("/{reference_path}"),
1074        }
1075    }
1076
1077    /// Resolve `reference` against the absolute `base` URL path, as a browser
1078    /// does when the user clicks a link on the page served at `base`.
1079    fn resolve_in_browser(base: &str, reference: &str) -> String {
1080        let (without_fragment, fragment) = match reference.split_once('#') {
1081            Some((head, tail)) => (head, format!("#{tail}")),
1082            None => (reference, String::new()),
1083        };
1084        let (ref_path, query) = match without_fragment.split_once('?') {
1085            Some((head, tail)) => (head, format!("?{tail}")),
1086            None => (without_fragment, String::new()),
1087        };
1088
1089        let target = if ref_path.is_empty() {
1090            base.to_string()
1091        } else if ref_path.starts_with('/') {
1092            remove_dot_segments(ref_path)
1093        } else {
1094            remove_dot_segments(&merge(base, ref_path))
1095        };
1096
1097        format!("{target}{query}{fragment}")
1098    }
1099
1100    // ---------------------------------------------------------------------
1101    // The repository every case is authored against.
1102    // ---------------------------------------------------------------------
1103
1104    /// A layout with a page and an extension-less static file at each depth
1105    /// that matters, plus two siblings so the cascade case has somewhere to go.
1106    fn fixture() -> TempDir {
1107        let dir = TempDir::new().expect("temp repo");
1108        let root = dir.path();
1109        for folder in ["docs", "folder", "a/b", "static"] {
1110            std::fs::create_dir_all(root.join(folder)).expect("create dir");
1111        }
1112        for page in [
1113            "index.md",
1114            "root.md",
1115            "docs/index.md",
1116            "docs/guide.md",
1117            "docs/other.md",
1118            "folder/file.md",
1119            "folder/sibling.md",
1120            "a/b/c.md",
1121            "a/b/d.md",
1122        ] {
1123            std::fs::write(root.join(page), "# page").expect("write page");
1124        }
1125        // Genuinely extension-less *files*: appending a trailing slash to a
1126        // link pointing at one of these would corrupt it.
1127        for asset in ["LICENSE", "docs/Makefile", "folder/Dockerfile"] {
1128            std::fs::write(root.join(asset), "text").expect("write asset");
1129        }
1130        std::fs::write(root.join("docs/photo.png"), b"\x89PNG").expect("write image");
1131        dir
1132    }
1133
1134    fn transform_config(root: &Path, source_rel: &str) -> LinkTransformConfig {
1135        let source = root.join(source_rel);
1136        let is_index_file = source
1137            .file_name()
1138            .and_then(|f| f.to_str())
1139            .is_some_and(|f| f == "index.md");
1140        LinkTransformConfig {
1141            markdown_extensions: vec!["md".to_string()],
1142            index_file: "index.md".to_string(),
1143            is_index_file,
1144            url_depth: None,
1145            current_page_url: crate::repo::build_markdown_url_path(&source, root, "index.md"),
1146            markdown_page_probe: Some(filesystem_markdown_page_probe(OwnedPathResolverConfig {
1147                vault: std::sync::Arc::new(crate::vault::LocalVault::new(root)),
1148                static_vault: None,
1149                static_folder: "static".to_string(),
1150                markdown_extensions: vec!["md".to_string()],
1151                index_file: "index.md".to_string(),
1152                tag_sources: Vec::new(),
1153                exempt_hidden_dirs: Vec::new(),
1154            })),
1155        }
1156    }
1157
1158    /// The page URL `source_rel` is served at.
1159    fn page_url(root: &Path, source_rel: &str) -> String {
1160        crate::repo::build_markdown_url_path(&root.join(source_rel), root, "index.md")
1161    }
1162
1163    /// Author `href` in `source_rel`, transform it, and follow it like a
1164    /// browser. Returns `(emitted href, final absolute URL)`.
1165    fn follow(root: &Path, source_rel: &str, href: &str) -> (String, String) {
1166        let config = transform_config(root, source_rel);
1167        let emitted = transform_link(href, &config);
1168        let landed = resolve_in_browser(&config.current_page_url, &emitted);
1169        (emitted, landed)
1170    }
1171
1172    fn assert_lands(root: &Path, source_rel: &str, href: &str, expected: &str) {
1173        let (emitted, landed) = follow(root, source_rel, href);
1174        assert_eq!(
1175            landed, expected,
1176            "[{source_rel}] `{href}` emitted `{emitted}` and landed on `{landed}`, \
1177             expected `{expected}`"
1178        );
1179    }
1180
1181    // ---------------------------------------------------------------------
1182    // Sanity: the browser model itself.
1183    // ---------------------------------------------------------------------
1184
1185    #[test]
1186    fn browser_model_matches_rfc_3986_examples() {
1187        // The normal examples from RFC 3986 §5.4.1, path-only.
1188        assert_eq!(resolve_in_browser("/b/c/d;p", "g"), "/b/c/g");
1189        assert_eq!(resolve_in_browser("/b/c/d;p", "./g"), "/b/c/g");
1190        assert_eq!(resolve_in_browser("/b/c/d;p", "g/"), "/b/c/g/");
1191        assert_eq!(resolve_in_browser("/b/c/d;p", "/g"), "/g");
1192        assert_eq!(resolve_in_browser("/b/c/d;p", "../g"), "/b/g");
1193        assert_eq!(resolve_in_browser("/b/c/d;p", "../../g"), "/g");
1194        // §5.4.2: excess `..` is discarded, not an error. This clamp is exactly
1195        // why an above-root link looks like it works.
1196        assert_eq!(resolve_in_browser("/b/c/d;p", "../../../g"), "/g");
1197        assert_eq!(resolve_in_browser("/b/c/d;p", "../../../../g"), "/g");
1198        // Query and fragment ride along untouched.
1199        assert_eq!(resolve_in_browser("/b/c/d;p", "g?y"), "/b/c/g?y");
1200        assert_eq!(resolve_in_browser("/b/c/d;p", "g#s"), "/b/c/g#s");
1201        assert_eq!(resolve_in_browser("/b/c/d;p", "g?y#s"), "/b/c/g?y#s");
1202        // A directory-style base keeps all of its segments.
1203        assert_eq!(
1204            resolve_in_browser("/docs/guide/", "../other/"),
1205            "/docs/other/"
1206        );
1207    }
1208
1209    // ---------------------------------------------------------------------
1210    // The table: every depth × every link form × both page kinds.
1211    // ---------------------------------------------------------------------
1212
1213    #[test]
1214    fn every_link_form_lands_on_its_canonical_url() {
1215        let dir = fixture();
1216        let root = dir.path();
1217
1218        // (source file, authored href, final absolute URL)
1219        let cases: &[(&str, &str, &str)] = &[
1220            // --- depth 0, non-index page (/root/) ---
1221            ("root.md", "docs/guide.md", "/docs/guide/"),
1222            ("root.md", "docs/guide", "/docs/guide/"),
1223            ("root.md", "docs/guide/", "/docs/guide/"),
1224            ("root.md", "./docs/guide.md", "/docs/guide/"),
1225            ("root.md", "docs/index.md", "/docs/"),
1226            ("root.md", "docs/guide.md#anchor", "/docs/guide/#anchor"),
1227            ("root.md", "docs/guide.md?q=1", "/docs/guide/?q=1"),
1228            ("root.md", "LICENSE", "/LICENSE"),
1229            ("root.md", "docs/photo.png", "/docs/photo.png"),
1230            // --- depth 0, index page (/) ---
1231            ("index.md", "docs/guide.md", "/docs/guide/"),
1232            ("index.md", "docs/guide", "/docs/guide/"),
1233            ("index.md", "root.md", "/root/"),
1234            ("index.md", "LICENSE", "/LICENSE"),
1235            // --- depth 1, non-index page (/docs/guide/) ---
1236            ("docs/guide.md", "other.md", "/docs/other/"),
1237            ("docs/guide.md", "other", "/docs/other/"),
1238            ("docs/guide.md", "other/", "/docs/other/"),
1239            ("docs/guide.md", "./other.md", "/docs/other/"),
1240            ("docs/guide.md", "index.md", "/docs/"),
1241            ("docs/guide.md", "../root.md", "/root/"),
1242            ("docs/guide.md", "../root", "/root/"),
1243            // The exact reported repro: an extension-less `../` link to a
1244            // markdown page two folders away.
1245            ("docs/guide.md", "../folder/file.md", "/folder/file/"),
1246            ("docs/guide.md", "../folder/file", "/folder/file/"),
1247            ("docs/guide.md", "../index.md", "/"),
1248            ("docs/guide.md", "other.md#anchor", "/docs/other/#anchor"),
1249            ("docs/guide.md", "other.md?q=1", "/docs/other/?q=1"),
1250            ("docs/guide.md", "other#anchor", "/docs/other/#anchor"),
1251            // Extension-less STATIC targets must NOT gain a slash.
1252            ("docs/guide.md", "Makefile", "/docs/Makefile"),
1253            ("docs/guide.md", "../LICENSE", "/LICENSE"),
1254            (
1255                "docs/guide.md",
1256                "../folder/Dockerfile",
1257                "/folder/Dockerfile",
1258            ),
1259            ("docs/guide.md", "photo.png", "/docs/photo.png"),
1260            // --- depth 1, index page at the same depth (/docs/) ---
1261            ("docs/index.md", "guide.md", "/docs/guide/"),
1262            ("docs/index.md", "guide", "/docs/guide/"),
1263            ("docs/index.md", "guide/", "/docs/guide/"),
1264            ("docs/index.md", "./guide.md", "/docs/guide/"),
1265            ("docs/index.md", "../root.md", "/root/"),
1266            ("docs/index.md", "../root", "/root/"),
1267            ("docs/index.md", "../folder/file", "/folder/file/"),
1268            ("docs/index.md", "Makefile", "/docs/Makefile"),
1269            ("docs/index.md", "../LICENSE", "/LICENSE"),
1270            ("docs/index.md", "guide.md#anchor", "/docs/guide/#anchor"),
1271            // --- depth 3, non-index page (/a/b/c/) ---
1272            ("a/b/c.md", "d.md", "/a/b/d/"),
1273            ("a/b/c.md", "d", "/a/b/d/"),
1274            ("a/b/c.md", "./d.md", "/a/b/d/"),
1275            ("a/b/c.md", "../../root.md", "/root/"),
1276            ("a/b/c.md", "../../root", "/root/"),
1277            ("a/b/c.md", "../../docs/guide.md", "/docs/guide/"),
1278            ("a/b/c.md", "../../docs/guide", "/docs/guide/"),
1279            ("a/b/c.md", "../../LICENSE", "/LICENSE"),
1280            ("a/b/c.md", "../../index.md", "/"),
1281            ("a/b/c.md", "d.md?q=1#anchor", "/a/b/d/?q=1#anchor"),
1282        ];
1283
1284        for (source, href, expected) in cases {
1285            assert_lands(root, source, href, expected);
1286        }
1287    }
1288
1289    /// The regression, stated as the thing a reader actually experiences: the
1290    /// extension-less form must land in the same place as the `.md` form.
1291    #[test]
1292    fn extensionless_markdown_link_lands_where_the_dot_md_form_does() {
1293        let dir = fixture();
1294        let root = dir.path();
1295
1296        let (with_ext, landed_with_ext) = follow(root, "docs/guide.md", "../folder/file.md");
1297        let (without_ext, landed_without_ext) = follow(root, "docs/guide.md", "../folder/file");
1298
1299        assert_eq!(with_ext, "../../folder/file/");
1300        assert_eq!(
1301            without_ext, "../../folder/file/",
1302            "an extension-less markdown target must get the trailing slash its \
1303             canonical URL has"
1304        );
1305        assert_eq!(landed_with_ext, "/folder/file/");
1306        assert_eq!(landed_without_ext, "/folder/file/");
1307    }
1308
1309    /// The cascade — the test that would have caught the reported bug. Follow
1310    /// the link, then follow a link *on the page it lands on*. A missing
1311    /// trailing slash is invisible on the first hop (the page still serves) and
1312    /// only breaks the second.
1313    #[test]
1314    fn links_on_the_landed_page_still_resolve() {
1315        let dir = fixture();
1316        let root = dir.path();
1317
1318        for authored in ["../folder/file.md", "../folder/file", "../folder/file/"] {
1319            let (_, landed) = follow(root, "docs/guide.md", authored);
1320            assert_eq!(
1321                landed,
1322                page_url(root, "folder/file.md"),
1323                "`{authored}` must land on the target's canonical URL"
1324            );
1325
1326            // Now stand on that page and click its own sibling link.
1327            let (emitted, second) = follow(root, "folder/file.md", "sibling.md");
1328            assert_eq!(
1329                second, "/folder/sibling/",
1330                "after following `{authored}`, `sibling.md` (emitted `{emitted}`) \
1331                 must still reach /folder/sibling/ — this is the hop the \
1332                 trailing-slash defect breaks"
1333            );
1334
1335            // And the second hop is only correct because the first one landed
1336            // canonically: resolving the *same* emitted href against the
1337            // non-canonical URL goes somewhere else entirely.
1338            let non_canonical = landed.trim_end_matches('/');
1339            assert_ne!(
1340                resolve_in_browser(non_canonical, &emitted),
1341                "/folder/sibling/",
1342                "sanity: a slashless landing URL must break the next hop, or \
1343                 this test proves nothing"
1344            );
1345        }
1346    }
1347
1348    /// Without a probe (CLI/QuickLook, link-grep, unit tests) the historical
1349    /// behaviour is preserved exactly: an extension-less target is treated as a
1350    /// static file. Appending a slash on a guess would corrupt `LICENSE`.
1351    #[test]
1352    fn extensionless_target_is_unchanged_without_a_probe() {
1353        let config = LinkTransformConfig {
1354            markdown_extensions: vec!["md".to_string()],
1355            index_file: "index.md".to_string(),
1356            is_index_file: false,
1357            url_depth: None,
1358            current_page_url: "/docs/guide/".to_string(),
1359            markdown_page_probe: None,
1360        };
1361        assert_eq!(
1362            transform_link("../folder/file", &config),
1363            "../../folder/file"
1364        );
1365        assert_eq!(transform_link("../LICENSE", &config), "../../LICENSE");
1366    }
1367
1368    /// A probe is also ignored when there is no page to resolve against, so a
1369    /// context without a current page URL can never invent a slash.
1370    #[test]
1371    fn probe_is_not_consulted_without_a_current_page_url() {
1372        let dir = fixture();
1373        let root = dir.path();
1374        let mut config = transform_config(root, "docs/guide.md");
1375        config.current_page_url = String::new();
1376        assert_eq!(
1377            transform_link("../folder/file", &config),
1378            "../../folder/file"
1379        );
1380    }
1381
1382    /// A link that climbs above the repository root is left exactly as authored
1383    /// (minus the usual `../` compensation): there is no markdown page out
1384    /// there to justify a trailing slash, and leaving it alone keeps the defect
1385    /// visible to the link validators instead of dressing it up as a page link.
1386    #[test]
1387    fn above_root_traversal_is_not_dressed_up_as_a_page() {
1388        let dir = fixture();
1389        let root = dir.path();
1390
1391        let (emitted, landed) = follow(root, "docs/guide.md", "../../escape/target");
1392        assert_eq!(emitted, "../../../escape/target");
1393        assert!(
1394            !emitted.ends_with('/'),
1395            "an above-root target must not be given a page's trailing slash: {emitted}"
1396        );
1397        // The browser clamps the excess `..`, which is exactly why this needs a
1398        // diagnostic rather than a silent repair — see
1399        // `page_errors::validate_rendered_links`.
1400        assert_eq!(landed, "/escape/target");
1401    }
1402
1403    /// Build mode relativizes root-relative URLs by page depth; that arithmetic
1404    /// must land on the same absolute URL a server-mode reader gets.
1405    #[test]
1406    fn build_mode_root_relative_links_land_on_the_same_url() {
1407        let dir = fixture();
1408        let root = dir.path();
1409
1410        for source in [
1411            "root.md",
1412            "index.md",
1413            "docs/guide.md",
1414            "docs/index.md",
1415            "a/b/c.md",
1416        ] {
1417            let base = page_url(root, source);
1418            let depth = base
1419                .trim_matches('/')
1420                .split('/')
1421                .filter(|s| !s.is_empty())
1422                .count();
1423            let mut config = transform_config(root, source);
1424            config.url_depth = Some(depth);
1425
1426            for target in ["/docs/guide/", "/folder/file/", "/"] {
1427                let emitted = transform_link(target, &config);
1428                assert_eq!(
1429                    resolve_in_browser(&base, &emitted),
1430                    target,
1431                    "[{source}] root-relative `{target}` emitted `{emitted}`"
1432                );
1433            }
1434        }
1435    }
1436}