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/// Split a URL into its path portion and any `?query` / `#fragment` suffix.
211/// The split happens at the first occurrence of `?` or `#` (whichever appears
212/// first); if neither is present the suffix is empty. The suffix is returned
213/// verbatim, including the leading `?` / `#`.
214///
215/// Used by [`percent_encode_url`] and by callers that need to apply
216/// path-only transformations (e.g. directory-name overrides) before reattaching
217/// an opaque query/fragment.
218pub fn split_url_path(url: &str) -> (&str, &str) {
219 let q = url.find('?');
220 let h = url.find('#');
221 let cut = match (q, h) {
222 (Some(qi), Some(hi)) => Some(qi.min(hi)),
223 (Some(qi), None) => Some(qi),
224 (None, Some(hi)) => Some(hi),
225 (None, None) => None,
226 };
227 let Some(i) = cut else {
228 return (url, "");
229 };
230 // `i` came from `find('?')` / `find('#')` — both ASCII bytes, so the
231 // returned position is on a UTF-8 char boundary by construction.
232 #[allow(clippy::string_slice)]
233 (&url[..i], &url[i..])
234}
235
236/// URL-aware sibling of [`percent_encode_path_segments`]. Splits at the first
237/// `?` or `#`, runs the segment encoder on the path portion, and re-attaches
238/// the query/fragment verbatim. Use this for any string that flows into an
239/// HTML `src=`/`href=` attribute or a markdown `[alt](url)` link, where the
240/// caller cannot guarantee the value is a pure path.
241pub fn percent_encode_url(url: &str) -> String {
242 let (path, suffix) = split_url_path(url);
243 if suffix.is_empty() {
244 percent_encode_path_segments(path)
245 } else {
246 let mut out = percent_encode_path_segments(path);
247 out.push_str(suffix);
248 out
249 }
250}
251
252fn push_encoded_segment(out: &mut String, segment: &str) {
253 for &b in segment.as_bytes() {
254 match b {
255 b'A'..=b'Z'
256 | b'a'..=b'z'
257 | b'0'..=b'9'
258 | b'-'
259 | b'.'
260 | b'_'
261 | b'~'
262 | b'!'
263 | b'$'
264 | b'&'
265 | b'\''
266 | b'+'
267 | b','
268 | b';'
269 | b'='
270 | b'@' => {
271 out.push(b as char);
272 }
273 _ => {
274 use std::fmt::Write;
275 let _ = write!(out, "%{:02X}", b);
276 }
277 }
278 }
279}
280
281/// Convert a source file path to its pretty URL directory path.
282///
283/// - `"posts/hello.md"` -> `"posts/hello"` (the URL becomes `posts/hello/`)
284/// - `"posts/index.md"` -> `"posts"` (the URL becomes `posts/`)
285/// - `"index.md"` -> `""` (the URL becomes `/`)
286pub(crate) fn to_pretty_url_dir(path: &str) -> String {
287 // Strip the file extension
288 let without_ext = match path.rsplit_once('.') {
289 Some((head, _)) => head,
290 None => path,
291 };
292
293 // If the filename is "index", the URL is the parent directory
294 let filename = without_ext.rsplit('/').next().unwrap_or(without_ext);
295 if filename == "index" {
296 let parent = parent_dir(without_ext);
297 parent.to_string()
298 } else {
299 without_ext.to_string()
300 }
301}
302
303// ---------------------------------------------------------------------------
304// Tests
305// ---------------------------------------------------------------------------
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::content_graph::ContentGraphBuilder;
311
312 /// Build a graph with common test files.
313 fn sample_graph() -> ContentGraph {
314 let mut b = ContentGraphBuilder::new();
315 b.add_file("posts/hello.md", "/posts/hello");
316 b.add_file("posts/world.md", "/posts/world");
317 b.add_file("guides/hello.md", "/guides/hello");
318 b.add_file("projects/index.md", "/projects");
319 b.add_file("about.md", "/about");
320 b.add_file("images/photo.png", "/images/photo.png");
321 b.add_file("index.md", "/");
322 b.build()
323 }
324
325 // -- resolve_reference tests --
326
327 #[test]
328 fn test_resolve_exact_relative() {
329 let graph = sample_graph();
330 assert_eq!(
331 resolve_reference("posts/hello.md", &graph, "posts/world.md"),
332 ResolvedRef::Found("posts/hello.md".into())
333 );
334 }
335
336 #[test]
337 fn test_resolve_filename_only() {
338 let graph = sample_graph();
339 // "world" is unique, so filename-only lookup should find it
340 assert_eq!(
341 resolve_reference("world", &graph, ""),
342 ResolvedRef::Found("posts/world.md".into())
343 );
344 }
345
346 #[test]
347 fn test_resolve_case_insensitive() {
348 let graph = sample_graph();
349 // "World" with different casing should still resolve
350 assert_eq!(
351 resolve_reference("World", &graph, ""),
352 ResolvedRef::Found("posts/world.md".into())
353 );
354 assert_eq!(
355 resolve_reference("ABOUT", &graph, ""),
356 ResolvedRef::Found("about.md".into())
357 );
358 }
359
360 #[test]
361 fn test_resolve_unresolved() {
362 let graph = sample_graph();
363 assert_eq!(
364 resolve_reference("nonexistent", &graph, "posts/hello.md"),
365 ResolvedRef::Unresolved
366 );
367 assert_eq!(
368 resolve_reference("missing/page.md", &graph, ""),
369 ResolvedRef::Unresolved
370 );
371 }
372
373 #[test]
374 fn test_resolve_image_reference() {
375 let graph = sample_graph();
376 // Non-markdown file (image) should also resolve
377 assert_eq!(
378 resolve_reference("images/photo.png", &graph, "posts/hello.md"),
379 ResolvedRef::Found("images/photo.png".into())
380 );
381 }
382
383 // -- relative_url tests --
384 //
385 // Pretty URL layout:
386 // guide.md → served at guide/index.html (browser dir: guide/)
387 // posts/a.md → served at posts/a/index.html (browser dir: posts/a/)
388 // index.md → served at index.html (browser dir: /)
389 // posts/index.md → served at posts/index.html (browser dir: posts/)
390
391 #[test]
392 fn test_relative_url_same_dir() {
393 // "posts/a.md" (at posts/a/) to "posts/b.md" (at posts/b/) → "../b/"
394 assert_eq!(relative_url("posts/a.md", "posts/b.md"), "../b/");
395 }
396
397 #[test]
398 fn test_relative_url_nested() {
399 // "posts/deep/a.md" (at posts/deep/a/) to "posts/b.md" (at posts/b/) → "../../b/"
400 assert_eq!(relative_url("posts/deep/a.md", "posts/b.md"), "../../b/");
401 }
402
403 #[test]
404 fn test_relative_url_sibling_dir() {
405 // "blog/a.md" (at blog/a/) to "notes/b.md" (at notes/b/) → "../../notes/b/"
406 assert_eq!(relative_url("blog/a.md", "notes/b.md"), "../../notes/b/");
407 }
408
409 #[test]
410 fn test_relative_url_index() {
411 // "posts/a.md" (at posts/a/) to "posts/index.md" (at posts/) → "../"
412 assert_eq!(relative_url("posts/a.md", "posts/index.md"), "../");
413
414 // "blog/a.md" (at blog/a/) to "posts/index.md" (at posts/) → "../../posts/"
415 assert_eq!(relative_url("blog/a.md", "posts/index.md"), "../../posts/");
416
417 // root index: "posts/a.md" (at posts/a/) to "index.md" (at /) → "../../"
418 assert_eq!(relative_url("posts/a.md", "index.md"), "../../");
419 }
420
421 #[test]
422 fn test_relative_url_root_to_nested() {
423 // "index.md" (at /) to "posts/hello.md" (at posts/hello/) → "posts/hello/"
424 assert_eq!(relative_url("index.md", "posts/hello.md"), "posts/hello/");
425 }
426
427 #[test]
428 fn test_relative_url_nested_to_root() {
429 // "posts/hello.md" (at posts/hello/) to "about.md" (at about/) → "../../about/"
430 assert_eq!(relative_url("posts/hello.md", "about.md"), "../../about/");
431 }
432
433 #[test]
434 fn test_relative_url_root_level_file() {
435 // "guide.md" (at guide/) to "notes/daily.md" (at notes/daily/) → "../notes/daily/"
436 assert_eq!(
437 relative_url("guide.md", "notes/daily.md"),
438 "../notes/daily/"
439 );
440 }
441
442 #[test]
443 fn test_relative_url_index_from_dir() {
444 // "posts/index.md" (at posts/) to "posts/a.md" (at posts/a/) → "a/"
445 assert_eq!(relative_url("posts/index.md", "posts/a.md"), "a/");
446 }
447
448 // -- relative_asset_path tests --
449 //
450 // Asset paths use the *filesystem* parent of `from_path` (no pretty-URL
451 // nesting). Each segment is percent-encoded for safe markdown/HTML emission.
452
453 #[test]
454 fn test_relative_asset_path_same_dir() {
455 assert_eq!(
456 relative_asset_path("posts/hello.md", "posts/photo.jpg"),
457 "photo.jpg"
458 );
459 }
460
461 #[test]
462 fn test_relative_asset_path_sibling_dir() {
463 assert_eq!(
464 relative_asset_path("posts/hello.md", "assets/photo.jpg"),
465 "../assets/photo.jpg"
466 );
467 }
468
469 #[test]
470 fn test_relative_asset_path_encodes_spaces() {
471 // The original symptom: a filename with spaces produced an unparsable
472 // markdown image link. Spaces must encode to %20.
473 assert_eq!(
474 relative_asset_path("posts/hello.md", "assets/Pasted image 20260505.png"),
475 "../assets/Pasted%20image%2020260505.png"
476 );
477 }
478
479 #[test]
480 fn test_relative_asset_path_encodes_non_ascii() {
481 // CJK directory + filename: every non-ASCII byte percent-encodes.
482 // 图片 = E5 9B BE E7 89 87, 摄影 = E6 91 84 E5 BD B1
483 assert_eq!(
484 relative_asset_path("文字/article.md", "图片/摄影/_43A2045.jpg"),
485 "../%E5%9B%BE%E7%89%87/%E6%91%84%E5%BD%B1/_43A2045.jpg"
486 );
487 }
488
489 #[test]
490 fn test_relative_asset_path_preserves_unreserved() {
491 // Unreserved RFC 3986 chars stay literal.
492 assert_eq!(
493 relative_asset_path("a.md", "img-1_v2.0~final.jpg"),
494 "img-1_v2.0~final.jpg"
495 );
496 }
497
498 #[test]
499 fn test_relative_asset_path_root_to_nested() {
500 // from is at root (no parent dir), to is nested with spaces.
501 assert_eq!(
502 relative_asset_path("index.md", "img/cover photo.png"),
503 "img/cover%20photo.png"
504 );
505 }
506
507 // -- percent_encode_url + split_url_path tests --
508 //
509 // `percent_encode_url` is the URL-aware sibling of `percent_encode_path_segments`.
510 // It splits at the first `?` or `#`, encodes only the path portion, and passes
511 // the query/fragment through verbatim. This is the right encoder for URLs
512 // produced by the embed renderers and for any caller that handles `src=`/`href=`
513 // attribute values.
514
515 #[test]
516 fn percent_encode_url_preserves_query_string() {
517 // The bug class: the path-only encoder turns `?` into `%3F`, breaking
518 // any iframe embed of the form `![[file.html?a=1&r=2]]`. The URL-aware
519 // encoder must keep the `?` literal.
520 assert_eq!(
521 percent_encode_url("../scale-compare.html?a=major_pent,major_blues&r=major_pent:D"),
522 "../scale-compare.html?a=major_pent,major_blues&r=major_pent:D"
523 );
524 }
525
526 #[test]
527 fn percent_encode_url_preserves_fragment() {
528 // Fragments must also pass through. `#` is the path/fragment separator.
529 assert_eq!(
530 percent_encode_url("../doc.html#section-2"),
531 "../doc.html#section-2"
532 );
533 }
534
535 #[test]
536 fn percent_encode_url_preserves_query_and_fragment_together() {
537 // Both `?` and `#` present — split at whichever appears first (RFC
538 // says it's always `?`, but we don't trust input shape).
539 assert_eq!(
540 percent_encode_url("../app.html?a=1#part"),
541 "../app.html?a=1#part"
542 );
543 }
544
545 #[test]
546 fn percent_encode_url_still_encodes_path_segments() {
547 // The path *part* still gets the segment encoder treatment — spaces
548 // and non-ASCII bytes become %20/%XX.
549 assert_eq!(
550 percent_encode_url("../assets/Pasted image.png?v=2"),
551 "../assets/Pasted%20image.png?v=2"
552 );
553 assert_eq!(
554 percent_encode_url("../图片/cover.jpg?v=2"),
555 "../%E5%9B%BE%E7%89%87/cover.jpg?v=2"
556 );
557 }
558
559 #[test]
560 fn percent_encode_url_no_suffix_matches_path_encoder() {
561 // For URLs without `?` or `#`, output is identical to
562 // `percent_encode_path_segments` — preserving the existing contract.
563 let input = "../assets/Pasted image 20260505.png";
564 assert_eq!(
565 percent_encode_url(input),
566 percent_encode_path_segments(input)
567 );
568 }
569
570 #[test]
571 fn split_url_path_at_question_mark() {
572 assert_eq!(split_url_path("foo.html?a=1&b=2"), ("foo.html", "?a=1&b=2"));
573 }
574
575 #[test]
576 fn split_url_path_at_fragment() {
577 assert_eq!(split_url_path("doc.html#section"), ("doc.html", "#section"));
578 }
579
580 #[test]
581 fn split_url_path_picks_first_separator() {
582 // `?` before `#` (RFC-compliant order).
583 assert_eq!(split_url_path("a.html?q=1#f"), ("a.html", "?q=1#f"));
584 // `#` before `?` (atypical but defined behavior — split at whichever
585 // appears first; everything after that point is opaque to the path
586 // encoder either way).
587 assert_eq!(split_url_path("a.html#f?q=1"), ("a.html", "#f?q=1"));
588 }
589
590 #[test]
591 fn split_url_path_no_separator_returns_empty_suffix() {
592 assert_eq!(split_url_path("plain/path.png"), ("plain/path.png", ""));
593 assert_eq!(split_url_path(""), ("", ""));
594 }
595}