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