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 let start = link::normalize(start);
154 // The title index is built lazily — only if a nominal (`[[alias]]`) link
155 // is actually encountered. A path/id workspace never needs it, so it never
156 // pays for a full-workspace scan (which, at the root of a larger repo,
157 // would read every file under `target/`, vendored trees, and the rest).
158 let mut titles: Option<crate::title::TitleIndex> = None;
159 let mut trail: Vec<PathBuf> = Vec::new();
160 let root = start.clone();
161 let cx = Walk {
162 root: &root,
163 options,
164 parked,
165 };
166 self.tree_node(start, None, &cx, &mut titles, &mut trail)
167 .await
168 }
169
170 /// What stays the same for every node of one walk: the root the title index
171 /// is scoped to, the option controlling how an unresolved spanning target is
172 /// rendered, and the directories whose interiors must not be indexed. Bundled
173 /// rather than passed one by one because the recursion threads all three
174 /// unchanged through every level.
175 fn tree_node<'a>(
176 &'a self,
177 path: PathBuf,
178 label: Option<String>,
179 cx: &'a Walk<'a>,
180 titles: &'a mut Option<crate::title::TitleIndex>,
181 trail: &'a mut Vec<PathBuf>,
182 ) -> Pin<Box<dyn Future<Output = Result<Node>> + 'a>> {
183 Box::pin(async move {
184 if trail.contains(&path) {
185 return Ok(Node {
186 path,
187 title: None,
188 label,
189 kind: NodeKind::Cycle,
190 children: Vec::new(),
191 });
192 }
193 // One read, not a stat and then a read: the open `load` performs
194 // already answers "does this exist", and its `NotFound` is exactly
195 // the `Missing` node a separate `try_exists` was asking for. The
196 // stat was pure overhead on every node of every walk — and on the
197 // memoized path it was the *only* syscall left, so a second pass
198 // inside a `read_scope` paid it for nothing. Checking existence
199 // first also meant stat-ing an escaping target (`../../etc/passwd`)
200 // before `load`'s root clamp got to refuse it; now the clamp is
201 // first.
202 let doc = match self.load(&path).await {
203 Ok((_, doc)) => doc,
204 Err(e) if is_missing(&e) => {
205 return Ok(Node {
206 path,
207 title: None,
208 label,
209 kind: NodeKind::Missing,
210 children: Vec::new(),
211 });
212 }
213 Err(e) => {
214 return Ok(Node {
215 path,
216 title: None,
217 label,
218 kind: NodeKind::Unreadable(e.to_string()),
219 children: Vec::new(),
220 });
221 }
222 };
223 let meta = fig::Value::from(&doc.meta);
224 let title = meta
225 .get("title")
226 .and_then(fig::Value::as_str)
227 .map(str::to_owned);
228
229 trail.push(path.clone());
230 let mut children = Vec::new();
231 for raw in self.relations().children(&meta) {
232 let child = Link::parse(&raw);
233 // Build the title index on first sight of a nominal link, never
234 // before — this is the only place the tree walk can need it.
235 if titles.is_none() && crate::title::is_alias_shaped(&child.target) {
236 *titles = Some(self.title_index_scoped(cx.root, cx.parked).await?);
237 }
238 let child_path = match self.resolve_link_with(&path, &child, titles.as_ref()) {
239 Target::External => continue,
240 Target::UnresolvedId(id) => {
241 children.push(Node {
242 path: PathBuf::from(child.target.clone()),
243 title: None,
244 label: child.label,
245 kind: NodeKind::UnresolvedId(id),
246 children: Vec::new(),
247 });
248 continue;
249 }
250 Target::AmbiguousAlias(name) => {
251 children.push(Node {
252 path: PathBuf::from(name.clone()),
253 title: None,
254 label: child.label,
255 kind: NodeKind::AmbiguousAlias(name),
256 children: Vec::new(),
257 });
258 continue;
259 }
260 Target::Foreign { workspace, id } => {
261 children.push(Node {
262 path: PathBuf::from(child.target.clone()),
263 title: None,
264 label: child.label,
265 kind: NodeKind::Foreign { workspace, id },
266 children: Vec::new(),
267 });
268 continue;
269 }
270 Target::Path(p) => p,
271 };
272 let child_node = self
273 .tree_node(child_path, child.label, cx, titles, trail)
274 .await?;
275 // `ignore_missing` only ever removes what the default would have
276 // included: a `Missing` child is dropped here rather than pushed,
277 // so a caller who asked for it sees no trace of the dead link at
278 // all, matching diaryx's traversal. Every other kind (including a
279 // deeper `Missing` several levels down, which surfaced as `Doc`
280 // with that descendant already filtered) is unaffected.
281 if !(cx.options.ignore_missing && child_node.kind == NodeKind::Missing) {
282 children.push(child_node);
283 }
284 // (titles carried by &mut, so a nominal link deeper in the tree
285 // reuses the index built above rather than rescanning.)
286 }
287 trail.pop();
288
289 Ok(Node {
290 path,
291 title,
292 label,
293 kind: NodeKind::Doc,
294 children,
295 })
296 })
297 }
298}
299
300// These tests use YAML frontmatter fixtures, so they run under the `yaml` feature.
301#[cfg(all(test, feature = "yaml"))]
302mod tests {
303 use super::*;
304 use crate::exec::block_on;
305 use crate::fs::StdFs;
306 use crate::graph::ReadSettings;
307 use crate::index::NoIndex;
308
309 fn write(dir: &Path, rel: &str, text: &str) {
310 let p = dir.join(rel);
311 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
312 std::fs::write(p, text).unwrap();
313 }
314
315 fn tempdir(tag: &str) -> PathBuf {
316 let dir = std::env::temp_dir().join(format!("prov-tree-{tag}-{}", std::process::id()));
317 let _ = std::fs::remove_dir_all(&dir);
318 std::fs::create_dir_all(&dir).unwrap();
319 dir
320 }
321
322 #[test]
323 fn walks_the_spanning_tree_with_labels_and_titles() {
324 let dir = tempdir("walk");
325 write(
326 &dir,
327 "index.md",
328 "---\ntitle: Root\ncontents:\n- '[A](notes/a.md)'\n- missing.md\n---\n",
329 );
330 write(
331 &dir,
332 "notes/a.md",
333 "---\ntitle: A\npart_of: ../index.md\n---\n",
334 );
335
336 let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
337 let root = block_on(ws.tree("index.md")).unwrap();
338 assert_eq!(root.title.as_deref(), Some("Root"));
339 assert_eq!(root.children.len(), 2);
340 assert_eq!(root.children[0].path, PathBuf::from("notes/a.md"));
341 assert_eq!(root.children[0].label.as_deref(), Some("A"));
342 assert_eq!(root.children[0].kind, NodeKind::Doc);
343 assert_eq!(root.children[1].kind, NodeKind::Missing);
344 }
345
346 #[test]
347 fn spanning_alias_links_resolve_through_the_title_index() {
348 // A workspace whose containment links are nominal `[[Title]]` aliases:
349 // the walk must resolve them through the title index and descend, and
350 // flag a name several documents share as ambiguous.
351 let dir = tempdir("alias");
352 write(
353 &dir,
354 "index.md",
355 "---\ntitle: Root\ncontents:\n- '[[Alpha]]'\n- '[[Dup]]'\n- '[[Ghost]]'\n---\n",
356 );
357 write(&dir, "notes/alpha.md", "---\ntitle: Alpha\n---\n");
358 write(&dir, "one.md", "---\ntitle: Dup\n---\n");
359 write(&dir, "two.md", "---\ntitle: Dup\n---\n");
360
361 let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
362 let root = block_on(ws.tree("index.md")).unwrap();
363 assert_eq!(root.children.len(), 3);
364
365 // `[[Alpha]]` → the unique document titled Alpha, descended into.
366 assert_eq!(root.children[0].kind, NodeKind::Doc);
367 assert_eq!(root.children[0].path, PathBuf::from("notes/alpha.md"));
368
369 // `[[Dup]]` → two documents claim the title, so it cannot resolve.
370 assert_eq!(
371 root.children[1].kind,
372 NodeKind::AmbiguousAlias("Dup".into())
373 );
374
375 // `[[Ghost]]` → no document claims it; falls through to a missing path.
376 assert_eq!(root.children[2].kind, NodeKind::Missing);
377 }
378
379 /// The walk asks for a document and reads the answer's *kind* — so the line
380 /// between "not there" (`Missing`) and "there and wrong" (`Unreadable`) now
381 /// lives in that one error match rather than in a preceding stat. Both
382 /// sides of it, pinned: a directory exists but is not a document, and a
383 /// target climbing out of the root is refused before it is opened at all.
384 #[test]
385 fn a_target_that_exists_but_cannot_be_read_is_unreadable_not_missing() {
386 let dir = tempdir("unreadable");
387 write(
388 &dir,
389 "index.md",
390 "---\ncontents:\n- sub\n- ../outside.md\n---\n",
391 );
392 std::fs::create_dir_all(dir.join("sub")).unwrap();
393
394 let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
395 let root = block_on(ws.tree("index.md")).unwrap();
396 assert_eq!(root.children.len(), 2);
397 assert!(
398 matches!(root.children[0].kind, NodeKind::Unreadable(_)),
399 "a directory is not a missing document: {:?}",
400 root.children[0].kind
401 );
402 assert!(
403 matches!(root.children[1].kind, NodeKind::Unreadable(_)),
404 "an escaping target is refused, not reported absent: {:?}",
405 root.children[1].kind
406 );
407 }
408
409 #[test]
410 fn cycles_are_marked_not_followed() {
411 let dir = tempdir("cycle");
412 write(&dir, "a.md", "---\ncontents:\n- b.md\n---\n");
413 write(&dir, "b.md", "---\ncontents:\n- a.md\n---\n");
414
415 let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
416 let root = block_on(ws.tree("a.md")).unwrap();
417 let b = &root.children[0];
418 assert_eq!(b.kind, NodeKind::Doc);
419 assert_eq!(b.children[0].kind, NodeKind::Cycle);
420 assert_eq!(b.children[0].path, PathBuf::from("a.md"));
421 }
422
423 #[test]
424 fn default_tree_materializes_a_missing_node_for_a_broken_contents_link() {
425 // `tree()` and `tree_with(TreeOptions::default())` must agree exactly —
426 // the same fixture as `ignore_missing_drops_the_broken_link_entirely`
427 // below, pinned against the default (unchanged) behavior.
428 let dir = tempdir("missing-default");
429 write(
430 &dir,
431 "index.md",
432 "---\ntitle: Root\ncontents:\n- '[A](notes/a.md)'\n- gone.md\n---\n",
433 );
434 write(&dir, "notes/a.md", "---\ntitle: A\n---\n");
435
436 let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
437 let root = block_on(ws.tree("index.md")).unwrap();
438 assert_eq!(root.children.len(), 2);
439 assert_eq!(root.children[1].kind, NodeKind::Missing);
440
441 let root = block_on(ws.tree_with("index.md", TreeOptions::default())).unwrap();
442 assert_eq!(root.children.len(), 2);
443 assert_eq!(root.children[1].kind, NodeKind::Missing);
444 }
445
446 #[test]
447 fn ignore_missing_drops_the_broken_link_entirely() {
448 let dir = tempdir("missing-ignore");
449 write(
450 &dir,
451 "index.md",
452 "---\ntitle: Root\ncontents:\n- '[A](notes/a.md)'\n- gone.md\n---\n",
453 );
454 write(&dir, "notes/a.md", "---\ntitle: A\n---\n");
455
456 let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
457 let options = TreeOptions {
458 ignore_missing: true,
459 };
460 let root = block_on(ws.tree_with("index.md", options)).unwrap();
461 // No trace of `gone.md` at all — not a `Missing` node, just absent.
462 assert_eq!(root.children.len(), 1);
463 assert_eq!(root.children[0].path, PathBuf::from("notes/a.md"));
464 }
465
466 #[test]
467 fn ignore_missing_only_filters_missing_not_other_marker_kinds() {
468 // A cycle is a different failure mode from a target that never existed;
469 // `ignore_missing` must leave it alone.
470 let dir = tempdir("missing-ignore-cycle");
471 write(&dir, "a.md", "---\ncontents:\n- b.md\n- gone.md\n---\n");
472 write(&dir, "b.md", "---\ncontents:\n- a.md\n---\n");
473
474 let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
475 let options = TreeOptions {
476 ignore_missing: true,
477 };
478 let root = block_on(ws.tree_with("a.md", options)).unwrap();
479 assert_eq!(root.children.len(), 1);
480 let b = &root.children[0];
481 assert_eq!(b.kind, NodeKind::Doc);
482 assert_eq!(b.children.len(), 1);
483 assert_eq!(b.children[0].kind, NodeKind::Cycle);
484 }
485
486 #[test]
487 fn fs_path_joins_a_node_path_onto_the_workspace_root() {
488 let dir = tempdir("fs-path");
489 write(&dir, "notes/a.md", "---\ntitle: A\n---\n");
490
491 let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
492 let node = block_on(ws.tree("notes/a.md")).unwrap();
493 assert_eq!(ws.fs_path(&node.path), dir.join("notes/a.md"));
494 }
495}