Skip to main content

okf_validator/
validate.rs

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