prov_graph/title.rs
1//! Title index — the derived `name → document` map that resolves nominal
2//! ("alias") references like `[[My File]]`.
3//!
4//! This is a **derived cache** in DESIGN §5's sense: rebuildable from a scan of
5//! the workspace, never authoritative. A nominal link addresses a document by
6//! its `title` (or, failing that, its file stem) rather than by a path or a
7//! stable id — the readable-but-fallible option on the identity spectrum (see
8//! `docs/reference-styles.md`). Because titles are neither unique nor stable, a
9//! name can resolve to exactly one document, to several (ambiguous — a nominal
10//! link cannot choose), or to none.
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15/// A `name → document(s)` index built by scanning a workspace. Names are the
16/// documents' `title` fields and their file stems; a document is registered
17/// under both so `[[My File]]` (by title) and `[[my-file]]` (by stem) both find
18/// it.
19#[derive(Debug, Clone, Default)]
20pub struct TitleIndex {
21 by_name: HashMap<String, Vec<PathBuf>>,
22}
23
24/// The outcome of resolving a name against a [`TitleIndex`].
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum TitleMatch {
27 /// Exactly one document claims the name.
28 Unique(PathBuf),
29 /// Several documents claim it — a nominal link cannot disambiguate. The
30 /// paths are sorted for a stable, diffable report.
31 Ambiguous(Vec<PathBuf>),
32 /// No document claims it.
33 Unknown,
34}
35
36impl TitleIndex {
37 /// An empty index.
38 pub fn new() -> Self {
39 Self::default()
40 }
41
42 /// Index `path` under `name` (a title or a file stem). A blank name is
43 /// ignored; a duplicate `(name, path)` pair collapses, so registering a
44 /// document under both a title and an identical stem does not make it look
45 /// ambiguous.
46 pub fn insert(&mut self, name: impl Into<String>, path: impl Into<PathBuf>) {
47 let name = name.into();
48 if name.trim().is_empty() {
49 return;
50 }
51 let path = path.into();
52 let paths = self.by_name.entry(name).or_default();
53 if !paths.contains(&path) {
54 paths.push(path);
55 }
56 }
57
58 /// Resolve `name` to a document. `Unique` when exactly one claims it,
59 /// `Ambiguous` when several do, `Unknown` when none.
60 pub fn resolve(&self, name: &str) -> TitleMatch {
61 match self.by_name.get(name).map(Vec::as_slice) {
62 None | Some([]) => TitleMatch::Unknown,
63 Some([one]) => TitleMatch::Unique(one.clone()),
64 Some(many) => {
65 let mut paths = many.to_vec();
66 paths.sort();
67 TitleMatch::Ambiguous(paths)
68 }
69 }
70 }
71
72 /// Whether the index knows no names.
73 pub fn is_empty(&self) -> bool {
74 self.by_name.is_empty()
75 }
76
77 /// The number of distinct names known.
78 pub fn len(&self) -> usize {
79 self.by_name.len()
80 }
81}
82
83/// Whether `target` is shaped like a nominal reference — a single bare name,
84/// with no path separator and no file extension (`My File`, `intro`), as
85/// opposed to a path (`notes/a.md`, `README.md`) or a scheme'd id. Only such
86/// targets are looked up in the title index; everything else resolves as a path.
87pub fn is_alias_shaped(target: &str) -> bool {
88 !target.is_empty()
89 && !target.contains('/')
90 && !target.contains('\\')
91 && Path::new(target).extension().is_none()
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn resolves_unique_ambiguous_and_unknown() {
100 let mut ix = TitleIndex::new();
101 ix.insert("My File", "notes/a.md");
102 // Same document under its stem too — collapses, stays unique.
103 ix.insert("a", "notes/a.md");
104 ix.insert("Shared", "one.md");
105 ix.insert("Shared", "two.md");
106
107 assert_eq!(
108 ix.resolve("My File"),
109 TitleMatch::Unique(PathBuf::from("notes/a.md"))
110 );
111 assert_eq!(
112 ix.resolve("a"),
113 TitleMatch::Unique(PathBuf::from("notes/a.md"))
114 );
115 assert_eq!(
116 ix.resolve("Shared"),
117 TitleMatch::Ambiguous(vec![PathBuf::from("one.md"), PathBuf::from("two.md")])
118 );
119 assert_eq!(ix.resolve("nobody"), TitleMatch::Unknown);
120 }
121
122 #[test]
123 fn blank_names_are_ignored() {
124 let mut ix = TitleIndex::new();
125 ix.insert(" ", "a.md");
126 assert!(ix.is_empty());
127 }
128
129 #[test]
130 fn alias_shape_excludes_paths_and_extensions() {
131 assert!(is_alias_shaped("My File"));
132 assert!(is_alias_shaped("intro"));
133 assert!(!is_alias_shaped("notes/a.md"));
134 assert!(!is_alias_shaped("README.md"));
135 assert!(!is_alias_shaped(""));
136 }
137}