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