Skip to main content

rto_render/okf/
inspect.rs

1//! Inspect an OKF bundle **as a bundle**, without importing it.
2//!
3//! [`read`](super::read) answers "what would this add to the graph". This module
4//! answers questions about the bundle itself — what it claims, whether it hangs
5//! together, how it differs from another copy — and answers them with somebody
6//! else's implementation of the specification.
7//!
8//! # Why an independent implementation is the whole value
9//!
10//! Roteiro both *writes* OKF (`render okf`) and *reads* it (`import --from
11//! okf`). A reader of our own construction, run over our own output, would
12//! agree with us about a format we also invent: it can only catch a mistake we
13//! did not make twice. `okf-core` is an independent reading of the same
14//! specification by an author who is not us, so its disagreement is
15//! *information*.
16//!
17//! That is not hypothetical here. ADR-0021 records that deriving a concept's
18//! path from its node key "guessed wrong for 43 links" in a real render, and the
19//! reader's own YAML subset silently dropped every human sign-off in Google's
20//! published bundles until an independent oracle was pointed at it. Both were
21//! found by checking our output against something that did not share our
22//! assumptions.
23//!
24//! # What is here, and what is not
25//!
26//! [`trust_summary`], [`link_report`] and [`diff_report`], all built on
27//! `okf-core` — **one crate, zero transitive dependencies**.
28//!
29//! Conformance checking and hygiene linting are **not** here. They live
30//! upstream in a second crate, `okf-validator`, whose dependencies are not
31//! optional and which syntax-checks fenced code blocks in eight languages.
32//! Taking it means taking `rustpython-parser`: 61 crates, `LGPL-3.0-only`
33//! through the `malachite` tree, and six unmaintained advisories whose own text
34//! says no safe upgrade exists. `cargo deny` refuses it on both counts, and
35//! ADR-0017 §3 is explicit that a licence is not admitted merely to turn CI
36//! green.
37//!
38//! That price bought two of the validator's thirty-four checks, both of them
39//! about whether embedded *code* parses rather than whether the *bundle*
40//! conforms. See `Cargo.toml` for the full measurement.
41//!
42//! # Subcommand names are upstream's
43//!
44//! `trust`, `links` and `diff` match the `okf` CLI's own names for the same
45//! operations, so somebody who knows that tool already knows this one. The
46//! library is called **in-process**; Roteiro is a self-contained offline binary
47//! and requiring `okf` on `PATH` would reintroduce exactly the coupling the
48//! vendored interop fixtures exist to avoid.
49
50use std::path::Path;
51
52use okf_core::{Bundle, TrustTier};
53use serde::Serialize;
54
55/// Why a bundle could not be inspected.
56///
57/// One variant today: every failure here is "the path is not a bundle we could
58/// load". The underlying [`okf_core::BundleError`] is rendered into the message
59/// rather than wrapped, so this type stays free of the dependency in its public
60/// shape.
61///
62/// `#[non_exhaustive]` because that set is closed by nothing but current
63/// implementation — unlike [`super::Actor`], whose three variants are closed by
64/// §7 of the specification and which is deliberately exhaustive for that reason.
65/// A second failure mode here (a bundle that loads but declares an OKF version
66/// this crate cannot read, say) is an ordinary addition, and these crates are
67/// published, so it must not be a breaking change.
68#[derive(Debug, thiserror::Error)]
69#[non_exhaustive]
70pub enum InspectError {
71    /// The path could not be loaded as an OKF bundle.
72    #[error("`{path}` is not a readable OKF bundle: {detail}")]
73    Unreadable {
74        /// The path as the caller gave it.
75        path: String,
76        /// What `okf-core` said went wrong.
77        detail: String,
78    },
79}
80
81/// Load a bundle, naming the path in the error rather than only the cause.
82fn load(root: &Path) -> Result<Bundle, InspectError> {
83    Bundle::load(root).map_err(|e| InspectError::Unreadable {
84        path: root.display().to_string(),
85        detail: e.to_string(),
86    })
87}
88
89/// A concept's trust claim, as the bundle states it.
90#[derive(Debug, Clone, Serialize)]
91pub struct ConceptTrust {
92    /// The concept's path within the bundle, minus `.md`.
93    pub id: String,
94    /// §5.3's tier: `human-reviewed`, `machine-confirmed` or `unverified`.
95    pub tier: &'static str,
96    /// The lifecycle `status` §5.4 resolves for this concept.
97    pub status: String,
98    /// Every actor named in `verified`, in the order the document wrote them.
99    ///
100    /// Present even when the tier is `unverified`: an event with an unparseable
101    /// timestamp does not count toward the tier but is still an attribution the
102    /// bundle made, and dropping it would hide *why* the tier came out low.
103    pub verified_by: Vec<String>,
104}
105
106/// What a bundle claims about its own trustworthiness.
107///
108/// This is the answer to "should I trust this bundle", stated per concept and in
109/// aggregate, and it is deliberately a **plain data type over a path**: it is
110/// exactly the information a consent prompt wants at the moment it asks, and
111/// nothing here needs the import machinery to have run first.
112#[derive(Debug, Clone, Serialize)]
113pub struct TrustSummary {
114    /// The bundle root, as the caller named it.
115    pub root: String,
116    /// The `okf_version` the root `index.md` declares (§10), if any.
117    pub okf_version: Option<String>,
118    /// Concepts read, excluding the reserved `index.md` / `log.md` files.
119    pub total: usize,
120    /// Concepts carrying at least one valid `human:` verifier.
121    pub human_reviewed: usize,
122    /// Concepts verified only by non-`human:` actors.
123    pub machine_confirmed: usize,
124    /// Concepts with no valid `verified` event.
125    pub unverified: usize,
126    /// Every concept, in bundle order.
127    pub concepts: Vec<ConceptTrust>,
128}
129
130/// Derive [`TrustSummary`] for the bundle at `root`.
131///
132/// # Errors
133///
134/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
135pub fn trust_summary(root: &Path) -> Result<TrustSummary, InspectError> {
136    Ok(summarise_trust(&load(root)?, &root.display().to_string()))
137}
138
139/// The bundle-in-hand half of [`trust_summary`].
140///
141/// Split out so a caller that has already loaded a [`Bundle`] — to validate it,
142/// or to ask a person whether to import it — pays for the directory walk once.
143#[must_use]
144pub fn summarise_trust(bundle: &Bundle, root: &str) -> TrustSummary {
145    let mut summary = TrustSummary {
146        root: root.to_owned(),
147        okf_version: bundle.okf_version().map(ToOwned::to_owned),
148        total: bundle.concepts().len(),
149        human_reviewed: 0,
150        machine_confirmed: 0,
151        unverified: 0,
152        concepts: Vec::with_capacity(bundle.concepts().len()),
153    };
154    for concept in bundle.concepts() {
155        let tier = concept.trust_tier();
156        match tier {
157            TrustTier::HumanReviewed => summary.human_reviewed += 1,
158            TrustTier::MachineConfirmed => summary.machine_confirmed += 1,
159            TrustTier::Unverified => summary.unverified += 1,
160        }
161        summary.concepts.push(ConceptTrust {
162            id: concept.id.to_string(),
163            tier: tier.as_str(),
164            status: concept.status().to_string(),
165            verified_by: concept
166                .document
167                .frontmatter
168                .verified()
169                .into_iter()
170                .filter_map(|v| v.by.map(|by| by.as_str().to_owned()))
171                .collect(),
172        });
173    }
174    summary
175}
176
177/// A markdown link that names a concept the bundle does not contain.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct BrokenLink {
180    /// The concept whose body carries the link.
181    pub from: String,
182    /// The link target, exactly as written.
183    pub target: String,
184}
185
186/// Whether an emitted bundle's internal links resolve.
187///
188/// Roteiro's own link checking (`roteiro check`) covers the **graph** and the
189/// **rendered site**. Neither looks at an emitted OKF bundle, which is a third
190/// artefact produced by a third code path — the one ADR-0021 records guessing
191/// wrong for 43 links.
192#[derive(Debug, Clone, Serialize)]
193pub struct LinkReport {
194    /// The bundle root, as the caller named it.
195    pub root: String,
196    /// Concepts read.
197    pub concepts: usize,
198    /// Internal concept links found across every body.
199    pub links: usize,
200    /// Those that resolve to no concept in the bundle.
201    pub broken: Vec<BrokenLink>,
202}
203
204impl LinkReport {
205    /// `true` when every internal link resolves.
206    #[must_use]
207    pub const fn is_clean(&self) -> bool {
208        self.broken.is_empty()
209    }
210}
211
212/// Resolve every internal link in the bundle at `root`.
213///
214/// # Errors
215///
216/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
217pub fn link_report(root: &Path) -> Result<LinkReport, InspectError> {
218    let bundle = load(root)?;
219    let links = bundle
220        .concepts()
221        .iter()
222        .map(|c| bundle.links_from(&c.id).len())
223        .sum();
224    Ok(LinkReport {
225        root: root.display().to_string(),
226        concepts: bundle.concepts().len(),
227        links,
228        broken: bundle
229            .broken_links()
230            .into_iter()
231            .map(|(from, target)| BrokenLink {
232                from: from.to_string(),
233                target,
234            })
235            .collect(),
236    })
237}
238
239/// A concept whose trust tier or lifecycle status moved between two bundles.
240#[derive(Debug, Clone, Serialize)]
241pub struct TrustMove {
242    /// The concept that moved.
243    pub id: String,
244    /// `(before, after)` tiers, when the tier changed.
245    pub tier: Option<(String, String)>,
246    /// `(before, after)` statuses, when the status changed.
247    pub status: Option<(String, String)>,
248}
249
250/// What changed between two bundles, semantically rather than by bytes.
251///
252/// ADR-0021 made `render okf` byte-deterministic specifically so "a consumer can
253/// diff two downloads and learn something". This is that diff, and it is the
254/// first thing in the workspace to exercise the determinism: `review --base`
255/// diffs code, not bundles.
256///
257/// A **rename** is the interesting field. A textual diff of two bundles reports
258/// a moved concept as one deletion and one unrelated addition; this reports it
259/// as a rename, which is the difference between "we lost a concept" and "we
260/// moved one".
261#[derive(Debug, Clone, Serialize)]
262pub struct DiffReport {
263    /// The bundle taken as "before".
264    pub before: String,
265    /// The bundle taken as "after".
266    pub after: String,
267    /// Concepts present only in `after`.
268    pub added: Vec<String>,
269    /// Concepts present only in `before`.
270    pub removed: Vec<String>,
271    /// Concepts whose path changed, as `(from, to)`.
272    pub renamed: Vec<(String, String)>,
273    /// Concepts whose body changed.
274    pub content_changed: Vec<String>,
275    /// Concepts whose frontmatter keys changed.
276    pub frontmatter_changed: Vec<String>,
277    /// Concepts whose tier or status moved. The one to read first.
278    pub trust_changed: Vec<TrustMove>,
279    /// Links that broke between `before` and `after`, as `(concept, target)`.
280    pub links_broken: Vec<(String, String)>,
281    /// Links that were broken in `before` and resolve in `after`.
282    pub links_mended: Vec<(String, String)>,
283}
284
285impl DiffReport {
286    /// `true` when the two bundles are semantically identical.
287    #[must_use]
288    pub fn is_unchanged(&self) -> bool {
289        self.added.is_empty()
290            && self.removed.is_empty()
291            && self.renamed.is_empty()
292            && self.content_changed.is_empty()
293            && self.frontmatter_changed.is_empty()
294            && self.trust_changed.is_empty()
295            && self.links_broken.is_empty()
296            && self.links_mended.is_empty()
297    }
298}
299
300/// Compare two bundles semantically.
301///
302/// # Errors
303///
304/// [`InspectError::Unreadable`] if either path is not a loadable OKF bundle.
305pub fn diff_report(before: &Path, after: &Path) -> Result<DiffReport, InspectError> {
306    let a = load(before)?;
307    let b = load(after)?;
308    let d = okf_core::bundle_diff(&a, &b);
309    let ids = |v: Vec<okf_core::ConceptId>| v.iter().map(ToString::to_string).collect::<Vec<_>>();
310    let pairs = |v: Vec<(okf_core::ConceptId, String)>| {
311        v.into_iter()
312            .map(|(id, t)| (id.to_string(), t))
313            .collect::<Vec<_>>()
314    };
315    Ok(DiffReport {
316        before: before.display().to_string(),
317        after: after.display().to_string(),
318        added: ids(d.added),
319        removed: ids(d.removed),
320        renamed: d
321            .renamed
322            .into_iter()
323            .map(|r| (r.from.to_string(), r.to.to_string()))
324            .collect(),
325        content_changed: ids(d.content),
326        frontmatter_changed: d.frontmatter.iter().map(|c| c.id.to_string()).collect(),
327        trust_changed: d
328            .trust
329            .into_iter()
330            .map(|t| TrustMove {
331                id: t.id.to_string(),
332                tier: t
333                    .tier
334                    .map(|(a, b)| (a.as_str().to_owned(), b.as_str().to_owned())),
335                status: t.status.map(|(a, b)| (a.to_string(), b.to_string())),
336            })
337            .collect(),
338        links_broken: pairs(d.broken_links),
339        links_mended: pairs(d.mended_links),
340    })
341}