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