Skip to main content

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}