Skip to main content

okf_validator/
validate.rs

1//! Conformance checking against OKF v0.2 §11.
2//!
3//! A bundle is **conformant** if (1) every non-reserved `.md` file has a
4//! parseable frontmatter block, (2) every frontmatter has a non-empty `type`,
5//! and (3) reserved files follow their structure when present. Everything else
6//! is soft guidance: consumers MUST NOT reject a bundle for missing optional
7//! fields, unknown types or keys, broken links, or missing `index.md` files.
8//!
9//! Accordingly, [`validate_bundle`] reports only true §11 violations as
10//! [`Severity::Error`]. The v0.2 families (§5, §10) are all optional, so
11//! everything they contribute here is a [`Severity::Warning`] (a producer
12//! mistake worth fixing) or [`Severity::Info`] (a permitted state worth
13//! knowing about, such as a broken link or a concept past its `stale_after`).
14//!
15//! Staleness is the one check that depends on the wall clock, so it is opt-in:
16//! [`validate_bundle`] is deterministic and [`validate_bundle_at`] takes the
17//! date to compare against.
18
19use okf_core::bundle::Bundle;
20use okf_core::computation::{ATTESTED_COMPUTATION_TYPE, ComputationSource};
21use okf_core::concept_id::ConceptId;
22use okf_core::date::{Date, DateTime};
23use okf_core::document::Document;
24use okf_core::frontmatter::Frontmatter;
25use okf_core::log::Log;
26use okf_core::provenance::{ResourceKind, Source};
27use okf_core::trust::{STATUS_VALUES, Verification};
28use okf_core::yaml::Value;
29use std::collections::HashSet;
30use std::fs;
31use std::path::PathBuf;
32
33/// Severity of a diagnostic.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum Severity {
36    /// A §11 conformance violation.
37    Error,
38    /// A soft-guidance deviation (the bundle is still conformant).
39    Warning,
40    /// Informational note, for example a broken but permitted cross-link.
41    Info,
42}
43
44impl std::fmt::Display for Severity {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.write_str(match self {
47            Self::Error => "error",
48            Self::Warning => "warning",
49            Self::Info => "info",
50        })
51    }
52}
53
54/// A single finding about a bundle.
55#[derive(Clone, Debug)]
56pub struct Diagnostic {
57    /// How serious the finding is.
58    pub severity: Severity,
59    /// The file the finding relates to, if any.
60    pub path: Option<PathBuf>,
61    /// The concept the finding relates to, if any.
62    pub concept: Option<ConceptId>,
63    /// A human-readable message.
64    pub message: String,
65}
66
67impl std::fmt::Display for Diagnostic {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(f, "[{}] ", self.severity)?;
70        if let Some(p) = &self.path {
71            write!(f, "{}: ", p.display())?;
72        } else if let Some(c) = &self.concept {
73            write!(f, "{c}: ")?;
74        }
75        f.write_str(&self.message)
76    }
77}
78
79/// The result of validating a bundle.
80#[derive(Clone, Debug, Default)]
81pub struct Report {
82    /// All findings, errors first by construction order.
83    pub diagnostics: Vec<Diagnostic>,
84}
85
86impl Report {
87    /// `true` if there are no [`Severity::Error`] diagnostics, i.e. the bundle
88    /// conforms to §11.
89    #[must_use]
90    pub fn is_conformant(&self) -> bool {
91        !self
92            .diagnostics
93            .iter()
94            .any(|d| d.severity == Severity::Error)
95    }
96
97    /// Iterates over diagnostics of a given severity.
98    pub fn of(&self, severity: Severity) -> impl Iterator<Item = &Diagnostic> {
99        self.diagnostics
100            .iter()
101            .filter(move |d| d.severity == severity)
102    }
103
104    /// Count of error-level diagnostics.
105    #[must_use]
106    pub fn error_count(&self) -> usize {
107        self.of(Severity::Error).count()
108    }
109
110    /// Count of warning-level diagnostics.
111    #[must_use]
112    pub fn warning_count(&self) -> usize {
113        self.of(Severity::Warning).count()
114    }
115}
116
117/// Validates a loaded bundle against §11, returning all findings.
118///
119/// Deterministic: `stale_after` dates are checked for *syntax* but not against
120/// the clock. Use [`validate_bundle_at`] to also flag stale concepts.
121#[must_use]
122pub fn validate_bundle(bundle: &Bundle) -> Report {
123    validate_bundle_at(bundle, None)
124}
125
126/// Validates a bundle, additionally reporting concepts that are stale on
127/// `today` (§5.5).
128#[must_use]
129pub fn validate_bundle_at(bundle: &Bundle, today: Option<Date>) -> Report {
130    let mut report = Report::default();
131
132    // (1) Files whose frontmatter could not be parsed are conformance errors.
133    for (path, error) in bundle.parse_errors() {
134        report.error(
135            Some(path.clone()),
136            None,
137            format!("unparseable concept document: {error}"),
138        );
139    }
140
141    // (2) Every concept must carry a non-empty `type`. Everything else the
142    // families add is soft guidance.
143    for concept in bundle.concepts() {
144        let mut cx = Context {
145            report: &mut report,
146            path: concept.path.clone(),
147            id: concept.id.clone(),
148        };
149        let fm = &concept.document.frontmatter;
150
151        if concept.document.validate().is_err() {
152            if fm
153                .get("type")
154                .is_some_and(|value| value.as_display_str().is_none())
155            {
156                cx.error("`type` must be a non-empty scalar (§4.1)");
157            } else {
158                cx.error("missing required frontmatter field `type` (§4.1)");
159            }
160        }
161        check_recommended(&mut cx, &concept.document);
162        check_tags(&mut cx, fm);
163        check_trust(&mut cx, fm);
164        check_lifecycle(&mut cx, fm, today);
165        check_provenance(&mut cx, fm);
166        check_attribution(&mut cx, &concept.document);
167        check_legacy(&mut cx, &concept.document);
168        check_computation(&mut cx, &concept.document);
169        check_path_fields(&mut cx, bundle, fm);
170    }
171
172    // (3) Reserved files must follow their structure when present.
173    check_segment_portability(bundle, &mut report);
174    validate_reserved(bundle, &mut report);
175    check_declared_version(bundle, &mut report);
176
177    // Broken cross-links are permitted (§6.1); report them as info only.
178    for (source, raw) in bundle.broken_links() {
179        report.info(
180            None,
181            Some(source),
182            format!("link target does not resolve to a concept in the bundle: {raw}"),
183        );
184    }
185
186    report
187}
188
189/// The concept currently being checked, so each rule can emit diagnostics
190/// without repeating the path and id.
191struct Context<'a> {
192    report: &'a mut Report,
193    path: PathBuf,
194    id: ConceptId,
195}
196
197impl Context<'_> {
198    fn push(&mut self, severity: Severity, message: impl Into<String>) {
199        self.report.diagnostics.push(Diagnostic {
200            severity,
201            path: Some(self.path.clone()),
202            concept: Some(self.id.clone()),
203            message: message.into(),
204        });
205    }
206
207    fn error(&mut self, message: impl Into<String>) {
208        self.push(Severity::Error, message);
209    }
210
211    fn warn(&mut self, message: impl Into<String>) {
212        self.push(Severity::Warning, message);
213    }
214
215    fn info(&mut self, message: impl Into<String>) {
216        self.push(Severity::Info, message);
217    }
218}
219
220impl Report {
221    fn add(
222        &mut self,
223        severity: Severity,
224        path: Option<PathBuf>,
225        concept: Option<ConceptId>,
226        message: String,
227    ) {
228        self.diagnostics.push(Diagnostic {
229            severity,
230            path,
231            concept,
232            message,
233        });
234    }
235
236    fn error(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
237        self.add(Severity::Error, path, concept, message);
238    }
239
240    fn warn(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
241        self.add(Severity::Warning, path, concept, message);
242    }
243
244    fn info(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
245        self.add(Severity::Info, path, concept, message);
246    }
247}
248
249/// §4.1's recommended fields, plus `generated` from §5.2.
250///
251/// Always a warning: §11 forbids rejecting a concept for a missing optional
252/// field, however much a producer wants it filled in.
253fn check_recommended(cx: &mut Context, doc: &Document) {
254    for field in doc.missing_recommended() {
255        // `runtime` also comes back from `missing_recommended`, but
256        // `check_computation` reports it with the reason it is required.
257        if field == "runtime" {
258            continue;
259        }
260        cx.warn(format!(
261            "missing recommended frontmatter field `{field}` ({})",
262            recommended_by(field)
263        ));
264    }
265}
266
267/// The section that recommends a given frontmatter key, for the diagnostic.
268fn recommended_by(field: &str) -> &'static str {
269    match field {
270        "generated" => "§5.2",
271        _ => "§4.1",
272    }
273}
274
275/// The shape of `tags` (§4.1).
276///
277/// Worth its own check because the failure is silent: §4.1 asks for "a YAML list
278/// of short strings", and a producer that writes `tags: a, b, c` gets one plain
279/// scalar, so [`Frontmatter::tags`] reads no tags at all and the concept
280/// disappears from every tag view.
281fn check_tags(cx: &mut Context, fm: &Frontmatter) {
282    let Some(value) = fm.get("tags").filter(|v| !v.is_empty_value()) else {
283        return;
284    };
285    if !matches!(value, Value::Sequence(_)) {
286        cx.warn(format!(
287            "`tags` should be a list of short strings, found {}; no tags are read from it (§4.1)",
288            type_name(value)
289        ));
290    }
291}
292
293/// `generated` and `verified` (§5.2).
294fn check_trust(cx: &mut Context, fm: &Frontmatter) {
295    if let Some(value) = fm.get("generated").filter(|v| !v.is_empty_value()) {
296        match fm.generated() {
297            None => cx.warn(format!(
298                "`generated` should be a `{{ by, at }}` mapping, found {} (§5.2)",
299                type_name(value)
300            )),
301            Some(generated) => {
302                if generated.by.is_none() {
303                    cx.warn("`generated.by` is required within `generated` (§5.2)");
304                }
305                if let Some(at) = generated.at.filter(|a| !a.is_valid()) {
306                    cx.warn(format!(
307                        "`generated.at` is not an ISO-8601 datetime with an explicit offset: {:?} (§5.2)",
308                        at.raw
309                    ));
310                }
311            }
312        }
313    }
314
315    let Some(value) = fm.get("verified").filter(|v| !v.is_empty_value()) else {
316        return;
317    };
318    if !matches!(value, Value::Sequence(_) | Value::Mapping(_)) {
319        cx.warn(format!(
320            "`verified` should be a list of `{{ by, at }}` events (a bare mapping is read as \
321             a one-element list), found {} (§5.2)",
322            type_name(value)
323        ));
324        return;
325    }
326    let events = fm.verified();
327    if events.is_empty() {
328        cx.warn("`verified` contains no `{ by, at }` events (§5.2)");
329    }
330    match value {
331        Value::Sequence(items) => {
332            for (i, item) in items.iter().enumerate() {
333                let Some(event) = Verification::from_value(item) else {
334                    cx.warn(format!(
335                        "`verified[{i}]` should be a mapping with `by` and `at`, found {} (§5.2)",
336                        type_name(item)
337                    ));
338                    continue;
339                };
340                check_verification_event(cx, i, &event);
341            }
342        }
343        Value::Mapping(_) => {
344            if let Some(event) = Verification::from_value(value) {
345                check_verification_event(cx, 0, &event);
346            }
347        }
348        _ => unreachable!("verified shape checked above"),
349    }
350}
351
352fn check_verification_event(cx: &mut Context, i: usize, event: &Verification) {
353    if event
354        .by
355        .as_ref()
356        .is_none_or(|by| by.as_str().trim().is_empty())
357    {
358        cx.warn(format!("`verified[{i}].by` is missing (§5.2)"));
359    }
360    match &event.at {
361        None => cx.warn(format!("`verified[{i}].at` is missing (§5.2)")),
362        Some(at) if !at.is_valid() => cx.warn(format!(
363            "`verified[{i}].at` is not an ISO-8601 datetime with an explicit offset: {:?} (§5.2)",
364            at.raw
365        )),
366        Some(_) => {}
367    }
368}
369
370/// `status` and `stale_after` (§5.4, §5.5).
371fn check_lifecycle(cx: &mut Context, fm: &Frontmatter, today: Option<Date>) {
372    let status = fm.status();
373    if !status.is_known() {
374        cx.warn(format!(
375            "unknown `status` value {:?}; §5.4 defines {} (consumers must still accept it)",
376            status.to_string(),
377            STATUS_VALUES.join(", ")
378        ));
379    }
380
381    let Some(stale_after) = fm.stale_after() else {
382        return;
383    };
384    match &stale_after.datetime {
385        Some(dt) if stale_after.is_valid() => {
386            if let Some(today) = today
387                && today.to_utc_datetime() >= *dt
388            {
389                cx.info(format!("stale since {stale_after} (§5.5)"));
390            }
391        }
392        _ => {
393            cx.warn(format!(
394                "`stale_after` is not an ISO-8601 datetime with an explicit offset: {:?} (§5.5)",
395                stale_after.raw
396            ));
397        }
398    }
399}
400
401/// `sources` and its credibility signals (§5.1).
402fn check_provenance(cx: &mut Context, fm: &Frontmatter) {
403    let Some(value) = fm.get("sources").filter(|v| !v.is_empty_value()) else {
404        // A `usage_window` with nothing to frame is a producer slip.
405        if fm.get("usage_window").is_some() {
406            cx.warn("`usage_window` is present without `sources` to frame (§5.1)");
407        }
408        return;
409    };
410    if !matches!(value, Value::Sequence(_) | Value::Mapping(_)) {
411        cx.warn(format!(
412            "`sources` should be a list of entries, found {} (§5.1)",
413            type_name(value)
414        ));
415        return;
416    }
417
418    let shared_window = fm.usage_window();
419    if let Some(window) = &shared_window {
420        for (field, date) in [("from", &window.from), ("to", &window.to)] {
421            if let Some(d) = date.as_ref().filter(|d| !d.is_valid()) {
422                cx.warn(format!(
423                    "`usage_window.{field}` is not an ISO-8601 datetime with an explicit offset: {:?} (§5.1)",
424                    d.raw
425                ));
426            }
427        }
428    }
429
430    let mut seen_ids: HashSet<String> = HashSet::new();
431    let entries: Vec<(usize, Source)> = match value {
432        Value::Sequence(items) => items
433            .iter()
434            .enumerate()
435            .filter_map(|(i, item)| {
436                if item.as_mapping().is_none() {
437                    cx.warn(format!(
438                        "`sources[{i}]` should be a mapping entry, found {} (§5.1)",
439                        type_name(item)
440                    ));
441                    None
442                } else {
443                    Source::from_value(item).map(|source| (i, source))
444                }
445            })
446            .collect(),
447        Value::Mapping(_) => Source::from_value(value)
448            .into_iter()
449            .map(|source| (0, source))
450            .collect(),
451        _ => unreachable!("sources shape checked above"),
452    };
453    for (i, source) in &entries {
454        if source.resource_kind() == ResourceKind::Missing {
455            cx.warn(format!(
456                "`sources[{i}].resource` is required within an entry (§5.1)"
457            ));
458        }
459        if let Some(id) = &source.id
460            && !seen_ids.insert(id.clone())
461        {
462            cx.warn(format!(
463                "duplicate `sources[].id` {id:?}; ids are the join key for attribution (§5.1)"
464            ));
465        }
466        if let Some(last_modified) = source.last_modified.as_ref().filter(|d| !d.is_valid()) {
467            cx.warn(format!(
468                "`sources[{i}].last_modified` is not an ISO-8601 datetime with an explicit offset: {:?} (§5.1)",
469                last_modified.raw
470            ));
471        }
472        if source.usage_count.is_some()
473            && source
474                .effective_usage_window(shared_window.as_ref())
475                .is_none()
476        {
477            cx.warn(format!(
478                "`sources[{i}].usage_count` has no `usage_window` to frame it (§5.1)"
479            ));
480        }
481    }
482
483    // A non-integer `usage_count` is dropped by the typed reader, so check the
484    // raw values too.
485    if let Value::Sequence(items) = value {
486        for (i, item) in items.iter().enumerate() {
487            let raw = item.as_mapping().and_then(|m| m.get("usage_count"));
488            if let Some(raw) = raw.filter(|v| v.as_int().is_none()) {
489                cx.warn(format!(
490                    "`sources[{i}].usage_count` should be an integer, found {} (§5.1)",
491                    type_name(raw)
492                ));
493            }
494        }
495    }
496}
497
498/// Footnote attribution keyed to `sources[].id` (§5.1).
499fn check_attribution(cx: &mut Context, doc: &Document) {
500    let has_sources = !doc.frontmatter.sources().is_empty();
501    for attribution in doc.attributions() {
502        if !attribution.is_resolved() && has_sources {
503            cx.warn(format!(
504                "footnote [^{}] matches no `sources[].id`; the label is the join key for \
505                 attribution (§5.1)",
506                attribution.label
507            ));
508        }
509        if attribution.references > 0 && attribution.definitions == 0 {
510            cx.warn(format!(
511                "footnote [^{}] is cited but never defined (§5.1)",
512                attribution.label
513            ));
514        }
515    }
516}
517
518/// v0.1 constructs that v0.2 supersedes (§13.1).
519fn check_legacy(cx: &mut Context, doc: &Document) {
520    let fm = &doc.frontmatter;
521    if !is_blank(fm, "timestamp") {
522        if is_blank(fm, "generated") {
523            cx.warn("`timestamp` is superseded by `generated: { by, at }` (§13.1)");
524        } else {
525            cx.warn("`timestamp` is redundant alongside `generated` and should be removed (§13.1)");
526        }
527    }
528    if doc.has_legacy_citations() {
529        cx.warn(
530            "the body `# Citations` list is superseded by the `sources` frontmatter field (§13.1)",
531        );
532    }
533}
534
535/// The Attested Computation contract (§10).
536fn check_computation(cx: &mut Context, doc: &Document) {
537    let fm = &doc.frontmatter;
538    let computation_keys = [
539        "runtime",
540        "parameters",
541        "computation",
542        "executor",
543        "attester",
544    ];
545
546    if !fm.is_attested_computation() {
547        let present: Vec<&str> = computation_keys
548            .iter()
549            .copied()
550            .filter(|k| !is_blank(fm, k))
551            .collect();
552        if !present.is_empty() {
553            cx.info(format!(
554                "carries computation field(s) `{}` but `type` is not `{ATTESTED_COMPUTATION_TYPE}`; \
555                 a sanctioned computation is its own concept (§10.1)",
556                present.join("`, `")
557            ));
558        }
559        return;
560    }
561
562    let Some(contract) = doc.attested_computation() else {
563        return;
564    };
565
566    if contract.runtime.is_none() {
567        cx.warn("`runtime` is required on an `Attested Computation`; it defines what `parameters` mean (§10.2)");
568    }
569    match &contract.computation {
570        ComputationSource::Missing => cx.warn(
571            "no computation: set `computation` to a path or add a `# Computation` block to the body (§10.3)",
572        ),
573        ComputationSource::File(_) if contract.has_redundant_inline => cx.warn(
574            "`computation` names a file and the body also has a `# Computation` block; §10.3 asks for one or the other",
575        ),
576        _ => {}
577    }
578
579    for (i, parameter) in contract.parameters.iter().enumerate() {
580        if parameter.name.is_none() {
581            cx.warn(format!("`parameters[{i}].name` is missing (§10.2)"));
582        }
583        if parameter.type_.is_none() {
584            cx.warn(format!("`parameters[{i}].type` is missing (§10.2)"));
585        }
586    }
587
588    match &contract.executor {
589        None => cx.warn("missing `executor`: nothing says how to run the computation (§10.2)"),
590        Some(executor) => {
591            if executor.resource.is_none() {
592                cx.warn(
593                    "`executor.resource` is missing; it names the run instructions or code (§10.2)",
594                );
595            }
596            if executor.receipt.is_empty() {
597                cx.warn(
598                    "`executor.receipt` is empty; it declares the evidence the attester inspects (§10.2)",
599                );
600            }
601        }
602    }
603
604    match &contract.attester {
605        None => cx.warn("missing `attester`: nothing can check a run's receipt (§10.2)"),
606        Some(attester) if attester.resource.is_none() => {
607            cx.warn("`attester.resource` is missing; it names the deterministic check (§10.2)");
608        }
609        Some(_) => {}
610    }
611}
612
613/// Path-valued fields that point inside the bundle but resolve to nothing
614/// (§6.2). Informational, since a bundle may legitimately be shipped without
615/// the files its executor or attester references.
616///
617/// `resource` is only checked when it is written unambiguously as a path
618/// (`/...`, `./...`, `../...`). A bare `resource` such as
619/// `acme.sales.orders` is an opaque asset identifier, not a promise that a file
620/// exists, and reporting it as a broken path would be noise.
621fn check_path_fields(cx: &mut Context, bundle: &Bundle, fm: &Frontmatter) {
622    let id = cx.id.clone();
623    for (field, raw) in fm.path_fields() {
624        let target = raw.trim();
625        let explicit_path =
626            target.starts_with('/') || target.starts_with("./") || target.starts_with("../");
627        if field == "resource" && !explicit_path {
628            continue;
629        }
630        if okf_core::links::field_path_candidates(target, &id).is_empty() {
631            continue; // a URI, nothing in the bundle to resolve
632        }
633        if bundle.resolve_path_field(&id, target).is_none() {
634            cx.info(format!(
635                "`{field}` does not resolve to a file in the bundle: {raw} (§6.2)"
636            ));
637        }
638    }
639}
640
641/// Concept-id segments outside the reference implementation's
642/// `[A-Za-z0-9_][A-Za-z0-9_.\-]*` convention (§2).
643///
644/// Never an error. The spec places no character constraint on filenames and
645/// §11 makes conformance a question of frontmatter, so [`ConceptId`] accepts
646/// these names and the bundle stays conformant. It is still worth telling a
647/// producer: such a name has to be written as `<...>` or percent-encoded to be
648/// linked from markdown, and is not guaranteed to survive every filesystem
649/// unchanged.
650///
651/// Each distinct segment is reported once, against the first concept that uses
652/// it, so one awkwardly named directory does not warn on every file inside it.
653fn check_segment_portability(bundle: &Bundle, report: &mut Report) {
654    let mut seen: HashSet<&str> = HashSet::new();
655    for concept in bundle.concepts() {
656        for segment in concept.id.segments() {
657            if okf_core::concept_id::is_portable_segment(segment) || !seen.insert(segment) {
658                continue;
659            }
660            report.warn(
661                Some(concept.path.clone()),
662                Some(concept.id.clone()),
663                format!(
664                    "concept-id segment {segment:?} is outside the conventional \
665                     `[A-Za-z0-9_][A-Za-z0-9_.-]*` set; the bundle is still conformant, but \
666                     such a name needs `<...>` or percent-encoding to link portably and is \
667                     not guaranteed to survive every filesystem unchanged (§2)"
668                ),
669            );
670        }
671    }
672}
673
674fn validate_reserved(bundle: &Bundle, report: &mut Report) {
675    let root_index = bundle.root().join("index.md");
676
677    for path in bundle.index_files() {
678        let text = match fs::read_to_string(path) {
679            Ok(text) => text,
680            Err(error) => {
681                report.error(
682                    Some(path.clone()),
683                    None,
684                    format!("unreadable reserved index.md: {error} (§8)"),
685                );
686                continue;
687            }
688        };
689        let doc = match Document::parse(&text) {
690            Ok(doc) => doc,
691            Err(error) => {
692                report.error(
693                    Some(path.clone()),
694                    None,
695                    format!("unparseable reserved index.md: {error} (§8)"),
696                );
697                continue;
698            }
699        };
700        if doc.frontmatter.is_empty() {
701            continue;
702        }
703        // Frontmatter is only permitted in the bundle-root index.md, and only
704        // to declare `okf_version` (§12).
705        let is_root = path == &root_index;
706        if is_root {
707            let only_version = doc
708                .frontmatter
709                .as_mapping()
710                .keys()
711                .all(|k| k == "okf_version");
712            if !only_version {
713                report.error(
714                    Some(path.clone()),
715                    None,
716                    "root index.md frontmatter should declare only `okf_version` (§12)".to_string(),
717                );
718            }
719        } else {
720            report.error(
721                Some(path.clone()),
722                None,
723                "index.md should not contain frontmatter (§8)".to_string(),
724            );
725        }
726    }
727
728    for path in bundle.log_files() {
729        let text = match fs::read_to_string(path) {
730            Ok(text) => text,
731            Err(error) => {
732                report.error(
733                    Some(path.clone()),
734                    None,
735                    format!("unreadable reserved log.md: {error} (§9)"),
736                );
737                continue;
738            }
739        };
740        let log = Log::parse(&text);
741        for issue in log.structural_errors(&text) {
742            report.error(Some(path.clone()), None, format!("{issue} (§9)"));
743        }
744        for bad in log.invalid_dates() {
745            report.error(
746                Some(path.clone()),
747                None,
748                format!("log date heading is not ISO-8601 `YYYY-MM-DD`: {bad:?} (§9)"),
749            );
750        }
751    }
752}
753
754/// The `okf_version` a bundle declares (§12).
755///
756/// Never an error. §12 is explicit that a consumer which does not understand
757/// the declared version should attempt best-effort consumption rather than
758/// refusing the bundle.
759fn check_declared_version(bundle: &Bundle, report: &mut Report) {
760    let Some(declared) = bundle.okf_version() else {
761        return;
762    };
763    let declared = declared.trim();
764    if declared == okf_core::OKF_VERSION {
765        return;
766    }
767    let message = if okf_core::SUPPORTED_OKF_VERSIONS.contains(&declared) {
768        format!(
769            "bundle targets OKF v{declared}; read as v{} under the §13.1 fallbacks",
770            okf_core::OKF_VERSION
771        )
772    } else {
773        format!(
774            "bundle declares an unrecognized `okf_version: {declared}`; consuming it \
775             best-effort as v{} (§12)",
776            okf_core::OKF_VERSION
777        )
778    };
779    report.info(Some(bundle.root().join("index.md")), None, message);
780}
781
782fn is_blank(fm: &Frontmatter, key: &str) -> bool {
783    fm.get(key).is_none_or(Value::is_empty_value)
784}
785
786/// A short YAML type name, for diagnostics about a mis-shaped value.
787const fn type_name(value: &Value) -> &'static str {
788    match value {
789        Value::Null => "null",
790        Value::Bool(_) => "a boolean",
791        Value::Int(_) => "an integer",
792        Value::Float(_) => "a float",
793        Value::String(_) => "a string",
794        Value::Sequence(_) => "a list",
795        Value::Mapping(_) => "a mapping",
796    }
797}
798
799/// Checks an ISO-8601 datetime with a time of day and an explicit UTC offset.
800///
801/// OKF's timestamp fields require a time of day with an explicit offset (§5).
802#[must_use]
803pub fn is_iso8601_datetime(s: &str) -> bool {
804    DateTime::parse(s)
805        .is_some_and(|datetime| datetime.has_time && datetime.offset_minutes.is_some())
806}