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