Skip to main content

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}