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]). 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.
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 the spec 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}