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