Skip to main content

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}