Skip to main content

prov_graph/graph/
census.rs

1//! The census types, the spanning-tree walker that fills them in, and the
2//! reachability views built over the result. See the module doc at
3//! [`crate::graph`] for why the census is ground truth.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::fmt;
7use std::ops::Range;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use super::Graph;
12use crate::error::Result;
13use crate::fs::ReadStorage;
14use crate::identity::{self, Id};
15use crate::index::IdIndex;
16use crate::link::{self, Link};
17use crate::memo::DirNames;
18use crate::title::{self, TitleIndex, TitleMatch};
19
20use super::Target;
21
22/// Where in a document a forward link is written — a frontmatter relation
23/// field or a body wikilink. Carried by every link-resolution finding
24/// (`prov`'s `Finding`, derived in `validate` — see
25/// [`StructuralFact`]) and every [`CensusEntry`] so a report can point at the
26/// exact site.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum LinkSite {
29    /// A frontmatter relation field, by name (e.g. `contents`, `links`) and,
30    /// where the field is a list, by the item's position in it.
31    ///
32    /// The index is what lets a consumer point at *the* item rather than the
33    /// key: a `contents` list that names the same missing target twice is two
34    /// findings, and without it nothing in either says which row is which.
35    /// `None` for a scalar field (`part_of`, `content`) — see
36    /// [`Edge::index`](crate::relation::Edge::index) for how a list is counted.
37    Relation { field: String, index: Option<usize> },
38    /// A link in the body, at this byte span: a `[[…]]` wikilink, a
39    /// markdown/djot `[label](target)`, or the `[alt](target)` of an
40    /// `![alt](target)` image (the span starts after the `!`, as
41    /// [`BodyLink`](crate::link::BodyLink) reports it).
42    Body(Range<usize>),
43}
44
45impl LinkSite {
46    /// A relation site for a scalar field — the form every field that is not
47    /// a list takes (`content`, `manifest`, `root`).
48    pub fn field(name: impl Into<String>) -> Self {
49        LinkSite::Relation {
50            field: name.into(),
51            index: None,
52        }
53    }
54
55    /// The relation field this site is in, if it is a frontmatter site.
56    pub fn relation(&self) -> Option<&str> {
57        match self {
58            LinkSite::Relation { field, .. } => Some(field),
59            LinkSite::Body(_) => None,
60        }
61    }
62}
63
64impl fmt::Display for LinkSite {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            LinkSite::Relation {
68                field,
69                index: Some(i),
70            } => write!(f, "{field}[{i}]"),
71            LinkSite::Relation { field, index: None } => f.write_str(field),
72            LinkSite::Body(_) => f.write_str("body"),
73        }
74    }
75}
76
77/// How a forward link resolves against the workspace. Path and id forms stay
78/// distinct on purpose: the registry owns id resolution (location-independent,
79/// stable across moves), while a path is checked against the on-disk name — so
80/// a caller can tell which links a rename must rewrite (paths) from which it
81/// must leave alone (ids).
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum Resolution {
84    /// A path target that resolves to an existing file (exact name).
85    Path(PathBuf),
86    /// A path target that only matches case-insensitively; `got` is the target
87    /// as resolved, `actual` the exact on-disk name.
88    CaseMismatch { got: PathBuf, actual: String },
89    /// A path target with nothing on disk.
90    Broken,
91    /// A `prov:<id>` target the registry resolves to the live path `to`.
92    Id { id: Id, to: PathBuf },
93    /// A well-formed `prov:<id>` target with no live registry entry;
94    /// `tombstoned` separates "deleted" from "never issued here" (§4 hazard).
95    DanglingId { id: Id, tombstoned: bool },
96    /// A `prov:<id>` target failing its check character — a typo.
97    MalformedId,
98    /// A nominal (alias) target several documents claim — unresolvable.
99    /// `candidates` are the sharers, sorted.
100    AmbiguousAlias {
101        name: String,
102        candidates: Vec<PathBuf>,
103    },
104    /// A URL / mail address — off-workspace, never resolved or rewritten.
105    External,
106    /// A target that is *only* a locator (`#3`) — a place inside the document
107    /// the link is written in.
108    ///
109    /// A clean resolution, not a finding, on the same grounds as any other
110    /// locator: prov does not read a document's internal address space, so it
111    /// has no evidence about whether `#3` names anything. See
112    /// [`Target::SameDocument`] for why this is its own case rather than a
113    /// [`Resolution::Path`] of the citing document.
114    SameDocument,
115    /// An `id:<workspace>/<id>` target naming a document in another workspace.
116    ///
117    /// A clean resolution, not a finding: prov holds no map from a workspace
118    /// name to a location (see
119    /// [`Target::Foreign`]), so it has no
120    /// evidence either way about whether the target exists. Reporting a link it
121    /// cannot check as broken would be a false positive every host would then
122    /// have to suppress — and a `check` that must be filtered is one nobody
123    /// reads. The id is deliberately **not** check-verified: the foreign
124    /// workspace owns its id space and need not be a prov workspace.
125    Foreign { workspace: String, id: Id },
126}
127
128impl Resolution {
129    /// The workspace path this link reaches, if it resolves to one (by path or
130    /// through the registry) — what the spanning walk descends into and what a
131    /// backlink map keys on. `None` for broken, dangling, malformed, external.
132    pub fn resolved_path(&self) -> Option<&PathBuf> {
133        match self {
134            Resolution::Path(p)
135            | Resolution::CaseMismatch { got: p, .. }
136            | Resolution::Id { to: p, .. } => Some(p),
137            _ => None,
138        }
139    }
140}
141
142/// One forward link as found in a document: where it is written and how it
143/// resolves. The unit of the
144/// [`census`](Graph::census).
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct CensusEntry {
147    /// The document that declares the link (workspace-relative).
148    pub source: PathBuf,
149    /// Where in `source` the link is written.
150    pub site: LinkSite,
151    /// The target exactly as written (bare — the `[label](…)` wrapper stripped).
152    pub target_text: String,
153    /// The display label the link carried, when written `[label](target)` /
154    /// `[[target|label]]` — `None` for a bare target. Kept so a caller can check
155    /// a label against the target's current title (stale-label detection) without
156    /// re-reading the source.
157    pub label: Option<String>,
158    /// How the target resolves.
159    pub resolution: Resolution,
160}
161
162/// An inbound reference to a document, as discovered by the census: which
163/// document links here ([`source`](Backlink::source)), where in it
164/// ([`site`](Backlink::site)), and whether the link is by stable id (survives
165/// moves) or by path (rewritten on a move). The inverse of a forward
166/// [`CensusEntry`] — the marquee payoff of the identity layer (DESIGN §6).
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Backlink {
169    /// The document that links to the target.
170    pub source: PathBuf,
171    /// Where in `source` the link is written.
172    pub site: LinkSite,
173    /// `true` when the link is a `prov:<id>` reference (location-independent),
174    /// `false` when it is a path.
175    pub by_id: bool,
176}
177
178enum NameMatch {
179    Exact,
180    CaseOnly(String),
181    None,
182}
183
184/// A structural observation the walk makes as it traverses — not a verdict,
185/// just what it saw: a document that would not load, a self-stored id
186/// disagreeing with (or absent from) the registry, a spanning edge that
187/// revisits an already-reached node, a spanning child whose inverse field
188/// does not point back, or a `content` pointer that failed to resolve.
189///
190/// These are facts about *traversal state* — they need the queue, the
191/// visited set, the inverse lookup — so only the walk can raise them; a
192/// single [`CensusEntry`]'s [`Resolution`] is not enough (that half of the
193/// story is `validate`'s [`CensusEntry`]-keyed
194/// `prov`'s `validate` instead, since a resolution *is* already the
195/// fact). `validate::check` turns each variant here into the
196/// `prov`'s `Finding` that names it — one to one, since
197/// the walk already knows exactly what happened and there is nothing left to
198/// infer. Keeping the enum here rather than importing `Finding` is what
199/// keeps `graph` a pure "plain text → walkable graph" layer: it reports what
200/// it found, never how that should be judged.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum StructuralFact {
203    /// A document that exists but could not be read or parsed.
204    Unreadable { doc: PathBuf, error: String },
205    /// A document's self-stored `id` frontmatter disagrees with the registry
206    /// (or claims an id the registry hands to a different document).
207    /// `registry` is `None` when the registry has no record of the path at
208    /// all under this id.
209    IdMismatch {
210        doc: PathBuf,
211        frontmatter: Id,
212        registry: Option<Id>,
213    },
214    /// A document carries a self-stored `id` the registry has no record of.
215    UnregisteredId { doc: PathBuf, frontmatter: Id },
216    /// A stamping workspace's registered document does not carry its own
217    /// `id` frontmatter.
218    UnstampedId { doc: PathBuf, registry: Id },
219    /// A spanning target already reached by the walk — a cycle or a second
220    /// parent.
221    DuplicateContainment { doc: PathBuf, target: String },
222    /// A spanning child whose inverse field does not link back to `doc`.
223    MissingInverse {
224        doc: PathBuf,
225        child: PathBuf,
226        inverse: String,
227    },
228    /// A `content` pointer resolving only case-insensitively.
229    CaseMismatch {
230        doc: PathBuf,
231        site: LinkSite,
232        target: String,
233        actual: String,
234    },
235    /// A `content` pointer resolving to nothing on disk.
236    BrokenLink {
237        doc: PathBuf,
238        site: LinkSite,
239        target: String,
240    },
241    /// A node declaring both `content` and `manifest` — a sidecar for one
242    /// payload and for a whole directory at once. The two are mutually
243    /// exclusive ([`crate::manifest`]), and neither reading is safe to pick.
244    ManifestConflict { doc: PathBuf },
245}
246
247/// The result of one spanning-tree
248/// [`walk`](Graph::census): the forward-link census,
249/// the structural facts observed from traversal state, and the prose body
250/// files reached through separated nodes' `content` pointers (tracked for
251/// the orphan check, deliberately absent from the census).
252pub struct Walk {
253    pub census: Vec<CensusEntry>,
254    pub facts: Vec<StructuralFact>,
255    pub content_bodies: Vec<PathBuf>,
256}
257
258/// The set of workspace-relative paths a walk from `start` reaches: `start`
259/// itself, every path a census link resolves to (any relation, a body link or
260/// image, or an id through the registry), and every `content` target.
261///
262/// A **case-mismatched** link counts its *actual* on-disk file as reached, so a
263/// file is never both case-mismatched and orphaned. Prose bodies (and attachment
264/// payloads) arrive through `content_bodies` rather than the census, because a
265/// `content` pointer is not a graph edge — but it does reach a file, which is
266/// what every caller here cares about.
267///
268/// The one definition of "reachable" that the orphan check, the fixity pass, the
269/// vocabulary pass, and the history capture set all share (DESIGN §8).
270pub fn reachable_set(
271    start: &Path,
272    census: &[CensusEntry],
273    content_bodies: &[PathBuf],
274) -> BTreeSet<PathBuf> {
275    let mut reachable: BTreeSet<PathBuf> = BTreeSet::new();
276    reachable.insert(link::normalize(start));
277    reachable.extend(content_bodies.iter().cloned());
278    for entry in census {
279        match &entry.resolution {
280            Resolution::Path(p) | Resolution::Id { to: p, .. } => {
281                reachable.insert(p.clone());
282            }
283            Resolution::CaseMismatch { got, actual } => {
284                reachable.insert(got.with_file_name(actual));
285            }
286            _ => {}
287        }
288    }
289    reachable
290}
291
292impl<FS: ReadStorage, Ix: IdIndex> Graph<FS, Ix> {
293    /// [`reachable_set`], minus any **shadowed attachment payload**
294    /// (`attach --opaque`) — the population a pass may parse *as a document*.
295    ///
296    /// A shadowed payload is still reachable (it must not be reported as an
297    /// orphan, and it is still fixity-checked *through its sidecar*), but its
298    /// bytes are an exhibit prov promised never to interpret. That is the same
299    /// bound [`is_shadowed_payload`](Graph::is_shadowed_payload) already
300    /// holds the flat title and id scans to; this is its reachability-walk
301    /// counterpart, for `prov`'s `vocabulary_findings` and
302    /// `prov`'s `fixity_findings` — the two passes that load
303    /// every reachable path and read its frontmatter.
304    ///
305    /// The listing `is_shadowed_payload` needs is built the same way
306    /// `prov`'s `orphans` builds one: the direct children of every
307    /// directory the reachable set occupies, so a shadow check costs a set
308    /// lookup per candidate extension rather than a stat.
309    pub async fn reachable_documents(
310        &self,
311        start: &Path,
312        census: &[CensusEntry],
313        content_bodies: &[PathBuf],
314    ) -> Result<BTreeSet<PathBuf>> {
315        let reachable = reachable_set(start, census, content_bodies);
316        let reached_dirs = Self::reached_dirs(&reachable);
317        let probe = super::ShadowProbe::over(self.direct_child_files(&reached_dirs).await?.iter());
318        let mut documents = BTreeSet::new();
319        for path in reachable {
320            if !self.is_shadowed_payload(&path, &probe).await {
321                documents.insert(path);
322            }
323        }
324        Ok(documents)
325    }
326
327    /// Every file the workspace reaches from `start` that actually exists on
328    /// disk — [`reachable_set`] over a fresh walk, filtered to real files.
329    ///
330    /// This is §8's bounded walk expressed as a *file set* rather than a findings
331    /// list: the same population `check` validates. `prov`'s `Workspace::ignore_list`
332    /// subtracts it from a top-down walk of the folder to say what is *not* the
333    /// workspace — so the two answers come from one definition of what the
334    /// workspace considers its own, rather than two that can disagree.
335    ///
336    /// [`reachable_files_within`](Self::reachable_files_within) is the same walk
337    /// bounded away from directories prov parks its own bytes in.
338    pub async fn reachable_files(&self, start: impl AsRef<Path>) -> Result<BTreeSet<PathBuf>> {
339        self.reachable_files_within(start, &[]).await
340    }
341
342    /// [`reachable_files`](Self::reachable_files), told which directories are
343    /// parked — see [`title_index_scoped`](Self::title_index_scoped).
344    pub async fn reachable_files_within(
345        &self,
346        start: impl AsRef<Path>,
347        parked: &[PathBuf],
348    ) -> Result<BTreeSet<PathBuf>> {
349        let start = link::normalize(start);
350        let Walk {
351            census,
352            content_bodies,
353            ..
354        } = self.walk(&start, parked).await?;
355        let mut files = BTreeSet::new();
356        for path in reachable_set(&start, &census, &content_bodies) {
357            if self.fs().try_exists(&self.root().join(&path)).await? {
358                files.insert(path);
359            }
360        }
361        Ok(files)
362    }
363
364    /// Take a census of every forward link reachable from `start`: one
365    /// [`CensusEntry`] per frontmatter relation edge *and* per body link —
366    /// `[[…]]` wikilink, `[t](a)` link, `![a](t)` image — each carrying its
367    /// [`LinkSite`] and [`Resolution`].
368    ///
369    /// This is the one traversal the backlink map, the integrity findings, and
370    /// (via `mutate`) inbound-rename maintenance are all views over. Because it
371    /// is read from the documents, it is ground truth: a stored backlink index
372    /// heals *toward* the census, never the reverse.
373    pub async fn census(&self, start: impl AsRef<Path>) -> Result<Vec<CensusEntry>> {
374        self.census_within(start, &[]).await
375    }
376
377    /// [`census`](Self::census), told which directories are parked — see
378    /// [`title_index_scoped`](Self::title_index_scoped).
379    pub async fn census_within(
380        &self,
381        start: impl AsRef<Path>,
382        parked: &[PathBuf],
383    ) -> Result<Vec<CensusEntry>> {
384        Ok(self.walk(start.as_ref(), parked).await?.census)
385    }
386
387    /// The backlink map for the workspace reachable from `start`: every resolved
388    /// target to the inbound references ([`Backlink`]s) that reach it, path- and
389    /// id-form alike. This is the census inverted — recomputed from the
390    /// documents, so it is always fresh (the Route-N "reconcile-on-load": no
391    /// stored index to drift). Each target's backlinks are sorted by source.
392    pub async fn backlinks(
393        &self,
394        start: impl AsRef<Path>,
395    ) -> Result<BTreeMap<PathBuf, Vec<Backlink>>> {
396        Ok(invert(self.census(start).await?))
397    }
398
399    /// The inbound references to a single `target` (workspace-relative) reachable
400    /// from `start`, sorted by source. The focused form of
401    /// [`backlinks`](Self::backlinks) for "who links here?".
402    pub async fn backlinks_to(
403        &self,
404        start: impl AsRef<Path>,
405        target: impl AsRef<Path>,
406    ) -> Result<Vec<Backlink>> {
407        Ok(inbound(self.census(start).await?, target.as_ref()))
408    }
409
410    /// The shared spanning-tree walk: gathers the forward-link census and the
411    /// structural facts ([`StructuralFact`], which depend on traversal state,
412    /// not on a single link's resolution) in one pass. Frontmatter edges may
413    /// be spanning and so drive descent, the single-parent check, and the
414    /// inverse check; body links and images are always overlay references —
415    /// censused, never spanning.
416    ///
417    /// "One pass" describes what it *reports*, not how many times it opens a
418    /// file: descent reads each document, the inverse check reads every spanning
419    /// child again to see whether it points back, and a workspace using
420    /// `[[alias]]` links pays a third read per document for the title index.
421    /// Three reads of everything, for one walk. So the walk opens a scope of its
422    /// own rather than waiting to be given one — a caller with no interest in
423    /// memos still gets a walk that reads each document once, and a caller that
424    /// already opened one (`check`, a `mutate` verb) nests inside it and keeps
425    /// everything the walk read.
426    pub async fn walk(&self, start: &Path, parked: &[PathBuf]) -> Result<Walk> {
427        let _scope = self.read_scope();
428        let mut census = Vec::new();
429        let mut structural = Vec::new();
430        // Prose bodies reached through a separated node's `content` pointer.
431        // Kept out of the census (not a graph edge), but tracked so the orphan
432        // check does not mistake a linked body file for an unlinked one.
433        let mut content_bodies = Vec::new();
434        let mut visited = BTreeSet::new();
435        let mut queue = vec![link::normalize(start)];
436
437        // The nominal-resolution index, built lazily — only if a `[[alias]]` link
438        // is actually encountered. A path/id workspace never scans (which, at the
439        // root of a larger repo, would read every file under `target/`, vendored
440        // trees, and the rest — the reported multi-second `tree`/`check`).
441        let mut titles: Option<TitleIndex> = None;
442
443        let spanning = self.relations().spanning_relation().map(str::to_owned);
444        let inverse = spanning.as_deref().and_then(|s| {
445            self.relations()
446                .relations()
447                .iter()
448                .find(|r| r.name == s)
449                .and_then(|r| r.inverse.clone())
450        });
451
452        while let Some(path) = queue.pop() {
453            if !visited.insert(path.clone()) {
454                continue;
455            }
456            let doc = match self.load(&path).await {
457                Ok((_, doc)) => doc,
458                Err(e) => {
459                    structural.push(StructuralFact::Unreadable {
460                        doc: path,
461                        error: e.to_string(),
462                    });
463                    continue;
464                }
465            };
466            let meta = fig::Value::from(&doc.meta);
467
468            // Reconcile a self-stored `id` against the registry (frontmatter
469            // storage, DESIGN §5). Three outcomes when a document carries its own
470            // `id`: the registry agrees (nothing to do); the registry records a
471            // *different* id for this path, or hands this id to another document
472            // (`IdMismatch` — a drift); or the registry has never heard of the id
473            // (`UnregisteredId` — the shadow got ahead of the cache).
474            if let Some(fm) = meta.get("id").and_then(fig::Value::as_str)
475                && !fm.trim().is_empty()
476            {
477                let fm = Id(fm.trim().to_string());
478                match self.index().id_for_path(&path) {
479                    Some(reg) if reg != fm => structural.push(StructuralFact::IdMismatch {
480                        doc: path.clone(),
481                        frontmatter: fm,
482                        registry: Some(reg),
483                    }),
484                    Some(_) => {} // the registry agrees with the frontmatter
485                    None => match self.index().resolve(&fm) {
486                        // The id is live, but points at a *different* document.
487                        Some(other) if other != path => {
488                            structural.push(StructuralFact::IdMismatch {
489                                doc: path.clone(),
490                                frontmatter: fm,
491                                registry: None,
492                            })
493                        }
494                        // resolve == this path but no reverse entry: consistent.
495                        Some(_) => {}
496                        // The registry has no record of this id at all.
497                        None => structural.push(StructuralFact::UnregisteredId {
498                            doc: path.clone(),
499                            frontmatter: fm,
500                        }),
501                    },
502                }
503            } else if self.id_storage().stamps_frontmatter()
504                && let Some(reg) = self.index().id_for_path(&path)
505            {
506                // The other direction: a stamping workspace expects every
507                // registered document to carry its own id, and this one does not
508                // (a workspace converted from registry-only storage, or an `id`
509                // stripped out of band). The registry is the authority — the id
510                // is already live and linked to — so the repair writes it down.
511                structural.push(StructuralFact::UnstampedId {
512                    doc: path.clone(),
513                    registry: reg,
514                });
515            }
516
517            // Frontmatter relation edges — the only links that can be spanning.
518            for edge in self.relations().edges(&meta) {
519                // Parse once: `link.target` is the bare target (any `[label](…)`
520                // stripped), which is what both the census and findings record.
521                let link = Link::parse(&edge.target);
522                if titles.is_none() && title::is_alias_shaped(&link.target) {
523                    titles = Some(self.title_index_scoped(start, parked).await?);
524                }
525                let resolution = self.resolve_forward(&path, &link, titles.as_ref()).await;
526
527                if Some(edge.relation.as_str()) == spanning.as_deref()
528                    && let Some(resolved) = resolution.resolved_path().cloned()
529                {
530                    // Single-parent check, inverse check, descent.
531                    if visited.contains(&resolved) || queue.contains(&resolved) {
532                        structural.push(StructuralFact::DuplicateContainment {
533                            doc: path.clone(),
534                            target: link.target.clone(),
535                        });
536                    } else {
537                        if let Some(inverse) = inverse.as_deref()
538                            && let Ok((_, child_doc)) = self.load(&resolved).await
539                            && child_doc.has_meta()
540                        {
541                            let child_meta = fig::Value::from(&child_doc.meta);
542                            let inverse_targets = child_meta
543                                .get(inverse)
544                                .map(crate::meta::link_strings)
545                                .unwrap_or_default();
546                            // Build the title index if a nominal inverse link needs it.
547                            if titles.is_none()
548                                && inverse_targets
549                                    .iter()
550                                    .any(|t| title::is_alias_shaped(&Link::parse(t).target))
551                            {
552                                titles = Some(self.title_index_scoped(start, parked).await?);
553                            }
554                            let points_back = inverse_targets.iter().any(|t| {
555                                self.resolve_link_with(&resolved, &Link::parse(t), titles.as_ref())
556                                    == Target::Path(path.clone())
557                            });
558                            if !points_back {
559                                structural.push(StructuralFact::MissingInverse {
560                                    doc: path.clone(),
561                                    child: resolved.clone(),
562                                    inverse: inverse.to_string(),
563                                });
564                            }
565                        }
566                        queue.push(resolved);
567                    }
568                }
569
570                census.push(CensusEntry {
571                    source: path.clone(),
572                    site: LinkSite::Relation {
573                        field: edge.relation,
574                        index: edge.index,
575                    },
576                    label: link.label,
577                    target_text: link.target,
578                    resolution,
579                });
580            }
581
582            // Body links — `[[wikilinks]]`, markdown/djot `[t](a)` links and
583            // `![a](t)` images alike — overlay references, censused but never
584            // spanning. An image names a payload rather than a document, and a
585            // payload is not a node; but the page has said where its picture
586            // is, and a picture that is not there is exactly what `check` is
587            // for. So an image is censused **by path only**: a bare name in an
588            // image is a file that is missing, never a nominal reference to a
589            // document by title, and it neither builds the title index nor
590            // resolves through it.
591            for body_link in link::scan_body_links(&path, &doc.body) {
592                let image = body_link.image;
593                let wl = body_link.link;
594                if !image && titles.is_none() && title::is_alias_shaped(&wl.target) {
595                    titles = Some(self.title_index_scoped(start, parked).await?);
596                }
597                let resolution = self
598                    .resolve_forward(&path, &wl, titles.as_ref().filter(|_| !image))
599                    .await;
600                census.push(CensusEntry {
601                    source: path.clone(),
602                    site: LinkSite::Body(body_link.span),
603                    label: wl.label,
604                    target_text: wl.target,
605                    resolution,
606                });
607            }
608
609            // A separated document's `content` must resolve to an existing body
610            // file. Validated here (not a graph edge, so kept out of the census).
611            if let Some(content) = doc.content_attr() {
612                let target = link::resolve(&path, content);
613                let site = LinkSite::field("content");
614                match self.exact_name(&target).await {
615                    NameMatch::Exact => content_bodies.push(target),
616                    NameMatch::CaseOnly(actual) => {
617                        // The linked body exists under a different case: record its
618                        // real name as reached (so it is not also an orphan), and
619                        // still flag the portability hazard.
620                        content_bodies.push(target.with_file_name(&actual));
621                        structural.push(StructuralFact::CaseMismatch {
622                            doc: path.clone(),
623                            site,
624                            target: content.to_string(),
625                            actual,
626                        });
627                    }
628                    NameMatch::None => structural.push(StructuralFact::BrokenLink {
629                        doc: path.clone(),
630                        site,
631                        target: content.to_string(),
632                    }),
633                }
634            }
635
636            // A manifest node's `manifest` must resolve to an existing document,
637            // the same way and for the same reason: it is not a graph edge (the
638            // manifest is machinery, carrying no `part_of` and no id), but it
639            // does reach a file, so the orphan pass must count it as reached.
640            //
641            // The rows *inside* it reach files too, and deliberately do not
642            // arrive here. A covered file is opaque bytes — never a content
643            // document, so never an orphan candidate — and adding ten thousand
644            // of them to every walk's reachable set would make a photo archive
645            // pay for a check none of those files can fail. What the manifest
646            // promises about them is `check`'s manifest pass, once, not the
647            // census's, per document.
648            if let Some(manifest) = doc.manifest_attr() {
649                if doc.content_attr().is_some() {
650                    structural.push(StructuralFact::ManifestConflict { doc: path.clone() });
651                }
652                let target = link::resolve(&path, manifest);
653                let site = LinkSite::field(crate::manifest::MANIFEST_KEY);
654                match self.exact_name(&target).await {
655                    NameMatch::Exact => content_bodies.push(target),
656                    NameMatch::CaseOnly(actual) => {
657                        content_bodies.push(target.with_file_name(&actual));
658                        structural.push(StructuralFact::CaseMismatch {
659                            doc: path.clone(),
660                            site,
661                            target: manifest.to_string(),
662                            actual,
663                        });
664                    }
665                    NameMatch::None => structural.push(StructuralFact::BrokenLink {
666                        doc: path.clone(),
667                        site,
668                        target: manifest.to_string(),
669                    }),
670                }
671            }
672        }
673        Ok(Walk {
674            census,
675            facts: structural,
676            content_bodies,
677        })
678    }
679
680    /// Resolve one forward link (declared in the document at `source`) into a
681    /// [`Resolution`]. A path target is checked against the on-disk name; an
682    /// `id:<id>` target resolves through the registry and stays an id-form
683    /// resolution; an `id:<workspace>/<id>` target naming another workspace
684    /// stops at [`Resolution::Foreign`]; a nominal (`[[My File]]`) target
685    /// resolves through `titles` — `Unique` to the on-disk path, `Ambiguous` to
686    /// [`Resolution::AmbiguousAlias`], `Unknown` falling through to a path (so a
687    /// nominal link to nothing reports as `Broken`, like any dead link).
688    async fn resolve_forward(
689        &self,
690        source: &Path,
691        link: &Link,
692        titles: Option<&TitleIndex>,
693    ) -> Resolution {
694        if link.is_external() {
695            return Resolution::External;
696        }
697        if link.is_same_document() {
698            return Resolution::SameDocument;
699        }
700        // Mirrors `Workspace::resolve_link_with`: a reference qualified with
701        // this workspace's own name is local, any other qualifier is foreign,
702        // and a malformed `id:` body is a broken id rather than a filename that
703        // happens to contain a colon.
704        let local_id = match link.id_ref() {
705            Some(crate::link::IdRef::Local(id)) => Some(id),
706            Some(crate::link::IdRef::Foreign { workspace, id }) => {
707                if self.workspace_id().is_empty() || workspace != self.workspace_id() {
708                    return Resolution::Foreign { workspace, id };
709                }
710                Some(id)
711            }
712            Some(crate::link::IdRef::Malformed) => return Resolution::MalformedId,
713            None => None,
714        };
715        if let Some(id) = local_id {
716            if !identity::verify(id.as_str()) {
717                return Resolution::MalformedId;
718            }
719            return match self.index().resolve(&id) {
720                Some(path) => Resolution::Id {
721                    id,
722                    to: link::normalize(path),
723                },
724                None => Resolution::DanglingId {
725                    tombstoned: self.index().is_known(&id),
726                    id,
727                },
728            };
729        }
730        // Only a nominal link needs the title index; the caller builds it lazily
731        // the first time one appears, so `titles` is `Some` here whenever it is
732        // consulted. If absent, fall through to path resolution.
733        //
734        // The *addressed* target, not the whole one — the same care
735        // `resolve_link_with` takes: a locator names a place inside the document
736        // an alias names, so `[[My File#v2]]` is the nominal reference
737        // `[[My File]]`. Asking the index for the spelling with the locator
738        // still on it misses every time and falls through to the path branch,
739        // which then reports a live document as a broken link.
740        let addressed = link.addressed_target();
741        if let Some(titles) = titles.filter(|_| title::is_alias_shaped(addressed)) {
742            match titles.resolve(addressed) {
743                TitleMatch::Unique(path) => {
744                    return match self.exact_name(&path).await {
745                        NameMatch::Exact => Resolution::Path(path),
746                        NameMatch::CaseOnly(actual) => {
747                            Resolution::CaseMismatch { got: path, actual }
748                        }
749                        NameMatch::None => Resolution::Broken,
750                    };
751                }
752                TitleMatch::Ambiguous(candidates) => {
753                    return Resolution::AmbiguousAlias {
754                        name: link.target.clone(),
755                        candidates,
756                    };
757                }
758                TitleMatch::Unknown => {}
759            }
760        }
761        let resolved = link::resolve(source, &link.target);
762        match self.exact_name(&resolved).await {
763            NameMatch::Exact => Resolution::Path(resolved),
764            NameMatch::CaseOnly(actual) => Resolution::CaseMismatch {
765                got: resolved,
766                actual,
767            },
768            NameMatch::None => Resolution::Broken,
769        }
770    }
771
772    /// How `path`'s final component matches its parent directory's listing:
773    /// exactly, only case-insensitively (the portability hazard), or not at all.
774    ///
775    /// Answered from the read scope's directory memo where there is one
776    /// ([`crate::memo`]). This runs once per link resolved, and a workspace's
777    /// links point overwhelmingly at directories the same walk has already
778    /// asked about — without the memo, a flat workspace of N documents reads
779    /// one directory of N entries N times over, which is where `check`'s cost
780    /// went quadratic. Outside a scope it is the plain directory read it always
781    /// was.
782    async fn exact_name(&self, path: &Path) -> NameMatch {
783        let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
784            return NameMatch::None;
785        };
786        let names = match self.memo_dir(parent) {
787            Some(hit) => hit,
788            None => {
789                let Ok(entries) = self.fs().read_dir(&self.root().join(parent)).await else {
790                    return NameMatch::None;
791                };
792                let names = Arc::new(DirNames::index(&entries));
793                self.memo_remember_dir(parent, Arc::clone(&names));
794                names
795            }
796        };
797        if names.holds(name) {
798            return NameMatch::Exact;
799        }
800        match names.case_variant(name) {
801            Some(actual) => NameMatch::CaseOnly(actual.to_string_lossy().into_owned()),
802            None => NameMatch::None,
803        }
804    }
805}
806
807// These tests use YAML frontmatter fixtures, so they run under the `yaml` feature.
808#[cfg(all(test, feature = "yaml"))]
809mod tests {
810    use super::*;
811    use crate::exec::block_on;
812    use crate::fs::StdFs;
813    use crate::graph::ReadSettings;
814    use crate::index::NoIndex;
815
816    use prov_testkit::write;
817    fn tempdir(tag: &str) -> PathBuf {
818        prov_testkit::scratch("census", tag)
819    }
820
821    /// A [`StdFs`] that counts its directory reads — the observable behind the
822    /// claim that resolution asks a directory about itself once per operation
823    /// and not once per link.
824    #[derive(Debug, Default)]
825    struct CountingFs {
826        reads: std::cell::Cell<usize>,
827    }
828
829    impl CountingFs {
830        fn dir_reads(&self) -> usize {
831            self.reads.get()
832        }
833    }
834
835    impl ReadStorage for CountingFs {
836        async fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
837            StdFs.read(path).await
838        }
839        async fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
840            StdFs.read_to_string(path).await
841        }
842        async fn read_dir(&self, path: &Path) -> std::io::Result<Vec<crate::fs::DirEntry>> {
843            self.reads.set(self.reads.get() + 1);
844            StdFs.read_dir(path).await
845        }
846        async fn metadata(&self, path: &Path) -> std::io::Result<crate::fs::Metadata> {
847            StdFs.metadata(path).await
848        }
849    }
850
851    /// The regression this memo exists for. Every link resolved asks its target's
852    /// parent directory whether the name is there, so without a memo a workspace
853    /// of N documents in one directory reads that directory ~N times — the
854    /// quadratic term that made `check` unusable on a few thousand documents.
855    /// One directory, one read.
856    #[test]
857    fn a_walk_reads_each_directory_once_however_many_links_point_into_it() {
858        let dir = tempdir("dir-memo");
859        let children: Vec<String> = (0..24).map(|i| format!("n{i}.md")).collect();
860        let contents = children
861            .iter()
862            .map(|c| format!("- {c}"))
863            .collect::<Vec<_>>()
864            .join("\n");
865        write(
866            &dir,
867            "index.md",
868            format!("---\ncontents:\n{contents}\n---\n"),
869        );
870        for child in &children {
871            write(&dir, child, "---\npart_of: index.md\n---\n");
872        }
873
874        let ws = Graph::new(
875            CountingFs::default(),
876            &dir,
877            NoIndex,
878            ReadSettings::default(),
879        );
880        let census = block_on(ws.census("index.md")).unwrap();
881        assert_eq!(census.len(), 48, "24 children, each edge and its inverse");
882        assert_eq!(
883            ws.fs().dir_reads(),
884            1,
885            "48 links into one directory should cost one listing, not 48"
886        );
887    }
888
889    /// The memo is bounded by a scope, and `census` opens its own — so a second
890    /// census sees the directory as it stands now, not as the first one found it.
891    #[test]
892    fn a_directory_read_does_not_outlive_the_operation_that_made_it() {
893        let dir = tempdir("dir-memo-scope");
894        write(&dir, "index.md", "---\ncontents:\n- a.md\n---\n");
895
896        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
897        let before = block_on(ws.census("index.md")).unwrap();
898        assert!(
899            before
900                .iter()
901                .any(|e| matches!(e.resolution, Resolution::Broken)),
902            "a.md is not there yet: {before:?}"
903        );
904
905        write(&dir, "a.md", "---\npart_of: index.md\n---\n");
906        let after = block_on(ws.census("index.md")).unwrap();
907        assert!(
908            after
909                .iter()
910                .all(|e| !matches!(e.resolution, Resolution::Broken)),
911            "the second census resolved against a stale listing: {after:?}"
912        );
913    }
914
915    #[test]
916    fn census_covers_frontmatter_edges_and_body_wikilinks() {
917        let dir = tempdir("census");
918        write(
919            &dir,
920            "index.md",
921            "---\ncontents:\n- a.md\n---\nBody links [[a.md]] and [[gone.md]].\n",
922        );
923        write(&dir, "a.md", "---\npart_of: index.md\n---\n");
924        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
925        let census = block_on(ws.census("index.md")).unwrap();
926
927        // The frontmatter `contents` edge, resolving to the existing file.
928        assert!(
929            census.iter().any(|e| e.site.relation() == Some("contents")
930                && matches!(&e.resolution, Resolution::Path(p) if p == &PathBuf::from("a.md"))),
931            "{census:?}"
932        );
933        // The body wikilink to the same file — sited in the body, resolving.
934        assert!(
935            census.iter().any(|e| matches!(e.site, LinkSite::Body(_))
936                && e.target_text == "a.md"
937                && matches!(&e.resolution, Resolution::Path(_))),
938            "{census:?}"
939        );
940        // The body wikilink to a missing file — a Broken resolution.
941        assert!(
942            census
943                .iter()
944                .any(|e| e.target_text == "gone.md" && matches!(e.resolution, Resolution::Broken)),
945            "{census:?}"
946        );
947    }
948
949    #[test]
950    fn a_same_document_anchor_is_a_clean_resolution_not_a_broken_link() {
951        let dir = tempdir("anchor");
952        write(
953            &dir,
954            "index.md",
955            "---\ncontents:\n- a.md\n---\n## Section One\n\nSee [Section One](#section-one).\n",
956        );
957        write(&dir, "a.md", "---\npart_of: index.md\n---\n");
958        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
959        let census = block_on(ws.census("index.md")).unwrap();
960
961        let anchor = census
962            .iter()
963            .find(|e| e.target_text == "#section-one")
964            .expect("the anchor is still a link the census reports");
965        assert_eq!(anchor.resolution, Resolution::SameDocument, "{census:?}");
966        // And so it is no backlink: index.md's inbound references are a.md's
967        // `part_of` and nothing else — the anchor did not make the document
968        // link to itself.
969        let inbound = block_on(ws.backlinks_to("index.md", "index.md")).unwrap();
970        assert!(
971            inbound.iter().all(|bl| bl.source != Path::new("index.md")),
972            "{inbound:?}"
973        );
974    }
975
976    #[test]
977    fn an_alias_with_a_locator_resolves_to_the_document_the_alias_names() {
978        // §4's equivalence, at the layer `check` actually reads: the locator
979        // changes where in a document a reader lands, never which document is
980        // found. Asking the title index for `Mosiah 1#v2` misses every time and
981        // used to fall through to a path, reporting a live document as broken.
982        let dir = tempdir("alias-locator");
983        write(
984            &dir,
985            "index.md",
986            "---\ncontents:\n- mosiah-1.md\n---\nSee [[Mosiah 1#v2]] and [[Mosiah 1]].\n",
987        );
988        write(
989            &dir,
990            "mosiah-1.md",
991            "---\ntitle: Mosiah 1\npart_of: index.md\n---\n",
992        );
993        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
994        let census = block_on(ws.census("index.md")).unwrap();
995
996        let located = census
997            .iter()
998            .find(|e| e.target_text == "Mosiah 1#v2")
999            .expect("the located alias is in the census");
1000        let plain = census
1001            .iter()
1002            .find(|e| e.target_text == "Mosiah 1")
1003            .expect("the plain alias is in the census");
1004        assert_eq!(located.resolution, plain.resolution, "{census:?}");
1005        assert_eq!(
1006            located.resolution,
1007            Resolution::Path(PathBuf::from("mosiah-1.md")),
1008            "{census:?}"
1009        );
1010    }
1011
1012    #[test]
1013    fn backlinks_invert_the_census_across_relations_and_body() {
1014        let dir = tempdir("backlinks");
1015        write(&dir, "index.md", "---\ncontents:\n- a.md\n- b.md\n---\n");
1016        write(&dir, "a.md", "---\npart_of: index.md\n---\n");
1017        write(
1018            &dir,
1019            "b.md",
1020            "---\npart_of: index.md\nlinks:\n- a.md\n---\nSee [[a.md]] again.\n",
1021        );
1022        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
1023
1024        // Who links to a.md? index.md (contents), b.md (links), b.md (body).
1025        let to_a = block_on(ws.backlinks_to("index.md", "a.md")).unwrap();
1026        assert_eq!(to_a.len(), 3, "{to_a:?}");
1027        assert!(
1028            to_a.iter()
1029                .any(|bl| bl.source == Path::new("index.md")
1030                    && bl.site.relation() == Some("contents")),
1031            "{to_a:?}"
1032        );
1033        assert!(
1034            to_a.iter()
1035                .any(|bl| bl.source == Path::new("b.md") && bl.site.relation() == Some("links")),
1036            "{to_a:?}"
1037        );
1038        assert!(
1039            to_a.iter()
1040                .any(|bl| bl.source == Path::new("b.md") && matches!(bl.site, LinkSite::Body(_))),
1041            "{to_a:?}"
1042        );
1043        // All path-form (this workspace has no registry / id links).
1044        assert!(to_a.iter().all(|bl| !bl.by_id), "{to_a:?}");
1045
1046        // The full map keys targets by path; a.md is one of them.
1047        let map = block_on(ws.backlinks("index.md")).unwrap();
1048        assert_eq!(map[&PathBuf::from("a.md")].len(), 3);
1049    }
1050}
1051
1052/// Invert a census into a backlink map: every resolved target to the inbound
1053/// references that reach it, each target's sorted by source.
1054///
1055/// A free function over an already-taken census, rather than a method that takes
1056/// one, because the caller who has to bound the walk — `prov`, which knows where
1057/// it parks its own bytes — has already done the walking. Taking the census as
1058/// an argument is what lets the bounded and unbounded callers share this.
1059pub fn invert(census: Vec<CensusEntry>) -> BTreeMap<PathBuf, Vec<Backlink>> {
1060    let mut map: BTreeMap<PathBuf, Vec<Backlink>> = BTreeMap::new();
1061    for entry in census {
1062        let by_id = matches!(entry.resolution, Resolution::Id { .. });
1063        let Some(target) = entry.resolution.resolved_path().cloned() else {
1064            continue;
1065        };
1066        map.entry(target).or_default().push(Backlink {
1067            source: entry.source,
1068            site: entry.site,
1069            by_id,
1070        });
1071    }
1072    for links in map.values_mut() {
1073        links.sort_by(|a, b| a.source.cmp(&b.source).then(a.by_id.cmp(&b.by_id)));
1074    }
1075    map
1076}
1077
1078/// The inbound references to one `target` within an already-taken census,
1079/// sorted by source — [`invert`] focused on a single entry.
1080pub fn inbound(census: Vec<CensusEntry>, target: &Path) -> Vec<Backlink> {
1081    let target = link::normalize(target);
1082    let mut links: Vec<Backlink> = census
1083        .into_iter()
1084        .filter(|entry| entry.resolution.resolved_path() == Some(&target))
1085        .map(|entry| {
1086            let by_id = matches!(entry.resolution, Resolution::Id { .. });
1087            Backlink {
1088                source: entry.source,
1089                site: entry.site,
1090                by_id,
1091            }
1092        })
1093        .collect();
1094    links.sort_by(|a, b| a.source.cmp(&b.source).then(a.by_id.cmp(&b.by_id)));
1095    links
1096}