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