Skip to main content

okf_core/yaml/
mod.rs

1//! A small, dependency-free YAML *subset* used for OKF frontmatter.
2//!
3//! OKF frontmatter is, in practice, a flat-ish YAML mapping of scalars, lists,
4//! and occasionally nested mappings (see the [specification][spec] §4.1). A
5//! full YAML 1.2 engine would be overkill and would pull in dependencies, so
6//! this module implements the pragmatic subset that real frontmatter uses:
7//!
8//! - block mappings (`key: value`), including nested/indented blocks;
9//! - block sequences (`- item`);
10//! - flow collections (`[a, b]`, `{a: 1, b: 2}`);
11//! - plain, single-quoted, and double-quoted scalars;
12//! - literal (`|`) and folded (`>`) block scalars;
13//! - `#` comments and blank lines;
14//! - the core scalar types: null, bool, int, float, string.
15//!
16//! Plain and quoted scalars may span lines, folding each break into a single
17//! space, because `PyYAML`'s `safe_dump` wraps any value past its 80-column line
18//! width and the reference implementation publishes bundles that way.
19//!
20//! It deliberately does **not** support anchors/aliases, explicit tags
21//! (`!!str`), multiple documents, or complex (non-scalar) mapping keys. Those
22//! never appear in well-formed OKF frontmatter; encountering them yields a
23//! clear [`YamlError`] rather than silent misbehaviour.
24//!
25//! The guarantee that matters for OKF round-tripping is:
26//! `parse(emit(parse(x))) == parse(x)`. Emitting and re-parsing preserves the
27//! logical value and key order. This mirrors the reference implementation's
28//! `OKFDocument` round-trip test.
29//!
30//! ## Timestamps are strings
31//!
32//! One deliberate divergence from `PyYAML`: YAML's implicit resolver types a bare
33//! `2026-12-31` as a date and a bare `2026-06-30T14:00:00Z` as a datetime, while
34//! this module keeps every scalar of either shape as a string. The OKF layer
35//! loses nothing, since [`DateField`](crate::DateField) and
36//! [`DateTimeField`](crate::DateTimeField) keep the text beside the parsed
37//! value, and it means a malformed date can be reported rather than silently
38//! dropped (§11).
39//!
40//! The consequence shows up on the way out. A bare ISO datetime is not stable
41//! even under the reference's own round-trip: `PyYAML` loads it into a `datetime`
42//! and dumps it back as `2026-06-30 14:00:00+00:00`, losing the `T` and `Z`
43//! separators §5.2 asks for. A quoted one survives byte-identical. So the
44//! emitter quotes a datetime-valued string and leaves a bare `YYYY-MM-DD` plain,
45//! which is how both the specification and the reference write `stale_after`,
46//! `last_modified`, and `usage_window`.
47//!
48//! [spec]: https://github.com/GoogleCloudPlatform/open-knowledge-format/blob/main/SPEC.md
49
50mod emitter;
51mod parser;
52
53use std::fmt;
54
55pub use parser::YamlError;
56
57/// An ordered YAML mapping (preserves insertion / source order, like the
58/// reference implementation which dumps with `sort_keys=False`).
59///
60/// Keys are [`Value`]s for generality, but OKF frontmatter keys are always
61/// strings; the [`get`](Mapping::get) / [`insert`](Mapping::insert) helpers
62/// operate on string keys for convenience.
63#[derive(Clone, Debug, Default, PartialEq)]
64pub struct Mapping {
65    entries: Vec<(Value, Value)>,
66}
67
68impl Mapping {
69    /// Creates an empty mapping.
70    #[must_use]
71    pub const fn new() -> Self {
72        Self {
73            entries: Vec::new(),
74        }
75    }
76
77    /// Number of key/value pairs.
78    #[must_use]
79    pub const fn len(&self) -> usize {
80        self.entries.len()
81    }
82
83    /// Returns `true` if the mapping has no entries.
84    #[must_use]
85    pub const fn is_empty(&self) -> bool {
86        self.entries.is_empty()
87    }
88
89    /// Looks up a value by string key.
90    #[must_use]
91    pub fn get(&self, key: &str) -> Option<&Value> {
92        self.entries
93            .iter()
94            .find(|(k, _)| k.as_str() == Some(key))
95            .map(|(_, v)| v)
96    }
97
98    /// Returns `true` if the mapping contains the given string key.
99    #[must_use]
100    pub fn contains_key(&self, key: &str) -> bool {
101        self.get(key).is_some()
102    }
103
104    /// Inserts (or, if the string key already exists, replaces) a value,
105    /// preserving the position of an existing key. Returns the previous value.
106    pub fn insert(&mut self, key: impl Into<String>, value: Value) -> Option<Value> {
107        let key = key.into();
108        if let Some(slot) = self
109            .entries
110            .iter_mut()
111            .find(|(k, _)| k.as_str() == Some(&key))
112        {
113            return Some(std::mem::replace(&mut slot.1, value));
114        }
115        self.entries.push((Value::String(key), value));
116        None
117    }
118
119    /// Removes a value by string key, preserving order of the rest.
120    pub fn remove(&mut self, key: &str) -> Option<Value> {
121        let idx = self
122            .entries
123            .iter()
124            .position(|(k, _)| k.as_str() == Some(key))?;
125        Some(self.entries.remove(idx).1)
126    }
127
128    /// Pushes a raw key/value pair (used by the parser; keeps non-string keys).
129    pub(crate) fn push_raw(&mut self, key: Value, value: Value) {
130        self.entries.push((key, value));
131    }
132
133    /// Iterates over `(key, value)` pairs in order.
134    pub fn iter(&self) -> impl Iterator<Item = (&Value, &Value)> {
135        self.entries.iter().map(|(k, v)| (k, v))
136    }
137
138    /// Iterates over string keys (skipping any non-string keys).
139    pub fn keys(&self) -> impl Iterator<Item = &str> {
140        self.entries.iter().filter_map(|(k, _)| k.as_str())
141    }
142}
143
144/// A parsed YAML value.
145#[derive(Clone, Debug, PartialEq)]
146pub enum Value {
147    /// `null`, `~`, or an empty value.
148    Null,
149    /// `true` / `false`.
150    Bool(bool),
151    /// An integer scalar.
152    Int(i64),
153    /// A floating-point scalar.
154    Float(f64),
155    /// A string scalar.
156    String(String),
157    /// A sequence (`[...]` or block `- ...`).
158    Sequence(Vec<Self>),
159    /// A mapping (`{...}` or block `key: value`).
160    Mapping(Mapping),
161}
162
163impl Value {
164    /// Parses a single YAML value from text (the OKF frontmatter subset).
165    ///
166    /// # Errors
167    ///
168    /// Returns [`YamlError`] for any input outside the supported subset
169    /// (anchors, tags, multiple documents, or syntactically malformed YAML).
170    pub fn parse(text: &str) -> Result<Self, YamlError> {
171        parser::parse(text)
172    }
173
174    /// Emits this value as YAML text using block style, preserving key order.
175    #[must_use]
176    pub fn to_yaml_string(&self) -> String {
177        emitter::emit(self)
178    }
179
180    /// Returns the string contents if this is a [`Value::String`].
181    #[must_use]
182    pub fn as_str(&self) -> Option<&str> {
183        match self {
184            Self::String(s) => Some(s),
185            _ => None,
186        }
187    }
188
189    /// Returns the boolean if this is a [`Value::Bool`].
190    #[must_use]
191    pub const fn as_bool(&self) -> Option<bool> {
192        match self {
193            Self::Bool(b) => Some(*b),
194            _ => None,
195        }
196    }
197
198    /// Returns the integer if this is a [`Value::Int`].
199    #[must_use]
200    pub const fn as_int(&self) -> Option<i64> {
201        match self {
202            Self::Int(i) => Some(*i),
203            _ => None,
204        }
205    }
206
207    /// Returns the sequence elements if this is a [`Value::Sequence`].
208    #[must_use]
209    pub fn as_sequence(&self) -> Option<&[Self]> {
210        match self {
211            Self::Sequence(s) => Some(s),
212            _ => None,
213        }
214    }
215
216    /// Returns the mapping if this is a [`Value::Mapping`].
217    #[must_use]
218    pub const fn as_mapping(&self) -> Option<&Mapping> {
219        match self {
220            Self::Mapping(m) => Some(m),
221            _ => None,
222        }
223    }
224
225    /// True for `Null`, an empty string, an empty sequence, or an empty
226    /// mapping. Mirrors Python's "falsy" check used by the reference
227    /// implementation's `validate()` (`not frontmatter.get(k)`).
228    #[must_use]
229    pub const fn is_empty_value(&self) -> bool {
230        match self {
231            Self::Null | Self::Bool(false) | Self::Int(0) => true,
232            Self::String(s) => s.is_empty(),
233            Self::Sequence(s) => s.is_empty(),
234            Self::Mapping(m) => m.is_empty(),
235            _ => false,
236        }
237    }
238
239    /// Renders a scalar as a plain display string (used for typed frontmatter
240    /// accessors that coerce scalars to text, matching the reference's
241    /// `str(fm.get(...))`).
242    #[must_use]
243    pub fn as_display_string(&self) -> Option<String> {
244        match self {
245            Self::String(s) => Some(s.clone()),
246            Self::Bool(b) => Some(b.to_string()),
247            Self::Int(i) => Some(i.to_string()),
248            Self::Float(f) => Some(format!("{f}")),
249            _ => None,
250        }
251    }
252
253    /// The borrowing form of [`as_display_string`](Self::as_display_string):
254    /// returns a [`std::borrow::Cow`] borrowing the [`String`](Self::String) case and
255    /// owning the coerced form for [`Bool`](Self::Bool)/[`Int`](Self::Int)/
256    /// [`Float`](Self::Float). `None` for non-scalar variants.
257    ///
258    /// Frontmatter accessors use this so the common case (a YAML string) is
259    /// allocation-free, while the deviation case (e.g. `type: 42`) still
260    /// coerces to text the way the reference's `str(fm.get(...))` does, rather
261    /// than silently reading as `None`.
262    #[must_use]
263    pub fn as_display_str(&self) -> Option<std::borrow::Cow<'_, str>> {
264        match self {
265            Self::String(s) => Some(std::borrow::Cow::Borrowed(s)),
266            Self::Bool(b) => Some(std::borrow::Cow::Owned(b.to_string())),
267            Self::Int(i) => Some(std::borrow::Cow::Owned(i.to_string())),
268            Self::Float(f) => Some(std::borrow::Cow::Owned(format!("{f}"))),
269            _ => None,
270        }
271    }
272}
273
274impl fmt::Display for Value {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        f.write_str(&self.to_yaml_string())
277    }
278}
279
280impl From<&str> for Value {
281    fn from(s: &str) -> Self {
282        Self::String(s.to_string())
283    }
284}
285
286impl From<String> for Value {
287    fn from(s: String) -> Self {
288        Self::String(s)
289    }
290}
291
292impl From<bool> for Value {
293    fn from(b: bool) -> Self {
294        Self::Bool(b)
295    }
296}
297
298impl From<i64> for Value {
299    fn from(i: i64) -> Self {
300        Self::Int(i)
301    }
302}
303
304impl<T: Into<Self>> From<Vec<T>> for Value {
305    fn from(v: Vec<T>) -> Self {
306        Self::Sequence(v.into_iter().map(Into::into).collect())
307    }
308}
309
310impl From<Mapping> for Value {
311    fn from(m: Mapping) -> Self {
312        Self::Mapping(m)
313    }
314}