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