Skip to main content

moss_core/resolve/
fuzzy_path.rs

1//! Fuzzy path resolution and relative URL computation.
2//!
3//! Wraps [`ContentGraph::resolve_path`] with typed results and provides
4//! [`relative_url`] for generating correct relative links between files
5//! using pretty URL format (directory-based).
6
7use crate::content_graph::ContentGraph;
8
9use super::parent_dir;
10
11/// The result of resolving a reference against the content graph.
12#[derive(Debug, PartialEq, Clone)]
13pub enum ResolvedRef {
14    /// The reference resolved to a file at this normalized path.
15    Found(String),
16    /// The reference could not be resolved to any known file.
17    Unresolved,
18}
19
20/// Resolve a reference string against the content graph.
21///
22/// This is a thin wrapper over [`ContentGraph::resolve_path`] that returns
23/// a typed [`ResolvedRef`] instead of `Option<String>`.
24///
25/// # Arguments
26///
27/// * `reference` — the link target (e.g. `"hello"`, `"posts/hello.md"`)
28/// * `graph` — the content graph to search
29/// * `from_path` — the file containing the link (for disambiguation)
30pub fn resolve_reference(reference: &str, graph: &ContentGraph, from_path: &str) -> ResolvedRef {
31    match graph.resolve_path(reference, from_path) {
32        Some(path) => ResolvedRef::Found(path),
33        None => ResolvedRef::Unresolved,
34    }
35}
36
37/// Compute the relative URL from one file to another using pretty URL format.
38///
39/// Both paths should be relative to the source root (e.g. `"posts/hello.md"`).
40/// The output uses directory-based pretty URLs:
41///
42/// - `"posts/hello.md"` becomes the URL `"posts/hello/"` (a directory)
43/// - `"posts/index.md"` becomes the URL `"posts/"` (the directory itself)
44/// - The returned URL is relative from `from_path`'s **pretty URL directory**
45///   (e.g. `guide.md` is served from `guide/`, not root)
46///
47/// # Examples
48///
49/// ```
50/// use moss_core::resolve::fuzzy_path::relative_url;
51///
52/// // Same directory → sibling pages need ".."
53/// assert_eq!(relative_url("posts/a.md", "posts/b.md"), "../b/");
54///
55/// // Nested file to parent directory
56/// assert_eq!(relative_url("posts/deep/a.md", "posts/b.md"), "../../b/");
57///
58/// // Target is index.md (URL is the directory)
59/// assert_eq!(relative_url("posts/a.md", "posts/index.md"), "../");
60/// ```
61pub fn relative_url(from_path: &str, to_path: &str) -> String {
62    // Use the pretty URL directory of the source file, not the file's parent.
63    // A root-level `guide.md` is served from `guide/index.html`, so the
64    // browser's base directory is `guide/`, not the project root.
65    let from_dir = to_pretty_url_dir(from_path);
66    let to_url_path = to_pretty_url_dir(to_path);
67
68    // Split both into components
69    let from_parts: Vec<&str> = if from_dir.is_empty() {
70        vec![]
71    } else {
72        from_dir.split('/').collect()
73    };
74
75    let to_parts: Vec<&str> = if to_url_path.is_empty() {
76        vec![]
77    } else {
78        // Remove trailing slash for splitting, then we'll add it back
79        let trimmed = to_url_path.trim_end_matches('/');
80        if trimmed.is_empty() {
81            vec![]
82        } else {
83            trimmed.split('/').collect()
84        }
85    };
86
87    // Find common prefix length
88    let common = from_parts
89        .iter()
90        .zip(to_parts.iter())
91        .take_while(|(a, b)| a == b)
92        .count();
93
94    // Number of ".." needed to go up from from_dir
95    let ups = from_parts.len() - common;
96
97    // Remaining path components after the common prefix
98    let remaining = &to_parts[common..];
99
100    let mut result = String::new();
101
102    if ups == 0 && remaining.is_empty() {
103        // Same directory — from_dir IS the target URL directory
104        return "./".to_string();
105    }
106
107    // Add "../" for each level we need to go up
108    for _ in 0..ups {
109        result.push_str("../");
110    }
111
112    // Add the remaining path components
113    for (i, part) in remaining.iter().enumerate() {
114        if i > 0 {
115            result.push('/');
116        }
117        result.push_str(part);
118    }
119
120    // Ensure trailing slash for pretty URLs
121    if !result.ends_with('/') {
122        result.push('/');
123    }
124
125    result
126}
127
128/// Compute a relative URL from `from_path`'s parent directory to `to_path`,
129/// preserving the target filename and extension. Each path segment is
130/// percent-encoded so spaces and non-ASCII characters round-trip safely
131/// through the markdown parser and HTML attribute boundaries.
132///
133/// Unlike [`relative_url`], which uses pretty-URL directories, this function
134/// uses the *filesystem* parent directory (e.g. `posts/hello.md` -> `posts`).
135/// The extra `../` needed for pretty-URL nesting is added later by
136/// `adjust_relative_paths_for_pretty_urls` in the Tauri build layer.
137/// Using `to_pretty_url_dir` here would double-count that adjustment.
138///
139/// Use this for binary assets (images, fonts, etc.) and any reference that
140/// should keep its file extension in the URL.
141pub fn relative_asset_path(from_path: &str, to_path: &str) -> String {
142    let from_dir = parent_dir(from_path);
143    let from_parts: Vec<&str> = if from_dir.is_empty() {
144        vec![]
145    } else {
146        from_dir.split('/').collect()
147    };
148
149    let to_parts: Vec<&str> = if to_path.is_empty() {
150        vec![]
151    } else {
152        to_path.split('/').collect()
153    };
154
155    let common = from_parts
156        .iter()
157        .zip(to_parts.iter())
158        .take_while(|(a, b)| a == b)
159        .count();
160
161    let ups = from_parts.len() - common;
162    let remaining = &to_parts[common..];
163
164    let mut result = String::new();
165    for _ in 0..ups {
166        result.push_str("../");
167    }
168    for (i, part) in remaining.iter().enumerate() {
169        if i > 0 {
170            result.push('/');
171        }
172        push_encoded_segment(&mut result, part);
173    }
174
175    if result.is_empty() {
176        // Same directory, just the filename
177        let filename = to_path.rsplit('/').next().unwrap_or(to_path);
178        let mut out = String::new();
179        push_encoded_segment(&mut out, filename);
180        out
181    } else {
182        result
183    }
184}
185
186/// Percent-encode a path, segment by segment, preserving `/` as the separator.
187///
188/// Each segment keeps the RFC 3986 unreserved set (`A-Z a-z 0-9 - . _ ~`) plus
189/// the sub-delim/extra characters that don't break a markdown `[alt](url)`
190/// parse or an HTML attribute boundary: `!`, `$`, `&`, `'`, `+`, `,`, `;`,
191/// `=`, `@`. Everything else — SPACE, parens, `#`, `?`, all non-ASCII bytes —
192/// becomes `%XX`. The `..` and `.` segments survive untouched.
193///
194/// This is a *path* encoder: it treats `?` and `#` as ordinary bytes (e.g.
195/// for filenames that contain them). For an HTML attribute that may carry a
196/// `?query` or `#fragment`, use [`percent_encode_url`] instead — calling this
197/// function on a URL silently turns `foo.html?a=1` into `foo.html%3Fa=1`,
198/// which the browser then reads as a literal-filename 404.
199pub fn percent_encode_path_segments(path: &str) -> String {
200    let mut out = String::with_capacity(path.len());
201    for (i, segment) in path.split('/').enumerate() {
202        if i > 0 {
203            out.push('/');
204        }
205        push_encoded_segment(&mut out, segment);
206    }
207    out
208}
209
210/// Percent-*decode* `%XX` byte sequences back to the on-disk name. The inverse
211/// of [`percent_encode_path_segments`], and it lives next to it so the pair
212/// cannot drift.
213///
214/// Call this at every point where a value that has already been through the
215/// encoder is used as a **filesystem path or a cache key** rather than as a
216/// URL. A cover named `封面.jpg` reaches such a reader as
217/// `%E5%B0%81%E9%9D%A2.jpg`; joining that onto a source root looks for a file
218/// whose name is literally the escape sequence and silently misses.
219///
220/// Lenient by construction: a lone or malformed `%` passes through verbatim,
221/// and a decode that isn't valid UTF-8 falls back to the input unchanged, so
222/// this is safe to call on a string that was never encoded (it is a no-op when
223/// there is no `%`).
224pub fn percent_decode_path(path: &str) -> String {
225    if !path.contains('%') {
226        return path.to_string();
227    }
228    let bytes = path.as_bytes();
229    let mut out = Vec::with_capacity(bytes.len());
230    let mut i = 0;
231    while i < bytes.len() {
232        if bytes[i] == b'%' && i + 2 < bytes.len() {
233            if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
234                out.push((hi << 4) | lo);
235                i += 3;
236                continue;
237            }
238        }
239        out.push(bytes[i]);
240        i += 1;
241    }
242    String::from_utf8(out).unwrap_or_else(|_| path.to_string())
243}
244
245fn hex_val(b: u8) -> Option<u8> {
246    match b {
247        b'0'..=b'9' => Some(b - b'0'),
248        b'a'..=b'f' => Some(b - b'a' + 10),
249        b'A'..=b'F' => Some(b - b'A' + 10),
250        _ => None,
251    }
252}
253
254/// Split a URL into its path portion and any `?query` / `#fragment` suffix.
255/// The split happens at the first occurrence of `?` or `#` (whichever appears
256/// first); if neither is present the suffix is empty. The suffix is returned
257/// verbatim, including the leading `?` / `#`.
258///
259/// Used by [`percent_encode_url`] and by callers that need to apply
260/// path-only transformations (e.g. directory-name overrides) before reattaching
261/// an opaque query/fragment.
262pub fn split_url_path(url: &str) -> (&str, &str) {
263    let q = url.find('?');
264    let h = url.find('#');
265    let cut = match (q, h) {
266        (Some(qi), Some(hi)) => Some(qi.min(hi)),
267        (Some(qi), None) => Some(qi),
268        (None, Some(hi)) => Some(hi),
269        (None, None) => None,
270    };
271    let Some(i) = cut else {
272        return (url, "");
273    };
274    // `i` came from `find('?')` / `find('#')` — both ASCII bytes, so the
275    // returned position is on a UTF-8 char boundary by construction.
276    #[allow(clippy::string_slice)]
277    (&url[..i], &url[i..])
278}
279
280/// URL-aware sibling of [`percent_encode_path_segments`]. Splits at the first
281/// `?` or `#`, runs the segment encoder on the path portion, and re-attaches
282/// the query/fragment verbatim. Use this for any string that flows into an
283/// HTML `src=`/`href=` attribute or a markdown `[alt](url)` link, where the
284/// caller cannot guarantee the value is a pure path.
285pub fn percent_encode_url(url: &str) -> String {
286    let (path, suffix) = split_url_path(url);
287    if suffix.is_empty() {
288        percent_encode_path_segments(path)
289    } else {
290        let mut out = percent_encode_path_segments(path);
291        out.push_str(suffix);
292        out
293    }
294}
295
296fn push_encoded_segment(out: &mut String, segment: &str) {
297    for &b in segment.as_bytes() {
298        match b {
299            b'A'..=b'Z'
300            | b'a'..=b'z'
301            | b'0'..=b'9'
302            | b'-'
303            | b'.'
304            | b'_'
305            | b'~'
306            | b'!'
307            | b'$'
308            | b'&'
309            | b'\''
310            | b'+'
311            | b','
312            | b';'
313            | b'='
314            | b'@' => {
315                out.push(b as char);
316            }
317            _ => {
318                use std::fmt::Write;
319                let _ = write!(out, "%{:02X}", b);
320            }
321        }
322    }
323}
324
325/// Convert a source file path to its pretty URL directory path.
326///
327/// - `"posts/hello.md"` -> `"posts/hello"`  (the URL becomes `posts/hello/`)
328/// - `"posts/index.md"` -> `"posts"`        (the URL becomes `posts/`)
329/// - `"index.md"`       -> `""`             (the URL becomes `/`)
330pub(crate) fn to_pretty_url_dir(path: &str) -> String {
331    // Strip the file extension
332    let without_ext = match path.rsplit_once('.') {
333        Some((head, _)) => head,
334        None => path,
335    };
336
337    // If the filename is "index", the URL is the parent directory
338    let filename = without_ext.rsplit('/').next().unwrap_or(without_ext);
339    if filename == "index" {
340        let parent = parent_dir(without_ext);
341        parent.to_string()
342    } else {
343        without_ext.to_string()
344    }
345}
346
347// ---------------------------------------------------------------------------
348// Tests
349// ---------------------------------------------------------------------------
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::content_graph::ContentGraphBuilder;
355
356    /// Build a graph with common test files.
357    fn sample_graph() -> ContentGraph {
358        let mut b = ContentGraphBuilder::new();
359        b.add_file("posts/hello.md", "/posts/hello");
360        b.add_file("posts/world.md", "/posts/world");
361        b.add_file("guides/hello.md", "/guides/hello");
362        b.add_file("projects/index.md", "/projects");
363        b.add_file("about.md", "/about");
364        b.add_file("images/photo.png", "/images/photo.png");
365        b.add_file("index.md", "/");
366        b.build()
367    }
368
369    // -- percent_decode_path tests --
370
371    /// The encoder and its inverse live next to each other so they cannot
372    /// drift; this asserts the round trip for the names that actually broke
373    /// production (CJK covers, spaces, commas).
374    #[test]
375    fn percent_decode_path_inverts_the_segment_encoder() {
376        for raw in [
377            "獎項/封面.jpg",
378            "News/Winter Song.mov",
379            "img/a,b.png",
380            "plain/ascii.webp",
381            "nested/深い/道/photo.jpeg",
382        ] {
383            let encoded = percent_encode_path_segments(raw);
384            assert_eq!(percent_decode_path(&encoded), raw, "round trip for {raw}");
385        }
386    }
387
388    /// Decoding must be safe to call on a value that was never encoded — the
389    /// cover-color readers call it unconditionally.
390    #[test]
391    fn percent_decode_path_is_a_noop_without_escapes() {
392        assert_eq!(percent_decode_path("images/photo.png"), "images/photo.png");
393        assert_eq!(percent_decode_path(""), "");
394    }
395
396    /// A lone or malformed `%` is a legal filename byte, not a decode error.
397    #[test]
398    fn percent_decode_path_passes_through_malformed_escapes() {
399        assert_eq!(percent_decode_path("100%.png"), "100%.png");
400        assert_eq!(percent_decode_path("a%zz b.png"), "a%zz b.png");
401        assert_eq!(percent_decode_path("trailing%2"), "trailing%2");
402    }
403
404    /// Invalid UTF-8 must fall back to the input rather than panic or lose it.
405    #[test]
406    fn percent_decode_path_falls_back_on_invalid_utf8() {
407        assert_eq!(percent_decode_path("bad%FF.png"), "bad%FF.png");
408    }
409
410    // -- resolve_reference tests --
411
412    #[test]
413    fn test_resolve_exact_relative() {
414        let graph = sample_graph();
415        assert_eq!(
416            resolve_reference("posts/hello.md", &graph, "posts/world.md"),
417            ResolvedRef::Found("posts/hello.md".into())
418        );
419    }
420
421    #[test]
422    fn test_resolve_filename_only() {
423        let graph = sample_graph();
424        // "world" is unique, so filename-only lookup should find it
425        assert_eq!(
426            resolve_reference("world", &graph, ""),
427            ResolvedRef::Found("posts/world.md".into())
428        );
429    }
430
431    #[test]
432    fn test_resolve_case_insensitive() {
433        let graph = sample_graph();
434        // "World" with different casing should still resolve
435        assert_eq!(
436            resolve_reference("World", &graph, ""),
437            ResolvedRef::Found("posts/world.md".into())
438        );
439        assert_eq!(
440            resolve_reference("ABOUT", &graph, ""),
441            ResolvedRef::Found("about.md".into())
442        );
443    }
444
445    #[test]
446    fn test_resolve_unresolved() {
447        let graph = sample_graph();
448        assert_eq!(
449            resolve_reference("nonexistent", &graph, "posts/hello.md"),
450            ResolvedRef::Unresolved
451        );
452        assert_eq!(
453            resolve_reference("missing/page.md", &graph, ""),
454            ResolvedRef::Unresolved
455        );
456    }
457
458    #[test]
459    fn test_resolve_image_reference() {
460        let graph = sample_graph();
461        // Non-markdown file (image) should also resolve
462        assert_eq!(
463            resolve_reference("images/photo.png", &graph, "posts/hello.md"),
464            ResolvedRef::Found("images/photo.png".into())
465        );
466    }
467
468    // -- relative_url tests --
469    //
470    // Pretty URL layout:
471    //   guide.md    → served at guide/index.html    (browser dir: guide/)
472    //   posts/a.md  → served at posts/a/index.html  (browser dir: posts/a/)
473    //   index.md    → served at index.html           (browser dir: /)
474    //   posts/index.md → served at posts/index.html  (browser dir: posts/)
475
476    #[test]
477    fn test_relative_url_same_dir() {
478        // "posts/a.md" (at posts/a/) to "posts/b.md" (at posts/b/) → "../b/"
479        assert_eq!(relative_url("posts/a.md", "posts/b.md"), "../b/");
480    }
481
482    #[test]
483    fn test_relative_url_nested() {
484        // "posts/deep/a.md" (at posts/deep/a/) to "posts/b.md" (at posts/b/) → "../../b/"
485        assert_eq!(relative_url("posts/deep/a.md", "posts/b.md"), "../../b/");
486    }
487
488    #[test]
489    fn test_relative_url_sibling_dir() {
490        // "blog/a.md" (at blog/a/) to "notes/b.md" (at notes/b/) → "../../notes/b/"
491        assert_eq!(relative_url("blog/a.md", "notes/b.md"), "../../notes/b/");
492    }
493
494    #[test]
495    fn test_relative_url_index() {
496        // "posts/a.md" (at posts/a/) to "posts/index.md" (at posts/) → "../"
497        assert_eq!(relative_url("posts/a.md", "posts/index.md"), "../");
498
499        // "blog/a.md" (at blog/a/) to "posts/index.md" (at posts/) → "../../posts/"
500        assert_eq!(relative_url("blog/a.md", "posts/index.md"), "../../posts/");
501
502        // root index: "posts/a.md" (at posts/a/) to "index.md" (at /) → "../../"
503        assert_eq!(relative_url("posts/a.md", "index.md"), "../../");
504    }
505
506    #[test]
507    fn test_relative_url_root_to_nested() {
508        // "index.md" (at /) to "posts/hello.md" (at posts/hello/) → "posts/hello/"
509        assert_eq!(relative_url("index.md", "posts/hello.md"), "posts/hello/");
510    }
511
512    #[test]
513    fn test_relative_url_nested_to_root() {
514        // "posts/hello.md" (at posts/hello/) to "about.md" (at about/) → "../../about/"
515        assert_eq!(relative_url("posts/hello.md", "about.md"), "../../about/");
516    }
517
518    #[test]
519    fn test_relative_url_root_level_file() {
520        // "guide.md" (at guide/) to "notes/daily.md" (at notes/daily/) → "../notes/daily/"
521        assert_eq!(
522            relative_url("guide.md", "notes/daily.md"),
523            "../notes/daily/"
524        );
525    }
526
527    #[test]
528    fn test_relative_url_index_from_dir() {
529        // "posts/index.md" (at posts/) to "posts/a.md" (at posts/a/) → "a/"
530        assert_eq!(relative_url("posts/index.md", "posts/a.md"), "a/");
531    }
532
533    // -- relative_asset_path tests --
534    //
535    // Asset paths use the *filesystem* parent of `from_path` (no pretty-URL
536    // nesting). Each segment is percent-encoded for safe markdown/HTML emission.
537
538    #[test]
539    fn test_relative_asset_path_same_dir() {
540        assert_eq!(
541            relative_asset_path("posts/hello.md", "posts/photo.jpg"),
542            "photo.jpg"
543        );
544    }
545
546    #[test]
547    fn test_relative_asset_path_sibling_dir() {
548        assert_eq!(
549            relative_asset_path("posts/hello.md", "assets/photo.jpg"),
550            "../assets/photo.jpg"
551        );
552    }
553
554    #[test]
555    fn test_relative_asset_path_encodes_spaces() {
556        // The original symptom: a filename with spaces produced an unparsable
557        // markdown image link. Spaces must encode to %20.
558        assert_eq!(
559            relative_asset_path("posts/hello.md", "assets/Pasted image 20260505.png"),
560            "../assets/Pasted%20image%2020260505.png"
561        );
562    }
563
564    #[test]
565    fn test_relative_asset_path_encodes_non_ascii() {
566        // CJK directory + filename: every non-ASCII byte percent-encodes.
567        // 图片 = E5 9B BE E7 89 87, 摄影 = E6 91 84 E5 BD B1
568        assert_eq!(
569            relative_asset_path("文字/article.md", "图片/摄影/_43A2045.jpg"),
570            "../%E5%9B%BE%E7%89%87/%E6%91%84%E5%BD%B1/_43A2045.jpg"
571        );
572    }
573
574    #[test]
575    fn test_relative_asset_path_preserves_unreserved() {
576        // Unreserved RFC 3986 chars stay literal.
577        assert_eq!(
578            relative_asset_path("a.md", "img-1_v2.0~final.jpg"),
579            "img-1_v2.0~final.jpg"
580        );
581    }
582
583    #[test]
584    fn test_relative_asset_path_root_to_nested() {
585        // from is at root (no parent dir), to is nested with spaces.
586        assert_eq!(
587            relative_asset_path("index.md", "img/cover photo.png"),
588            "img/cover%20photo.png"
589        );
590    }
591
592    // -- percent_encode_url + split_url_path tests --
593    //
594    // `percent_encode_url` is the URL-aware sibling of `percent_encode_path_segments`.
595    // It splits at the first `?` or `#`, encodes only the path portion, and passes
596    // the query/fragment through verbatim. This is the right encoder for URLs
597    // produced by the embed renderers and for any caller that handles `src=`/`href=`
598    // attribute values.
599
600    #[test]
601    fn percent_encode_url_preserves_query_string() {
602        // The bug class: the path-only encoder turns `?` into `%3F`, breaking
603        // any iframe embed of the form `![[file.html?a=1&r=2]]`. The URL-aware
604        // encoder must keep the `?` literal.
605        assert_eq!(
606            percent_encode_url("../scale-compare.html?a=major_pent,major_blues&r=major_pent:D"),
607            "../scale-compare.html?a=major_pent,major_blues&r=major_pent:D"
608        );
609    }
610
611    #[test]
612    fn percent_encode_url_preserves_fragment() {
613        // Fragments must also pass through. `#` is the path/fragment separator.
614        assert_eq!(
615            percent_encode_url("../doc.html#section-2"),
616            "../doc.html#section-2"
617        );
618    }
619
620    #[test]
621    fn percent_encode_url_preserves_query_and_fragment_together() {
622        // Both `?` and `#` present — split at whichever appears first (RFC
623        // says it's always `?`, but we don't trust input shape).
624        assert_eq!(
625            percent_encode_url("../app.html?a=1#part"),
626            "../app.html?a=1#part"
627        );
628    }
629
630    #[test]
631    fn percent_encode_url_still_encodes_path_segments() {
632        // The path *part* still gets the segment encoder treatment — spaces
633        // and non-ASCII bytes become %20/%XX.
634        assert_eq!(
635            percent_encode_url("../assets/Pasted image.png?v=2"),
636            "../assets/Pasted%20image.png?v=2"
637        );
638        assert_eq!(
639            percent_encode_url("../图片/cover.jpg?v=2"),
640            "../%E5%9B%BE%E7%89%87/cover.jpg?v=2"
641        );
642    }
643
644    #[test]
645    fn percent_encode_url_no_suffix_matches_path_encoder() {
646        // For URLs without `?` or `#`, output is identical to
647        // `percent_encode_path_segments` — preserving the existing contract.
648        let input = "../assets/Pasted image 20260505.png";
649        assert_eq!(
650            percent_encode_url(input),
651            percent_encode_path_segments(input)
652        );
653    }
654
655    #[test]
656    fn split_url_path_at_question_mark() {
657        assert_eq!(split_url_path("foo.html?a=1&b=2"), ("foo.html", "?a=1&b=2"));
658    }
659
660    #[test]
661    fn split_url_path_at_fragment() {
662        assert_eq!(split_url_path("doc.html#section"), ("doc.html", "#section"));
663    }
664
665    #[test]
666    fn split_url_path_picks_first_separator() {
667        // `?` before `#` (RFC-compliant order).
668        assert_eq!(split_url_path("a.html?q=1#f"), ("a.html", "?q=1#f"));
669        // `#` before `?` (atypical but defined behavior — split at whichever
670        // appears first; everything after that point is opaque to the path
671        // encoder either way).
672        assert_eq!(split_url_path("a.html#f?q=1"), ("a.html", "#f?q=1"));
673    }
674
675    #[test]
676    fn split_url_path_no_separator_returns_empty_suffix() {
677        assert_eq!(split_url_path("plain/path.png"), ("plain/path.png", ""));
678        assert_eq!(split_url_path(""), ("", ""));
679    }
680}