Skip to main content

prov_graph/graph/
tree.rs

1//! Traversal — materialize the spanning containment tree from a root document.
2//!
3//! This is the discovery walk the whole crate exists for: start at a document,
4//! follow the spanning relation's links declared *in* each document, and the
5//! workspace structure unfolds. The walk is resilient by design — a missing or
6//! unparseable target becomes a marked node, not an error — because a
7//! traversal that dies on the first broken link cannot power `tree`, `check`,
8//! or any editor view of an imperfect (i.e. real) workspace.
9//!
10//! **Why this is a second walker, not a view over [`census`](super::census).**
11//! The census is a flat BFS over a global `visited` set: once a path is
12//! reached it is never redescended, and a spanning edge back into it is a
13//! *finding* (a second parent breaking the single-parent tree). This walk is
14//! a DFS over a per-branch `trail`: revisiting a node from another branch is
15//! fine (each branch materializes its own subtree — that is what makes `tree`
16//! a tree rather than a DAG rendered flat), and only a back-edge to an
17//! *ancestor on the current path* is a cycle. Forcing one skeleton to serve
18//! both would mean threading two different revisit policies through a single
19//! traversal, which is more machinery than two short, separately-readable
20//! walks. They stay side by side in `graph` because they walk the same edges
21//! from the same [`Graph`], not because they
22//! share a shape.
23
24use std::future::Future;
25use std::path::{Path, PathBuf};
26use std::pin::Pin;
27
28use super::Graph;
29use crate::error::Result;
30use crate::fs::ReadStorage;
31use crate::index::IdIndex;
32use crate::link::{self, Link};
33
34use super::Target;
35
36/// Why a node appears in the tree the way it does.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum NodeKind {
39    /// A document that was read and parsed.
40    Doc,
41    /// A spanning target that does not exist on disk.
42    Missing,
43    /// A target already on the path from the root — a containment cycle. Not
44    /// descended into.
45    Cycle,
46    /// A file that exists but could not be read or parsed; the message says why.
47    Unreadable(String),
48    /// An `id:<id>` target the registry does not currently resolve
49    /// (unknown, tombstoned, or no registry attached).
50    UnresolvedId(crate::identity::Id),
51    /// A nominal (alias) target whose name several documents claim — a
52    /// containment link that cannot be resolved to one child.
53    AmbiguousAlias(String),
54    /// An `id:<workspace>/<id>` target naming a document in another workspace.
55    ///
56    /// A leaf, always: the tree is *this* workspace's spanning walk, and prov
57    /// has no map from a workspace name to a location to follow (see
58    /// [`Target::Foreign`]). Shown rather
59    /// than dropped, because the link is really declared and a reader deserves
60    /// to see the structure leave the building.
61    Foreign {
62        workspace: String,
63        id: crate::identity::Id,
64    },
65}
66
67/// Options controlling how [`Graph::tree_with`] materializes a spanning
68/// target that does not resolve on disk.
69///
70/// The default (`tree()`'s behavior) materializes a [`NodeKind::Missing`]
71/// node for every such target, so a caller can report *which* link is broken.
72/// Some callers instead want the tree to look exactly as if the dead link were
73/// never declared — an editor's outline view, say, which has nothing useful to
74/// render for a node with no title, no children, and no file. `ignore_missing`
75/// is the additive escape hatch for that: it only ever *removes* nodes the
76/// default would have included, so a workspace with no broken links traverses
77/// identically either way.
78#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79pub struct TreeOptions {
80    /// When `true`, a spanning target that does not exist on disk is omitted
81    /// from its parent's `children` entirely, rather than becoming a
82    /// [`NodeKind::Missing`] node. Default: `false`.
83    pub ignore_missing: bool,
84}
85
86/// One node of the materialized spanning tree.
87#[derive(Debug, Clone)]
88pub struct Node {
89    /// Workspace-relative, normalized path — relative to [`Graph::root`],
90    /// *not* fs-readable as-is. Join it onto the root with
91    /// [`Graph::fs_path`] before handing it to a [`crate::fs::ReadStorage`]
92    /// read; the raw form here is what makes a [`Node`] stable across a
93    /// workspace re-rooted to a different directory.
94    pub path: PathBuf,
95    /// The document's `title` field, when present.
96    pub title: Option<String>,
97    /// The label the *parent's* link carried (`[label](path)`), when any.
98    pub label: Option<String>,
99    /// How this node was resolved.
100    pub kind: NodeKind,
101    /// Spanning children, in declaration order.
102    pub children: Vec<Node>,
103}
104
105/// Whether a failed [`load`](Workspace::load) means "the target is not there"
106/// — the [`NodeKind::Missing`] case — rather than "the target is there and
107/// something about it went wrong". Both spellings of absent count: a storage
108/// backend's `io::ErrorKind::NotFound`, and prov's own typed
109/// [`Error::NotFound`](crate::error::Error::NotFound), which a backend that
110/// reports absence structurally raises instead.
111fn is_missing(error: &crate::error::Error) -> bool {
112    match error {
113        crate::error::Error::NotFound(_) => true,
114        crate::error::Error::Io(e) => e.kind() == std::io::ErrorKind::NotFound,
115        _ => false,
116    }
117}
118
119/// The context one [`tree`](Graph::tree) walk carries unchanged from its root
120/// to every leaf.
121struct Walk<'a> {
122    root: &'a Path,
123    options: TreeOptions,
124    parked: &'a [PathBuf],
125}
126
127impl<FS: ReadStorage, Ix: IdIndex> Graph<FS, Ix> {
128    /// Materialize the spanning tree rooted at `start` (a workspace-relative
129    /// path). Missing, unreadable, cyclic, unresolved-ID, and ambiguous-alias
130    /// targets become marked nodes. `id:<id>` targets resolve through the
131    /// registry; nominal (`[[My File]]`) targets resolve through the title
132    /// index, built once for the whole walk so spanning alias links (a
133    /// `contents: alias` vocabulary) descend like any other.
134    pub async fn tree(&self, start: impl AsRef<Path>) -> Result<Node> {
135        self.tree_with(start, TreeOptions::default()).await
136    }
137
138    /// Materialize the spanning tree rooted at `start`, as [`tree`](Self::tree),
139    /// with [`TreeOptions`] controlling how an unresolved spanning target is
140    /// represented. `TreeOptions::default()` is exactly `tree()`'s behavior.
141    pub async fn tree_with(&self, start: impl AsRef<Path>, options: TreeOptions) -> Result<Node> {
142        self.tree_within(start, options, &[]).await
143    }
144
145    /// [`tree_with`](Self::tree_with), told which directories are parked — see
146    /// [`title_index_scoped`](Self::title_index_scoped).
147    pub async fn tree_within(
148        &self,
149        start: impl AsRef<Path>,
150        options: TreeOptions,
151        parked: &[PathBuf],
152    ) -> Result<Node> {
153        // Two passes over the same documents whenever the workspace uses
154        // `[[alias]]` links: the descent reads each node, and the title index it
155        // builds on meeting the first alias reads every document in the reached
156        // directories — most of them the same ones. Scoped here for the same
157        // reason [`walk`](Self::walk) is: a caller should not have to know that
158        // materializing a tree is more than one read of each document.
159        let _scope = self.read_scope();
160        let start = link::normalize(start);
161        // The title index is built lazily — only if a nominal (`[[alias]]`) link
162        // is actually encountered. A path/id workspace never needs it, so it never
163        // pays for a full-workspace scan (which, at the root of a larger repo,
164        // would read every file under `target/`, vendored trees, and the rest).
165        let mut titles: Option<crate::title::TitleIndex> = None;
166        let mut trail: Vec<PathBuf> = Vec::new();
167        let root = start.clone();
168        let cx = Walk {
169            root: &root,
170            options,
171            parked,
172        };
173        self.tree_node(start, None, &cx, &mut titles, &mut trail)
174            .await
175    }
176
177    /// What stays the same for every node of one walk: the root the title index
178    /// is scoped to, the option controlling how an unresolved spanning target is
179    /// rendered, and the directories whose interiors must not be indexed. Bundled
180    /// rather than passed one by one because the recursion threads all three
181    /// unchanged through every level.
182    fn tree_node<'a>(
183        &'a self,
184        path: PathBuf,
185        label: Option<String>,
186        cx: &'a Walk<'a>,
187        titles: &'a mut Option<crate::title::TitleIndex>,
188        trail: &'a mut Vec<PathBuf>,
189    ) -> Pin<Box<dyn Future<Output = Result<Node>> + 'a>> {
190        Box::pin(async move {
191            if trail.contains(&path) {
192                return Ok(Node {
193                    path,
194                    title: None,
195                    label,
196                    kind: NodeKind::Cycle,
197                    children: Vec::new(),
198                });
199            }
200            // One read, not a stat and then a read: the open `load` performs
201            // already answers "does this exist", and its `NotFound` is exactly
202            // the `Missing` node a separate `try_exists` was asking for. The
203            // stat was pure overhead on every node of every walk — and on the
204            // memoized path it was the *only* syscall left, so a second pass
205            // inside a `read_scope` paid it for nothing. Checking existence
206            // first also meant stat-ing an escaping target (`../../etc/passwd`)
207            // before `load`'s root clamp got to refuse it; now the clamp is
208            // first.
209            let doc = match self.load(&path).await {
210                Ok((_, doc)) => doc,
211                Err(e) if is_missing(&e) => {
212                    return Ok(Node {
213                        path,
214                        title: None,
215                        label,
216                        kind: NodeKind::Missing,
217                        children: Vec::new(),
218                    });
219                }
220                Err(e) => {
221                    return Ok(Node {
222                        path,
223                        title: None,
224                        label,
225                        kind: NodeKind::Unreadable(e.to_string()),
226                        children: Vec::new(),
227                    });
228                }
229            };
230            let meta = fig::Value::from(&doc.meta);
231            let title = meta
232                .get("title")
233                .and_then(fig::Value::as_str)
234                .map(str::to_owned);
235
236            trail.push(path.clone());
237            let mut children = Vec::new();
238            for raw in self.relations().children(&meta) {
239                let child = Link::parse(&raw);
240                // Build the title index on first sight of a nominal link, never
241                // before — this is the only place the tree walk can need it.
242                if titles.is_none() && crate::title::is_alias_shaped(&child.target) {
243                    *titles = Some(self.title_index_scoped(cx.root, cx.parked).await?);
244                }
245                let child_path = match self.resolve_link_with(&path, &child, titles.as_ref()) {
246                    // Neither names a document in this workspace, so neither can
247                    // be a child: a URL leaves the building, and a bare `#3`
248                    // never left this one.
249                    Target::External | Target::SameDocument => continue,
250                    Target::UnresolvedId(id) => {
251                        children.push(Node {
252                            path: PathBuf::from(child.target.clone()),
253                            title: None,
254                            label: child.label,
255                            kind: NodeKind::UnresolvedId(id),
256                            children: Vec::new(),
257                        });
258                        continue;
259                    }
260                    Target::AmbiguousAlias(name) => {
261                        children.push(Node {
262                            path: PathBuf::from(name.clone()),
263                            title: None,
264                            label: child.label,
265                            kind: NodeKind::AmbiguousAlias(name),
266                            children: Vec::new(),
267                        });
268                        continue;
269                    }
270                    Target::Foreign { workspace, id } => {
271                        children.push(Node {
272                            path: PathBuf::from(child.target.clone()),
273                            title: None,
274                            label: child.label,
275                            kind: NodeKind::Foreign { workspace, id },
276                            children: Vec::new(),
277                        });
278                        continue;
279                    }
280                    Target::Path(p) => p,
281                };
282                let child_node = self
283                    .tree_node(child_path, child.label, cx, titles, trail)
284                    .await?;
285                // `ignore_missing` only ever removes what the default would have
286                // included: a `Missing` child is dropped here rather than pushed,
287                // so a caller who asked for it sees no trace of the dead link at
288                // all, matching diaryx's traversal. Every other kind (including a
289                // deeper `Missing` several levels down, which surfaced as `Doc`
290                // with that descendant already filtered) is unaffected.
291                if !(cx.options.ignore_missing && child_node.kind == NodeKind::Missing) {
292                    children.push(child_node);
293                }
294                // (titles carried by &mut, so a nominal link deeper in the tree
295                // reuses the index built above rather than rescanning.)
296            }
297            trail.pop();
298
299            Ok(Node {
300                path,
301                title,
302                label,
303                kind: NodeKind::Doc,
304                children,
305            })
306        })
307    }
308}
309
310// These tests use YAML frontmatter fixtures, so they run under the `yaml` feature.
311#[cfg(all(test, feature = "yaml"))]
312mod tests {
313    use super::*;
314    use crate::exec::block_on;
315    use crate::fs::StdFs;
316    use crate::graph::ReadSettings;
317    use crate::index::NoIndex;
318
319    use prov_testkit::write;
320    fn tempdir(tag: &str) -> PathBuf {
321        prov_testkit::scratch("tree", tag)
322    }
323
324    #[test]
325    fn walks_the_spanning_tree_with_labels_and_titles() {
326        let dir = tempdir("walk");
327        write(
328            &dir,
329            "index.md",
330            "---\ntitle: Root\ncontents:\n- '[A](notes/a.md)'\n- missing.md\n---\n",
331        );
332        write(
333            &dir,
334            "notes/a.md",
335            "---\ntitle: A\npart_of: ../index.md\n---\n",
336        );
337
338        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
339        let root = block_on(ws.tree("index.md")).unwrap();
340        assert_eq!(root.title.as_deref(), Some("Root"));
341        assert_eq!(root.children.len(), 2);
342        assert_eq!(root.children[0].path, PathBuf::from("notes/a.md"));
343        assert_eq!(root.children[0].label.as_deref(), Some("A"));
344        assert_eq!(root.children[0].kind, NodeKind::Doc);
345        assert_eq!(root.children[1].kind, NodeKind::Missing);
346    }
347
348    #[test]
349    fn spanning_alias_links_resolve_through_the_title_index() {
350        // A workspace whose containment links are nominal `[[Title]]` aliases:
351        // the walk must resolve them through the title index and descend, and
352        // flag a name several documents share as ambiguous.
353        let dir = tempdir("alias");
354        write(
355            &dir,
356            "index.md",
357            "---\ntitle: Root\ncontents:\n- '[[Alpha]]'\n- '[[Dup]]'\n- '[[Ghost]]'\n---\n",
358        );
359        write(&dir, "notes/alpha.md", "---\ntitle: Alpha\n---\n");
360        write(&dir, "one.md", "---\ntitle: Dup\n---\n");
361        write(&dir, "two.md", "---\ntitle: Dup\n---\n");
362
363        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
364        let root = block_on(ws.tree("index.md")).unwrap();
365        assert_eq!(root.children.len(), 3);
366
367        // `[[Alpha]]` → the unique document titled Alpha, descended into.
368        assert_eq!(root.children[0].kind, NodeKind::Doc);
369        assert_eq!(root.children[0].path, PathBuf::from("notes/alpha.md"));
370
371        // `[[Dup]]` → two documents claim the title, so it cannot resolve.
372        assert_eq!(
373            root.children[1].kind,
374            NodeKind::AmbiguousAlias("Dup".into())
375        );
376
377        // `[[Ghost]]` → no document claims it; falls through to a missing path.
378        assert_eq!(root.children[2].kind, NodeKind::Missing);
379    }
380
381    /// The walk asks for a document and reads the answer's *kind* — so the line
382    /// between "not there" (`Missing`) and "there and wrong" (`Unreadable`) now
383    /// lives in that one error match rather than in a preceding stat. Both
384    /// sides of it, pinned: a directory exists but is not a document, and a
385    /// target climbing out of the root is refused before it is opened at all.
386    #[test]
387    fn a_target_that_exists_but_cannot_be_read_is_unreadable_not_missing() {
388        let dir = tempdir("unreadable");
389        write(
390            &dir,
391            "index.md",
392            "---\ncontents:\n- sub\n- ../outside.md\n---\n",
393        );
394        std::fs::create_dir_all(dir.join("sub")).unwrap();
395
396        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
397        let root = block_on(ws.tree("index.md")).unwrap();
398        assert_eq!(root.children.len(), 2);
399        assert!(
400            matches!(root.children[0].kind, NodeKind::Unreadable(_)),
401            "a directory is not a missing document: {:?}",
402            root.children[0].kind
403        );
404        assert!(
405            matches!(root.children[1].kind, NodeKind::Unreadable(_)),
406            "an escaping target is refused, not reported absent: {:?}",
407            root.children[1].kind
408        );
409    }
410
411    #[test]
412    fn cycles_are_marked_not_followed() {
413        let dir = tempdir("cycle");
414        write(&dir, "a.md", "---\ncontents:\n- b.md\n---\n");
415        write(&dir, "b.md", "---\ncontents:\n- a.md\n---\n");
416
417        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
418        let root = block_on(ws.tree("a.md")).unwrap();
419        let b = &root.children[0];
420        assert_eq!(b.kind, NodeKind::Doc);
421        assert_eq!(b.children[0].kind, NodeKind::Cycle);
422        assert_eq!(b.children[0].path, PathBuf::from("a.md"));
423    }
424
425    #[test]
426    fn default_tree_materializes_a_missing_node_for_a_broken_contents_link() {
427        // `tree()` and `tree_with(TreeOptions::default())` must agree exactly —
428        // the same fixture as `ignore_missing_drops_the_broken_link_entirely`
429        // below, pinned against the default (unchanged) behavior.
430        let dir = tempdir("missing-default");
431        write(
432            &dir,
433            "index.md",
434            "---\ntitle: Root\ncontents:\n- '[A](notes/a.md)'\n- gone.md\n---\n",
435        );
436        write(&dir, "notes/a.md", "---\ntitle: A\n---\n");
437
438        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
439        let root = block_on(ws.tree("index.md")).unwrap();
440        assert_eq!(root.children.len(), 2);
441        assert_eq!(root.children[1].kind, NodeKind::Missing);
442
443        let root = block_on(ws.tree_with("index.md", TreeOptions::default())).unwrap();
444        assert_eq!(root.children.len(), 2);
445        assert_eq!(root.children[1].kind, NodeKind::Missing);
446    }
447
448    #[test]
449    fn ignore_missing_drops_the_broken_link_entirely() {
450        let dir = tempdir("missing-ignore");
451        write(
452            &dir,
453            "index.md",
454            "---\ntitle: Root\ncontents:\n- '[A](notes/a.md)'\n- gone.md\n---\n",
455        );
456        write(&dir, "notes/a.md", "---\ntitle: A\n---\n");
457
458        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
459        let options = TreeOptions {
460            ignore_missing: true,
461        };
462        let root = block_on(ws.tree_with("index.md", options)).unwrap();
463        // No trace of `gone.md` at all — not a `Missing` node, just absent.
464        assert_eq!(root.children.len(), 1);
465        assert_eq!(root.children[0].path, PathBuf::from("notes/a.md"));
466    }
467
468    #[test]
469    fn ignore_missing_only_filters_missing_not_other_marker_kinds() {
470        // A cycle is a different failure mode from a target that never existed;
471        // `ignore_missing` must leave it alone.
472        let dir = tempdir("missing-ignore-cycle");
473        write(&dir, "a.md", "---\ncontents:\n- b.md\n- gone.md\n---\n");
474        write(&dir, "b.md", "---\ncontents:\n- a.md\n---\n");
475
476        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
477        let options = TreeOptions {
478            ignore_missing: true,
479        };
480        let root = block_on(ws.tree_with("a.md", options)).unwrap();
481        assert_eq!(root.children.len(), 1);
482        let b = &root.children[0];
483        assert_eq!(b.kind, NodeKind::Doc);
484        assert_eq!(b.children.len(), 1);
485        assert_eq!(b.children[0].kind, NodeKind::Cycle);
486    }
487
488    #[test]
489    fn fs_path_joins_a_node_path_onto_the_workspace_root() {
490        let dir = tempdir("fs-path");
491        write(&dir, "notes/a.md", "---\ntitle: A\n---\n");
492
493        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
494        let node = block_on(ws.tree("notes/a.md")).unwrap();
495        assert_eq!(ws.fs_path(&node.path), dir.join("notes/a.md"));
496    }
497}