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