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
//! 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,
/// 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;
}
// 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_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(_)
));
}
}