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