prov_graph/graph/scan.rs
1//! Flat scans — the passes that read the tree as a *directory*, not as a graph.
2//!
3//! Three of the four things resolution needs cannot themselves be found by
4//! following links, because they are what makes following links possible. The
5//! [`TitleIndex`] is the clearest case: nominal references (`[[My File]]`)
6//! resolve through it, and a nominal reference may itself be *spanning*
7//! (`contents: alias`), so building the index by walking the tree would need the
8//! index to walk the tree. It is a flat filesystem scan for exactly that reason
9//! — a derived cache (DESIGN §5), rebuilt on demand and never persisted.
10//!
11//! [`scan_ids`](Graph::scan_ids) and [`content_documents`](Graph::content_documents)
12//! are the same shape: enumerate what is on disk, decide nothing about it.
13//! [`direct_child_files`](Graph::direct_child_files) is the bounded variant the
14//! census uses — the directories the graph already reaches, and no others, so a
15//! vendored subtree or a nested workspace is never swept in (DESIGN §8).
16
17use std::collections::BTreeSet;
18use std::future::Future;
19use std::path::{Path, PathBuf};
20use std::pin::Pin;
21
22use super::{Graph, ShadowProbe, Target};
23use crate::content::ContentFormat;
24use crate::document::is_opaque_payload;
25use crate::error::Result;
26use crate::fs::ReadStorage;
27use crate::index::IdIndex;
28use crate::link::{self, Link};
29use crate::title::{self, TitleIndex};
30
31impl<FS: ReadStorage, Ix: IdIndex> Graph<FS, Ix> {
32 /// Build the workspace's [`TitleIndex`] by scanning every document under the
33 /// root and registering it under its `title` and its file stem. This is a
34 /// **derived cache** (DESIGN §5): rebuilt on demand, never persisted. It is
35 /// what makes nominal (`[[My File]]`) references resolvable — a flat
36 /// filesystem scan, deliberately independent of link resolution so that
37 /// alias links can themselves be *spanning* (`contents: alias`) without a
38 /// chicken-and-egg between "walk the tree" and "resolve the walk's links."
39 pub async fn title_index(&self) -> Result<TitleIndex> {
40 let mut index = TitleIndex::new();
41 self.scan_titles(PathBuf::new(), &[], &mut index).await?;
42 Ok(index)
43 }
44
45 /// The title index bounded to the directories the workspace reaches from
46 /// `start` (DESIGN §8) — the reachability-scoped counterpart to
47 /// [`title_index`](Self::title_index). Only documents in a directory some
48 /// link path/id-reaches are indexed, so a `[[alias]]` resolves within the
49 /// workspace without scanning `target/`, a vendored tree, or a nested
50 /// workspace at the repo root.
51 ///
52 /// Falls back to the full [`title_index`](Self::title_index) when the
53 /// **spanning** relation is addressed by alias: descending the tree then needs
54 /// every title up front, so the scan cannot be bounded (the chicken-and-egg
55 /// the flat scan was written to avoid). An overlay alias to an *orphan* (a doc
56 /// no path/id link reaches) likewise falls outside the scope and reads as
57 /// broken — which it effectively is.
58 /// `parked` names the directories whose *interiors* are prov's own
59 /// bookkeeping — a history store's events and blobs, the recycle bin's items.
60 /// They are reached like anything else (the root points at each store's index
61 /// document) but a title found inside one is not a place a reader can go, so
62 /// indexing it would let `[[Some Note]]` resolve to a deleted copy or an old
63 /// version — silently, since neither is anywhere the reader can see. The
64 /// caller supplies them because *which* directories those are is a question
65 /// about prov's storage layout, and this crate has no opinion about it.
66 pub async fn title_index_scoped(&self, start: &Path, parked: &[PathBuf]) -> Result<TitleIndex> {
67 let (dirs, needs_full) = self.title_scope(start, parked).await?;
68 if needs_full {
69 // The unbounded fallback still owes the same exclusion: falling back
70 // is about not being able to *bound* the scan, not about suddenly
71 // being willing to name prov's bookkeeping.
72 let mut index = TitleIndex::new();
73 self.scan_titles(PathBuf::new(), parked, &mut index).await?;
74 return Ok(index);
75 }
76 let mut index = TitleIndex::new();
77 let files = self.direct_child_files(&dirs).await?;
78 let probe = ShadowProbe::over(files.iter());
79 for rel in files {
80 if !is_document_path(&rel) || self.is_shadowed_payload(&rel, &probe).await {
81 continue;
82 }
83 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
84 index.insert(stem, rel.clone());
85 }
86 if let Ok((_, doc)) = self.load(&rel).await {
87 let meta = fig::Value::from(&doc.meta);
88 if let Some(title) = meta.get("title").and_then(fig::Value::as_str) {
89 index.insert(title, rel.clone());
90 }
91 }
92 }
93 Ok(index)
94 }
95
96 /// The directories the workspace occupies, reached from `start` by following
97 /// path/id links — spanning links drive descent, and every relation's (and
98 /// body wikilink's) path/id target contributes its directory, so an alias can
99 /// resolve to anything the tree links. The scope [`title_index_scoped`] indexes.
100 ///
101 /// The returned flag is `true` when a **spanning** link is alias-shaped: it
102 /// cannot be followed without the title index, so the scope would be
103 /// incomplete and the caller must scan in full instead. That answer is
104 /// final the moment it is reached, and the only caller throws `dirs` away
105 /// when it comes back set — so the walk **stops there** rather than
106 /// finishing a traversal whose result is already known to be discarded.
107 /// The abandoned half is not cheap: every remaining document would be read
108 /// and its prose body parsed (`scan_body_links`) purely to contribute
109 /// directories to a set nobody reads.
110 async fn title_scope(
111 &self,
112 start: &Path,
113 parked: &[PathBuf],
114 ) -> Result<(BTreeSet<PathBuf>, bool)> {
115 let spanning = self.relations().spanning_relation().map(str::to_owned);
116 let dir_of = |p: &Path| p.parent().unwrap_or(Path::new("")).to_path_buf();
117 let is_parked = |dir: &Path| parked.iter().any(|p| dir.starts_with(p));
118 let mut dirs: BTreeSet<PathBuf> = BTreeSet::new();
119 let mut visited: BTreeSet<PathBuf> = BTreeSet::new();
120 let mut queue = vec![link::normalize(start)];
121 while let Some(path) = queue.pop() {
122 if !visited.insert(path.clone()) {
123 continue;
124 }
125 let dir = dir_of(&path);
126 if is_parked(&dir) {
127 continue;
128 }
129 dirs.insert(dir);
130 let Ok((_, doc)) = self.load(&path).await else {
131 continue;
132 };
133 let meta = fig::Value::from(&doc.meta);
134 for edge in self.relations().edges(&meta) {
135 let link = Link::parse(&edge.target);
136 let is_spanning = Some(edge.relation.as_str()) == spanning.as_deref();
137 if link.is_external() {
138 continue;
139 }
140 if title::is_alias_shaped(&link.target) {
141 // Can't resolve without the index; a spanning alias defeats
142 // bounding, and nothing later can un-defeat it.
143 if is_spanning {
144 return Ok((BTreeSet::new(), true));
145 }
146 continue;
147 }
148 if let Target::Path(target) = self.resolve_link(&path, &link) {
149 let dir = dir_of(&target);
150 if is_parked(&dir) {
151 continue;
152 }
153 dirs.insert(dir);
154 if is_spanning {
155 queue.push(target);
156 }
157 }
158 }
159 for body_link in link::scan_body_links(&path, &doc.body) {
160 // An image names a payload, and a payload's directory is not
161 // one a document occupies: the census reports the image, but
162 // a folder of pictures holds no title to index.
163 if body_link.image {
164 continue;
165 }
166 let link = body_link.link;
167 if link.is_external() || title::is_alias_shaped(&link.target) {
168 continue;
169 }
170 if let Target::Path(target) = self.resolve_link(&path, &link) {
171 let dir = dir_of(&target);
172 if !is_parked(&dir) {
173 dirs.insert(dir);
174 }
175 }
176 }
177 }
178 // Reaching here means no spanning link was alias-shaped — every early
179 // return above is the only way `true` comes back.
180 Ok((dirs, false))
181 }
182
183 /// Scan every document under the root for a self-stored `id` frontmatter
184 /// field, returning the `(id, path)` pairs — the rebuildable id→path map for
185 /// the frontmatter-only identity storage mode ([`IdStorage::FrontmatterOnly`]).
186 /// Like [`title_index`](Self::title_index) this is a flat filesystem scan,
187 /// deliberately independent of link resolution (so it can bootstrap the very
188 /// index that id links resolve through, with no chicken-and-egg).
189 ///
190 /// [`IdStorage::FrontmatterOnly`]: crate::identity::IdStorage::FrontmatterOnly
191 pub async fn scan_ids(&self) -> Result<Vec<(crate::identity::Id, PathBuf)>> {
192 let mut ids = Vec::new();
193 self.scan_ids_dir(PathBuf::new(), &mut ids).await?;
194 Ok(ids)
195 }
196
197 /// Every content document (Markdown/Djot/HTML) under the root, as sorted
198 /// workspace-relative paths — the on-disk population the orphan check diffs
199 /// against what the spanning tree reaches (DESIGN §8). Deliberately restricted
200 /// to *content* documents: whole-file metadata sidecars (a config or registry
201 /// document, a stray `.yaml`) are not prose a user orphans, so they are not
202 /// candidates. A flat filesystem scan (hidden entries skipped), independent of
203 /// link resolution, like the title/id scans beside it.
204 pub async fn content_documents(&self) -> Result<Vec<PathBuf>> {
205 let mut docs = Vec::new();
206 self.scan_content_dir(PathBuf::new(), &mut docs).await?;
207 docs.sort();
208 Ok(docs)
209 }
210
211 /// The workspace-relative direct-child files of each directory in `dirs`
212 /// (non-recursive), skipping hidden entries and unreadable directories.
213 ///
214 /// The bounded-scan primitive behind reachability-scoped discovery (DESIGN
215 /// §8): it opens only the directories it is handed and never descends into
216 /// subdirectories, so an *unreached* directory — a vendored tree, a nested
217 /// prov workspace — is neither read nor reported. Callers filter the
218 /// result for the file kind they care about (content documents for the orphan
219 /// check, opaque payloads for `attach --all`).
220 pub async fn direct_child_files(&self, dirs: &BTreeSet<PathBuf>) -> Result<Vec<PathBuf>> {
221 let mut files = Vec::new();
222 for dir in dirs {
223 let Ok(entries) = self.listing(dir).await else {
224 continue;
225 };
226 for entry in entries {
227 let Some(name) = entry
228 .file_name()
229 .and_then(|n| n.to_str())
230 .map(str::to_owned)
231 else {
232 continue;
233 };
234 if name.starts_with('.') || !entry.file_type().is_file() {
235 continue;
236 }
237 files.push(if dir.as_os_str().is_empty() {
238 PathBuf::from(&name)
239 } else {
240 dir.join(&name)
241 });
242 }
243 }
244 Ok(files)
245 }
246
247 /// The directories the reachable set `reachable` occupies — each reached
248 /// document's own directory (the workspace root's directory always among
249 /// them, since the root document is reachable). The scope
250 /// [`direct_child_files`](Self::direct_child_files) is bounded to: a directory
251 /// is "known" precisely when a linked document lives directly in it.
252 pub fn reached_dirs(reachable: &BTreeSet<PathBuf>) -> BTreeSet<PathBuf> {
253 reachable
254 .iter()
255 .map(|p| p.parent().unwrap_or(Path::new("")).to_path_buf())
256 .collect()
257 }
258
259 /// Recursively collect content-document paths under `rel_dir`. Same walk as
260 /// [`scan_ids_dir`](Self::scan_ids_dir); unreadable/hidden entries are skipped.
261 fn scan_content_dir<'a>(
262 &'a self,
263 rel_dir: PathBuf,
264 docs: &'a mut Vec<PathBuf>,
265 ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
266 Box::pin(async move {
267 let Ok(entries) = self.listing(&rel_dir).await else {
268 return Ok(());
269 };
270 for entry in entries {
271 let Some(name) = entry
272 .file_name()
273 .and_then(|n| n.to_str())
274 .map(str::to_owned)
275 else {
276 continue;
277 };
278 if name.starts_with('.') {
279 continue;
280 }
281 let rel = if rel_dir.as_os_str().is_empty() {
282 PathBuf::from(&name)
283 } else {
284 rel_dir.join(&name)
285 };
286 if entry.file_type().is_dir() {
287 self.scan_content_dir(rel, docs).await?;
288 } else if entry.file_type().is_file()
289 && ContentFormat::from_extension(&rel).is_some()
290 {
291 docs.push(rel);
292 }
293 }
294 Ok(())
295 })
296 }
297
298 /// Recursively collect self-stored `id` fields under `rel_dir`. Same walk as
299 /// [`scan_titles`](Self::scan_titles); unreadable/hidden entries are skipped.
300 fn scan_ids_dir<'a>(
301 &'a self,
302 rel_dir: PathBuf,
303 ids: &'a mut Vec<(crate::identity::Id, PathBuf)>,
304 ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
305 Box::pin(async move {
306 let Ok(entries) = self.listing(&rel_dir).await else {
307 return Ok(());
308 };
309 let probe = shadow_probe(&rel_dir, &entries);
310 for entry in entries {
311 let Some(name) = entry
312 .file_name()
313 .and_then(|n| n.to_str())
314 .map(str::to_owned)
315 else {
316 continue;
317 };
318 if name.starts_with('.') {
319 continue;
320 }
321 let rel = if rel_dir.as_os_str().is_empty() {
322 PathBuf::from(&name)
323 } else {
324 rel_dir.join(&name)
325 };
326 if entry.file_type().is_dir() {
327 self.scan_ids_dir(rel, ids).await?;
328 } else if entry.file_type().is_file()
329 && is_document_path(&rel)
330 // An `id:` inside a shadowed payload is an example, not a
331 // claim on the registry (see `attach_opaque`).
332 && !self.is_shadowed_payload(&rel, &probe).await
333 && let Ok((_, doc)) = self.load(&rel).await
334 {
335 let meta = fig::Value::from(&doc.meta);
336 if let Some(id) = meta.get("id").and_then(fig::Value::as_str)
337 && !id.trim().is_empty()
338 {
339 ids.push((crate::identity::Id(id.trim().to_string()), rel));
340 }
341 }
342 }
343 Ok(())
344 })
345 }
346
347 /// Recursively index the documents under the workspace-relative `rel_dir`,
348 /// never descending into a directory under `parked`. Unreadable directories
349 /// and files are skipped (a title index is a best-effort cache, not a
350 /// validation pass); hidden entries (`.`-prefixed) are ignored.
351 ///
352 /// `parked` is [`parked_dirs`](Self::parked_dirs) — prov's byte-parking
353 /// stores. Excluded by *not descending* rather than by filtering afterwards,
354 /// so a workspace with a thousand history events does not read a thousand
355 /// event documents in order to throw their titles away.
356 fn scan_titles<'a>(
357 &'a self,
358 rel_dir: PathBuf,
359 parked: &'a [PathBuf],
360 index: &'a mut TitleIndex,
361 ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
362 Box::pin(async move {
363 if parked.iter().any(|p| rel_dir.starts_with(p)) {
364 return Ok(());
365 }
366 let Ok(entries) = self.listing(&rel_dir).await else {
367 return Ok(());
368 };
369 let probe = shadow_probe(&rel_dir, &entries);
370 for entry in entries {
371 let Some(name) = entry
372 .file_name()
373 .and_then(|n| n.to_str())
374 .map(str::to_owned)
375 else {
376 continue;
377 };
378 if name.starts_with('.') {
379 continue;
380 }
381 let rel = if rel_dir.as_os_str().is_empty() {
382 PathBuf::from(&name)
383 } else {
384 rel_dir.join(&name)
385 };
386 if entry.file_type().is_dir() {
387 self.scan_titles(rel, parked, index).await?;
388 } else if entry.file_type().is_file()
389 && is_document_path(&rel)
390 // A shadowed payload is bytes prov agreed not to read: its
391 // title is a specimen's, and must not answer `[[alias]]`.
392 && !self.is_shadowed_payload(&rel, &probe).await
393 {
394 // Always index by stem (name-based resolution, Obsidian-style)…
395 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
396 index.insert(stem, rel.clone());
397 }
398 // …and by the declared `title` when the document parses.
399 if let Ok((_, doc)) = self.load(&rel).await {
400 let meta = fig::Value::from(&doc.meta);
401 if let Some(title) = meta.get("title").and_then(fig::Value::as_str) {
402 index.insert(title, rel.clone());
403 }
404 }
405 }
406 }
407 Ok(())
408 })
409 }
410}
411
412/// Whether `path` names a document the title scan should read — one whose
413/// extension is a recognized body format (Markdown/Djot/HTML) or a whole-file
414/// metadata format (YAML/JSON/…). Non-document files (images, binaries) are
415/// skipped so the scan neither reads nor mis-indexes them.
416fn is_document_path(path: &Path) -> bool {
417 !is_opaque_payload(path)
418}
419
420/// The shadow probe over the *files* among a directory's `entries`
421/// ([`is_shadowed_payload`](Graph::is_shadowed_payload)). Hidden entries are
422/// skipped, matching the scans that build this.
423fn shadow_probe(rel_dir: &Path, entries: &[crate::fs::DirEntry]) -> ShadowProbe {
424 let files: Vec<PathBuf> = entries
425 .iter()
426 .filter(|e| e.file_type().is_file())
427 .filter_map(|e| e.file_name().and_then(|n| n.to_str()).map(str::to_owned))
428 .filter(|name| !name.starts_with('.'))
429 .map(|name| {
430 if rel_dir.as_os_str().is_empty() {
431 PathBuf::from(name)
432 } else {
433 rel_dir.join(name)
434 }
435 })
436 .collect();
437 ShadowProbe::over(files.iter())
438}