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 same line the census draws.
162 if body_link.image {
163 continue;
164 }
165 let link = body_link.link;
166 if link.is_external() || title::is_alias_shaped(&link.target) {
167 continue;
168 }
169 if let Target::Path(target) = self.resolve_link(&path, &link) {
170 let dir = dir_of(&target);
171 if !is_parked(&dir) {
172 dirs.insert(dir);
173 }
174 }
175 }
176 }
177 // Reaching here means no spanning link was alias-shaped — every early
178 // return above is the only way `true` comes back.
179 Ok((dirs, false))
180 }
181
182 /// Scan every document under the root for a self-stored `id` frontmatter
183 /// field, returning the `(id, path)` pairs — the rebuildable id→path map for
184 /// the frontmatter-only identity storage mode ([`IdStorage::FrontmatterOnly`]).
185 /// Like [`title_index`](Self::title_index) this is a flat filesystem scan,
186 /// deliberately independent of link resolution (so it can bootstrap the very
187 /// index that id links resolve through, with no chicken-and-egg).
188 ///
189 /// [`IdStorage::FrontmatterOnly`]: crate::identity::IdStorage::FrontmatterOnly
190 pub async fn scan_ids(&self) -> Result<Vec<(crate::identity::Id, PathBuf)>> {
191 let mut ids = Vec::new();
192 self.scan_ids_dir(PathBuf::new(), &mut ids).await?;
193 Ok(ids)
194 }
195
196 /// Every content document (Markdown/Djot/HTML) under the root, as sorted
197 /// workspace-relative paths — the on-disk population the orphan check diffs
198 /// against what the spanning tree reaches (DESIGN §8). Deliberately restricted
199 /// to *content* documents: whole-file metadata sidecars (a config or registry
200 /// document, a stray `.yaml`) are not prose a user orphans, so they are not
201 /// candidates. A flat filesystem scan (hidden entries skipped), independent of
202 /// link resolution, like the title/id scans beside it.
203 pub async fn content_documents(&self) -> Result<Vec<PathBuf>> {
204 let mut docs = Vec::new();
205 self.scan_content_dir(PathBuf::new(), &mut docs).await?;
206 docs.sort();
207 Ok(docs)
208 }
209
210 /// The workspace-relative direct-child files of each directory in `dirs`
211 /// (non-recursive), skipping hidden entries and unreadable directories.
212 ///
213 /// The bounded-scan primitive behind reachability-scoped discovery (DESIGN
214 /// §8): it opens only the directories it is handed and never descends into
215 /// subdirectories, so an *unreached* directory — a vendored tree, a nested
216 /// prov workspace — is neither read nor reported. Callers filter the
217 /// result for the file kind they care about (content documents for the orphan
218 /// check, opaque payloads for `attach --all`).
219 pub async fn direct_child_files(&self, dirs: &BTreeSet<PathBuf>) -> Result<Vec<PathBuf>> {
220 let mut files = Vec::new();
221 for dir in dirs {
222 let Ok(entries) = self.listing(dir).await else {
223 continue;
224 };
225 for entry in entries {
226 let Some(name) = entry
227 .file_name()
228 .and_then(|n| n.to_str())
229 .map(str::to_owned)
230 else {
231 continue;
232 };
233 if name.starts_with('.') || !entry.file_type().is_file() {
234 continue;
235 }
236 files.push(if dir.as_os_str().is_empty() {
237 PathBuf::from(&name)
238 } else {
239 dir.join(&name)
240 });
241 }
242 }
243 Ok(files)
244 }
245
246 /// The directories the reachable set `reachable` occupies — each reached
247 /// document's own directory (the workspace root's directory always among
248 /// them, since the root document is reachable). The scope
249 /// [`direct_child_files`](Self::direct_child_files) is bounded to: a directory
250 /// is "known" precisely when a linked document lives directly in it.
251 pub fn reached_dirs(reachable: &BTreeSet<PathBuf>) -> BTreeSet<PathBuf> {
252 reachable
253 .iter()
254 .map(|p| p.parent().unwrap_or(Path::new("")).to_path_buf())
255 .collect()
256 }
257
258 /// Recursively collect content-document paths under `rel_dir`. Same walk as
259 /// [`scan_ids_dir`](Self::scan_ids_dir); unreadable/hidden entries are skipped.
260 fn scan_content_dir<'a>(
261 &'a self,
262 rel_dir: PathBuf,
263 docs: &'a mut Vec<PathBuf>,
264 ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
265 Box::pin(async move {
266 let Ok(entries) = self.listing(&rel_dir).await else {
267 return Ok(());
268 };
269 for entry in entries {
270 let Some(name) = entry
271 .file_name()
272 .and_then(|n| n.to_str())
273 .map(str::to_owned)
274 else {
275 continue;
276 };
277 if name.starts_with('.') {
278 continue;
279 }
280 let rel = if rel_dir.as_os_str().is_empty() {
281 PathBuf::from(&name)
282 } else {
283 rel_dir.join(&name)
284 };
285 if entry.file_type().is_dir() {
286 self.scan_content_dir(rel, docs).await?;
287 } else if entry.file_type().is_file()
288 && ContentFormat::from_extension(&rel).is_some()
289 {
290 docs.push(rel);
291 }
292 }
293 Ok(())
294 })
295 }
296
297 /// Recursively collect self-stored `id` fields under `rel_dir`. Same walk as
298 /// [`scan_titles`](Self::scan_titles); unreadable/hidden entries are skipped.
299 fn scan_ids_dir<'a>(
300 &'a self,
301 rel_dir: PathBuf,
302 ids: &'a mut Vec<(crate::identity::Id, PathBuf)>,
303 ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
304 Box::pin(async move {
305 let Ok(entries) = self.listing(&rel_dir).await else {
306 return Ok(());
307 };
308 let probe = shadow_probe(&rel_dir, &entries);
309 for entry in entries {
310 let Some(name) = entry
311 .file_name()
312 .and_then(|n| n.to_str())
313 .map(str::to_owned)
314 else {
315 continue;
316 };
317 if name.starts_with('.') {
318 continue;
319 }
320 let rel = if rel_dir.as_os_str().is_empty() {
321 PathBuf::from(&name)
322 } else {
323 rel_dir.join(&name)
324 };
325 if entry.file_type().is_dir() {
326 self.scan_ids_dir(rel, ids).await?;
327 } else if entry.file_type().is_file()
328 && is_document_path(&rel)
329 // An `id:` inside a shadowed payload is an example, not a
330 // claim on the registry (see `attach_opaque`).
331 && !self.is_shadowed_payload(&rel, &probe).await
332 && let Ok((_, doc)) = self.load(&rel).await
333 {
334 let meta = fig::Value::from(&doc.meta);
335 if let Some(id) = meta.get("id").and_then(fig::Value::as_str)
336 && !id.trim().is_empty()
337 {
338 ids.push((crate::identity::Id(id.trim().to_string()), rel));
339 }
340 }
341 }
342 Ok(())
343 })
344 }
345
346 /// Recursively index the documents under the workspace-relative `rel_dir`,
347 /// never descending into a directory under `parked`. Unreadable directories
348 /// and files are skipped (a title index is a best-effort cache, not a
349 /// validation pass); hidden entries (`.`-prefixed) are ignored.
350 ///
351 /// `parked` is [`parked_dirs`](Self::parked_dirs) — prov's byte-parking
352 /// stores. Excluded by *not descending* rather than by filtering afterwards,
353 /// so a workspace with a thousand history events does not read a thousand
354 /// event documents in order to throw their titles away.
355 fn scan_titles<'a>(
356 &'a self,
357 rel_dir: PathBuf,
358 parked: &'a [PathBuf],
359 index: &'a mut TitleIndex,
360 ) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
361 Box::pin(async move {
362 if parked.iter().any(|p| rel_dir.starts_with(p)) {
363 return Ok(());
364 }
365 let Ok(entries) = self.listing(&rel_dir).await else {
366 return Ok(());
367 };
368 let probe = shadow_probe(&rel_dir, &entries);
369 for entry in entries {
370 let Some(name) = entry
371 .file_name()
372 .and_then(|n| n.to_str())
373 .map(str::to_owned)
374 else {
375 continue;
376 };
377 if name.starts_with('.') {
378 continue;
379 }
380 let rel = if rel_dir.as_os_str().is_empty() {
381 PathBuf::from(&name)
382 } else {
383 rel_dir.join(&name)
384 };
385 if entry.file_type().is_dir() {
386 self.scan_titles(rel, parked, index).await?;
387 } else if entry.file_type().is_file()
388 && is_document_path(&rel)
389 // A shadowed payload is bytes prov agreed not to read: its
390 // title is a specimen's, and must not answer `[[alias]]`.
391 && !self.is_shadowed_payload(&rel, &probe).await
392 {
393 // Always index by stem (name-based resolution, Obsidian-style)…
394 if let Some(stem) = rel.file_stem().and_then(|s| s.to_str()) {
395 index.insert(stem, rel.clone());
396 }
397 // …and by the declared `title` when the document parses.
398 if let Ok((_, doc)) = self.load(&rel).await {
399 let meta = fig::Value::from(&doc.meta);
400 if let Some(title) = meta.get("title").and_then(fig::Value::as_str) {
401 index.insert(title, rel.clone());
402 }
403 }
404 }
405 }
406 Ok(())
407 })
408 }
409}
410
411/// Whether `path` names a document the title scan should read — one whose
412/// extension is a recognized body format (Markdown/Djot/HTML) or a whole-file
413/// metadata format (YAML/JSON/…). Non-document files (images, binaries) are
414/// skipped so the scan neither reads nor mis-indexes them.
415fn is_document_path(path: &Path) -> bool {
416 !is_opaque_payload(path)
417}
418
419/// The shadow probe over the *files* among a directory's `entries`
420/// ([`is_shadowed_payload`](Graph::is_shadowed_payload)). Hidden entries are
421/// skipped, matching the scans that build this.
422fn shadow_probe(rel_dir: &Path, entries: &[crate::fs::DirEntry]) -> ShadowProbe {
423 let files: Vec<PathBuf> = entries
424 .iter()
425 .filter(|e| e.file_type().is_file())
426 .filter_map(|e| e.file_name().and_then(|n| n.to_str()).map(str::to_owned))
427 .filter(|name| !name.starts_with('.'))
428 .map(|name| {
429 if rel_dir.as_os_str().is_empty() {
430 PathBuf::from(name)
431 } else {
432 rel_dir.join(name)
433 }
434 })
435 .collect();
436 ShadowProbe::over(files.iter())
437}