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}