prov_graph/graph/resolve.rs
1//! Link resolution — turning one declared target (a path, an `id:`
2//! reference, or a nominal `[[alias]]`) into a [`Target`] against a
3//! workspace. See the module doc at [`crate::graph`] for how this sits beside
4//! the census and the read primitive in [`load`](super::load).
5
6use std::path::{Path, PathBuf};
7
8use super::Graph;
9use crate::identity;
10use crate::index::IdIndex;
11use crate::link::{self, IdRef, Link};
12use crate::title::{self, TitleIndex, TitleMatch};
13
14/// The resolution of one link target against a workspace: a path, an ID the
15/// registry does not currently resolve, or an off-workspace reference.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Target {
18 /// A (normalized, workspace-relative) path.
19 Path(PathBuf),
20 /// An `id:<id>` reference with no live registry entry — unknown,
21 /// tombstoned, or the workspace has no registry at all.
22 UnresolvedId(identity::Id),
23 /// A nominal (alias) reference whose name several documents claim, so it
24 /// cannot be resolved to one. The `String` is the name as written.
25 AmbiguousAlias(String),
26 /// A URL or mail address — never resolved against the workspace and never
27 /// rewritten by moves.
28 External,
29 /// A target that is *only* a locator (`#3`) — a place inside the document
30 /// the link is written in, naming no other document.
31 ///
32 /// Deliberately not [`Target::Path`] of the citing document. It is true that
33 /// the reference lands there, but saying so would make every consumer that
34 /// keys on a resolved path act as if a *link* to that document existed: the
35 /// document would become its own backlink, its own reachability edge, and —
36 /// worst — a rename would rewrite `#3` into a path to the moved file, which
37 /// is exactly the byte-literal guarantee `docs/reference-styles.md` gives.
38 /// A locator is carried, never resolved; this is that answer, in the shape
39 /// resolution speaks.
40 SameDocument,
41 /// An `id:<workspace>/<id>` reference naming a document in *another*
42 /// workspace — carried, never rewritten, and never reported broken.
43 ///
44 /// prov stops here on purpose. Resolving this would require a map from a
45 /// workspace name to a location, and that map is a property of the device
46 /// doing the reading, not of the archive being read: the same reference
47 /// resolves to a directory on one machine, a URL on another, and nothing at
48 /// all on a third. So the library reports *what was named* and leaves
49 /// *where it lives* to the host — `prov-cli` keeps a device-local peer map,
50 /// diaryx resolves through its published ARK permalinks.
51 ///
52 /// The shape that answer comes back in, and the check that makes it
53 /// trustworthy, are [`crate::peer`]. Following one is a step a caller takes
54 /// *after* this, never a deeper mode of it.
55 ///
56 /// A reference qualified with this workspace's own
57 /// [`workspace_id`](Graph::workspace_id) is **not** foreign: it is
58 /// resolved locally through the registry, so a document carrying one keeps
59 /// working when it is copied into the workspace it names.
60 Foreign {
61 /// The workspace qualifier, exactly as written.
62 workspace: String,
63 /// The id within that workspace, exactly as written — never
64 /// check-verified here (that workspace owns its id space, and may not
65 /// be a prov workspace at all).
66 id: identity::Id,
67 },
68}
69
70impl<FS, Ix: IdIndex> Graph<FS, Ix> {
71 /// Resolve `link` (declared in the document at `doc`) to a workspace target,
72 /// without nominal (alias) resolution — path and `id:` targets only. Use
73 /// [`resolve_link_with`](Self::resolve_link_with) when a [`TitleIndex`] is
74 /// available and `[[My File]]`-style aliases should resolve.
75 pub fn resolve_link(&self, doc: &Path, link: &Link) -> Target {
76 self.resolve_link_with(doc, link, None)
77 }
78
79 /// Resolve `link` to a workspace target. Path targets resolve relative to
80 /// `doc`'s directory; an `id:<id>` target resolves through the registry (the
81 /// location-independent path that stays valid across moves); an
82 /// alias-shaped target (a bare name) resolves through `titles` when one is
83 /// supplied — `Unique` to its path, `Ambiguous` to
84 /// [`Target::AmbiguousAlias`], and `Unknown` falling through to a path (so a
85 /// nominal link to nothing surfaces as a missing/broken path, exactly as
86 /// before aliases existed). With `titles` `None`, alias resolution is off
87 /// and this is the pure path/id resolver.
88 pub fn resolve_link_with(
89 &self,
90 doc: &Path,
91 link: &Link,
92 titles: Option<&TitleIndex>,
93 ) -> Target {
94 if link.is_external() {
95 return Target::External;
96 }
97 // Before anything path-shaped is considered: `#3` addresses this
98 // document, so there is no filename to look for. Falling through would
99 // resolve it against `doc`'s directory and hand back `dir/#3`, a file
100 // nothing will ever put there.
101 if link.is_same_document() {
102 return Target::SameDocument;
103 }
104 // A reference qualified with this workspace's own name *is* local — the
105 // registry that issued the id is the one in hand. That equivalence is
106 // what makes a qualified reference survive being copied into the
107 // workspace it names, instead of going inert at the boundary.
108 let id = match link.id_ref() {
109 Some(IdRef::Local(id)) => Some(id),
110 Some(IdRef::Foreign { workspace, id }) => {
111 if !self.workspace_id().is_empty() && workspace == self.workspace_id() {
112 Some(id)
113 } else {
114 return Target::Foreign { workspace, id };
115 }
116 }
117 // Malformed: the author wrote `id:`, so this is a broken id
118 // reference, not a filename that happens to contain a colon.
119 Some(IdRef::Malformed) => {
120 return Target::UnresolvedId(identity::Id(link.target.clone()));
121 }
122 None => None,
123 };
124 if let Some(id) = id {
125 return match self.index().resolve(&id) {
126 Some(path) => Target::Path(link::normalize(path)),
127 None => Target::UnresolvedId(id),
128 };
129 }
130 // The *addressed* target, not the whole one: a locator names a place
131 // inside the document an alias names, so `[[My File#v2]]` is the same
132 // nominal reference as `[[My File]]`. Asking the index for the spelling
133 // with the locator still on it would miss, fall through to the path
134 // branch, and quietly turn a nominal reference into a relative path.
135 // (The path branch below needs no such care — `link::resolve` splits the
136 // locator off itself.)
137 let addressed = link.addressed_target();
138 if let Some(titles) = titles
139 && title::is_alias_shaped(addressed)
140 {
141 match titles.resolve(addressed) {
142 TitleMatch::Unique(path) => return Target::Path(link::normalize(path)),
143 TitleMatch::Ambiguous(_) => return Target::AmbiguousAlias(addressed.to_string()),
144 // Unknown: fall through — a bare name with nothing behind it is
145 // treated as a path, so it reads as missing like any dead link.
146 TitleMatch::Unknown => {}
147 }
148 }
149 Target::Path(link::resolve(doc, &link.target))
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use std::path::{Path, PathBuf};
156
157 use super::*;
158 use crate::graph::ReadSettings;
159 use crate::index::IdIndex;
160
161 #[derive(Clone)]
162 struct DummyFs;
163
164 /// A registry holding exactly one registration. The concrete stores live in
165 /// `prov-store`, on the write side of the port — what resolution needs from
166 /// an index is only the two lookups below, so the fixture supplies only
167 /// those rather than reaching across the split for a store it would then
168 /// have to mutate to populate.
169 struct OneEntry(identity::Id, PathBuf);
170
171 impl IdIndex for OneEntry {
172 fn resolve(&self, id: &identity::Id) -> Option<PathBuf> {
173 (*id == self.0).then(|| self.1.clone())
174 }
175
176 fn id_for_path(&self, path: &Path) -> Option<identity::Id> {
177 (path == self.1).then(|| self.0.clone())
178 }
179 }
180
181 /// A graph named `notes` whose registry resolves `ajp7eq`.
182 fn named_ws(name: &str) -> Graph<DummyFs, OneEntry> {
183 Graph::new(
184 DummyFs,
185 "vault",
186 OneEntry(identity::Id("ajp7eq".into()), PathBuf::from("note.md")),
187 ReadSettings {
188 workspace_id: name.to_string(),
189 ..ReadSettings::default()
190 },
191 )
192 }
193
194 #[test]
195 fn a_reference_to_another_workspace_resolves_to_foreign() {
196 let ws = named_ws("notes");
197 let link = Link::parse("id:diaryx/xk4m2p");
198 assert_eq!(
199 ws.resolve_link(Path::new("a.md"), &link),
200 Target::Foreign {
201 workspace: "diaryx".into(),
202 id: identity::Id("xk4m2p".into()),
203 }
204 );
205 }
206
207 #[test]
208 fn a_reference_qualified_with_our_own_name_is_local() {
209 // The invariant with teeth: a document written elsewhere as
210 // `id:notes/ajp7eq` keeps working once it is copied *into* `notes`,
211 // instead of going inert at the boundary.
212 let ws = named_ws("notes");
213 assert_eq!(
214 ws.resolve_link(Path::new("a.md"), &Link::parse("id:notes/ajp7eq")),
215 Target::Path(PathBuf::from("note.md"))
216 );
217 // And it agrees with the unqualified spelling of the same reference.
218 assert_eq!(
219 ws.resolve_link(Path::new("a.md"), &Link::parse("id:ajp7eq")),
220 ws.resolve_link(Path::new("a.md"), &Link::parse("id:notes/ajp7eq"))
221 );
222 }
223
224 #[test]
225 fn an_anonymous_workspace_treats_every_qualifier_as_foreign() {
226 // With no name of its own, a workspace has nothing to compare against —
227 // so it must not guess that `id:notes/…` means itself.
228 let ws = named_ws("");
229 assert_eq!(
230 ws.resolve_link(Path::new("a.md"), &Link::parse("id:notes/ajp7eq")),
231 Target::Foreign {
232 workspace: "notes".into(),
233 id: identity::Id("ajp7eq".into()),
234 }
235 );
236 }
237
238 #[test]
239 fn a_locator_names_a_place_in_the_document_every_style_already_resolved_to() {
240 // §4's contract, checked across all three target styles at once: the
241 // locator changes *where in* a document a reader lands, never *which*
242 // document resolution finds. A style that lost the equivalence would
243 // send a `#v2` reference somewhere its unsuffixed twin never goes.
244 let ws = named_ws("notes");
245 let mut titles = TitleIndex::new();
246 titles.insert("Mosiah 1", "mosiah/mosiah-1.md");
247
248 for (plain, located) in [
249 ("/mosiah/mosiah-1.md", "/mosiah/mosiah-1.md#v2"),
250 ("./sibling.md", "./sibling.md#v2"),
251 ("id:ajp7eq", "id:ajp7eq#v2"),
252 ("Mosiah 1", "Mosiah 1#v2"),
253 ] {
254 let doc = Path::new("1-nephi/1-nephi-1.md");
255 assert_eq!(
256 ws.resolve_link_with(doc, &Link::parse(located), Some(&titles)),
257 ws.resolve_link_with(doc, &Link::parse(plain), Some(&titles)),
258 "`{located}` should land on the same document as `{plain}`"
259 );
260 }
261 // And the alias one really did go through the title index rather than
262 // falling through to a relative path beside the citing document.
263 assert_eq!(
264 ws.resolve_link_with(
265 Path::new("1-nephi/1-nephi-1.md"),
266 &Link::parse("[[Mosiah 1#v2]]"),
267 Some(&titles)
268 ),
269 Target::Path(PathBuf::from("mosiah/mosiah-1.md"))
270 );
271 }
272
273 #[test]
274 fn a_same_document_reference_resolves_to_no_path_at_all() {
275 // Not `Path("1-nephi/#v2")` — nothing will ever put a file there, so
276 // every consumer downstream would call the link broken. And not
277 // `Path(doc)` either: that would make the document its own backlink and
278 // let a rename rewrite `#v2` into a path.
279 let ws = named_ws("notes");
280 let doc = Path::new("1-nephi/1-nephi-1.md");
281 for target in ["#v2", "[[#v2]]", "[Verse 2](#v2)"] {
282 assert_eq!(
283 ws.resolve_link(doc, &Link::parse(target)),
284 Target::SameDocument,
285 "{target}"
286 );
287 }
288 // Even with a title index in hand: `#v2` is not a name to look up.
289 let mut titles = TitleIndex::new();
290 titles.insert("Mosiah 1", "mosiah/mosiah-1.md");
291 assert_eq!(
292 ws.resolve_link_with(doc, &Link::parse("#v2"), Some(&titles)),
293 Target::SameDocument
294 );
295 }
296
297 #[test]
298 fn a_malformed_id_reference_is_not_reread_as_a_path() {
299 // `id:a/b/c` is a broken id reference, not a filename. Resolving it as a
300 // path would turn a typo into a plausible-looking dead path link.
301 let ws = named_ws("notes");
302 assert!(matches!(
303 ws.resolve_link(Path::new("a.md"), &Link::parse("id:a/b/c")),
304 Target::UnresolvedId(_)
305 ));
306 }
307}