Skip to main content

moss_core/
content_graph.rs

1//! In-memory index of content files for fuzzy path resolution.
2//!
3//! `ContentGraph` is the read-only query structure built by `ContentGraphBuilder`.
4//! It supports Obsidian-style fuzzy path resolution: exact path, filename-only,
5//! folder notes, and ambiguity tiebreaking by longest common directory prefix.
6//!
7//! Pure Rust, zero I/O.
8
9use std::collections::{HashMap, HashSet};
10use unicode_normalization::UnicodeNormalization;
11
12use crate::path_ext::path_extension;
13
14// ---------------------------------------------------------------------------
15// Path normalization helpers
16// ---------------------------------------------------------------------------
17
18/// NFC-normalize and lowercase a single path component.
19fn normalize_component(s: &str) -> String {
20    s.nfc().collect::<String>().to_lowercase()
21}
22
23/// NFC-normalize and lowercase every component of a `/`-separated path.
24/// Also normalises backslashes to forward slashes and collapses runs of
25/// separators.
26///
27/// `pub(crate)` so the wikilink-completion ranker (`link_completions`) folds
28/// paths identically to the resolver when scoring same-language / tree
29/// proximity, keeping the completion order aligned with how links resolve.
30pub(crate) fn normalize_path(path: &str) -> String {
31    path.replace('\\', "/")
32        .split('/')
33        .filter(|c| !c.is_empty())
34        .map(normalize_component)
35        .collect::<Vec<_>>()
36        .join("/")
37}
38
39/// Extract the filename stem (no extension) from a normalized path.
40fn filename_stem(normalized: &str) -> &str {
41    let filename = normalized.rsplit('/').next().unwrap_or(normalized);
42    match filename.rsplit_once('.') {
43        // Guard against `pos == 0` (e.g. ".gitignore"): treat the whole name
44        // as the stem rather than returning an empty stem.
45        Some((stem, _)) if !stem.is_empty() => stem,
46        _ => filename,
47    }
48}
49
50/// Extract the filename (with extension) from a path.
51fn filename_with_ext(path: &str) -> &str {
52    path.rsplit('/').next().unwrap_or(path)
53}
54
55
56/// Return the directory prefix components of a path as a Vec.
57/// `pub(crate)` — shared with `link_completions` (see `normalize_path`).
58pub(crate) fn dir_components(path: &str) -> Vec<&str> {
59    let parts: Vec<&str> = path.split('/').collect();
60    if parts.len() <= 1 {
61        vec![]
62    } else {
63        parts[..parts.len() - 1].to_vec()
64    }
65}
66
67/// Count the length of the longest common prefix between two component lists.
68/// `pub(crate)` — shared with `link_completions` (see `normalize_path`).
69pub(crate) fn common_prefix_len(a: &[&str], b: &[&str]) -> usize {
70    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
71}
72
73/// Score a candidate's extension against the reference's extension for the
74/// ambiguity tiebreaker. Returns 1 only when the reference carries an
75/// extension AND the candidate's matches it (case-insensitive). Returns 0
76/// otherwise so bare references — which can't express extension intent —
77/// keep their existing tiebreaker behavior.
78fn ext_match_score(ref_ext: Option<&str>, candidate: &str) -> u8 {
79    let Some(want) = ref_ext else { return 0 };
80    match path_extension(candidate) {
81        Some(have) if have == want => 1,
82        _ => 0,
83    }
84}
85
86/// Score a candidate path's language-tree alignment with the source's
87/// language-tree prefix.
88///
89/// Both inputs should be normalized (lowercase).  Returns 1 when the candidate
90/// is in the same language tree as the source (either both share the same
91/// language prefix, or both are tree-less/root-level), 0 otherwise.
92///
93/// This is used as a tiebreaker in [`ContentGraph::resolve_path`] so that
94/// `![[footer]]` from `zh-hans/about.md` picks `zh-hans/footer.md` over a
95/// root-level `footer.md`, and conversely root sources prefer root candidates.
96fn lang_tree_match(candidate: &str, from_lang: Option<&str>) -> u8 {
97    let cand_lang = crate::home::lang_tree_prefix(candidate);
98    match (from_lang, cand_lang) {
99        (Some(f), Some(c)) if f.eq_ignore_ascii_case(c) => 1,
100        (None, None) => 1,
101        _ => 0,
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Slug generation
107// ---------------------------------------------------------------------------
108
109/// Generate a URL slug from a relative file path.
110///
111/// Strips the file extension, normalizes separators to `/`, lowercases, and
112/// sanitizes each segment: drops ASCII punctuation that is neither alphanumeric
113/// nor a word separator, normalizes spaces/underscores to hyphens, collapses
114/// runs of hyphens, trims edges. Non-ASCII characters (CJK, Cyrillic, Greek,
115/// etc.) pass through unchanged.
116///
117/// Examples:
118/// - `"posts/Hello World.md"` -> `"posts/hello-world"`
119/// - `"guides/Setup.md"` -> `"guides/setup"`
120/// - `"news/Farewell, and Erase on BroadwayWorld.md"`
121///   -> `"news/farewell-and-erase-on-broadwayworld"`
122/// - `"posts/Hello (World)!.md"` -> `"posts/hello-world"`
123/// - `"posts/foo--bar.md"` -> `"posts/foo-bar"`
124/// - `"image.png"` -> `"image"`
125/// - `"视频/视频.md"` -> `"视频/视频"`  (non-ASCII passes through)
126pub fn generate_slug(relative_path: &str) -> String {
127    // Normalize separators
128    let normalized = relative_path.replace('\\', "/");
129
130    // Strip extension only when the last `.` lives inside the trailing
131    // segment AND has at least one character before it. This preserves the
132    // original `dot_pos > last_slash` semantics, including the dotfile case
133    // (`.gitignore`, `.bashrc`) where the leading dot must be kept as part
134    // of the stem rather than yielding an empty string.
135    let last_segment = normalized.rsplit('/').next().unwrap_or(&normalized);
136    let stem_in_segment = match last_segment.rsplit_once('.') {
137        Some((stem, _ext)) if !stem.is_empty() => Some(stem),
138        _ => None,
139    };
140    let prefix = match normalized.rsplit_once('/') {
141        Some((p, _)) => Some(p),
142        None => None,
143    };
144    let without_ext: String = match (prefix, stem_in_segment) {
145        (Some(p), Some(stem)) => format!("{p}/{stem}"),
146        (None, Some(stem)) => stem.to_string(),
147        _ => normalized.clone(),
148    };
149
150    // Sanitize each path segment independently so hyphen-collapse + edge-trim
151    // operate within a segment without touching the path separators.
152    without_ext
153        .split('/')
154        .map(sanitize_slug_segment)
155        .collect::<Vec<_>>()
156        .join("/")
157}
158
159/// Sanitize a single path segment: drop ASCII punctuation, normalize
160/// space/underscore to hyphen, collapse runs of hyphens, trim edges.
161fn sanitize_slug_segment(segment: &str) -> String {
162    let lowered = segment.to_lowercase();
163
164    let mut buf = String::with_capacity(lowered.len());
165    for c in lowered.chars() {
166        if c.is_alphanumeric() {
167            buf.push(c);
168        } else if c == ' ' || c == '-' || c == '_' {
169            buf.push('-');
170        }
171        // else: drop ASCII punctuation (',', '.', '!', '(', ')', etc.) and
172        // control characters.
173    }
174
175    // Collapse consecutive hyphens, then trim leading/trailing.
176    let mut collapsed = String::with_capacity(buf.len());
177    let mut prev_hyphen = false;
178    for c in buf.chars() {
179        if c == '-' {
180            if !prev_hyphen {
181                collapsed.push('-');
182            }
183            prev_hyphen = true;
184        } else {
185            collapsed.push(c);
186            prev_hyphen = false;
187        }
188    }
189    collapsed.trim_matches('-').to_string()
190}
191
192// ---------------------------------------------------------------------------
193// ContentGraph — the immutable, queryable index
194// ---------------------------------------------------------------------------
195
196/// An in-memory index of all content files.
197///
198/// Created via [`ContentGraphBuilder::build`]. All lookups are
199/// case-insensitive (NFC-normalized, lowercased).
200#[derive(Debug, Clone)]
201pub struct ContentGraph {
202    /// All file paths (normalized), in insertion order.
203    files: Vec<String>,
204
205    /// Normalized filename stem (no extension, lowercase) -> list of file indices.
206    filename_index: HashMap<String, Vec<usize>>,
207
208    /// Normalized full path -> file index.
209    path_index: HashMap<String, usize>,
210
211    /// Normalized full path -> slug.
212    slug_map: HashMap<String, String>,
213
214    /// Exact-case asset index: original-case paths for O(1) membership checks.
215    asset_exact: HashSet<String>,
216
217    /// Lowercased path -> Vec<original-case paths> for case-insensitive lookup.
218    asset_ci: HashMap<String, Vec<String>>,
219
220    /// The build's source-directory → URL-slug overrides, so this graph can
221    /// answer "what URL is this file served at" (see [`Self::pinned_url`]) and
222    /// not just "which file does this reference mean". Empty unless the host
223    /// installed them via [`Self::with_output_overrides`]; an empty map still
224    /// yields base slugification, which is what every case-fold bug needed.
225    output_overrides: HashMap<String, String>,
226}
227
228impl ContentGraph {
229    /// Install the build's source-directory → URL-slug overrides, making this
230    /// graph the authority on emitted URLs as well as on resolution.
231    ///
232    /// The host calls this once, right after the overrides are computed
233    /// (`build_page_map` in src-tauri), and every emitter downstream reads the
234    /// answer off the same graph it already holds. Sites that don't map any
235    /// directory still benefit: base slugification (`MIRROR/` → `mirror/`) runs
236    /// with an empty map.
237    pub fn with_output_overrides(mut self, overrides: HashMap<String, String>) -> Self {
238        self.output_overrides = overrides;
239        self
240    }
241
242    /// **The one URL a resolved reference may be emitted as.**
243    ///
244    /// `root_rel` is a source path this graph resolved (via
245    /// [`Self::resolve_path`] or the asset engine). The result is the pinned,
246    /// root-absolute, case-canonical URL the site serves that file at — see
247    /// [`crate::resolve::output_url::pinned_url`] for the properties this
248    /// guarantees.
249    ///
250    /// **Never** re-derive an emitted URL from a folder name, a referencing
251    /// page's depth, or a case-insensitive retry: those are the four-times-
252    /// recurring bug class this method exists to end (moss#903 bug 3). A
253    /// reference that does not resolve gets a `Diagnostic`, not a guessed path.
254    pub fn pinned_url(&self, root_rel: &str) -> String {
255        crate::resolve::output_url::pinned_url(root_rel, &self.output_overrides)
256    }
257
258    /// **Single source of truth for target resolution in moss.**
259    ///
260    /// Every link syntax — wikilinks `[[x]]`, standard markdown links
261    /// `[t](x)`, image refs `![](x)`, embeds `![[x]]`, frontmatter refs —
262    /// MUST resolve through this function. See the resolve pipeline in
263    /// [`crate::resolve::resolve_content`] and the prose overview in
264    /// `moss/docs/reference/link-resolution.md` for the per-syntax call sites.
265    ///
266    /// Downstream code (the compiler's URL-prettifier, for instance)
267    /// receives already-resolved hrefs and MUST NOT reimplement any
268    /// part of this chain. Adding a parallel resolver was the root
269    /// cause of the `[文字](文字.md)` regression on sites using folder
270    /// notes.
271    ///
272    /// Resolution chain (first match wins):
273    /// 1. Exact normalized path
274    /// 2. Exact + `.md`
275    /// 3. Filename match (case-insensitive, without extension)
276    /// 4. Filename + `.md` match
277    /// 5. Folder note: `reference/index.md` or `reference/<reference>.md`
278    ///
279    /// Ambiguity tiebreakers, applied in order:
280    /// candidates whose extension matches the reference's extension win first
281    /// (e.g. `![[scale-compare.png]]` prefers a `.png` sibling over a `.html`
282    /// sibling — only applies when the reference carries an extension);
283    /// candidates in the same language tree as the source are preferred next;
284    /// then longest common directory prefix with `from_path`; then alphabetical
285    /// by normalized path (so results are independent of registration order
286    /// when all earlier keys tie).
287    pub fn resolve_path(&self, reference: &str, from_path: &str) -> Option<String> {
288        let norm_ref = normalize_path(reference);
289        let norm_from = normalize_path(from_path);
290        let ref_ext = path_extension(&norm_ref);
291
292        // Language-tree prefix of the source file, if any.
293        // E.g. "zh-hans/about.md" -> Some("zh-hans").  Used to prefer
294        // same-language-tree candidates when the reference is bare (no slash).
295        let from_lang = crate::home::lang_tree_prefix(&norm_from);
296
297        // 1. Exact path match
298        if self.path_index.contains_key(&norm_ref) {
299            return Some(self.files[self.path_index[&norm_ref]].clone());
300        }
301
302        // 1b. Bare reference (no slash) from a language-tree source:
303        // prefer a same-language-tree sibling before falling back to root.
304        // e.g. ![[footer]] from "zh-hans/about.md" should match
305        //      "zh-hans/footer.md" if it exists, not root "footer.md".
306        if !norm_ref.contains('/') {
307            if let Some(lang) = from_lang {
308                let scoped = format!("{}/{}", lang, norm_ref);
309                if let Some(&idx) = self.path_index.get(&scoped) {
310                    return Some(self.files[idx].clone());
311                }
312                let scoped_md = format!("{}/{}.md", lang, norm_ref);
313                if let Some(&idx) = self.path_index.get(&scoped_md) {
314                    return Some(self.files[idx].clone());
315                }
316            }
317        }
318
319        // 2. Exact + .md
320        let with_md = format!("{}.md", norm_ref);
321        if self.path_index.contains_key(&with_md) {
322            return Some(self.files[self.path_index[&with_md]].clone());
323        }
324
325        // 2b. Suffix match for partial paths (Obsidian shortest-path resolution).
326        // e.g. "游记/index.md" matches "文字/游记/index.md"
327        // Also handles vault-root prefix: "刘果/交互实验/index.md" → try
328        // progressively shorter sub-paths until a match is found.
329        if norm_ref.contains('/') {
330            let parts: Vec<&str> = norm_ref.split('/').collect();
331            // start=0 tries the full path as suffix; start=1.. strips leading components
332            for start in 0..parts.len().saturating_sub(1) {
333                let subpath = parts[start..].join("/");
334                if !subpath.contains('/') {
335                    break; // Single component — handled by filename stem match below
336                }
337
338                // Try exact match on the sub-path
339                if self.path_index.contains_key(&subpath) {
340                    return Some(self.files[self.path_index[&subpath]].clone());
341                }
342                // Try exact + .md
343                let with_md = format!("{}.md", subpath);
344                if self.path_index.contains_key(&with_md) {
345                    return Some(self.files[self.path_index[&with_md]].clone());
346                }
347
348                // Try suffix match (sub-path as suffix of a longer graph path)
349                let suffix = format!("/{}", subpath);
350                let candidates: Vec<usize> = self.files.iter().enumerate()
351                    .filter(|(_, f)| normalize_path(f).ends_with(&suffix))
352                    .map(|(i, _)| i)
353                    .collect();
354                if candidates.len() == 1 {
355                    return Some(self.files[candidates[0]].clone());
356                }
357                if candidates.len() > 1 {
358                    let from_dirs = dir_components(&norm_from);
359                    let best = candidates.iter().copied().max_by_key(|&idx| {
360                        // self.files stores original (pre-normalized) paths for
361                        // filesystem fidelity; re-normalize here to compare
362                        // against norm_from and lang_tree_prefix output.
363                        let normalized = normalize_path(&self.files[idx]);
364                        let candidate_dirs = dir_components(&normalized);
365                        let tree_match = lang_tree_match(&normalized, from_lang);
366                        let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
367                        // Final key: alphabetical-by-path, ascending (Reverse so
368                        // smaller path wins under max_by_key). Removes residual
369                        // dependence on registration order when all other keys
370                        // tie — see "then alphabetical" in the doc comment.
371                        (
372                            ext_match,
373                            tree_match,
374                            common_prefix_len(&candidate_dirs, &from_dirs),
375                            std::cmp::Reverse(normalized.clone()),
376                        )
377                    });
378                    if let Some(idx) = best {
379                        return Some(self.files[idx].clone());
380                    }
381                }
382            }
383        }
384
385        // 3/4. Filename match (stem, case-insensitive)
386        // Skip stem matching when the reference is a multi-component path with an
387        // index stem — falling back to just "index" would match every index.md in
388        // the vault and return an arbitrary wrong result.
389        let ref_stem = normalize_component(
390            filename_stem(filename_with_ext(&norm_ref)),
391        );
392        let skip_stem = norm_ref.contains('/') && crate::home::is_index_stem(&ref_stem);
393        if !skip_stem {
394            if let Some(candidates) = self.filename_index.get(&ref_stem) {
395                if candidates.len() == 1 {
396                    return Some(self.files[candidates[0]].clone());
397                }
398                // Ambiguity tiebreakers, in priority order:
399                //   1. Reference-extension match (only when the reference has
400                //      an extension — otherwise this term is constant)
401                //   2. Same language tree as the source (or both tree-less)
402                //   3. Longest common directory prefix with from_path
403                let from_dirs = dir_components(&norm_from);
404                let best = candidates
405                    .iter()
406                    .copied()
407                    .max_by_key(|&idx| {
408                        // self.files stores original (pre-normalized) paths for
409                        // filesystem fidelity; re-normalize here to compare
410                        // against norm_from and lang_tree_prefix output.
411                        let normalized = normalize_path(&self.files[idx]);
412                        let candidate_dirs = dir_components(&normalized);
413                        let tree_match = lang_tree_match(&normalized, from_lang);
414                        let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
415                        // Final key: alphabetical-by-path, ascending (Reverse so
416                        // smaller path wins under max_by_key). Removes residual
417                        // dependence on registration order when all other keys
418                        // tie — see "then alphabetical" in the doc comment.
419                        (
420                            ext_match,
421                            tree_match,
422                            common_prefix_len(&candidate_dirs, &from_dirs),
423                            std::cmp::Reverse(normalized.clone()),
424                        )
425                    });
426                if let Some(idx) = best {
427                    return Some(self.files[idx].clone());
428                }
429            }
430        }
431
432        // 5. Folder note: a folder reference resolves to that folder's home
433        // file — either a recognized index stem (`<ref>/index.md`, in priority
434        // order) or the self-named note (`<ref>/<leaf>.md`).
435        let folder_note = |base: &str| -> Option<String> {
436            for stem in crate::home::INDEX_STEMS {
437                let folder_index = format!("{}/{}.md", base, stem);
438                if let Some(&idx) = self.path_index.get(&folder_index) {
439                    return Some(self.files[idx].clone());
440                }
441            }
442            let leaf = base.rsplit('/').next().unwrap_or(base);
443            let self_named = format!("{}/{}.md", base, leaf);
444            self.path_index
445                .get(&self_named)
446                .map(|&idx| self.files[idx].clone())
447        };
448
449        // 5a. Language-tree-scoped folder note: a bare folder reference like
450        // `docs/` written inside a `zh-hans/` page should resolve to the
451        // same-language `zh-hans/docs/index.md`, not the root `docs/index.md`.
452        // Mirrors the bare-name language scoping at step 1b. Skipped when the
453        // reference already names a language tree explicitly (handled below).
454        if let Some(lang) = from_lang {
455            if crate::home::lang_tree_prefix(&norm_ref).is_none() {
456                let scoped = format!("{}/{}", lang, norm_ref);
457                if let Some(found) = folder_note(&scoped) {
458                    return Some(found);
459                }
460            }
461        }
462
463        // 5b. Folder note in the reference's own namespace (root fallback).
464        if let Some(found) = folder_note(&norm_ref) {
465            return Some(found);
466        }
467
468        None
469    }
470
471    /// Return the slug for the given path, if registered.
472    pub fn get_slug(&self, path: &str) -> Option<&str> {
473        let norm = normalize_path(path);
474        self.slug_map.get(&norm).map(|s| s.as_str())
475    }
476
477    /// All file paths in insertion order.
478    pub fn all_files(&self) -> &[String] {
479        &self.files
480    }
481
482    // -----------------------------------------------------------------------
483    // Exact-case asset index — backed by real-case paths, NOT the lowercased
484    // path_index / filename_index. Task 6 wires these to the AssetIndex trait.
485    // -----------------------------------------------------------------------
486
487    /// Return `true` iff `p` is present in the graph with exactly this casing.
488    pub fn asset_contains(&self, p: &str) -> bool {
489        self.asset_exact.contains(p)
490    }
491
492    /// Case-insensitive membership: return the first canonical real-case path
493    /// whose lowercased form equals `p.to_lowercase()`, or `None`.
494    pub fn asset_contains_ci(&self, p: &str) -> Option<String> {
495        self.asset_ci.get(&p.to_lowercase()).and_then(|v| v.first().cloned())
496    }
497
498    /// Return all real-case paths whose lowercased form ends with `/<suffix>`
499    /// (or equals `suffix` exactly). Results are sorted for determinism.
500    pub fn asset_find_by_suffix(&self, suffix: &str) -> Vec<String> {
501        let ls = suffix.to_lowercase();
502        let mut v: Vec<String> = self.asset_exact.iter().filter(|p| {
503            let lp = p.to_lowercase();
504            lp.ends_with(&ls)
505                && (lp.len() == ls.len()
506                    || lp.as_bytes()[lp.len() - ls.len() - 1] == b'/')
507        }).cloned().collect();
508        v.sort();
509        v
510    }
511
512    /// Build a graph from a bare list of file paths (no slugs).
513    ///
514    /// Each file is registered with an empty slug. Useful for tests and for
515    /// lightweight index construction in integration scenarios where only asset
516    /// lookup (not slug routing) is needed.
517    pub fn from_paths(paths: &[&str]) -> ContentGraph {
518        let mut b = ContentGraphBuilder::new();
519        for &p in paths {
520            b.add_file(p, "");
521        }
522        b.build()
523    }
524}
525
526// ---------------------------------------------------------------------------
527// ContentGraphBuilder
528// ---------------------------------------------------------------------------
529
530/// Incrementally builds a [`ContentGraph`].
531///
532/// Call `add_file` as content is scanned, then `build()` to obtain the
533/// immutable graph.
534#[derive(Debug, Default)]
535pub struct ContentGraphBuilder {
536    files: Vec<String>,
537    filename_index: HashMap<String, Vec<usize>>,
538    path_index: HashMap<String, usize>,
539    slug_map: HashMap<String, String>,
540    asset_exact: HashSet<String>,
541    asset_ci: HashMap<String, Vec<String>>,
542}
543
544impl ContentGraphBuilder {
545    /// Create a new, empty builder.
546    pub fn new() -> Self {
547        Self::default()
548    }
549
550    /// Register a content file.
551    ///
552    /// `relative_path` is the path relative to the source root (e.g.
553    /// `"posts/hello.md"`). `slug` is the URL slug for this file.
554    pub fn add_file(&mut self, relative_path: &str, slug: &str) {
555        let norm = normalize_path(relative_path);
556
557        // Skip duplicates: if this normalized path is already registered, don't
558        // add another entry to `files` or `filename_index`.
559        if self.path_index.contains_key(&norm) {
560            return;
561        }
562
563        let idx = self.files.len();
564
565        // Build filename stem index
566        let stem = filename_stem(&norm).to_owned();
567        self.filename_index.entry(stem).or_default().push(idx);
568
569        // Build path index
570        self.path_index.insert(norm.clone(), idx);
571
572        // Slug map
573        self.slug_map.insert(norm.clone(), slug.to_owned());
574
575        // Store original path (preserve casing for filesystem operations)
576        self.files.push(relative_path.to_string());
577
578        // Exact-case asset index: keyed on real-case path, NOT normalized.
579        self.asset_exact.insert(relative_path.to_string());
580        self.asset_ci
581            .entry(relative_path.to_lowercase())
582            .or_default()
583            .push(relative_path.to_string());
584    }
585
586    /// Consume the builder and produce an immutable [`ContentGraph`].
587    pub fn build(self) -> ContentGraph {
588        ContentGraph {
589            files: self.files,
590            filename_index: self.filename_index,
591            path_index: self.path_index,
592            slug_map: self.slug_map,
593            asset_exact: self.asset_exact,
594            asset_ci: self.asset_ci,
595            // Installed by the host via `with_output_overrides` once the build's
596            // page map is known; the builder itself is scan-time and has none.
597            output_overrides: HashMap::new(),
598        }
599    }
600}
601
602// ---------------------------------------------------------------------------
603// Tests
604// ---------------------------------------------------------------------------
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    // Convenience: build a graph with common test files.
611    fn sample_graph() -> ContentGraph {
612        let mut b = ContentGraphBuilder::new();
613        b.add_file("posts/hello.md", "/posts/hello");
614        b.add_file("posts/world.md", "/posts/world");
615        b.add_file("guides/hello.md", "/guides/hello");
616        b.add_file("projects/index.md", "/projects");
617        b.add_file("notes/daily/daily.md", "/notes/daily");
618        b.build()
619    }
620
621    // 1. Basic file addition and resolution
622    #[test]
623    fn test_builder_adds_file() {
624        let mut b = ContentGraphBuilder::new();
625        b.add_file("notes/first.md", "/notes/first");
626        let g = b.build();
627
628        assert_eq!(g.all_files(), &["notes/first.md"]);
629        assert_eq!(
630            g.resolve_path("notes/first.md", ""),
631            Some("notes/first.md".into())
632        );
633    }
634
635    // 2. Case-insensitive filename lookup
636    #[test]
637    fn test_filename_index_case_insensitive() {
638        let mut b = ContentGraphBuilder::new();
639        b.add_file("Notes/MyFile.md", "/notes/myfile");
640        let g = b.build();
641
642        // Lookup with different casing — should return original path
643        assert_eq!(
644            g.resolve_path("myfile", ""),
645            Some("Notes/MyFile.md".into())
646        );
647        assert_eq!(
648            g.resolve_path("MYFILE", ""),
649            Some("Notes/MyFile.md".into())
650        );
651        assert_eq!(
652            g.resolve_path("MyFile", ""),
653            Some("Notes/MyFile.md".into())
654        );
655    }
656
657    // 3. Lookup without .md extension
658    #[test]
659    fn test_filename_index_without_extension() {
660        let g = sample_graph();
661
662        // "world" (no extension) should find "posts/world.md"
663        assert_eq!(
664            g.resolve_path("world", ""),
665            Some("posts/world.md".into())
666        );
667    }
668
669    // 4. Ambiguous filename resolved by longest common directory prefix
670    #[test]
671    fn test_ambiguous_resolved_by_common_prefix() {
672        let g = sample_graph();
673
674        // "hello" is ambiguous: posts/hello.md vs guides/hello.md
675        // from "posts/other.md" -> posts/hello.md should win
676        assert_eq!(
677            g.resolve_path("hello", "posts/other.md"),
678            Some("posts/hello.md".into())
679        );
680
681        // from "guides/other.md" -> guides/hello.md should win
682        assert_eq!(
683            g.resolve_path("hello", "guides/other.md"),
684            Some("guides/hello.md".into())
685        );
686    }
687
688    // 7. Folder note resolution: [[projects]] -> projects/index.md
689    #[test]
690    fn test_folder_note_resolution() {
691        let g = sample_graph();
692
693        assert_eq!(
694            g.resolve_path("projects", ""),
695            Some("projects/index.md".into())
696        );
697    }
698
699    // 7a. Folder-note resolution prefers the source's language tree.
700    // A bare folder reference like `docs/` written inside a `zh-hans/` page
701    // must resolve to the same-language `zh-hans/docs/index.md`, not the
702    // root-level `docs/index.md`. Mirrors the bare-name language scoping at
703    // step 1b for the folder-note (step 5) path.
704    #[test]
705    fn test_folder_note_prefers_same_language_tree() {
706        let g = ContentGraph::from_paths(&[
707            "docs/index.md",
708            "zh-hans/docs/index.md",
709            "zh-hans/index.md",
710        ]);
711
712        // From a zh-hans page, `docs/` resolves to the zh-hans docs folder.
713        assert_eq!(
714            g.resolve_path("docs/", "zh-hans/index.md"),
715            Some("zh-hans/docs/index.md".into())
716        );
717
718        // From a root page, `docs/` still resolves to the root docs folder.
719        assert_eq!(
720            g.resolve_path("docs/", "index.md"),
721            Some("docs/index.md".into())
722        );
723    }
724
725    // 7a-fallback. When no same-language folder note exists, a language-tree
726    // page falls back to the root folder note rather than failing.
727    #[test]
728    fn test_folder_note_falls_back_to_root_when_no_language_sibling() {
729        let g = ContentGraph::from_paths(&["docs/index.md", "zh-hans/index.md"]);
730
731        assert_eq!(
732            g.resolve_path("docs/", "zh-hans/index.md"),
733            Some("docs/index.md".into())
734        );
735    }
736
737    // 7b. Self-named folder note: [[daily]] -> notes/daily/daily.md
738    #[test]
739    fn test_self_named_folder_note_resolution() {
740        // "daily" as a filename stem appears in the filename index,
741        // so it resolves via step 3 rather than step 5.
742        let g = sample_graph();
743
744        assert_eq!(
745            g.resolve_path("daily", ""),
746            Some("notes/daily/daily.md".into())
747        );
748    }
749
750    // 7c. Self-named folder note via path
751    #[test]
752    fn test_self_named_folder_note_via_path() {
753        let mut b = ContentGraphBuilder::new();
754        // Only register the self-named note, no filename stem shortcut
755        b.add_file("archive/archive.md", "/archive");
756        let g = b.build();
757
758        // Path-based reference should find it via the folder-note fallback
759        assert_eq!(
760            g.resolve_path("archive", ""),
761            Some("archive/archive.md".into())
762        );
763    }
764
765    // 8. Unresolved returns None
766    #[test]
767    fn test_unresolved_returns_none() {
768        let g = sample_graph();
769
770        assert_eq!(g.resolve_path("nonexistent", ""), None);
771        assert_eq!(g.resolve_path("posts/missing.md", ""), None);
772    }
773
774    // 9. Exact relative path wins over filename
775    #[test]
776    fn test_exact_path_match() {
777        let g = sample_graph();
778
779        // Exact path should resolve directly, even though "hello" is ambiguous
780        assert_eq!(
781            g.resolve_path("guides/hello.md", "posts/other.md"),
782            Some("guides/hello.md".into())
783        );
784    }
785
786    // 10. Partial path match: "posts/hello" matches "posts/hello.md"
787    #[test]
788    fn test_partial_path_match() {
789        let g = sample_graph();
790
791        assert_eq!(
792            g.resolve_path("posts/hello", ""),
793            Some("posts/hello.md".into())
794        );
795        assert_eq!(
796            g.resolve_path("posts/world", ""),
797            Some("posts/world.md".into())
798        );
799    }
800
801    // Slug lookup
802    #[test]
803    fn test_get_slug() {
804        let g = sample_graph();
805
806        assert_eq!(g.get_slug("posts/hello.md"), Some("/posts/hello"));
807        assert_eq!(g.get_slug("Posts/Hello.md"), Some("/posts/hello"));
808        assert_eq!(g.get_slug("nope.md"), None);
809    }
810
811    // all_files preserves insertion order
812    #[test]
813    fn test_all_files_order() {
814        let g = sample_graph();
815
816        assert_eq!(
817            g.all_files(),
818            &[
819                "posts/hello.md",
820                "posts/world.md",
821                "guides/hello.md",
822                "projects/index.md",
823                "notes/daily/daily.md",
824            ]
825        );
826    }
827
828    // Unicode normalization (NFC)
829    #[test]
830    fn test_unicode_normalization() {
831        let mut b = ContentGraphBuilder::new();
832        // e + combining acute accent (NFD)
833        b.add_file("caf\u{0065}\u{0301}.md", "/cafe");
834        let g = b.build();
835
836        // Lookup with NFC form (precomposed e-acute) — returns original NFD form
837        assert_eq!(
838            g.resolve_path("caf\u{00e9}.md", ""),
839            Some("caf\u{0065}\u{0301}.md".into())
840        );
841        // Lookup with NFD form — returns original NFD form
842        assert_eq!(
843            g.resolve_path("caf\u{0065}\u{0301}.md", ""),
844            Some("caf\u{0065}\u{0301}.md".into())
845        );
846    }
847
848    // generate_slug tests
849    #[test]
850    fn test_generate_slug_strips_extension() {
851        assert_eq!(generate_slug("posts/hello.md"), "posts/hello");
852        assert_eq!(generate_slug("image.png"), "image");
853    }
854
855    #[test]
856    fn test_generate_slug_lowercases() {
857        assert_eq!(generate_slug("Posts/Hello.md"), "posts/hello");
858    }
859
860    #[test]
861    fn test_generate_slug_replaces_spaces() {
862        assert_eq!(generate_slug("posts/Hello World.md"), "posts/hello-world");
863    }
864
865    #[test]
866    fn test_generate_slug_normalizes_backslashes() {
867        assert_eq!(generate_slug("posts\\hello.md"), "posts/hello");
868    }
869
870    #[test]
871    fn test_generate_slug_no_extension() {
872        assert_eq!(generate_slug("readme"), "readme");
873    }
874
875    #[test]
876    fn test_generate_slug_dotfile_keeps_leading_dot() {
877        // Regression: a refactor of the extension-stripping branch (commit
878        // 0d128270e) accidentally yielded an empty stem for `.gitignore` and
879        // `.bashrc` because `rsplit_once('.')` returns `("", "gitignore")` and
880        // an `is_empty()` guard wasn't in place. Pin the original semantics:
881        // when the dot is at position 0 of the last segment, treat the whole
882        // segment as the stem.
883        assert_eq!(generate_slug(".gitignore"), "gitignore");
884        assert_eq!(generate_slug(".bashrc"), "bashrc");
885        assert_eq!(generate_slug("posts/.hidden"), "posts/hidden");
886    }
887
888    #[test]
889    fn test_generate_slug_deep_path() {
890        assert_eq!(
891            generate_slug("deep/path/to/file.txt"),
892            "deep/path/to/file"
893        );
894    }
895
896    #[test]
897    fn test_generate_slug_strips_ascii_punctuation() {
898        assert_eq!(
899            generate_slug("news/Farewell, and Erase on BroadwayWorld.md"),
900            "news/farewell-and-erase-on-broadwayworld"
901        );
902        assert_eq!(generate_slug("posts/Hello (World)!.md"), "posts/hello-world");
903        assert_eq!(generate_slug("posts/it's-mine.md"), "posts/its-mine");
904        assert_eq!(generate_slug("posts/foo:bar.md"), "posts/foobar");
905    }
906
907    #[test]
908    fn test_generate_slug_collapses_consecutive_hyphens() {
909        assert_eq!(generate_slug("posts/foo--bar.md"), "posts/foo-bar");
910        assert_eq!(generate_slug("posts/foo - bar.md"), "posts/foo-bar");
911        assert_eq!(generate_slug("posts/a---b.md"), "posts/a-b");
912    }
913
914    #[test]
915    fn test_generate_slug_trims_leading_trailing_hyphens_per_segment() {
916        assert_eq!(generate_slug("posts/-hello.md"), "posts/hello");
917        assert_eq!(generate_slug("posts/hello-.md"), "posts/hello");
918    }
919
920    #[test]
921    fn test_generate_slug_preserves_non_ascii() {
922        assert_eq!(generate_slug("视频/视频.md"), "视频/视频");
923        assert_eq!(
924            generate_slug("posts/AI 带来写作的黄金时代.md"),
925            "posts/ai-带来写作的黄金时代"
926        );
927    }
928
929    #[test]
930    fn test_generate_slug_preserves_path_separators() {
931        assert_eq!(generate_slug("a/b/c.md"), "a/b/c");
932        assert_eq!(generate_slug("a, b/c.md"), "a-b/c");
933    }
934
935    // When both index.md and self-named exist, filename stem match (step 3)
936    // resolves "recipes" to recipes/recipes.md (unique stem match).
937    // This is correct: the self-named note IS the folder's page in Obsidian links.
938    #[test]
939    fn test_resolve_self_named_via_filename_stem() {
940        let mut b = ContentGraphBuilder::new();
941        b.add_file("recipes/index.md", "/recipes");
942        b.add_file("recipes/recipes.md", "/recipes/recipes");
943        let g = b.build();
944
945        // "recipes" matches filename stem "recipes" → recipes/recipes.md (step 3)
946        assert_eq!(
947            g.resolve_path("recipes", "other.md"),
948            Some("recipes/recipes.md".into())
949        );
950    }
951
952    // When only index.md exists (no self-named), folder note fallback (step 5) works
953    #[test]
954    fn test_resolve_folder_note_fallback_to_index() {
955        let mut b = ContentGraphBuilder::new();
956        b.add_file("recipes/index.md", "/recipes");
957        b.add_file("recipes/pasta.md", "/recipes/pasta");
958        let g = b.build();
959
960        assert_eq!(
961            g.resolve_path("recipes", "other.md"),
962            Some("recipes/index.md".into())
963        );
964    }
965
966    // Suffix match: partial path resolves when a deeper file ends with the reference
967    #[test]
968    fn test_suffix_match_partial_path() {
969        let mut b = ContentGraphBuilder::new();
970        b.add_file("文字/游记/index.md", "/文字/游记");
971        b.add_file("index.md", "/");
972        let g = b.build();
973
974        // "游记/index.md" doesn't exist at root, but "文字/游记/index.md" ends with it
975        assert_eq!(
976            g.resolve_path("游记/index.md", "index.md"),
977            Some("文字/游记/index.md".into())
978        );
979    }
980
981    // Suffix match with ambiguity uses from_path tiebreaker
982    #[test]
983    fn test_suffix_match_ambiguous_uses_tiebreaker() {
984        let mut b = ContentGraphBuilder::new();
985        b.add_file("a/游记/index.md", "/a/游记");
986        b.add_file("b/游记/index.md", "/b/游记");
987        let g = b.build();
988
989        // From "a/other.md", should prefer "a/游记/index.md"
990        assert_eq!(
991            g.resolve_path("游记/index.md", "a/other.md"),
992            Some("a/游记/index.md".into())
993        );
994        // From "b/other.md", should prefer "b/游记/index.md"
995        assert_eq!(
996            g.resolve_path("游记/index.md", "b/other.md"),
997            Some("b/游记/index.md".into())
998        );
999    }
1000
1001    // Vault-root prefix: "刘果/交互实验/index.md" should resolve to "交互实验/index.md"
1002    // by stripping the leading component that doesn't match any graph path.
1003    // This matches Obsidian's behavior where vault name can prefix markdown links.
1004    #[test]
1005    fn test_vault_root_prefix_resolves_correctly() {
1006        let mut b = ContentGraphBuilder::new();
1007        b.add_file("交互实验/index.md", "/交互实验");
1008        b.add_file("文字/分布式信息网络/index.md", "/文字/分布式信息网络");
1009        let g = b.build();
1010
1011        // Should resolve to 交互实验/index.md, NOT 文字/分布式信息网络/index.md
1012        assert_eq!(
1013            g.resolve_path("刘果/交互实验/index.md", ""),
1014            Some("交互实验/index.md".into())
1015        );
1016    }
1017
1018    // Progressive sub-path stripping with non-index files
1019    #[test]
1020    fn test_vault_root_prefix_non_index() {
1021        let mut b = ContentGraphBuilder::new();
1022        b.add_file("posts/hello.md", "/posts/hello");
1023        b.add_file("guides/hello.md", "/guides/hello");
1024        let g = b.build();
1025
1026        // "mysite/posts/hello.md" should resolve to "posts/hello.md"
1027        assert_eq!(
1028            g.resolve_path("mysite/posts/hello.md", ""),
1029            Some("posts/hello.md".into())
1030        );
1031    }
1032
1033    // Progressive sub-path: deeper nesting still works
1034    #[test]
1035    fn test_vault_root_prefix_deep_nesting() {
1036        let mut b = ContentGraphBuilder::new();
1037        b.add_file("文字/游记/index.md", "/文字/游记");
1038        let g = b.build();
1039
1040        // "vault/文字/游记/index.md" should find "文字/游记/index.md"
1041        assert_eq!(
1042            g.resolve_path("vault/文字/游记/index.md", ""),
1043            Some("文字/游记/index.md".into())
1044        );
1045    }
1046
1047    // resolve_path preserves original casing of stored file paths
1048    #[test]
1049    fn test_resolve_path_preserves_original_case() {
1050        let mut b = ContentGraphBuilder::new();
1051        b.add_file("音乐/Winter-Song.mov", "音乐/winter-song");
1052        let g = b.build();
1053
1054        // Lookup with different casing should return original path
1055        assert_eq!(
1056            g.resolve_path("winter-song.mov", ""),
1057            Some("音乐/Winter-Song.mov".into())
1058        );
1059        assert_eq!(
1060            g.resolve_path("Winter-Song.mov", ""),
1061            Some("音乐/Winter-Song.mov".into())
1062        );
1063    }
1064
1065    // all_files preserves original casing
1066    #[test]
1067    fn test_all_files_preserves_original_case() {
1068        let mut b = ContentGraphBuilder::new();
1069        b.add_file("Notes/MyFile.md", "/notes/myfile");
1070        b.add_file("Posts/Hello-World.md", "/posts/hello-world");
1071        let g = b.build();
1072
1073        assert_eq!(
1074            g.all_files(),
1075            &["Notes/MyFile.md", "Posts/Hello-World.md"]
1076        );
1077    }
1078
1079    // ---------------------------------------------------------------------
1080    // Stem-collision: extension-aware tiebreaker
1081    //
1082    // When `![[scale-compare.png]]` and `![[scale-compare.html]]` are siblings,
1083    // the wikilink author's extension carries intent: `.png` should resolve to
1084    // the image, `.html` to the HTML file. Without an extension preference the
1085    // tiebreaker reduces to candidate registration order, which is brittle.
1086    // ---------------------------------------------------------------------
1087
1088    #[test]
1089    fn stem_collision_prefers_matching_extension_png() {
1090        let mut b = ContentGraphBuilder::new();
1091        b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1092        b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1093        let g = b.build();
1094
1095        assert_eq!(
1096            g.resolve_path("scale-compare.png", "interactive/article.md"),
1097            Some("interactive/scale-compare.png".into())
1098        );
1099    }
1100
1101    #[test]
1102    fn stem_collision_prefers_matching_extension_html() {
1103        let mut b = ContentGraphBuilder::new();
1104        b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1105        b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1106        let g = b.build();
1107
1108        assert_eq!(
1109            g.resolve_path("scale-compare.html", "interactive/article.md"),
1110            Some("interactive/scale-compare.html".into())
1111        );
1112    }
1113
1114    #[test]
1115    fn stem_collision_independent_of_registration_order() {
1116        // Same as above, with reverse insertion order. Result must not change.
1117        let mut b = ContentGraphBuilder::new();
1118        b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1119        b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1120        let g = b.build();
1121
1122        assert_eq!(
1123            g.resolve_path("scale-compare.png", "interactive/article.md"),
1124            Some("interactive/scale-compare.png".into())
1125        );
1126        assert_eq!(
1127            g.resolve_path("scale-compare.html", "interactive/article.md"),
1128            Some("interactive/scale-compare.html".into())
1129        );
1130    }
1131
1132    #[test]
1133    fn stem_collision_bare_ref_unchanged() {
1134        // A reference without an extension MUST keep existing behavior:
1135        // tiebreaker falls back to (lang_tree, common_prefix). The only
1136        // observable change is that ext-aware refs are now deterministic.
1137        let mut b = ContentGraphBuilder::new();
1138        b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1139        b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1140        let g = b.build();
1141
1142        // No extension on ref: returns *some* candidate (current behavior),
1143        // we just assert the call succeeds rather than pinning the choice.
1144        assert!(g.resolve_path("scale-compare", "interactive/article.md").is_some());
1145    }
1146
1147    #[test]
1148    fn stem_collision_md_wins_over_html_sibling() {
1149        // The most common real case: a wikilink to `.md` (or no-extension
1150        // markdown ref) should not get hijacked by a `.html` sibling that
1151        // happens to be registered later.
1152        let mut b = ContentGraphBuilder::new();
1153        b.add_file("notes/guide.md", "/notes/guide");
1154        b.add_file("notes/guide.html", "/notes/guide.html");
1155        let g = b.build();
1156
1157        assert_eq!(
1158            g.resolve_path("guide.md", "notes/index.md"),
1159            Some("notes/guide.md".into())
1160        );
1161    }
1162
1163    #[test]
1164    fn stem_collision_suffix_match_arm() {
1165        // The suffix-match tiebreaker (ContentGraph::resolve_path step 2b)
1166        // also benefits from extension preference. Reference is multi-component
1167        // (`a/scale.png`) so it goes through the suffix-match arm, not the
1168        // bare-stem arm.
1169        let mut b = ContentGraphBuilder::new();
1170        b.add_file("vault/a/scale.png", "/vault/a/scale.png");
1171        b.add_file("vault/a/scale.html", "/vault/a/scale.html");
1172        let g = b.build();
1173
1174        assert_eq!(
1175            g.resolve_path("a/scale.png", "vault/notes/article.md"),
1176            Some("vault/a/scale.png".into())
1177        );
1178    }
1179
1180    #[test]
1181    fn stem_collision_ext_match_overrides_lang_tree() {
1182        // Pin priority: extension match wins even when a lang-tree candidate
1183        // exists. Without this, a `![[foo.png]]` in zh-hans/note.md against
1184        // siblings (zh-hans/foo.html + en/foo.png) would surprise users by
1185        // returning the .html file just because it shares a language tree.
1186        let mut b = ContentGraphBuilder::new();
1187        b.add_file("zh-hans/foo.html", "/zh-hans/foo.html");
1188        b.add_file("en/foo.png", "/en/foo.png");
1189        let g = b.build();
1190
1191        assert_eq!(
1192            g.resolve_path("foo.png", "zh-hans/note.md"),
1193            Some("en/foo.png".into())
1194        );
1195    }
1196
1197    #[test]
1198    fn stem_collision_alphabetical_final_tiebreaker() {
1199        // Bare ref + sibling stems: no extension intent, both same lang-tree,
1200        // equal common-prefix. The final alphabetical tiebreaker must make
1201        // the result independent of registration order.
1202        let mut b1 = ContentGraphBuilder::new();
1203        b1.add_file("notes/photo.png", "/notes/photo.png");
1204        b1.add_file("notes/photo.html", "/notes/photo.html");
1205        let g1 = b1.build();
1206
1207        let mut b2 = ContentGraphBuilder::new();
1208        b2.add_file("notes/photo.html", "/notes/photo.html");
1209        b2.add_file("notes/photo.png", "/notes/photo.png");
1210        let g2 = b2.build();
1211
1212        // "notes/photo.html" < "notes/photo.png" alphabetically → .html wins
1213        // in both insertion orders.
1214        let r1 = g1.resolve_path("photo", "notes/index.md");
1215        let r2 = g2.resolve_path("photo", "notes/index.md");
1216        assert_eq!(r1, r2, "result must not depend on registration order");
1217        assert_eq!(r1, Some("notes/photo.html".into()));
1218    }
1219
1220    #[test]
1221    fn stem_collision_case_insensitive_extension() {
1222        // Author may write `.PNG`; should still match `.png` candidate.
1223        let mut b = ContentGraphBuilder::new();
1224        b.add_file("interactive/photo.PNG", "/interactive/photo.png");
1225        b.add_file("interactive/photo.html", "/interactive/photo.html");
1226        let g = b.build();
1227
1228        assert_eq!(
1229            g.resolve_path("photo.png", "interactive/article.md"),
1230            Some("interactive/photo.PNG".into())
1231        );
1232    }
1233
1234    #[test]
1235    fn exact_case_asset_index() {
1236        let g = ContentGraph::from_paths(&["assets/Hoon.JPG", "News/post.md"]);
1237        assert!(g.asset_contains("assets/Hoon.JPG"));
1238        assert!(!g.asset_contains("assets/hoon.jpg")); // exact case
1239        assert_eq!(
1240            g.asset_contains_ci("assets/hoon.jpg").as_deref(),
1241            Some("assets/Hoon.JPG")
1242        );
1243        assert_eq!(
1244            g.asset_find_by_suffix("Hoon.JPG"),
1245            vec!["assets/Hoon.JPG".to_string()]
1246        );
1247    }
1248}