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 /// `--today` was given a value that is not an ISO `YYYY-MM-DD` date.
80 ///
81 /// Refused rather than silently falling back to the real clock: the flag
82 /// exists so a run is reproducible, and a typo that quietly restored
83 /// today's date would make a green pipeline mean nothing.
84 #[error("`{given}` is not an ISO date (expected YYYY-MM-DD)")]
85 BadDate {
86 /// The value as the caller gave it.
87 given: String,
88 },
89 /// The host clock could not be read and no `--today` was given.
90 #[error("cannot read the current date; pass --today YYYY-MM-DD")]
91 NoClock,
92}
93
94/// Load a bundle, naming the path in the error rather than only the cause.
95pub(super) fn load(root: &Path) -> Result<Bundle, InspectError> {
96 Bundle::load(root).map_err(|e| InspectError::Unreadable {
97 path: root.display().to_string(),
98 detail: e.to_string(),
99 })
100}
101
102/// A concept's trust claim, as the bundle states it.
103#[derive(Debug, Clone, Serialize)]
104pub struct ConceptTrust {
105 /// The concept's path within the bundle, minus `.md`.
106 pub id: String,
107 /// §5.3's tier: `human-reviewed`, `machine-confirmed` or `unverified`.
108 pub tier: &'static str,
109 /// The lifecycle `status` §5.4 resolves for this concept.
110 pub status: String,
111 /// Every actor named in `verified`, in the order the document wrote them.
112 ///
113 /// Present even when the tier is `unverified`: an event with an unparseable
114 /// timestamp does not count toward the tier but is still an attribution the
115 /// bundle made, and dropping it would hide *why* the tier came out low.
116 pub verified_by: Vec<String>,
117 /// The `stale_after` timestamp exactly as the document wrote it, if any.
118 pub stale_after: Option<String>,
119 /// Whether `today >= stale_after` (§5.4).
120 ///
121 /// Independent of `tier`: a concept can be human-reviewed *and* stale, and
122 /// that combination is the one most worth seeing before an import, because
123 /// the tier alone reads as reassurance.
124 pub stale: bool,
125}
126
127/// What a bundle claims about its own trustworthiness.
128///
129/// This is the answer to "should I trust this bundle", stated per concept and in
130/// aggregate, and it is deliberately a **plain data type over a path**: it is
131/// exactly the information a consent prompt wants at the moment it asks, and
132/// nothing here needs the import machinery to have run first.
133#[derive(Debug, Clone, Serialize)]
134pub struct TrustSummary {
135 /// The bundle root, as the caller named it.
136 pub root: String,
137 /// The `okf_version` the root `index.md` declares (§10), if any.
138 pub okf_version: Option<String>,
139 /// Concepts read, excluding the reserved `index.md` / `log.md` files.
140 pub total: usize,
141 /// Concepts carrying at least one valid `human:` verifier.
142 pub human_reviewed: usize,
143 /// Concepts verified only by non-`human:` actors.
144 pub machine_confirmed: usize,
145 /// Concepts with no valid `verified` event.
146 pub unverified: usize,
147 /// Concepts whose `stale_after` has passed, as of `today`.
148 pub stale: usize,
149 /// The date staleness was judged against, as `YYYY-MM-DD`.
150 ///
151 /// Always reported, whether it came from `--today` or the host clock, so a
152 /// captured summary says what it was true *of*. A tiered count with no date
153 /// beside it cannot be compared with the same bundle read a month later.
154 pub today: String,
155 /// Every concept, in bundle order.
156 pub concepts: Vec<ConceptTrust>,
157}
158
159/// Derive [`TrustSummary`] for the bundle at `root`.
160///
161/// # Errors
162///
163/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle,
164/// [`InspectError::BadDate`] if `today` is given and is not an ISO date, and
165/// [`InspectError::NoClock`] if `today` is `None` and the host date cannot be
166/// read. The last is the one worth handling deliberately: it is the only way
167/// this fails on a perfectly good bundle, and the remedy is to pass `today`.
168pub fn trust_summary(root: &Path, today: Option<&str>) -> Result<TrustSummary, InspectError> {
169 let today = resolve_today(today)?;
170 Ok(summarise_trust(
171 &load(root)?,
172 &root.display().to_string(),
173 today,
174 ))
175}
176
177/// The date staleness is judged against: `--today` when given, else the host's
178/// UTC date.
179///
180/// Separated out because it is the only non-deterministic input in this module,
181/// and every report that mentions staleness takes it the same way.
182fn resolve_today(given: Option<&str>) -> Result<okf_core::Date, InspectError> {
183 match given {
184 Some(raw) => okf_core::Date::parse(raw).ok_or_else(|| InspectError::BadDate {
185 given: raw.to_owned(),
186 }),
187 None => okf_core::Date::today_utc().ok_or(InspectError::NoClock),
188 }
189}
190
191/// The bundle-in-hand half of [`trust_summary`].
192///
193/// Split out so a caller that has already loaded a [`Bundle`] — to validate it,
194/// or to ask a person whether to import it — pays for the directory walk once.
195#[must_use]
196pub fn summarise_trust(bundle: &Bundle, root: &str, today: okf_core::Date) -> TrustSummary {
197 let mut summary = TrustSummary {
198 root: root.to_owned(),
199 okf_version: bundle.okf_version().map(ToOwned::to_owned),
200 total: bundle.concepts().len(),
201 human_reviewed: 0,
202 machine_confirmed: 0,
203 unverified: 0,
204 stale: 0,
205 today: today.to_string(),
206 concepts: Vec::with_capacity(bundle.concepts().len()),
207 };
208 for concept in bundle.concepts() {
209 let tier = concept.trust_tier();
210 match tier {
211 TrustTier::HumanReviewed => summary.human_reviewed += 1,
212 TrustTier::MachineConfirmed => summary.machine_confirmed += 1,
213 TrustTier::Unverified => summary.unverified += 1,
214 }
215 let stale = concept.is_stale_on(today);
216 if stale {
217 summary.stale += 1;
218 }
219 summary.concepts.push(ConceptTrust {
220 id: concept.id.to_string(),
221 tier: tier.as_str(),
222 status: concept.status().to_string(),
223 stale_after: concept
224 .document
225 .frontmatter
226 .stale_after()
227 .map(|d| d.to_string()),
228 stale,
229 verified_by: concept
230 .document
231 .frontmatter
232 .verified()
233 .into_iter()
234 .filter_map(|v| v.by.map(|by| by.as_str().to_owned()))
235 .collect(),
236 });
237 }
238 summary
239}
240
241/// A markdown link that names a concept the bundle does not contain.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
243pub struct BrokenLink {
244 /// The concept whose body carries the link.
245 pub from: String,
246 /// The link target, exactly as written.
247 pub target: String,
248}
249
250/// Whether an emitted bundle's internal links resolve.
251///
252/// Roteiro's own link checking (`roteiro check`) covers the **graph** and the
253/// **rendered site**. Neither looks at an emitted OKF bundle, which is a third
254/// artefact produced by a third code path — the one ADR-0021 records guessing
255/// wrong for 43 links.
256#[derive(Debug, Clone, Serialize)]
257pub struct LinkReport {
258 /// The bundle root, as the caller named it.
259 pub root: String,
260 /// Concepts read.
261 pub concepts: usize,
262 /// Internal concept links found across every body.
263 pub links: usize,
264 /// Those that resolve to no concept in the bundle.
265 pub broken: Vec<BrokenLink>,
266}
267
268impl LinkReport {
269 /// `true` when every internal link resolves.
270 #[must_use]
271 pub const fn is_clean(&self) -> bool {
272 self.broken.is_empty()
273 }
274}
275
276/// Resolve every internal link in the bundle at `root`.
277///
278/// # Errors
279///
280/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
281pub fn link_report(root: &Path) -> Result<LinkReport, InspectError> {
282 let bundle = load(root)?;
283 let links = bundle
284 .concepts()
285 .iter()
286 .map(|c| bundle.links_from(&c.id).len())
287 .sum();
288 Ok(LinkReport {
289 root: root.display().to_string(),
290 concepts: bundle.concepts().len(),
291 links,
292 broken: bundle
293 .broken_links()
294 .into_iter()
295 .map(|(from, target)| BrokenLink {
296 from: from.to_string(),
297 target,
298 })
299 .collect(),
300 })
301}
302
303/// A concept whose trust tier or lifecycle status moved between two bundles.
304#[derive(Debug, Clone, Serialize)]
305pub struct TrustMove {
306 /// The concept that moved.
307 pub id: String,
308 /// `(before, after)` tiers, when the tier changed.
309 pub tier: Option<(String, String)>,
310 /// `(before, after)` statuses, when the status changed.
311 pub status: Option<(String, String)>,
312}
313
314/// What changed between two bundles, semantically rather than by bytes.
315///
316/// ADR-0021 made `render okf` byte-deterministic specifically so "a consumer can
317/// diff two downloads and learn something". This is that diff, and it is the
318/// first thing in the workspace to exercise the determinism: `review --base`
319/// diffs code, not bundles.
320///
321/// A **rename** is the interesting field. A textual diff of two bundles reports
322/// a moved concept as one deletion and one unrelated addition; this reports it
323/// as a rename, which is the difference between "we lost a concept" and "we
324/// moved one".
325#[derive(Debug, Clone, Serialize)]
326pub struct DiffReport {
327 /// The bundle taken as "before".
328 pub before: String,
329 /// The bundle taken as "after".
330 pub after: String,
331 /// Concepts present only in `after`.
332 pub added: Vec<String>,
333 /// Concepts present only in `before`.
334 pub removed: Vec<String>,
335 /// Concepts whose path changed, as `(from, to)`.
336 pub renamed: Vec<(String, String)>,
337 /// Concepts whose body changed.
338 pub content_changed: Vec<String>,
339 /// Concepts whose frontmatter keys changed.
340 pub frontmatter_changed: Vec<String>,
341 /// Concepts whose tier or status moved. The one to read first.
342 pub trust_changed: Vec<TrustMove>,
343 /// Links that broke between `before` and `after`, as `(concept, target)`.
344 pub links_broken: Vec<(String, String)>,
345 /// Links that were broken in `before` and resolve in `after`.
346 pub links_mended: Vec<(String, String)>,
347}
348
349impl DiffReport {
350 /// `true` when the two bundles are semantically identical.
351 #[must_use]
352 pub fn is_unchanged(&self) -> bool {
353 self.added.is_empty()
354 && self.removed.is_empty()
355 && self.renamed.is_empty()
356 && self.content_changed.is_empty()
357 && self.frontmatter_changed.is_empty()
358 && self.trust_changed.is_empty()
359 && self.links_broken.is_empty()
360 && self.links_mended.is_empty()
361 }
362}
363
364/// Compare two bundles semantically.
365///
366/// # Errors
367///
368/// [`InspectError::Unreadable`] if either path is not a loadable OKF bundle.
369pub fn diff_report(before: &Path, after: &Path) -> Result<DiffReport, InspectError> {
370 let a = load(before)?;
371 let b = load(after)?;
372 let d = okf_core::bundle_diff(&a, &b);
373 let ids = |v: Vec<okf_core::ConceptId>| v.iter().map(ToString::to_string).collect::<Vec<_>>();
374 let pairs = |v: Vec<(okf_core::ConceptId, String)>| {
375 v.into_iter()
376 .map(|(id, t)| (id.to_string(), t))
377 .collect::<Vec<_>>()
378 };
379 Ok(DiffReport {
380 before: before.display().to_string(),
381 after: after.display().to_string(),
382 added: ids(d.added),
383 removed: ids(d.removed),
384 renamed: d
385 .renamed
386 .into_iter()
387 .map(|r| (r.from.to_string(), r.to.to_string()))
388 .collect(),
389 content_changed: ids(d.content),
390 frontmatter_changed: d.frontmatter.iter().map(|c| c.id.to_string()).collect(),
391 trust_changed: d
392 .trust
393 .into_iter()
394 .map(|t| TrustMove {
395 id: t.id.to_string(),
396 tier: t
397 .tier
398 .map(|(a, b)| (a.as_str().to_owned(), b.as_str().to_owned())),
399 status: t.status.map(|(a, b)| (a.to_string(), b.to_string())),
400 })
401 .collect(),
402 links_broken: pairs(d.broken_links),
403 links_mended: pairs(d.mended_links),
404 })
405}
406
407/// One code block that did not parse.
408#[derive(Debug, Clone, Serialize)]
409pub struct SyntaxFinding {
410 /// The concept the block belongs to.
411 pub concept: String,
412 /// The concept's file, relative to the bundle root.
413 pub path: String,
414 /// 1-indexed line of the block's opening fence within that file's body,
415 /// when it could be determined.
416 ///
417 /// `None` for a computation whose code this crate could not locate in the
418 /// body — an indented block with no `# Computation` heading to anchor it.
419 /// Reporting a confident `1` there was worse than reporting nothing: it sent
420 /// a reader to the frontmatter for a fault further down the file.
421 pub line: Option<usize>,
422 /// The language the block was tagged with, canonicalised.
423 pub language: String,
424 /// What the parser said.
425 pub message: String,
426}
427
428/// The result of syntax-checking a bundle's code blocks.
429///
430/// `checked` and `skipped` are both reported, deliberately. A language with no
431/// backend compiled in is *not checked* rather than *clean*, and a report that
432/// conflated the two would be a check that passes by not looking.
433#[derive(Debug, Clone, Serialize)]
434pub struct SyntaxReport {
435 /// The bundle root, as the caller named it.
436 pub root: String,
437 /// `computations` or `all-blocks` — what was looked at.
438 pub scope: &'static str,
439 /// Blocks a backend actually parsed.
440 pub checked: usize,
441 /// Blocks left alone, for any of three reasons: the block carried no
442 /// language tag, this build has no backend for the language it carried, or
443 /// the computation named a file rather than inlining its code.
444 ///
445 /// All three are "not looked at" rather than "looked at and clean", which is
446 /// the distinction the whole report exists to keep.
447 pub skipped: usize,
448 /// The languages this build can check, so a reader can tell why.
449 pub languages: Vec<String>,
450 /// Findings, in bundle order.
451 pub findings: Vec<SyntaxFinding>,
452}
453
454impl SyntaxReport {
455 /// `true` when nothing failed to parse.
456 #[must_use]
457 pub const fn passed(&self) -> bool {
458 self.findings.is_empty()
459 }
460}
461
462/// The language an untagged computation block should be read as.
463///
464/// Only `bigquery` is mapped, and only because the corpus justifies it: every
465/// `runtime:` in the four bundles published with the specification is
466/// `bigquery`, and the spec's own Attested Computation example writes its query
467/// as an *indented* block, which carries no info string. Without this the one
468/// case that matters most would never be checked.
469///
470/// Deliberately not a general runtime→language table. Inventing a mapping for
471/// runtimes nobody has written yet is how a reader ends up with a confident
472/// diagnostic about a language the author never claimed.
473fn language_for_runtime(runtime: Option<&str>) -> Option<&'static str> {
474 // Case-insensitive, because every other tag here is: `Language::from_tag`
475 // lowercases, so `runtime: BigQuery` reading differently from `bigquery`
476 // would be an inconsistency inside one function's worth of code.
477 match runtime.map(|r| r.trim().to_ascii_lowercase()).as_deref() {
478 Some("bigquery") => Some("sql"),
479 _ => None,
480 }
481}
482
483/// Syntax-check the code blocks in a bundle.
484///
485/// With `computations_only`, just the bodies of Attested Computations — the
486/// concepts that declare a `runtime:` and that an agent is expected to *run*, so
487/// the ones where "does this parse" is a question about the bundle rather than
488/// about its prose. Otherwise every fenced block in every document.
489///
490/// Findings are the checker's, not conformance: a bundle can be perfectly
491/// conformant and contain a code sample that does not parse, which is why this
492/// is its own command rather than part of validation.
493///
494/// # Errors
495///
496/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
497pub fn syntax_report(root: &Path, computations_only: bool) -> Result<SyntaxReport, InspectError> {
498 let bundle = load(root)?;
499 let languages = rto_okf_syntax::checkable_languages()
500 .into_iter()
501 .map(|l| l.as_str().to_owned())
502 .collect();
503 let mut report = SyntaxReport {
504 root: root.display().to_string(),
505 scope: if computations_only {
506 "computations"
507 } else {
508 "all-blocks"
509 },
510 checked: 0,
511 skipped: 0,
512 languages,
513 findings: Vec::new(),
514 };
515
516 for concept in bundle.concepts() {
517 let rel = concept
518 .path
519 .strip_prefix(bundle.root())
520 .unwrap_or(&concept.path)
521 .display()
522 .to_string();
523
524 if computations_only {
525 let Some(computation) = concept.attested_computation() else {
526 continue;
527 };
528 let okf_core::ComputationSource::Inline(inline) = &computation.computation else {
529 // A `computation:` file reference is checked by whatever owns
530 // that file, and a `Missing` one has no code to check at all.
531 // Counted as **skipped** rather than passed over silently: a
532 // bundle whose computations all name files would otherwise
533 // report "0 checked, 0 skipped" and print "nothing to check",
534 // which reads as "there were none" when there were several.
535 report.skipped += 1;
536 continue;
537 };
538 // An indented block carries no info string, so fall back to the
539 // declared runtime — see `language_for_runtime`.
540 let tag = inline
541 .language
542 .as_deref()
543 .or_else(|| language_for_runtime(computation.runtime.as_deref()))
544 .unwrap_or("");
545 let line = computation_line(&concept.document.body, &inline.code);
546 record(
547 &mut report,
548 &concept.id.to_string(),
549 &rel,
550 line,
551 tag,
552 &inline.code,
553 );
554 } else {
555 for block in rto_okf_syntax::extract_fenced_code_blocks(&concept.document.body) {
556 let tag = block.language.as_deref().unwrap_or("");
557 record(
558 &mut report,
559 &concept.id.to_string(),
560 &rel,
561 Some(block.start_line),
562 tag,
563 &block.code,
564 );
565 }
566 }
567 }
568
569 Ok(report)
570}
571
572/// Where a computation's code starts in its document.
573///
574/// The fenced case is exact: the same extractor the all-blocks path uses finds
575/// the block whose contents are the computation's, and reports its opening
576/// fence. The indented case cannot be — `okf-core` dedents the code, so it no
577/// longer matches the file byte for byte — and the `# Computation` heading is the
578/// honest anchor there: it is where a reader should look, even though it is not
579/// where the parser stopped.
580///
581/// `None` rather than a confident `1` when neither is found. Pointing a reader at
582/// the frontmatter for a fault further down the file is worse than admitting the
583/// line is unknown.
584fn computation_line(body: &str, code: &str) -> Option<usize> {
585 let wanted = code.trim();
586 if let Some(block) = rto_okf_syntax::extract_fenced_code_blocks(body)
587 .into_iter()
588 .find(|b| b.code.trim() == wanted)
589 {
590 return Some(block.start_line);
591 }
592 body.lines().enumerate().find_map(|(i, l)| {
593 l.trim_start()
594 .strip_prefix('#')
595 .is_some_and(|rest| rest.trim().eq_ignore_ascii_case("computation"))
596 .then_some(i + 1)
597 })
598}
599
600/// Check one block and fold the outcome into the report.
601fn record(
602 report: &mut SyntaxReport,
603 concept: &str,
604 path: &str,
605 line: Option<usize>,
606 tag: &str,
607 code: &str,
608) {
609 let language = rto_okf_syntax::Language::from_tag(tag);
610 if !rto_okf_syntax::is_checkable(language) {
611 report.skipped += 1;
612 return;
613 }
614 report.checked += 1;
615 if let Err(err) = rto_okf_syntax::check_syntax(tag, code) {
616 report.findings.push(SyntaxFinding {
617 concept: concept.to_owned(),
618 path: path.to_owned(),
619 line,
620 language: err.language.clone(),
621 message: err.to_string(),
622 });
623 }
624}
625
626/// One concept's Attested Computation (§10), as the bundle declares it.
627#[derive(Debug, Clone, Serialize)]
628pub struct ComputationEntry {
629 /// The concept carrying the contract.
630 pub concept: String,
631 /// The bundle-relative file it lives in.
632 pub path: String,
633 /// §10's `runtime`, which decides how everything else is interpreted.
634 ///
635 /// `None` is a conformance error, not an absence — the spec makes it
636 /// REQUIRED — and it is surfaced here rather than skipped so a listing and
637 /// `okf validate` agree about what the bundle contains.
638 pub runtime: Option<String>,
639 /// `inline`, `file` or `missing`.
640 pub source: &'static str,
641 /// The file named by a `computation:` key, when `source` is `file`.
642 pub file: Option<String>,
643 /// The fenced language of an inline block, when it declared one.
644 pub language: Option<String>,
645 /// Lines of code in an inline block.
646 pub lines: Option<usize>,
647 /// The named holes an agent may fill.
648 pub parameters: Vec<String>,
649 /// Whether an executor is declared.
650 pub has_executor: bool,
651 /// Whether an attester is declared.
652 pub has_attester: bool,
653 /// `true` when the concept carries **both** an inline block and a
654 /// `computation:` file key.
655 ///
656 /// The spec asks for one or the other, so the two halves can disagree with
657 /// nothing to arbitrate between them. Listed rather than merely counted
658 /// because the fix is per concept.
659 pub redundant_inline: bool,
660}
661
662/// Every Attested Computation a bundle declares.
663#[derive(Debug, Clone, Serialize)]
664pub struct ComputationReport {
665 /// The bundle root, as the caller named it.
666 pub root: String,
667 /// Concepts read.
668 pub concepts: usize,
669 /// Concepts carrying a computation contract.
670 pub computations: usize,
671 /// Of those, how many carry the code inline.
672 pub inline: usize,
673 /// Of those, how many name a file instead.
674 pub file: usize,
675 /// Of those, how many declare neither — an incomplete contract.
676 pub missing: usize,
677 /// Every distinct `runtime`, sorted.
678 pub runtimes: Vec<String>,
679 /// The contracts themselves, in bundle order.
680 pub entries: Vec<ComputationEntry>,
681}
682
683impl ComputationReport {
684 /// Whether every contract found is complete: a runtime, and code somewhere.
685 ///
686 /// This is what `--check` gates on. A bundle with **no** computations is
687 /// clean by this measure, which is the right answer: §10 is optional, and
688 /// failing a bundle for not using an optional feature would make the gate
689 /// unusable on the three of four published bundles that declare none.
690 #[must_use]
691 pub fn is_clean(&self) -> bool {
692 self.incomplete() == 0
693 }
694
695 /// Contracts that are declared but not usable: no `runtime`, no code, or
696 /// both an inline block and a file with nothing to arbitrate between them.
697 #[must_use]
698 pub fn incomplete(&self) -> usize {
699 self.entries
700 .iter()
701 .filter(|e| e.runtime.is_none() || e.source == "missing" || e.redundant_inline)
702 .count()
703 }
704}
705
706/// List the Attested Computations in the bundle at `root`.
707///
708/// # Errors
709///
710/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
711pub fn computation_report(root: &Path) -> Result<ComputationReport, InspectError> {
712 let bundle = load(root)?;
713 let mut report = ComputationReport {
714 root: root.display().to_string(),
715 concepts: bundle.concepts().len(),
716 computations: 0,
717 inline: 0,
718 file: 0,
719 missing: 0,
720 runtimes: Vec::new(),
721 entries: Vec::new(),
722 };
723 let mut runtimes = std::collections::BTreeSet::new();
724
725 for concept in bundle.concepts() {
726 let Some(computation) = concept.attested_computation() else {
727 continue;
728 };
729 report.computations += 1;
730 if let Some(runtime) = computation.runtime.as_deref() {
731 runtimes.insert(runtime.to_owned());
732 }
733 let (source, file, language, lines) = match &computation.computation {
734 okf_core::ComputationSource::Inline(inline) => {
735 report.inline += 1;
736 (
737 "inline",
738 None,
739 inline.language.clone(),
740 Some(inline.code.lines().count()),
741 )
742 }
743 okf_core::ComputationSource::File(path) => {
744 report.file += 1;
745 ("file", Some(path.clone()), None, None)
746 }
747 okf_core::ComputationSource::Missing => {
748 report.missing += 1;
749 ("missing", None, None, None)
750 }
751 };
752 report.entries.push(ComputationEntry {
753 concept: concept.id.to_string(),
754 path: concept
755 .path
756 .strip_prefix(bundle.root())
757 .unwrap_or(&concept.path)
758 .display()
759 .to_string(),
760 runtime: computation.runtime.clone(),
761 source,
762 file,
763 language,
764 lines,
765 // An unnamed parameter is dropped rather than rendered as a hole:
766 // §10 requires the name, so `okf validate` is what reports its
767 // absence, and repeating it here as an empty slot in a listing would
768 // read as a parameter called "".
769 parameters: computation
770 .parameters
771 .iter()
772 .filter_map(|p| p.name.clone())
773 .collect(),
774 has_executor: computation.executor.is_some(),
775 has_attester: computation.attester.is_some(),
776 redundant_inline: computation.has_redundant_inline,
777 });
778 }
779 report.runtimes = runtimes.into_iter().collect();
780 Ok(report)
781}
782
783/// What a bundle is, in one answer.
784///
785/// Composed from the reports the other commands already produce rather than
786/// re-deriving anything: this is the command you run *first*, on a bundle
787/// somebody handed you, to decide which of the others is worth running.
788#[derive(Debug, Clone, Serialize)]
789pub struct BundleInfo {
790 /// The bundle root, as the caller named it.
791 pub root: String,
792 /// The `okf_version` the root `index.md` declares (§10), if any.
793 ///
794 /// Absent is conformant — §8 and §12 make it MAY — so this is reported and
795 /// never warned about.
796 pub okf_version: Option<String>,
797 /// The bundle's title, from `index.md`.
798 pub title: Option<String>,
799 /// Concepts, excluding the reserved `index.md` / `log.md`.
800 pub concepts: usize,
801 /// Trust tiers and staleness, as of `today`.
802 pub trust: TrustSummary,
803 /// How many concepts carry each `status`, sorted by status.
804 pub statuses: Vec<(String, usize)>,
805 /// Internal links, and how many resolve to nothing.
806 pub links: (usize, usize),
807 /// Attested Computations, and how many are incomplete.
808 pub computations: (usize, usize),
809 /// Every distinct computation `runtime`, sorted.
810 pub runtimes: Vec<String>,
811}
812
813/// Summarise the bundle at `root`.
814///
815/// # Errors
816///
817/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle,
818/// [`InspectError::BadDate`] if `today` is given and is not an ISO date, and
819/// [`InspectError::NoClock`] if `today` is `None` and the host date cannot be
820/// read — the same three as [`trust_summary`], which this calls.
821pub fn bundle_info(root: &Path, today: Option<&str>) -> Result<BundleInfo, InspectError> {
822 let today = resolve_today(today)?;
823 let bundle = load(root)?;
824 let trust = summarise_trust(&bundle, &root.display().to_string(), today);
825 let links = link_report(root)?;
826 let computations = computation_report(root)?;
827
828 let mut statuses: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
829 for concept in bundle.concepts() {
830 *statuses.entry(concept.status().to_string()).or_default() += 1;
831 }
832
833 Ok(BundleInfo {
834 root: root.display().to_string(),
835 okf_version: bundle.okf_version().map(ToOwned::to_owned),
836 title: bundle_title(&bundle),
837 concepts: bundle.concepts().len(),
838 trust,
839 statuses: statuses.into_iter().collect(),
840 links: (links.links, links.broken.len()),
841 computations: (computations.computations, computations.incomplete()),
842 runtimes: computations.runtimes,
843 })
844}
845
846/// The bundle's own title, from the `title` of its root `index.md`.
847///
848/// Read through `Document::parse` — the same parser `Bundle::load` uses — rather
849/// than a second reader of the same bytes, so the two cannot disagree about what
850/// the file says. `okf-core` exposes the index only as a path, so this re-reads
851/// one small file; that is cheap next to the directory walk, and a bundle whose
852/// index is unreadable simply has no title here, because `okf validate` is what
853/// reports a broken index.
854fn bundle_title(bundle: &Bundle) -> Option<String> {
855 let path = bundle.index_files().first()?;
856 let text = std::fs::read_to_string(path).ok()?;
857 let document = okf_core::Document::parse(&text).ok()?;
858 document
859 .frontmatter
860 .title()
861 .map(std::borrow::Cow::into_owned)
862}