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/// 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}