Skip to main content

moss_core/
content_graph.rs

1//! In-memory index of content files, headings, and block IDs.
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, headings, and block IDs.
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    /// Normalized full path -> Vec<(heading_text, anchor_id)>.
215    headings: HashMap<String, Vec<(String, String)>>,
216
217    /// Normalized full path -> Vec<block_id>.
218    blocks: HashMap<String, Vec<String>>,
219
220    /// Exact-case asset index: original-case paths for O(1) membership checks.
221    asset_exact: HashSet<String>,
222
223    /// Lowercased path -> Vec<original-case paths> for case-insensitive lookup.
224    asset_ci: HashMap<String, Vec<String>>,
225}
226
227impl ContentGraph {
228    /// **Single source of truth for target resolution in moss.**
229    ///
230    /// Every link syntax — wikilinks `[[x]]`, standard markdown links
231    /// `[t](x)`, image refs `![](x)`, embeds `![[x]]`, frontmatter refs —
232    /// MUST resolve through this function. See the resolve pipeline in
233    /// [`crate::resolve::resolve_content`] and the prose overview in
234    /// `moss/docs/link-resolution.md` for the per-syntax call sites.
235    ///
236    /// Downstream code (the compiler's URL-prettifier, for instance)
237    /// receives already-resolved hrefs and MUST NOT reimplement any
238    /// part of this chain. Adding a parallel resolver was the root
239    /// cause of the `[文字](文字.md)` regression on sites using folder
240    /// notes.
241    ///
242    /// Resolution chain (first match wins):
243    /// 1. Exact normalized path
244    /// 2. Exact + `.md`
245    /// 3. Filename match (case-insensitive, without extension)
246    /// 4. Filename + `.md` match
247    /// 5. Folder note: `reference/index.md` or `reference/<reference>.md`
248    ///
249    /// Ambiguity tiebreakers, applied in order:
250    /// candidates whose extension matches the reference's extension win first
251    /// (e.g. `![[scale-compare.png]]` prefers a `.png` sibling over a `.html`
252    /// sibling — only applies when the reference carries an extension);
253    /// candidates in the same language tree as the source are preferred next;
254    /// then longest common directory prefix with `from_path`; then alphabetical
255    /// by normalized path (so results are independent of registration order
256    /// when all earlier keys tie).
257    pub fn resolve_path(&self, reference: &str, from_path: &str) -> Option<String> {
258        let norm_ref = normalize_path(reference);
259        let norm_from = normalize_path(from_path);
260        let ref_ext = path_extension(&norm_ref);
261
262        // Language-tree prefix of the source file, if any.
263        // E.g. "zh-hans/about.md" -> Some("zh-hans").  Used to prefer
264        // same-language-tree candidates when the reference is bare (no slash).
265        let from_lang = crate::home::lang_tree_prefix(&norm_from);
266
267        // 1. Exact path match
268        if self.path_index.contains_key(&norm_ref) {
269            return Some(self.files[self.path_index[&norm_ref]].clone());
270        }
271
272        // 1b. Bare reference (no slash) from a language-tree source:
273        // prefer a same-language-tree sibling before falling back to root.
274        // e.g. ![[footer]] from "zh-hans/about.md" should match
275        //      "zh-hans/footer.md" if it exists, not root "footer.md".
276        if !norm_ref.contains('/') {
277            if let Some(lang) = from_lang {
278                let scoped = format!("{}/{}", lang, norm_ref);
279                if let Some(&idx) = self.path_index.get(&scoped) {
280                    return Some(self.files[idx].clone());
281                }
282                let scoped_md = format!("{}/{}.md", lang, norm_ref);
283                if let Some(&idx) = self.path_index.get(&scoped_md) {
284                    return Some(self.files[idx].clone());
285                }
286            }
287        }
288
289        // 2. Exact + .md
290        let with_md = format!("{}.md", norm_ref);
291        if self.path_index.contains_key(&with_md) {
292            return Some(self.files[self.path_index[&with_md]].clone());
293        }
294
295        // 2b. Suffix match for partial paths (Obsidian shortest-path resolution).
296        // e.g. "游记/index.md" matches "文字/游记/index.md"
297        // Also handles vault-root prefix: "刘果/交互实验/index.md" → try
298        // progressively shorter sub-paths until a match is found.
299        if norm_ref.contains('/') {
300            let parts: Vec<&str> = norm_ref.split('/').collect();
301            // start=0 tries the full path as suffix; start=1.. strips leading components
302            for start in 0..parts.len().saturating_sub(1) {
303                let subpath = parts[start..].join("/");
304                if !subpath.contains('/') {
305                    break; // Single component — handled by filename stem match below
306                }
307
308                // Try exact match on the sub-path
309                if self.path_index.contains_key(&subpath) {
310                    return Some(self.files[self.path_index[&subpath]].clone());
311                }
312                // Try exact + .md
313                let with_md = format!("{}.md", subpath);
314                if self.path_index.contains_key(&with_md) {
315                    return Some(self.files[self.path_index[&with_md]].clone());
316                }
317
318                // Try suffix match (sub-path as suffix of a longer graph path)
319                let suffix = format!("/{}", subpath);
320                let candidates: Vec<usize> = self.files.iter().enumerate()
321                    .filter(|(_, f)| normalize_path(f).ends_with(&suffix))
322                    .map(|(i, _)| i)
323                    .collect();
324                if candidates.len() == 1 {
325                    return Some(self.files[candidates[0]].clone());
326                }
327                if candidates.len() > 1 {
328                    let from_dirs = dir_components(&norm_from);
329                    let best = candidates.iter().copied().max_by_key(|&idx| {
330                        // self.files stores original (pre-normalized) paths for
331                        // filesystem fidelity; re-normalize here to compare
332                        // against norm_from and lang_tree_prefix output.
333                        let normalized = normalize_path(&self.files[idx]);
334                        let candidate_dirs = dir_components(&normalized);
335                        let tree_match = lang_tree_match(&normalized, from_lang);
336                        let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
337                        // Final key: alphabetical-by-path, ascending (Reverse so
338                        // smaller path wins under max_by_key). Removes residual
339                        // dependence on registration order when all other keys
340                        // tie — see "then alphabetical" in the doc comment.
341                        (
342                            ext_match,
343                            tree_match,
344                            common_prefix_len(&candidate_dirs, &from_dirs),
345                            std::cmp::Reverse(normalized.clone()),
346                        )
347                    });
348                    if let Some(idx) = best {
349                        return Some(self.files[idx].clone());
350                    }
351                }
352            }
353        }
354
355        // 3/4. Filename match (stem, case-insensitive)
356        // Skip stem matching when the reference is a multi-component path with an
357        // index stem — falling back to just "index" would match every index.md in
358        // the vault and return an arbitrary wrong result.
359        let ref_stem = normalize_component(
360            filename_stem(filename_with_ext(&norm_ref)),
361        );
362        let skip_stem = norm_ref.contains('/') && crate::home::is_index_stem(&ref_stem);
363        if !skip_stem {
364            if let Some(candidates) = self.filename_index.get(&ref_stem) {
365                if candidates.len() == 1 {
366                    return Some(self.files[candidates[0]].clone());
367                }
368                // Ambiguity tiebreakers, in priority order:
369                //   1. Reference-extension match (only when the reference has
370                //      an extension — otherwise this term is constant)
371                //   2. Same language tree as the source (or both tree-less)
372                //   3. Longest common directory prefix with from_path
373                let from_dirs = dir_components(&norm_from);
374                let best = candidates
375                    .iter()
376                    .copied()
377                    .max_by_key(|&idx| {
378                        // self.files stores original (pre-normalized) paths for
379                        // filesystem fidelity; re-normalize here to compare
380                        // against norm_from and lang_tree_prefix output.
381                        let normalized = normalize_path(&self.files[idx]);
382                        let candidate_dirs = dir_components(&normalized);
383                        let tree_match = lang_tree_match(&normalized, from_lang);
384                        let ext_match = ext_match_score(ref_ext.as_deref(), &normalized);
385                        // Final key: alphabetical-by-path, ascending (Reverse so
386                        // smaller path wins under max_by_key). Removes residual
387                        // dependence on registration order when all other keys
388                        // tie — see "then alphabetical" in the doc comment.
389                        (
390                            ext_match,
391                            tree_match,
392                            common_prefix_len(&candidate_dirs, &from_dirs),
393                            std::cmp::Reverse(normalized.clone()),
394                        )
395                    });
396                if let Some(idx) = best {
397                    return Some(self.files[idx].clone());
398                }
399            }
400        }
401
402        // 5. Folder note: a folder reference resolves to that folder's home
403        // file — either a recognized index stem (`<ref>/index.md`, in priority
404        // order) or the self-named note (`<ref>/<leaf>.md`).
405        let folder_note = |base: &str| -> Option<String> {
406            for stem in crate::home::INDEX_STEMS {
407                let folder_index = format!("{}/{}.md", base, stem);
408                if let Some(&idx) = self.path_index.get(&folder_index) {
409                    return Some(self.files[idx].clone());
410                }
411            }
412            let leaf = base.rsplit('/').next().unwrap_or(base);
413            let self_named = format!("{}/{}.md", base, leaf);
414            self.path_index
415                .get(&self_named)
416                .map(|&idx| self.files[idx].clone())
417        };
418
419        // 5a. Language-tree-scoped folder note: a bare folder reference like
420        // `docs/` written inside a `zh-hans/` page should resolve to the
421        // same-language `zh-hans/docs/index.md`, not the root `docs/index.md`.
422        // Mirrors the bare-name language scoping at step 1b. Skipped when the
423        // reference already names a language tree explicitly (handled below).
424        if let Some(lang) = from_lang {
425            if crate::home::lang_tree_prefix(&norm_ref).is_none() {
426                let scoped = format!("{}/{}", lang, norm_ref);
427                if let Some(found) = folder_note(&scoped) {
428                    return Some(found);
429                }
430            }
431        }
432
433        // 5b. Folder note in the reference's own namespace (root fallback).
434        if let Some(found) = folder_note(&norm_ref) {
435            return Some(found);
436        }
437
438        None
439    }
440
441    /// Check whether the file at `path` has a heading with the given `anchor`.
442    pub fn has_heading(&self, path: &str, anchor: &str) -> bool {
443        let norm = normalize_path(path);
444        let anchor_lower = normalize_component(anchor);
445        self.headings
446            .get(&norm)
447            .map_or(false, |hs| hs.iter().any(|(_, a)| *a == anchor_lower))
448    }
449
450    /// Check whether the file at `path` has a block with the given `block_id`.
451    pub fn has_block(&self, path: &str, block_id: &str) -> bool {
452        let norm = normalize_path(path);
453        let id_lower = normalize_component(block_id);
454        self.blocks
455            .get(&norm)
456            .map_or(false, |bs| bs.iter().any(|b| *b == id_lower))
457    }
458
459    /// Return the slug for the given path, if registered.
460    pub fn get_slug(&self, path: &str) -> Option<&str> {
461        let norm = normalize_path(path);
462        self.slug_map.get(&norm).map(|s| s.as_str())
463    }
464
465    /// All file paths in insertion order.
466    pub fn all_files(&self) -> &[String] {
467        &self.files
468    }
469
470    // -----------------------------------------------------------------------
471    // Exact-case asset index — backed by real-case paths, NOT the lowercased
472    // path_index / filename_index. Task 6 wires these to the AssetIndex trait.
473    // -----------------------------------------------------------------------
474
475    /// Return `true` iff `p` is present in the graph with exactly this casing.
476    pub fn asset_contains(&self, p: &str) -> bool {
477        self.asset_exact.contains(p)
478    }
479
480    /// Case-insensitive membership: return the first canonical real-case path
481    /// whose lowercased form equals `p.to_lowercase()`, or `None`.
482    pub fn asset_contains_ci(&self, p: &str) -> Option<String> {
483        self.asset_ci.get(&p.to_lowercase()).and_then(|v| v.first().cloned())
484    }
485
486    /// Return all real-case paths whose lowercased form ends with `/<suffix>`
487    /// (or equals `suffix` exactly). Results are sorted for determinism.
488    pub fn asset_find_by_suffix(&self, suffix: &str) -> Vec<String> {
489        let ls = suffix.to_lowercase();
490        let mut v: Vec<String> = self.asset_exact.iter().filter(|p| {
491            let lp = p.to_lowercase();
492            lp.ends_with(&ls)
493                && (lp.len() == ls.len()
494                    || lp.as_bytes()[lp.len() - ls.len() - 1] == b'/')
495        }).cloned().collect();
496        v.sort();
497        v
498    }
499
500    /// Build a graph from a bare list of file paths (no slugs).
501    ///
502    /// Each file is registered with an empty slug. Useful for tests and for
503    /// lightweight index construction in integration scenarios where only asset
504    /// lookup (not slug routing) is needed.
505    pub fn from_paths(paths: &[&str]) -> ContentGraph {
506        let mut b = ContentGraphBuilder::new();
507        for &p in paths {
508            b.add_file(p, "");
509        }
510        b.build()
511    }
512}
513
514// ---------------------------------------------------------------------------
515// ContentGraphBuilder
516// ---------------------------------------------------------------------------
517
518/// Incrementally builds a [`ContentGraph`].
519///
520/// Call `add_file`, `add_headings`, `add_blocks` as content is scanned,
521/// then `build()` to obtain the immutable graph.
522#[derive(Debug, Default)]
523pub struct ContentGraphBuilder {
524    files: Vec<String>,
525    filename_index: HashMap<String, Vec<usize>>,
526    path_index: HashMap<String, usize>,
527    slug_map: HashMap<String, String>,
528    headings: HashMap<String, Vec<(String, String)>>,
529    blocks: HashMap<String, Vec<String>>,
530    asset_exact: HashSet<String>,
531    asset_ci: HashMap<String, Vec<String>>,
532}
533
534impl ContentGraphBuilder {
535    /// Create a new, empty builder.
536    pub fn new() -> Self {
537        Self::default()
538    }
539
540    /// Register a content file.
541    ///
542    /// `relative_path` is the path relative to the source root (e.g.
543    /// `"posts/hello.md"`). `slug` is the URL slug for this file.
544    pub fn add_file(&mut self, relative_path: &str, slug: &str) {
545        let norm = normalize_path(relative_path);
546
547        // Skip duplicates: if this normalized path is already registered, don't
548        // add another entry to `files` or `filename_index`.
549        if self.path_index.contains_key(&norm) {
550            return;
551        }
552
553        let idx = self.files.len();
554
555        // Build filename stem index
556        let stem = filename_stem(&norm).to_owned();
557        self.filename_index.entry(stem).or_default().push(idx);
558
559        // Build path index
560        self.path_index.insert(norm.clone(), idx);
561
562        // Slug map
563        self.slug_map.insert(norm.clone(), slug.to_owned());
564
565        // Store original path (preserve casing for filesystem operations)
566        self.files.push(relative_path.to_string());
567
568        // Exact-case asset index: keyed on real-case path, NOT normalized.
569        self.asset_exact.insert(relative_path.to_string());
570        self.asset_ci
571            .entry(relative_path.to_lowercase())
572            .or_default()
573            .push(relative_path.to_string());
574    }
575
576    /// Register headings for a file. Each entry is `(heading_text, anchor_id)`.
577    pub fn add_headings(&mut self, relative_path: &str, entries: Vec<(String, String)>) {
578        let norm = normalize_path(relative_path);
579        let normalized_entries = entries
580            .into_iter()
581            .map(|(text, anchor)| (text, normalize_component(&anchor)))
582            .collect();
583        self.headings.insert(norm, normalized_entries);
584    }
585
586    /// Register block IDs for a file.
587    pub fn add_blocks(&mut self, relative_path: &str, ids: Vec<String>) {
588        let norm = normalize_path(relative_path);
589        let normalized_ids = ids.into_iter().map(|id| normalize_component(&id)).collect();
590        self.blocks.insert(norm, normalized_ids);
591    }
592
593    /// Consume the builder and produce an immutable [`ContentGraph`].
594    pub fn build(self) -> ContentGraph {
595        ContentGraph {
596            files: self.files,
597            filename_index: self.filename_index,
598            path_index: self.path_index,
599            slug_map: self.slug_map,
600            headings: self.headings,
601            blocks: self.blocks,
602            asset_exact: self.asset_exact,
603            asset_ci: self.asset_ci,
604        }
605    }
606}
607
608// ---------------------------------------------------------------------------
609// Tests
610// ---------------------------------------------------------------------------
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    // Convenience: build a graph with common test files.
617    fn sample_graph() -> ContentGraph {
618        let mut b = ContentGraphBuilder::new();
619        b.add_file("posts/hello.md", "/posts/hello");
620        b.add_file("posts/world.md", "/posts/world");
621        b.add_file("guides/hello.md", "/guides/hello");
622        b.add_file("projects/index.md", "/projects");
623        b.add_file("notes/daily/daily.md", "/notes/daily");
624        b.add_headings(
625            "posts/hello.md",
626            vec![
627                ("Introduction".into(), "introduction".into()),
628                ("Getting Started".into(), "getting-started".into()),
629            ],
630        );
631        b.add_blocks(
632            "posts/hello.md",
633            vec!["abc123".into(), "def456".into()],
634        );
635        b.build()
636    }
637
638    // 1. Basic file addition and resolution
639    #[test]
640    fn test_builder_adds_file() {
641        let mut b = ContentGraphBuilder::new();
642        b.add_file("notes/first.md", "/notes/first");
643        let g = b.build();
644
645        assert_eq!(g.all_files(), &["notes/first.md"]);
646        assert_eq!(
647            g.resolve_path("notes/first.md", ""),
648            Some("notes/first.md".into())
649        );
650    }
651
652    // 2. Case-insensitive filename lookup
653    #[test]
654    fn test_filename_index_case_insensitive() {
655        let mut b = ContentGraphBuilder::new();
656        b.add_file("Notes/MyFile.md", "/notes/myfile");
657        let g = b.build();
658
659        // Lookup with different casing — should return original path
660        assert_eq!(
661            g.resolve_path("myfile", ""),
662            Some("Notes/MyFile.md".into())
663        );
664        assert_eq!(
665            g.resolve_path("MYFILE", ""),
666            Some("Notes/MyFile.md".into())
667        );
668        assert_eq!(
669            g.resolve_path("MyFile", ""),
670            Some("Notes/MyFile.md".into())
671        );
672    }
673
674    // 3. Lookup without .md extension
675    #[test]
676    fn test_filename_index_without_extension() {
677        let g = sample_graph();
678
679        // "world" (no extension) should find "posts/world.md"
680        assert_eq!(
681            g.resolve_path("world", ""),
682            Some("posts/world.md".into())
683        );
684    }
685
686    // 4. Ambiguous filename resolved by longest common directory prefix
687    #[test]
688    fn test_ambiguous_resolved_by_common_prefix() {
689        let g = sample_graph();
690
691        // "hello" is ambiguous: posts/hello.md vs guides/hello.md
692        // from "posts/other.md" -> posts/hello.md should win
693        assert_eq!(
694            g.resolve_path("hello", "posts/other.md"),
695            Some("posts/hello.md".into())
696        );
697
698        // from "guides/other.md" -> guides/hello.md should win
699        assert_eq!(
700            g.resolve_path("hello", "guides/other.md"),
701            Some("guides/hello.md".into())
702        );
703    }
704
705    // 5. Heading query
706    #[test]
707    fn test_headings_registered() {
708        let g = sample_graph();
709
710        assert!(g.has_heading("posts/hello.md", "introduction"));
711        assert!(g.has_heading("posts/hello.md", "getting-started"));
712        // Case-insensitive
713        assert!(g.has_heading("posts/hello.md", "Introduction"));
714        // Non-existent heading
715        assert!(!g.has_heading("posts/hello.md", "nonexistent"));
716        // Non-existent file
717        assert!(!g.has_heading("nope.md", "introduction"));
718    }
719
720    // 6. Block ID query
721    #[test]
722    fn test_blocks_registered() {
723        let g = sample_graph();
724
725        assert!(g.has_block("posts/hello.md", "abc123"));
726        assert!(g.has_block("posts/hello.md", "def456"));
727        // Case-insensitive
728        assert!(g.has_block("posts/hello.md", "ABC123"));
729        // Non-existent block
730        assert!(!g.has_block("posts/hello.md", "zzz"));
731        // Non-existent file
732        assert!(!g.has_block("nope.md", "abc123"));
733    }
734
735    // 7. Folder note resolution: [[projects]] -> projects/index.md
736    #[test]
737    fn test_folder_note_resolution() {
738        let g = sample_graph();
739
740        assert_eq!(
741            g.resolve_path("projects", ""),
742            Some("projects/index.md".into())
743        );
744    }
745
746    // 7a. Folder-note resolution prefers the source's language tree.
747    // A bare folder reference like `docs/` written inside a `zh-hans/` page
748    // must resolve to the same-language `zh-hans/docs/index.md`, not the
749    // root-level `docs/index.md`. Mirrors the bare-name language scoping at
750    // step 1b for the folder-note (step 5) path.
751    #[test]
752    fn test_folder_note_prefers_same_language_tree() {
753        let g = ContentGraph::from_paths(&[
754            "docs/index.md",
755            "zh-hans/docs/index.md",
756            "zh-hans/index.md",
757        ]);
758
759        // From a zh-hans page, `docs/` resolves to the zh-hans docs folder.
760        assert_eq!(
761            g.resolve_path("docs/", "zh-hans/index.md"),
762            Some("zh-hans/docs/index.md".into())
763        );
764
765        // From a root page, `docs/` still resolves to the root docs folder.
766        assert_eq!(
767            g.resolve_path("docs/", "index.md"),
768            Some("docs/index.md".into())
769        );
770    }
771
772    // 7a-fallback. When no same-language folder note exists, a language-tree
773    // page falls back to the root folder note rather than failing.
774    #[test]
775    fn test_folder_note_falls_back_to_root_when_no_language_sibling() {
776        let g = ContentGraph::from_paths(&["docs/index.md", "zh-hans/index.md"]);
777
778        assert_eq!(
779            g.resolve_path("docs/", "zh-hans/index.md"),
780            Some("docs/index.md".into())
781        );
782    }
783
784    // 7b. Self-named folder note: [[daily]] -> notes/daily/daily.md
785    #[test]
786    fn test_self_named_folder_note_resolution() {
787        // "daily" as a filename stem appears in the filename index,
788        // so it resolves via step 3 rather than step 5.
789        let g = sample_graph();
790
791        assert_eq!(
792            g.resolve_path("daily", ""),
793            Some("notes/daily/daily.md".into())
794        );
795    }
796
797    // 7c. Self-named folder note via path
798    #[test]
799    fn test_self_named_folder_note_via_path() {
800        let mut b = ContentGraphBuilder::new();
801        // Only register the self-named note, no filename stem shortcut
802        b.add_file("archive/archive.md", "/archive");
803        let g = b.build();
804
805        // Path-based reference should find it via the folder-note fallback
806        assert_eq!(
807            g.resolve_path("archive", ""),
808            Some("archive/archive.md".into())
809        );
810    }
811
812    // 8. Unresolved returns None
813    #[test]
814    fn test_unresolved_returns_none() {
815        let g = sample_graph();
816
817        assert_eq!(g.resolve_path("nonexistent", ""), None);
818        assert_eq!(g.resolve_path("posts/missing.md", ""), None);
819    }
820
821    // 9. Exact relative path wins over filename
822    #[test]
823    fn test_exact_path_match() {
824        let g = sample_graph();
825
826        // Exact path should resolve directly, even though "hello" is ambiguous
827        assert_eq!(
828            g.resolve_path("guides/hello.md", "posts/other.md"),
829            Some("guides/hello.md".into())
830        );
831    }
832
833    // 10. Partial path match: "posts/hello" matches "posts/hello.md"
834    #[test]
835    fn test_partial_path_match() {
836        let g = sample_graph();
837
838        assert_eq!(
839            g.resolve_path("posts/hello", ""),
840            Some("posts/hello.md".into())
841        );
842        assert_eq!(
843            g.resolve_path("posts/world", ""),
844            Some("posts/world.md".into())
845        );
846    }
847
848    // Slug lookup
849    #[test]
850    fn test_get_slug() {
851        let g = sample_graph();
852
853        assert_eq!(g.get_slug("posts/hello.md"), Some("/posts/hello"));
854        assert_eq!(g.get_slug("Posts/Hello.md"), Some("/posts/hello"));
855        assert_eq!(g.get_slug("nope.md"), None);
856    }
857
858    // all_files preserves insertion order
859    #[test]
860    fn test_all_files_order() {
861        let g = sample_graph();
862
863        assert_eq!(
864            g.all_files(),
865            &[
866                "posts/hello.md",
867                "posts/world.md",
868                "guides/hello.md",
869                "projects/index.md",
870                "notes/daily/daily.md",
871            ]
872        );
873    }
874
875    // Unicode normalization (NFC)
876    #[test]
877    fn test_unicode_normalization() {
878        let mut b = ContentGraphBuilder::new();
879        // e + combining acute accent (NFD)
880        b.add_file("caf\u{0065}\u{0301}.md", "/cafe");
881        let g = b.build();
882
883        // Lookup with NFC form (precomposed e-acute) — returns original NFD form
884        assert_eq!(
885            g.resolve_path("caf\u{00e9}.md", ""),
886            Some("caf\u{0065}\u{0301}.md".into())
887        );
888        // Lookup with NFD form — returns original NFD form
889        assert_eq!(
890            g.resolve_path("caf\u{0065}\u{0301}.md", ""),
891            Some("caf\u{0065}\u{0301}.md".into())
892        );
893    }
894
895    // generate_slug tests
896    #[test]
897    fn test_generate_slug_strips_extension() {
898        assert_eq!(generate_slug("posts/hello.md"), "posts/hello");
899        assert_eq!(generate_slug("image.png"), "image");
900    }
901
902    #[test]
903    fn test_generate_slug_lowercases() {
904        assert_eq!(generate_slug("Posts/Hello.md"), "posts/hello");
905    }
906
907    #[test]
908    fn test_generate_slug_replaces_spaces() {
909        assert_eq!(generate_slug("posts/Hello World.md"), "posts/hello-world");
910    }
911
912    #[test]
913    fn test_generate_slug_normalizes_backslashes() {
914        assert_eq!(generate_slug("posts\\hello.md"), "posts/hello");
915    }
916
917    #[test]
918    fn test_generate_slug_no_extension() {
919        assert_eq!(generate_slug("readme"), "readme");
920    }
921
922    #[test]
923    fn test_generate_slug_dotfile_keeps_leading_dot() {
924        // Regression: a refactor of the extension-stripping branch (commit
925        // 0d128270e) accidentally yielded an empty stem for `.gitignore` and
926        // `.bashrc` because `rsplit_once('.')` returns `("", "gitignore")` and
927        // an `is_empty()` guard wasn't in place. Pin the original semantics:
928        // when the dot is at position 0 of the last segment, treat the whole
929        // segment as the stem.
930        assert_eq!(generate_slug(".gitignore"), "gitignore");
931        assert_eq!(generate_slug(".bashrc"), "bashrc");
932        assert_eq!(generate_slug("posts/.hidden"), "posts/hidden");
933    }
934
935    #[test]
936    fn test_generate_slug_deep_path() {
937        assert_eq!(
938            generate_slug("deep/path/to/file.txt"),
939            "deep/path/to/file"
940        );
941    }
942
943    #[test]
944    fn test_generate_slug_strips_ascii_punctuation() {
945        assert_eq!(
946            generate_slug("news/Farewell, and Erase on BroadwayWorld.md"),
947            "news/farewell-and-erase-on-broadwayworld"
948        );
949        assert_eq!(generate_slug("posts/Hello (World)!.md"), "posts/hello-world");
950        assert_eq!(generate_slug("posts/it's-mine.md"), "posts/its-mine");
951        assert_eq!(generate_slug("posts/foo:bar.md"), "posts/foobar");
952    }
953
954    #[test]
955    fn test_generate_slug_collapses_consecutive_hyphens() {
956        assert_eq!(generate_slug("posts/foo--bar.md"), "posts/foo-bar");
957        assert_eq!(generate_slug("posts/foo - bar.md"), "posts/foo-bar");
958        assert_eq!(generate_slug("posts/a---b.md"), "posts/a-b");
959    }
960
961    #[test]
962    fn test_generate_slug_trims_leading_trailing_hyphens_per_segment() {
963        assert_eq!(generate_slug("posts/-hello.md"), "posts/hello");
964        assert_eq!(generate_slug("posts/hello-.md"), "posts/hello");
965    }
966
967    #[test]
968    fn test_generate_slug_preserves_non_ascii() {
969        assert_eq!(generate_slug("视频/视频.md"), "视频/视频");
970        assert_eq!(
971            generate_slug("posts/AI 带来写作的黄金时代.md"),
972            "posts/ai-带来写作的黄金时代"
973        );
974    }
975
976    #[test]
977    fn test_generate_slug_preserves_path_separators() {
978        assert_eq!(generate_slug("a/b/c.md"), "a/b/c");
979        assert_eq!(generate_slug("a, b/c.md"), "a-b/c");
980    }
981
982    // When both index.md and self-named exist, filename stem match (step 3)
983    // resolves "recipes" to recipes/recipes.md (unique stem match).
984    // This is correct: the self-named note IS the folder's page in Obsidian links.
985    #[test]
986    fn test_resolve_self_named_via_filename_stem() {
987        let mut b = ContentGraphBuilder::new();
988        b.add_file("recipes/index.md", "/recipes");
989        b.add_file("recipes/recipes.md", "/recipes/recipes");
990        let g = b.build();
991
992        // "recipes" matches filename stem "recipes" → recipes/recipes.md (step 3)
993        assert_eq!(
994            g.resolve_path("recipes", "other.md"),
995            Some("recipes/recipes.md".into())
996        );
997    }
998
999    // When only index.md exists (no self-named), folder note fallback (step 5) works
1000    #[test]
1001    fn test_resolve_folder_note_fallback_to_index() {
1002        let mut b = ContentGraphBuilder::new();
1003        b.add_file("recipes/index.md", "/recipes");
1004        b.add_file("recipes/pasta.md", "/recipes/pasta");
1005        let g = b.build();
1006
1007        assert_eq!(
1008            g.resolve_path("recipes", "other.md"),
1009            Some("recipes/index.md".into())
1010        );
1011    }
1012
1013    // Suffix match: partial path resolves when a deeper file ends with the reference
1014    #[test]
1015    fn test_suffix_match_partial_path() {
1016        let mut b = ContentGraphBuilder::new();
1017        b.add_file("文字/游记/index.md", "/文字/游记");
1018        b.add_file("index.md", "/");
1019        let g = b.build();
1020
1021        // "游记/index.md" doesn't exist at root, but "文字/游记/index.md" ends with it
1022        assert_eq!(
1023            g.resolve_path("游记/index.md", "index.md"),
1024            Some("文字/游记/index.md".into())
1025        );
1026    }
1027
1028    // Suffix match with ambiguity uses from_path tiebreaker
1029    #[test]
1030    fn test_suffix_match_ambiguous_uses_tiebreaker() {
1031        let mut b = ContentGraphBuilder::new();
1032        b.add_file("a/游记/index.md", "/a/游记");
1033        b.add_file("b/游记/index.md", "/b/游记");
1034        let g = b.build();
1035
1036        // From "a/other.md", should prefer "a/游记/index.md"
1037        assert_eq!(
1038            g.resolve_path("游记/index.md", "a/other.md"),
1039            Some("a/游记/index.md".into())
1040        );
1041        // From "b/other.md", should prefer "b/游记/index.md"
1042        assert_eq!(
1043            g.resolve_path("游记/index.md", "b/other.md"),
1044            Some("b/游记/index.md".into())
1045        );
1046    }
1047
1048    // Vault-root prefix: "刘果/交互实验/index.md" should resolve to "交互实验/index.md"
1049    // by stripping the leading component that doesn't match any graph path.
1050    // This matches Obsidian's behavior where vault name can prefix markdown links.
1051    #[test]
1052    fn test_vault_root_prefix_resolves_correctly() {
1053        let mut b = ContentGraphBuilder::new();
1054        b.add_file("交互实验/index.md", "/交互实验");
1055        b.add_file("文字/分布式信息网络/index.md", "/文字/分布式信息网络");
1056        let g = b.build();
1057
1058        // Should resolve to 交互实验/index.md, NOT 文字/分布式信息网络/index.md
1059        assert_eq!(
1060            g.resolve_path("刘果/交互实验/index.md", ""),
1061            Some("交互实验/index.md".into())
1062        );
1063    }
1064
1065    // Progressive sub-path stripping with non-index files
1066    #[test]
1067    fn test_vault_root_prefix_non_index() {
1068        let mut b = ContentGraphBuilder::new();
1069        b.add_file("posts/hello.md", "/posts/hello");
1070        b.add_file("guides/hello.md", "/guides/hello");
1071        let g = b.build();
1072
1073        // "mysite/posts/hello.md" should resolve to "posts/hello.md"
1074        assert_eq!(
1075            g.resolve_path("mysite/posts/hello.md", ""),
1076            Some("posts/hello.md".into())
1077        );
1078    }
1079
1080    // Progressive sub-path: deeper nesting still works
1081    #[test]
1082    fn test_vault_root_prefix_deep_nesting() {
1083        let mut b = ContentGraphBuilder::new();
1084        b.add_file("文字/游记/index.md", "/文字/游记");
1085        let g = b.build();
1086
1087        // "vault/文字/游记/index.md" should find "文字/游记/index.md"
1088        assert_eq!(
1089            g.resolve_path("vault/文字/游记/index.md", ""),
1090            Some("文字/游记/index.md".into())
1091        );
1092    }
1093
1094    // resolve_path preserves original casing of stored file paths
1095    #[test]
1096    fn test_resolve_path_preserves_original_case() {
1097        let mut b = ContentGraphBuilder::new();
1098        b.add_file("音乐/Winter-Song.mov", "音乐/winter-song");
1099        let g = b.build();
1100
1101        // Lookup with different casing should return original path
1102        assert_eq!(
1103            g.resolve_path("winter-song.mov", ""),
1104            Some("音乐/Winter-Song.mov".into())
1105        );
1106        assert_eq!(
1107            g.resolve_path("Winter-Song.mov", ""),
1108            Some("音乐/Winter-Song.mov".into())
1109        );
1110    }
1111
1112    // all_files preserves original casing
1113    #[test]
1114    fn test_all_files_preserves_original_case() {
1115        let mut b = ContentGraphBuilder::new();
1116        b.add_file("Notes/MyFile.md", "/notes/myfile");
1117        b.add_file("Posts/Hello-World.md", "/posts/hello-world");
1118        let g = b.build();
1119
1120        assert_eq!(
1121            g.all_files(),
1122            &["Notes/MyFile.md", "Posts/Hello-World.md"]
1123        );
1124    }
1125
1126    // ---------------------------------------------------------------------
1127    // Stem-collision: extension-aware tiebreaker
1128    //
1129    // When `![[scale-compare.png]]` and `![[scale-compare.html]]` are siblings,
1130    // the wikilink author's extension carries intent: `.png` should resolve to
1131    // the image, `.html` to the HTML file. Without an extension preference the
1132    // tiebreaker reduces to candidate registration order, which is brittle.
1133    // ---------------------------------------------------------------------
1134
1135    #[test]
1136    fn stem_collision_prefers_matching_extension_png() {
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        assert_eq!(
1143            g.resolve_path("scale-compare.png", "interactive/article.md"),
1144            Some("interactive/scale-compare.png".into())
1145        );
1146    }
1147
1148    #[test]
1149    fn stem_collision_prefers_matching_extension_html() {
1150        let mut b = ContentGraphBuilder::new();
1151        b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1152        b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1153        let g = b.build();
1154
1155        assert_eq!(
1156            g.resolve_path("scale-compare.html", "interactive/article.md"),
1157            Some("interactive/scale-compare.html".into())
1158        );
1159    }
1160
1161    #[test]
1162    fn stem_collision_independent_of_registration_order() {
1163        // Same as above, with reverse insertion order. Result must not change.
1164        let mut b = ContentGraphBuilder::new();
1165        b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1166        b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1167        let g = b.build();
1168
1169        assert_eq!(
1170            g.resolve_path("scale-compare.png", "interactive/article.md"),
1171            Some("interactive/scale-compare.png".into())
1172        );
1173        assert_eq!(
1174            g.resolve_path("scale-compare.html", "interactive/article.md"),
1175            Some("interactive/scale-compare.html".into())
1176        );
1177    }
1178
1179    #[test]
1180    fn stem_collision_bare_ref_unchanged() {
1181        // A reference without an extension MUST keep existing behavior:
1182        // tiebreaker falls back to (lang_tree, common_prefix). The only
1183        // observable change is that ext-aware refs are now deterministic.
1184        let mut b = ContentGraphBuilder::new();
1185        b.add_file("interactive/scale-compare.png", "/interactive/scale-compare.png");
1186        b.add_file("interactive/scale-compare.html", "/interactive/scale-compare.html");
1187        let g = b.build();
1188
1189        // No extension on ref: returns *some* candidate (current behavior),
1190        // we just assert the call succeeds rather than pinning the choice.
1191        assert!(g.resolve_path("scale-compare", "interactive/article.md").is_some());
1192    }
1193
1194    #[test]
1195    fn stem_collision_md_wins_over_html_sibling() {
1196        // The most common real case: a wikilink to `.md` (or no-extension
1197        // markdown ref) should not get hijacked by a `.html` sibling that
1198        // happens to be registered later.
1199        let mut b = ContentGraphBuilder::new();
1200        b.add_file("notes/guide.md", "/notes/guide");
1201        b.add_file("notes/guide.html", "/notes/guide.html");
1202        let g = b.build();
1203
1204        assert_eq!(
1205            g.resolve_path("guide.md", "notes/index.md"),
1206            Some("notes/guide.md".into())
1207        );
1208    }
1209
1210    #[test]
1211    fn stem_collision_suffix_match_arm() {
1212        // The suffix-match tiebreaker (ContentGraph::resolve_path step 2b)
1213        // also benefits from extension preference. Reference is multi-component
1214        // (`a/scale.png`) so it goes through the suffix-match arm, not the
1215        // bare-stem arm.
1216        let mut b = ContentGraphBuilder::new();
1217        b.add_file("vault/a/scale.png", "/vault/a/scale.png");
1218        b.add_file("vault/a/scale.html", "/vault/a/scale.html");
1219        let g = b.build();
1220
1221        assert_eq!(
1222            g.resolve_path("a/scale.png", "vault/notes/article.md"),
1223            Some("vault/a/scale.png".into())
1224        );
1225    }
1226
1227    #[test]
1228    fn stem_collision_ext_match_overrides_lang_tree() {
1229        // Pin priority: extension match wins even when a lang-tree candidate
1230        // exists. Without this, a `![[foo.png]]` in zh-hans/note.md against
1231        // siblings (zh-hans/foo.html + en/foo.png) would surprise users by
1232        // returning the .html file just because it shares a language tree.
1233        let mut b = ContentGraphBuilder::new();
1234        b.add_file("zh-hans/foo.html", "/zh-hans/foo.html");
1235        b.add_file("en/foo.png", "/en/foo.png");
1236        let g = b.build();
1237
1238        assert_eq!(
1239            g.resolve_path("foo.png", "zh-hans/note.md"),
1240            Some("en/foo.png".into())
1241        );
1242    }
1243
1244    #[test]
1245    fn stem_collision_alphabetical_final_tiebreaker() {
1246        // Bare ref + sibling stems: no extension intent, both same lang-tree,
1247        // equal common-prefix. The final alphabetical tiebreaker must make
1248        // the result independent of registration order.
1249        let mut b1 = ContentGraphBuilder::new();
1250        b1.add_file("notes/photo.png", "/notes/photo.png");
1251        b1.add_file("notes/photo.html", "/notes/photo.html");
1252        let g1 = b1.build();
1253
1254        let mut b2 = ContentGraphBuilder::new();
1255        b2.add_file("notes/photo.html", "/notes/photo.html");
1256        b2.add_file("notes/photo.png", "/notes/photo.png");
1257        let g2 = b2.build();
1258
1259        // "notes/photo.html" < "notes/photo.png" alphabetically → .html wins
1260        // in both insertion orders.
1261        let r1 = g1.resolve_path("photo", "notes/index.md");
1262        let r2 = g2.resolve_path("photo", "notes/index.md");
1263        assert_eq!(r1, r2, "result must not depend on registration order");
1264        assert_eq!(r1, Some("notes/photo.html".into()));
1265    }
1266
1267    #[test]
1268    fn stem_collision_case_insensitive_extension() {
1269        // Author may write `.PNG`; should still match `.png` candidate.
1270        let mut b = ContentGraphBuilder::new();
1271        b.add_file("interactive/photo.PNG", "/interactive/photo.png");
1272        b.add_file("interactive/photo.html", "/interactive/photo.html");
1273        let g = b.build();
1274
1275        assert_eq!(
1276            g.resolve_path("photo.png", "interactive/article.md"),
1277            Some("interactive/photo.PNG".into())
1278        );
1279    }
1280
1281    #[test]
1282    fn exact_case_asset_index() {
1283        let g = ContentGraph::from_paths(&["assets/Hoon.JPG", "News/post.md"]);
1284        assert!(g.asset_contains("assets/Hoon.JPG"));
1285        assert!(!g.asset_contains("assets/hoon.jpg")); // exact case
1286        assert_eq!(
1287            g.asset_contains_ci("assets/hoon.jpg").as_deref(),
1288            Some("assets/Hoon.JPG")
1289        );
1290        assert_eq!(
1291            g.asset_find_by_suffix("Hoon.JPG"),
1292            vec!["assets/Hoon.JPG".to_string()]
1293        );
1294    }
1295}