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    /// `--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/// A file a bundle carries that is not one of its concepts.
784#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
785pub struct BundleFile {
786    /// Bundle-relative path.
787    pub path: String,
788    /// Size in bytes, or `None` when it could not be read.
789    ///
790    /// Distinguished from zero rather than conflated with it, because an empty
791    /// file and an unstattable one are different facts and only one of them is
792    /// reassuring — the same reason the walk reports what it could not open. A
793    /// caller summing sizes treats `None` as contributing nothing; a caller
794    /// printing one says so.
795    pub bytes: Option<u64>,
796    /// Lowercased extension, or `""` when the file has none.
797    pub extension: String,
798}
799
800/// What a bundle carries that is not one of its concepts.
801#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
802pub struct BundleContents {
803    /// Every non-markdown file, ordered by path.
804    pub files: Vec<BundleFile>,
805    /// Everything the walk could not inspect, so the inventory above is
806    /// **incomplete**.
807    ///
808    /// Reported rather than swallowed. An inventory that answers "none" because
809    /// something would not open is the same false reassurance this feature exists
810    /// to remove — a reader would take silence for absence, which is precisely
811    /// what "0 violations" over an unread PDF did.
812    ///
813    /// Three failures land here, not one: a directory that will not list, a
814    /// directory entry that will not yield, and an entry whose type cannot be
815    /// read. The first was the obvious case and the other two are the same defect
816    /// one level in — `entries.flatten()` and a `let Ok(kind) = … else continue`
817    /// each discard an error and leave `is_complete()` saying the walk saw
818    /// everything.
819    ///
820    /// A file whose **size** cannot be read is not here: the file itself was
821    /// seen, named and reported, so the inventory is complete. See
822    /// [`BundleFile::bytes`].
823    pub unreadable: Vec<String>,
824}
825
826impl BundleContents {
827    /// Whether the walk saw everything it tried to.
828    #[must_use]
829    pub fn is_complete(&self) -> bool {
830        self.unreadable.is_empty()
831    }
832}
833
834/// Every file in the bundle that is not markdown.
835///
836/// **Nothing here is opened.** The path, the size and the extension come from
837/// the directory entry and its metadata; the bytes are never read, so this adds
838/// no parser and no attack surface of its own.
839///
840/// It exists because a bundle is **not** markdown, whatever the four published
841/// ones happen to contain: `okf-core`'s `resolve_path_field` resolves a
842/// frontmatter path to any file with `is_file()` and no extension filter, and
843/// §10's `computation:` names a file. So a conformant bundle can cite a document
844/// nobody here can read — and until now every report we produced would call that
845/// bundle clean without mentioning the document existed (ADR-0024).
846///
847/// **What it costs**, because it looks cheaper than it is and has more than one
848/// caller: one walk of this repository's own 9,633-file bundle measures **5.9 ms
849/// warm** (40 ms cold), against the `Bundle::load` of the same bundle at
850/// **1.29 s** — which every caller has already paid before reaching here, since
851/// there is nothing to report about a bundle that did not load. Two walks in a
852/// run is under one percent of what the run already spent, so this is not cached.
853/// If that ratio changes, cache it then and put the new number here.
854///
855/// Symlinked directories are **not** followed: this walks a directory a peer
856/// controls, and `loop -> ..` inside one would otherwise never terminate.
857/// Entries are classified with `file_type()`, which reads the directory entry
858/// rather than the link's target, and a symlink is counted as the file it is.
859///
860/// The walk keeps its own stack rather than recursing. Review raised a deep-tree
861/// stack overflow, and it does **not** reproduce — `PATH_MAX` caps the depth an
862/// attacker can build (509 here) and `Bundle::load` refuses such a tree before
863/// this ever runs, with `File name too long`. It is iterative anyway, because
864/// "bounded by the filesystem's path limit" is a platform accident rather than a
865/// property of this function, and an explicit stack costs nothing to make it one.
866#[must_use]
867pub fn bundle_files(root: &Path) -> BundleContents {
868    let mut out = BundleContents::default();
869    let mut stack = vec![root.to_path_buf()];
870    while let Some(dir) = stack.pop() {
871        let Ok(entries) = std::fs::read_dir(&dir) else {
872            out.unreadable.push(relative(root, &dir));
873            continue;
874        };
875        for entry in entries {
876            // Both of these were `flatten()` and `else continue`, which discard
877            // an error and then let `is_complete()` claim the walk saw
878            // everything — the swallowed-failure defect this type exists to
879            // report, one level further in.
880            let Ok(entry) = entry else {
881                out.unreadable.push(relative(root, &dir));
882                continue;
883            };
884            let path = entry.path();
885            let Ok(kind) = entry.file_type() else {
886                out.unreadable.push(relative(root, &path));
887                continue;
888            };
889            if kind.is_dir() {
890                stack.push(path);
891                continue;
892            }
893            let extension = path
894                .extension()
895                .and_then(|e| e.to_str())
896                .map(str::to_ascii_lowercase)
897                .unwrap_or_default();
898            if extension == "md" {
899                continue;
900            }
901            out.files.push(BundleFile {
902                path: relative(root, &path),
903                bytes: std::fs::symlink_metadata(&path).map(|m| m.len()).ok(),
904                extension,
905            });
906        }
907    }
908    // Sorted so two reads of one bundle, and two bundles with the same contents,
909    // report identically — the same determinism `render okf` guarantees. The
910    // stack alone gives no order at all, since it pops depth-first in whatever
911    // order the filesystem returned each directory.
912    out.files.sort_by(|a, b| a.path.cmp(&b.path));
913    // A directory that failed to yield several entries names itself once per
914    // failure, and the count is not information a reader can act on.
915    out.unreadable.sort();
916    out.unreadable.dedup();
917    out
918}
919
920/// A bundle-relative path, with `/` separators on every platform.
921///
922/// `Path::display()` alone emits `\` on Windows, and this string is compared
923/// against the `/`-separated paths a bundle's own frontmatter and links use — so
924/// on Windows an inventory entry would not match the document that cited it.
925///
926/// A path that is somehow **not** under `root` is rendered as the bare file name
927/// rather than falling back to the whole path: the fallback would print an
928/// absolute path from the host into a report about a peer's bundle, which is a
929/// small disclosure to make in a message whose subject is what a stranger can
930/// see.
931///
932/// The **root itself** renders as `"."`, never as the empty string. It reaches
933/// here when the bundle root is the thing that will not list, and `strip_prefix`
934/// against itself yields an empty path — so the report read `1 entry could not be
935/// inspected:` followed by a blank line, which is a worse failure than the one
936/// being reported, in the one message whose whole job is to say what could not be
937/// seen. `"."` is the spelling `AdrHome::dir` already uses for "the root" here.
938fn relative(root: &Path, path: &Path) -> String {
939    let rel = path
940        .strip_prefix(root)
941        .unwrap_or_else(|_| Path::new(path.file_name().unwrap_or(std::ffi::OsStr::new("?"))));
942    let joined = rel
943        .components()
944        .map(|c| c.as_os_str().to_string_lossy())
945        .collect::<Vec<_>>()
946        .join("/");
947    // The root itself yields an empty path from `strip_prefix` against itself.
948    if joined.is_empty() {
949        ".".to_owned()
950    } else {
951        joined
952    }
953}
954
955/// What a bundle is, in one answer.
956///
957/// Composed from the reports the other commands already produce rather than
958/// re-deriving anything: this is the command you run *first*, on a bundle
959/// somebody handed you, to decide which of the others is worth running.
960#[derive(Debug, Clone, Serialize)]
961pub struct BundleInfo {
962    /// The bundle root, as the caller named it.
963    pub root: String,
964    /// The `okf_version` the root `index.md` declares (§10), if any.
965    ///
966    /// Absent is conformant — §8 and §12 make it MAY — so this is reported and
967    /// never warned about.
968    pub okf_version: Option<String>,
969    /// The bundle's title, from `index.md`.
970    pub title: Option<String>,
971    /// Concepts, excluding the reserved `index.md` / `log.md`.
972    pub concepts: usize,
973    /// Trust tiers and staleness, as of `today`.
974    pub trust: TrustSummary,
975    /// How many concepts carry each `status`, sorted by status.
976    pub statuses: Vec<(String, usize)>,
977    /// Internal links, and how many resolve to nothing.
978    pub links: (usize, usize),
979    /// Attested Computations, and how many are incomplete.
980    pub computations: (usize, usize),
981    /// Every distinct computation `runtime`, sorted.
982    pub runtimes: Vec<String>,
983    /// Files the bundle carries that are not concepts, and any directory the
984    /// walk could not list — see [`bundle_files`].
985    ///
986    /// Reported whether or not there are any, because "no unscreenable files" is
987    /// information and a line that appears only sometimes is one a reader learns
988    /// to stop looking for.
989    pub files: BundleContents,
990}
991
992/// Summarise the bundle at `root`.
993///
994/// # Errors
995///
996/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle,
997/// [`InspectError::BadDate`] if `today` is given and is not an ISO date, and
998/// [`InspectError::NoClock`] if `today` is `None` and the host date cannot be
999/// read — the same three as [`trust_summary`], which this calls.
1000pub fn bundle_info(root: &Path, today: Option<&str>) -> Result<BundleInfo, InspectError> {
1001    let today = resolve_today(today)?;
1002    let bundle = load(root)?;
1003    let trust = summarise_trust(&bundle, &root.display().to_string(), today);
1004    let links = link_report(root)?;
1005    let computations = computation_report(root)?;
1006
1007    let mut statuses: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1008    for concept in bundle.concepts() {
1009        *statuses.entry(concept.status().to_string()).or_default() += 1;
1010    }
1011
1012    Ok(BundleInfo {
1013        root: root.display().to_string(),
1014        okf_version: bundle.okf_version().map(ToOwned::to_owned),
1015        title: bundle_title(&bundle),
1016        concepts: bundle.concepts().len(),
1017        trust,
1018        statuses: statuses.into_iter().collect(),
1019        links: (links.links, links.broken.len()),
1020        computations: (computations.computations, computations.incomplete()),
1021        runtimes: computations.runtimes,
1022        files: bundle_files(root),
1023    })
1024}
1025
1026/// The bundle's own title, from the `title` of its root `index.md`.
1027///
1028/// Read through `Document::parse` — the same parser `Bundle::load` uses — rather
1029/// than a second reader of the same bytes, so the two cannot disagree about what
1030/// the file says. `okf-core` exposes the index only as a path, so this re-reads
1031/// one small file; that is cheap next to the directory walk, and a bundle whose
1032/// index is unreadable simply has no title here, because `okf validate` is what
1033/// reports a broken index.
1034fn bundle_title(bundle: &Bundle) -> Option<String> {
1035    let path = bundle.index_files().first()?;
1036    let text = std::fs::read_to_string(path).ok()?;
1037    let document = okf_core::Document::parse(&text).ok()?;
1038    document
1039        .frontmatter
1040        .title()
1041        .map(std::borrow::Cow::into_owned)
1042}