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    /// 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}