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