Skip to main content

prov_graph/graph/
census.rs

1//! The census types, the spanning-tree walker that fills them in, and the
2//! reachability views built over the result. See the module doc at
3//! [`crate::graph`] for why the census is ground truth.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::fmt;
7use std::ops::Range;
8use std::path::{Path, PathBuf};
9
10use super::Graph;
11use crate::error::Result;
12use crate::fs::ReadStorage;
13use crate::identity::{self, Id};
14use crate::index::IdIndex;
15use crate::link::{self, Link};
16use crate::title::{self, TitleIndex, TitleMatch};
17
18use super::Target;
19
20/// Where in a document a forward link is written — a frontmatter relation
21/// field or a body wikilink. Carried by every link-resolution finding
22/// (`prov`'s `Finding`, derived in `validate` — see
23/// [`StructuralFact`]) and every [`CensusEntry`] so a report can point at the
24/// exact site.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum LinkSite {
27    /// A frontmatter relation field, by name (e.g. `contents`, `links`).
28    Relation(String),
29    /// A `[[…]]` wikilink in the body, at this byte span.
30    Body(Range<usize>),
31}
32
33impl fmt::Display for LinkSite {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            LinkSite::Relation(name) => f.write_str(name),
37            LinkSite::Body(_) => f.write_str("body"),
38        }
39    }
40}
41
42/// How a forward link resolves against the workspace. Path and id forms stay
43/// distinct on purpose: the registry owns id resolution (location-independent,
44/// stable across moves), while a path is checked against the on-disk name — so
45/// a caller can tell which links a rename must rewrite (paths) from which it
46/// must leave alone (ids).
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Resolution {
49    /// A path target that resolves to an existing file (exact name).
50    Path(PathBuf),
51    /// A path target that only matches case-insensitively; `got` is the target
52    /// as resolved, `actual` the exact on-disk name.
53    CaseMismatch { got: PathBuf, actual: String },
54    /// A path target with nothing on disk.
55    Broken,
56    /// A `prov:<id>` target the registry resolves to the live path `to`.
57    Id { id: Id, to: PathBuf },
58    /// A well-formed `prov:<id>` target with no live registry entry;
59    /// `tombstoned` separates "deleted" from "never issued here" (§4 hazard).
60    DanglingId { id: Id, tombstoned: bool },
61    /// A `prov:<id>` target failing its check character — a typo.
62    MalformedId,
63    /// A nominal (alias) target several documents claim — unresolvable.
64    /// `candidates` are the sharers, sorted.
65    AmbiguousAlias {
66        name: String,
67        candidates: Vec<PathBuf>,
68    },
69    /// A URL / mail address — off-workspace, never resolved or rewritten.
70    External,
71    /// 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::history_capture` captures
292    /// it (minus prov's two byte-parking stores) precisely so that an event is a
293    /// consistent cut across everything the workspace considers its own.
294    ///
295    /// `prov`'s `Workspace::history_capture`: `prov`'s `Workspace::history_capture`
296    pub async fn reachable_files(&self, start: impl AsRef<Path>) -> Result<BTreeSet<PathBuf>> {
297        self.reachable_files_within(start, &[]).await
298    }
299
300    /// [`reachable_files`](Self::reachable_files), told which directories are
301    /// parked — see [`title_index_scoped`](Self::title_index_scoped).
302    pub async fn reachable_files_within(
303        &self,
304        start: impl AsRef<Path>,
305        parked: &[PathBuf],
306    ) -> Result<BTreeSet<PathBuf>> {
307        let start = link::normalize(start);
308        let Walk {
309            census,
310            content_bodies,
311            ..
312        } = self.walk(&start, parked).await?;
313        let mut files = BTreeSet::new();
314        for path in reachable_set(&start, &census, &content_bodies) {
315            if self.fs().try_exists(&self.root().join(&path)).await? {
316                files.insert(path);
317            }
318        }
319        Ok(files)
320    }
321
322    /// Take a census of every forward link reachable from `start`: one
323    /// [`CensusEntry`] per frontmatter relation edge *and* per body `[[…]]`
324    /// wikilink, each carrying its [`LinkSite`] and [`Resolution`].
325    ///
326    /// This is the one traversal the backlink map, the integrity findings, and
327    /// (via `mutate`) inbound-rename maintenance are all views over. Because it
328    /// is read from the documents, it is ground truth: a stored backlink index
329    /// heals *toward* the census, never the reverse.
330    pub async fn census(&self, start: impl AsRef<Path>) -> Result<Vec<CensusEntry>> {
331        self.census_within(start, &[]).await
332    }
333
334    /// [`census`](Self::census), told which directories are parked — see
335    /// [`title_index_scoped`](Self::title_index_scoped).
336    pub async fn census_within(
337        &self,
338        start: impl AsRef<Path>,
339        parked: &[PathBuf],
340    ) -> Result<Vec<CensusEntry>> {
341        Ok(self.walk(start.as_ref(), parked).await?.census)
342    }
343
344    /// The backlink map for the workspace reachable from `start`: every resolved
345    /// target to the inbound references ([`Backlink`]s) that reach it, path- and
346    /// id-form alike. This is the census inverted — recomputed from the
347    /// documents, so it is always fresh (the Route-N "reconcile-on-load": no
348    /// stored index to drift). Each target's backlinks are sorted by source.
349    pub async fn backlinks(
350        &self,
351        start: impl AsRef<Path>,
352    ) -> Result<BTreeMap<PathBuf, Vec<Backlink>>> {
353        Ok(invert(self.census(start).await?))
354    }
355
356    /// The inbound references to a single `target` (workspace-relative) reachable
357    /// from `start`, sorted by source. The focused form of
358    /// [`backlinks`](Self::backlinks) for "who links here?".
359    pub async fn backlinks_to(
360        &self,
361        start: impl AsRef<Path>,
362        target: impl AsRef<Path>,
363    ) -> Result<Vec<Backlink>> {
364        Ok(inbound(self.census(start).await?, target.as_ref()))
365    }
366
367    /// The shared spanning-tree walk: gathers the forward-link census and the
368    /// structural facts ([`StructuralFact`], which depend on traversal state,
369    /// not on a single link's resolution) in one pass. Frontmatter edges may
370    /// be spanning and so drive descent, the single-parent check, and the
371    /// inverse check; body wikilinks are always overlay references —
372    /// censused, never spanning.
373    pub async fn walk(&self, start: &Path, parked: &[PathBuf]) -> Result<Walk> {
374        let mut census = Vec::new();
375        let mut structural = Vec::new();
376        // Prose bodies reached through a separated node's `content` pointer.
377        // Kept out of the census (not a graph edge), but tracked so the orphan
378        // check does not mistake a linked body file for an unlinked one.
379        let mut content_bodies = Vec::new();
380        let mut visited = BTreeSet::new();
381        let mut queue = vec![link::normalize(start)];
382
383        // The nominal-resolution index, built lazily — only if a `[[alias]]` link
384        // is actually encountered. A path/id workspace never scans (which, at the
385        // root of a larger repo, would read every file under `target/`, vendored
386        // trees, and the rest — the reported multi-second `tree`/`check`).
387        let mut titles: Option<TitleIndex> = None;
388
389        let spanning = self.relations().spanning_relation().map(str::to_owned);
390        let inverse = spanning.as_deref().and_then(|s| {
391            self.relations()
392                .relations()
393                .iter()
394                .find(|r| r.name == s)
395                .and_then(|r| r.inverse.clone())
396        });
397
398        while let Some(path) = queue.pop() {
399            if !visited.insert(path.clone()) {
400                continue;
401            }
402            let doc = match self.load(&path).await {
403                Ok((_, doc)) => doc,
404                Err(e) => {
405                    structural.push(StructuralFact::Unreadable {
406                        doc: path,
407                        error: e.to_string(),
408                    });
409                    continue;
410                }
411            };
412            let meta = fig::Value::from(&doc.meta);
413
414            // Reconcile a self-stored `id` against the registry (frontmatter
415            // storage, DESIGN §5). Three outcomes when a document carries its own
416            // `id`: the registry agrees (nothing to do); the registry records a
417            // *different* id for this path, or hands this id to another document
418            // (`IdMismatch` — a drift); or the registry has never heard of the id
419            // (`UnregisteredId` — the shadow got ahead of the cache).
420            if let Some(fm) = meta.get("id").and_then(fig::Value::as_str)
421                && !fm.trim().is_empty()
422            {
423                let fm = Id(fm.trim().to_string());
424                match self.index().id_for_path(&path) {
425                    Some(reg) if reg != fm => structural.push(StructuralFact::IdMismatch {
426                        doc: path.clone(),
427                        frontmatter: fm,
428                        registry: Some(reg),
429                    }),
430                    Some(_) => {} // the registry agrees with the frontmatter
431                    None => match self.index().resolve(&fm) {
432                        // The id is live, but points at a *different* document.
433                        Some(other) if other != path => {
434                            structural.push(StructuralFact::IdMismatch {
435                                doc: path.clone(),
436                                frontmatter: fm,
437                                registry: None,
438                            })
439                        }
440                        // resolve == this path but no reverse entry: consistent.
441                        Some(_) => {}
442                        // The registry has no record of this id at all.
443                        None => structural.push(StructuralFact::UnregisteredId {
444                            doc: path.clone(),
445                            frontmatter: fm,
446                        }),
447                    },
448                }
449            } else if self.id_storage().stamps_frontmatter()
450                && let Some(reg) = self.index().id_for_path(&path)
451            {
452                // The other direction: a stamping workspace expects every
453                // registered document to carry its own id, and this one does not
454                // (a workspace converted from registry-only storage, or an `id`
455                // stripped out of band). The registry is the authority — the id
456                // is already live and linked to — so the repair writes it down.
457                structural.push(StructuralFact::UnstampedId {
458                    doc: path.clone(),
459                    registry: reg,
460                });
461            }
462
463            // Frontmatter relation edges — the only links that can be spanning.
464            for edge in self.relations().edges(&meta) {
465                // Parse once: `link.target` is the bare target (any `[label](…)`
466                // stripped), which is what both the census and findings record.
467                let link = Link::parse(&edge.target);
468                if titles.is_none() && title::is_alias_shaped(&link.target) {
469                    titles = Some(self.title_index_scoped(start, parked).await?);
470                }
471                let resolution = self.resolve_forward(&path, &link, titles.as_ref()).await;
472
473                if Some(edge.relation.as_str()) == spanning.as_deref()
474                    && let Some(resolved) = resolution.resolved_path().cloned()
475                {
476                    // Single-parent check, inverse check, descent.
477                    if visited.contains(&resolved) || queue.contains(&resolved) {
478                        structural.push(StructuralFact::DuplicateContainment {
479                            doc: path.clone(),
480                            target: link.target.clone(),
481                        });
482                    } else {
483                        if let Some(inverse) = inverse.as_deref()
484                            && let Ok((_, child_doc)) = self.load(&resolved).await
485                            && child_doc.has_meta()
486                        {
487                            let child_meta = fig::Value::from(&child_doc.meta);
488                            let inverse_targets = child_meta
489                                .get(inverse)
490                                .map(crate::meta::link_strings)
491                                .unwrap_or_default();
492                            // Build the title index if a nominal inverse link needs it.
493                            if titles.is_none()
494                                && inverse_targets
495                                    .iter()
496                                    .any(|t| title::is_alias_shaped(&Link::parse(t).target))
497                            {
498                                titles = Some(self.title_index_scoped(start, parked).await?);
499                            }
500                            let points_back = inverse_targets.iter().any(|t| {
501                                self.resolve_link_with(&resolved, &Link::parse(t), titles.as_ref())
502                                    == Target::Path(path.clone())
503                            });
504                            if !points_back {
505                                structural.push(StructuralFact::MissingInverse {
506                                    doc: path.clone(),
507                                    child: resolved.clone(),
508                                    inverse: inverse.to_string(),
509                                });
510                            }
511                        }
512                        queue.push(resolved);
513                    }
514                }
515
516                census.push(CensusEntry {
517                    source: path.clone(),
518                    site: LinkSite::Relation(edge.relation),
519                    label: link.label,
520                    target_text: link.target,
521                    resolution,
522                });
523            }
524
525            // Body links — `[[wikilinks]]` and markdown/djot `[t](a)` links
526            // alike — overlay references, censused but never spanning.
527            for body_link in link::scan_body_links(&path, &doc.body) {
528                let wl = body_link.link;
529                if titles.is_none() && title::is_alias_shaped(&wl.target) {
530                    titles = Some(self.title_index_scoped(start, parked).await?);
531                }
532                let resolution = self.resolve_forward(&path, &wl, titles.as_ref()).await;
533                census.push(CensusEntry {
534                    source: path.clone(),
535                    site: LinkSite::Body(body_link.span),
536                    label: wl.label,
537                    target_text: wl.target,
538                    resolution,
539                });
540            }
541
542            // A separated document's `content` must resolve to an existing body
543            // file. Validated here (not a graph edge, so kept out of the census).
544            if let Some(content) = doc.content_attr() {
545                let target = link::resolve(&path, content);
546                let site = LinkSite::Relation("content".to_string());
547                match self.exact_name(&target).await {
548                    NameMatch::Exact => content_bodies.push(target),
549                    NameMatch::CaseOnly(actual) => {
550                        // The linked body exists under a different case: record its
551                        // real name as reached (so it is not also an orphan), and
552                        // still flag the portability hazard.
553                        content_bodies.push(target.with_file_name(&actual));
554                        structural.push(StructuralFact::CaseMismatch {
555                            doc: path.clone(),
556                            site,
557                            target: content.to_string(),
558                            actual,
559                        });
560                    }
561                    NameMatch::None => structural.push(StructuralFact::BrokenLink {
562                        doc: path.clone(),
563                        site,
564                        target: content.to_string(),
565                    }),
566                }
567            }
568
569            // A manifest node's `manifest` must resolve to an existing document,
570            // the same way and for the same reason: it is not a graph edge (the
571            // manifest is machinery, carrying no `part_of` and no id), but it
572            // does reach a file, so the orphan pass must count it as reached.
573            //
574            // The rows *inside* it reach files too, and deliberately do not
575            // arrive here. A covered file is opaque bytes — never a content
576            // document, so never an orphan candidate — and adding ten thousand
577            // of them to every walk's reachable set would make a photo archive
578            // pay for a check none of those files can fail. What the manifest
579            // promises about them is `check`'s manifest pass, once, not the
580            // census's, per document.
581            if let Some(manifest) = doc.manifest_attr() {
582                if doc.content_attr().is_some() {
583                    structural.push(StructuralFact::ManifestConflict { doc: path.clone() });
584                }
585                let target = link::resolve(&path, manifest);
586                let site = LinkSite::Relation(crate::manifest::MANIFEST_KEY.to_string());
587                match self.exact_name(&target).await {
588                    NameMatch::Exact => content_bodies.push(target),
589                    NameMatch::CaseOnly(actual) => {
590                        content_bodies.push(target.with_file_name(&actual));
591                        structural.push(StructuralFact::CaseMismatch {
592                            doc: path.clone(),
593                            site,
594                            target: manifest.to_string(),
595                            actual,
596                        });
597                    }
598                    NameMatch::None => structural.push(StructuralFact::BrokenLink {
599                        doc: path.clone(),
600                        site,
601                        target: manifest.to_string(),
602                    }),
603                }
604            }
605        }
606        Ok(Walk {
607            census,
608            facts: structural,
609            content_bodies,
610        })
611    }
612
613    /// Resolve one forward link (declared in the document at `source`) into a
614    /// [`Resolution`]. A path target is checked against the on-disk name; an
615    /// `id:<id>` target resolves through the registry and stays an id-form
616    /// resolution; an `id:<workspace>/<id>` target naming another workspace
617    /// stops at [`Resolution::Foreign`]; a nominal (`[[My File]]`) target
618    /// resolves through `titles` — `Unique` to the on-disk path, `Ambiguous` to
619    /// [`Resolution::AmbiguousAlias`], `Unknown` falling through to a path (so a
620    /// nominal link to nothing reports as `Broken`, like any dead link).
621    async fn resolve_forward(
622        &self,
623        source: &Path,
624        link: &Link,
625        titles: Option<&TitleIndex>,
626    ) -> Resolution {
627        if link.is_external() {
628            return Resolution::External;
629        }
630        // Mirrors `Workspace::resolve_link_with`: a reference qualified with
631        // this workspace's own name is local, any other qualifier is foreign,
632        // and a malformed `id:` body is a broken id rather than a filename that
633        // happens to contain a colon.
634        let local_id = match link.id_ref() {
635            Some(crate::link::IdRef::Local(id)) => Some(id),
636            Some(crate::link::IdRef::Foreign { workspace, id }) => {
637                if self.workspace_id().is_empty() || workspace != self.workspace_id() {
638                    return Resolution::Foreign { workspace, id };
639                }
640                Some(id)
641            }
642            Some(crate::link::IdRef::Malformed) => return Resolution::MalformedId,
643            None => None,
644        };
645        if let Some(id) = local_id {
646            if !identity::verify(id.as_str()) {
647                return Resolution::MalformedId;
648            }
649            return match self.index().resolve(&id) {
650                Some(path) => Resolution::Id {
651                    id,
652                    to: link::normalize(path),
653                },
654                None => Resolution::DanglingId {
655                    tombstoned: self.index().is_known(&id),
656                    id,
657                },
658            };
659        }
660        // Only a nominal link needs the title index; the caller builds it lazily
661        // the first time one appears, so `titles` is `Some` here whenever it is
662        // consulted. If absent, fall through to path resolution.
663        if let Some(titles) = titles.filter(|_| title::is_alias_shaped(&link.target)) {
664            match titles.resolve(&link.target) {
665                TitleMatch::Unique(path) => {
666                    return match self.exact_name(&path).await {
667                        NameMatch::Exact => Resolution::Path(path),
668                        NameMatch::CaseOnly(actual) => {
669                            Resolution::CaseMismatch { got: path, actual }
670                        }
671                        NameMatch::None => Resolution::Broken,
672                    };
673                }
674                TitleMatch::Ambiguous(candidates) => {
675                    return Resolution::AmbiguousAlias {
676                        name: link.target.clone(),
677                        candidates,
678                    };
679                }
680                TitleMatch::Unknown => {}
681            }
682        }
683        let resolved = link::resolve(source, &link.target);
684        match self.exact_name(&resolved).await {
685            NameMatch::Exact => Resolution::Path(resolved),
686            NameMatch::CaseOnly(actual) => Resolution::CaseMismatch {
687                got: resolved,
688                actual,
689            },
690            NameMatch::None => Resolution::Broken,
691        }
692    }
693
694    /// How `path`'s final component matches its parent directory's listing:
695    /// exactly, only case-insensitively (the portability hazard), or not at all.
696    async fn exact_name(&self, path: &Path) -> NameMatch {
697        let full = self.root().join(path);
698        let (Some(parent), Some(name)) = (full.parent(), full.file_name()) else {
699            return NameMatch::None;
700        };
701        let Ok(entries) = self.fs().read_dir(parent).await else {
702            return NameMatch::None;
703        };
704        let mut case_only = None;
705        for entry in entries {
706            let Some(entry_name) = entry.file_name() else {
707                continue;
708            };
709            if entry_name == name {
710                return NameMatch::Exact;
711            }
712            if entry_name.eq_ignore_ascii_case(name) {
713                case_only = Some(entry_name.to_string_lossy().into_owned());
714            }
715        }
716        match case_only {
717            Some(actual) => NameMatch::CaseOnly(actual),
718            None => NameMatch::None,
719        }
720    }
721}
722
723// These tests use YAML frontmatter fixtures, so they run under the `yaml` feature.
724#[cfg(all(test, feature = "yaml"))]
725mod tests {
726    use super::*;
727    use crate::exec::block_on;
728    use crate::fs::StdFs;
729    use crate::graph::ReadSettings;
730    use crate::index::NoIndex;
731
732    fn write(dir: &Path, rel: &str, text: &str) {
733        let p = dir.join(rel);
734        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
735        std::fs::write(p, text).unwrap();
736    }
737
738    fn tempdir(tag: &str) -> PathBuf {
739        let dir = std::env::temp_dir().join(format!("prov-census-{tag}-{}", std::process::id()));
740        let _ = std::fs::remove_dir_all(&dir);
741        std::fs::create_dir_all(&dir).unwrap();
742        dir
743    }
744
745    #[test]
746    fn census_covers_frontmatter_edges_and_body_wikilinks() {
747        let dir = tempdir("census");
748        write(
749            &dir,
750            "index.md",
751            "---\ncontents:\n- a.md\n---\nBody links [[a.md]] and [[gone.md]].\n",
752        );
753        write(&dir, "a.md", "---\npart_of: index.md\n---\n");
754        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
755        let census = block_on(ws.census("index.md")).unwrap();
756
757        // The frontmatter `contents` edge, resolving to the existing file.
758        assert!(
759            census.iter().any(
760                |e| matches!(&e.site, LinkSite::Relation(r) if r == "contents")
761                    && matches!(&e.resolution, Resolution::Path(p) if p == &PathBuf::from("a.md"))
762            ),
763            "{census:?}"
764        );
765        // The body wikilink to the same file — sited in the body, resolving.
766        assert!(
767            census.iter().any(|e| matches!(e.site, LinkSite::Body(_))
768                && e.target_text == "a.md"
769                && matches!(&e.resolution, Resolution::Path(_))),
770            "{census:?}"
771        );
772        // The body wikilink to a missing file — a Broken resolution.
773        assert!(
774            census
775                .iter()
776                .any(|e| e.target_text == "gone.md" && matches!(e.resolution, Resolution::Broken)),
777            "{census:?}"
778        );
779    }
780
781    #[test]
782    fn backlinks_invert_the_census_across_relations_and_body() {
783        let dir = tempdir("backlinks");
784        write(&dir, "index.md", "---\ncontents:\n- a.md\n- b.md\n---\n");
785        write(&dir, "a.md", "---\npart_of: index.md\n---\n");
786        write(
787            &dir,
788            "b.md",
789            "---\npart_of: index.md\nlinks:\n- a.md\n---\nSee [[a.md]] again.\n",
790        );
791        let ws = Graph::new(StdFs, &dir, NoIndex, ReadSettings::default());
792
793        // Who links to a.md? index.md (contents), b.md (links), b.md (body).
794        let to_a = block_on(ws.backlinks_to("index.md", "a.md")).unwrap();
795        assert_eq!(to_a.len(), 3, "{to_a:?}");
796        assert!(
797            to_a.iter().any(|bl| bl.source == Path::new("index.md")
798                && matches!(&bl.site, LinkSite::Relation(r) if r == "contents")),
799            "{to_a:?}"
800        );
801        assert!(
802            to_a.iter().any(|bl| bl.source == Path::new("b.md")
803                && matches!(&bl.site, LinkSite::Relation(r) if r == "links")),
804            "{to_a:?}"
805        );
806        assert!(
807            to_a.iter()
808                .any(|bl| bl.source == Path::new("b.md") && matches!(bl.site, LinkSite::Body(_))),
809            "{to_a:?}"
810        );
811        // All path-form (this workspace has no registry / id links).
812        assert!(to_a.iter().all(|bl| !bl.by_id), "{to_a:?}");
813
814        // The full map keys targets by path; a.md is one of them.
815        let map = block_on(ws.backlinks("index.md")).unwrap();
816        assert_eq!(map[&PathBuf::from("a.md")].len(), 3);
817    }
818}
819
820/// Invert a census into a backlink map: every resolved target to the inbound
821/// references that reach it, each target's sorted by source.
822///
823/// A free function over an already-taken census, rather than a method that takes
824/// one, because the caller who has to bound the walk — `prov`, which knows where
825/// it parks its own bytes — has already done the walking. Taking the census as
826/// an argument is what lets the bounded and unbounded callers share this.
827pub fn invert(census: Vec<CensusEntry>) -> BTreeMap<PathBuf, Vec<Backlink>> {
828    let mut map: BTreeMap<PathBuf, Vec<Backlink>> = BTreeMap::new();
829    for entry in census {
830        let by_id = matches!(entry.resolution, Resolution::Id { .. });
831        let Some(target) = entry.resolution.resolved_path().cloned() else {
832            continue;
833        };
834        map.entry(target).or_default().push(Backlink {
835            source: entry.source,
836            site: entry.site,
837            by_id,
838        });
839    }
840    for links in map.values_mut() {
841        links.sort_by(|a, b| a.source.cmp(&b.source).then(a.by_id.cmp(&b.by_id)));
842    }
843    map
844}
845
846/// The inbound references to one `target` within an already-taken census,
847/// sorted by source — [`invert`] focused on a single entry.
848pub fn inbound(census: Vec<CensusEntry>, target: &Path) -> Vec<Backlink> {
849    let target = link::normalize(target);
850    let mut links: Vec<Backlink> = census
851        .into_iter()
852        .filter(|entry| entry.resolution.resolved_path() == Some(&target))
853        .map(|entry| {
854            let by_id = matches!(entry.resolution, Resolution::Id { .. });
855            Backlink {
856                source: entry.source,
857                site: entry.site,
858                by_id,
859            }
860        })
861        .collect();
862    links.sort_by(|a, b| a.source.cmp(&b.source).then(a.by_id.cmp(&b.by_id)));
863    links
864}