okf_core/frontmatter.rs
1//! Typed, order-preserving access to a concept's YAML frontmatter.
2//!
3//! OKF frontmatter is an open mapping: a few well-known keys (defined by the
4//! [spec]) plus arbitrary producer-defined extensions that consumers MUST
5//! preserve when round-tripping. [`Frontmatter`] therefore stores the full
6//! [`Mapping`] verbatim and layers typed accessors on top, rather than
7//! deserializing into a fixed struct that would drop unknown keys.
8//!
9//! v0.2 adds four families of well-known keys on top of the v0.1 core, all of
10//! them optional:
11//!
12//! | Family | Keys |
13//! |---------------------------|----------------------------------------------------------|
14//! | Core | `type`, `title`, `description`, `resource`, `tags` |
15//! | Provenance | `sources`, `usage_window` |
16//! | Trust | `generated`, `verified` |
17//! | Lifecycle | `status`, `stale_after` |
18//! | Computation | `runtime`, `parameters`, `computation`, `executor`, `attester` |
19//!
20//! Absence is meaningful but never fatal: [`Frontmatter::status`] defaults to
21//! `stable`, [`Frontmatter::trust_tier`] to `unverified`, and a concept
22//! carrying nothing but `type` is fully conformant.
23//!
24//! [spec]: https://github.com/GoogleCloudPlatform/open-knowledge-format/blob/main/SPEC.md
25
26use crate::computation::{ATTESTED_COMPUTATION_TYPE, Attester, Executor, Parameter};
27use crate::date::{Date, DateTime, DateTimeField};
28use crate::provenance::{Source, UsageWindow};
29use crate::trust::{self, Generated, Status, TrustTier, Verification};
30use crate::yaml::{Mapping, Value};
31use std::borrow::Cow;
32
33/// The only frontmatter key OKF always requires: a concept carrying
34/// nothing but `type` is fully conformant.
35///
36/// This is what [`Document::validate`](crate::Document::validate) enforces, and
37/// it matches the reference implementation's `REQUIRED_FRONTMATTER_KEYS`. v0.1
38/// required four keys; v0.2 narrowed the requirement to this one and demoted
39/// the rest to recommendations ([`RECOMMENDED_FRONTMATTER_KEYS`]).
40pub const REQUIRED_FRONTMATTER_KEYS: [&str; 1] = ["type"];
41
42/// Keys a producer should fill in before publishing, in the order
43/// [`Document::missing_recommended`](crate::Document::missing_recommended)
44/// reports them.
45///
46/// `title` and `description` are recommended fields; `generated` is the
47/// record of how the content was produced. The spec also recommends `resource` and
48/// `tags`, which are deliberately left out here: `resource` is "absent for
49/// concepts that describe abstract ideas rather than physical resources", so
50/// flagging either would be noise rather than guidance.
51///
52/// Leaving any of these unset is never a conformance failure.
53pub const RECOMMENDED_FRONTMATTER_KEYS: [&str; 3] = ["title", "description", "generated"];
54
55/// Keys v0.2 retired but consumers may still encounter in v0.1 documents.
56/// `timestamp` is superseded by `generated.at`.
57pub const LEGACY_FRONTMATTER_KEYS: [&str; 1] = ["timestamp"];
58
59/// Every frontmatter key the specification gives a meaning to, across all
60/// families. Anything else is a producer extension.
61pub const KNOWN_FRONTMATTER_KEYS: [&str; 17] = [
62 // Core.
63 "type",
64 "title",
65 "description",
66 "resource",
67 "tags",
68 // Provenance.
69 "sources",
70 "usage_window",
71 // Trust.
72 "generated",
73 "verified",
74 // Lifecycle.
75 "status",
76 "stale_after",
77 // Attested Computation.
78 "runtime",
79 "parameters",
80 "computation",
81 "executor",
82 "attester",
83 // Legacy.
84 "timestamp",
85];
86
87/// The key order the reference implementation writes documents in (its
88/// `_PREFERRED_KEY_ORDER`): identity first, then lifecycle, trust, and
89/// provenance.
90///
91/// Presentational only. Frontmatter has no required key order, and a
92/// consumer must not depend on one; see [`Frontmatter::reorder_preferred`].
93pub const PREFERRED_KEY_ORDER: [&str; 11] = [
94 "type",
95 "resource",
96 "title",
97 "description",
98 "tags",
99 "status",
100 "generated",
101 "verified",
102 "stale_after",
103 "sources",
104 "usage_window",
105];
106
107/// A concept's frontmatter: an ordered key/value mapping with typed accessors
108/// for the well-known OKF fields.
109#[derive(Clone, Debug, Default, PartialEq)]
110pub struct Frontmatter {
111 map: Mapping,
112}
113
114impl Frontmatter {
115 /// Creates an empty frontmatter block.
116 #[must_use]
117 pub const fn new() -> Self {
118 Self {
119 map: Mapping::new(),
120 }
121 }
122
123 /// Wraps an existing mapping.
124 #[must_use]
125 pub const fn from_mapping(map: Mapping) -> Self {
126 Self { map }
127 }
128
129 /// Borrows the underlying ordered mapping.
130 #[must_use]
131 pub const fn as_mapping(&self) -> &Mapping {
132 &self.map
133 }
134
135 /// Mutably borrows the underlying ordered mapping.
136 pub const fn as_mapping_mut(&mut self) -> &mut Mapping {
137 &mut self.map
138 }
139
140 /// Consumes the wrapper, returning the underlying mapping.
141 #[must_use]
142 pub fn into_mapping(self) -> Mapping {
143 self.map
144 }
145
146 /// `true` if there are no keys.
147 #[must_use]
148 pub const fn is_empty(&self) -> bool {
149 self.map.is_empty()
150 }
151
152 /// Raw value for an arbitrary key (including producer extensions).
153 #[must_use]
154 pub fn get(&self, key: &str) -> Option<&Value> {
155 self.map.get(key)
156 }
157
158 /// Sets a raw value for a key, preserving position if it already exists.
159 pub fn set(&mut self, key: impl Into<String>, value: Value) {
160 self.map.insert(key, value);
161 }
162
163 /// Removes a key from frontmatter, returning the removed value if present.
164 pub fn remove(&mut self, key: &str) -> Option<Value> {
165 self.map.remove(key)
166 }
167
168 /// Reorders the keys into [`PREFERRED_KEY_ORDER`], leaving every other key
169 /// after them in its current relative order.
170 ///
171 /// A port of the reference implementation's `_reorder_frontmatter`, which it
172 /// applies whenever it writes a concept document; call this before
173 /// [`Document::serialize`](crate::Document::serialize) to produce
174 /// frontmatter laid out the same way. No key is added, dropped, or
175 /// rewritten, so only the serialized order changes.
176 pub fn reorder_preferred(&mut self) {
177 let mut ordered = Mapping::new();
178 for key in PREFERRED_KEY_ORDER {
179 if let Some(value) = self.map.get(key) {
180 ordered.insert(key, value.clone());
181 }
182 }
183 for (key, value) in self.map.iter() {
184 let already_placed = key
185 .as_str()
186 .is_some_and(|k| PREFERRED_KEY_ORDER.contains(&k));
187 if !already_placed {
188 ordered.push_raw(key.clone(), value.clone());
189 }
190 }
191 self.map = ordered;
192 }
193
194 /// The **required** `type` field. `None` if absent or not a scalar.
195 ///
196 /// Non-string scalars (`type: 42`) are coerced to their display form, the
197 /// way the reference's `str(fm.get("type"))` does, rather than read as
198 /// `None`: the spec calls `type` "a short string", so a non-string value
199 /// is a producer deviation, but a consumer still gets *something* to
200 /// route on rather than treating the concept as typeless.
201 #[must_use]
202 pub fn type_(&self) -> Option<Cow<'_, str>> {
203 self.display_str("type")
204 }
205
206 /// The optional `title` field.
207 #[must_use]
208 pub fn title(&self) -> Option<Cow<'_, str>> {
209 self.display_str("title")
210 }
211
212 /// The optional one-line `description`.
213 #[must_use]
214 pub fn description(&self) -> Option<Cow<'_, str>> {
215 self.display_str("description")
216 }
217
218 /// The optional `resource` URI for the underlying asset.
219 #[must_use]
220 pub fn resource(&self) -> Option<Cow<'_, str>> {
221 self.display_str("resource")
222 }
223
224 /// The optional `tags` list. Non-string elements are coerced to their
225 /// display form; a non-sequence `tags` value yields an empty vector.
226 pub fn tags(&self) -> Vec<String> {
227 match self.map.get("tags") {
228 Some(Value::Sequence(items)) => {
229 items.iter().filter_map(Value::as_display_string).collect()
230 }
231 _ => Vec::new(),
232 }
233 }
234
235 /// The `sources` entries: the materials this concept derives from.
236 pub fn sources(&self) -> Vec<Source> {
237 self.map
238 .get("sources")
239 .map(Source::list_from_value)
240 .unwrap_or_default()
241 }
242
243 /// The shared `usage_window` that frames every `sources[].usage_count`.
244 pub fn usage_window(&self) -> Option<UsageWindow> {
245 self.map
246 .get("usage_window")
247 .and_then(UsageWindow::from_value)
248 }
249
250 /// The `generated` block: how the current content was produced.
251 pub fn generated(&self) -> Option<Generated> {
252 self.map.get("generated").and_then(Generated::from_value)
253 }
254
255 /// The `verified` events: who or what has confirmed this content.
256 ///
257 /// A bare `{ by, at }` mapping is returned as a one-element list, as the
258 /// spec requires.
259 pub fn verified(&self) -> Vec<Verification> {
260 self.map
261 .get("verified")
262 .map(Verification::list_from_value)
263 .unwrap_or_default()
264 }
265
266 /// The verification with the latest parseable `at`.
267 #[must_use]
268 pub fn latest_verification(&self) -> Option<Verification> {
269 let events = self.verified();
270 trust::latest_verification(&events).cloned()
271 }
272
273 /// The trust tier derived from `verified`.
274 #[must_use]
275 pub fn trust_tier(&self) -> TrustTier {
276 TrustTier::derive(&self.verified())
277 }
278
279 /// When the content last meaningfully changed: `generated.at`,
280 /// falling back to a legacy v0.1 `timestamp` when `generated` is absent, as
281 /// permitted.
282 #[must_use]
283 pub fn content_changed_at(&self) -> Option<DateTimeField> {
284 self.generated()
285 .and_then(|g| g.at)
286 .or_else(|| self.timestamp().map(|s| DateTimeField::new(s.into_owned())))
287 }
288
289 /// The legacy v0.1 `timestamp` field, superseded by `generated.at`.
290 ///
291 /// Prefer [`Frontmatter::content_changed_at`], which reads `generated.at`
292 /// first and falls back to this.
293 #[must_use]
294 pub fn timestamp(&self) -> Option<Cow<'_, str>> {
295 self.display_str("timestamp")
296 }
297
298 /// The lifecycle `status`. An absent key is [`Status::Stable`].
299 #[must_use]
300 pub fn status(&self) -> Status {
301 Status::parse(self.display_str("status").as_deref())
302 }
303
304 /// The `stale_after` timestamp, on and after which the content is stale.
305 #[must_use]
306 pub fn stale_after(&self) -> Option<DateTimeField> {
307 self.display_str("stale_after")
308 .map(|s| DateTimeField::new(s.into_owned()))
309 }
310
311 /// Whether the concept is stale at `now`: `now >= stale_after`.
312 /// A concept with no (or an unreadable / offset-less) `stale_after` is never stale.
313 #[must_use]
314 pub fn is_stale_at(&self, now: DateTime) -> bool {
315 let Some(stale_after) = self.stale_after() else {
316 return false;
317 };
318 if !stale_after.is_valid() {
319 return false;
320 }
321 trust::is_stale_at(stale_after.datetime, now)
322 }
323
324 /// Whether the concept is stale on `today`: `today >= stale_after`.
325 /// Evaluates staleness at midnight UTC on `today`.
326 #[must_use]
327 pub fn is_stale_on(&self, today: Date) -> bool {
328 self.is_stale_at(today.to_utc_datetime())
329 }
330
331 /// `true` when `type` is `Attested Computation`.
332 #[must_use]
333 pub fn is_attested_computation(&self) -> bool {
334 self.type_().as_deref() == Some(ATTESTED_COMPUTATION_TYPE)
335 }
336
337 /// The `runtime`: how to run the computation, and so what `parameters`
338 /// mean. REQUIRED on an Attested Computation concept.
339 #[must_use]
340 pub fn runtime(&self) -> Option<Cow<'_, str>> {
341 self.display_str("runtime")
342 }
343
344 /// The declared `parameters` an agent may fill.
345 pub fn parameters(&self) -> Vec<Parameter> {
346 self.map
347 .get("parameters")
348 .map(Parameter::list_from_value)
349 .unwrap_or_default()
350 }
351
352 /// The `computation` path, when the computation lives in a file rather than
353 /// a body block.
354 #[must_use]
355 pub fn computation(&self) -> Option<Cow<'_, str>> {
356 self.display_str("computation")
357 }
358
359 /// The `executor`: how the computation is run, and what a receipt carries.
360 pub fn executor(&self) -> Option<Executor> {
361 self.map.get("executor").and_then(Executor::from_value)
362 }
363
364 /// The `attester`: deterministic code that turns a receipt into a verdict.
365 pub fn attester(&self) -> Option<Attester> {
366 self.map.get("attester").and_then(Attester::from_value)
367 }
368
369 /// The path-valued frontmatter fields present, as
370 /// `(field name, raw value)`.
371 ///
372 /// `sources[].resource` is deliberately excluded: it may be a scope
373 /// descriptor rather than a path. Use
374 /// [`Source::resource_kind`](crate::provenance::Source::resource_kind) to
375 /// filter those yourself.
376 #[must_use]
377 pub fn path_fields(&self) -> Vec<(&'static str, String)> {
378 let mut out = Vec::new();
379 let mut push = |name: &'static str, value: Option<String>| {
380 if let Some(v) = value.filter(|v| !v.trim().is_empty()) {
381 out.push((name, v));
382 }
383 };
384 push(
385 "resource",
386 self.resource().map(std::borrow::Cow::into_owned),
387 );
388 push(
389 "computation",
390 self.computation().map(std::borrow::Cow::into_owned),
391 );
392 push(
393 "executor.resource",
394 self.executor().and_then(|e| e.resource),
395 );
396 push(
397 "attester.resource",
398 self.attester().and_then(|a| a.resource),
399 );
400 out
401 }
402
403 /// Returns the keys present that are not well-known OKF fields, i.e. the
404 /// producer-defined extension keys consumers must preserve.
405 #[must_use]
406 pub fn extension_keys(&self) -> Vec<&str> {
407 self.map
408 .keys()
409 .filter(|k| !KNOWN_FRONTMATTER_KEYS.contains(k))
410 .collect()
411 }
412
413 /// Returns the legacy v0.1 keys present that v0.2 supersedes.
414 #[must_use]
415 pub fn legacy_keys(&self) -> Vec<&str> {
416 self.map
417 .keys()
418 .filter(|k| LEGACY_FRONTMATTER_KEYS.contains(k))
419 .collect()
420 }
421
422 /// Borrows the scalar at `key` as a display string, coercing non-string
423 /// scalars (a `type: 42` deviation yields `Some("42")`) the way the
424 /// reference's `str(fm.get(...))` does. Returns `None` for absent keys
425 /// and non-scalar values. The common YAML-string case borrows without
426 /// allocation; only the coerced case owns.
427 fn display_str(&self, key: &str) -> Option<Cow<'_, str>> {
428 self.map.get(key).and_then(Value::as_display_str)
429 }
430}
431
432impl From<Mapping> for Frontmatter {
433 fn from(map: Mapping) -> Self {
434 Self { map }
435 }
436}