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