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}