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