Skip to main content

spec_spine_types/
frontmatter.rs

1//! The spec frontmatter grammar: the typed `Frontmatter` struct, the value
2//! enums, the `extra_frontmatter` overflow, and the parse entry points.
3//!
4//! Parsing is pure. The `---`-delimited block is split out
5//! ([`split_frontmatter`]), the known keys are deserialized into [`Frontmatter`],
6//! and every key not in [`KNOWN_KEYS`] overflows into `extra_frontmatter` as a
7//! `serde_json::Value`. The value domain splits on declaration (spec 013):
8//! a key listed in `config.frontmatter.extra_known_keys` (passed to
9//! [`parse_frontmatter_with`]) carries **any JSON-representable YAML value**,
10//! transported verbatim under canonical-JSON normalization; an undeclared key
11//! keeps the original scalar / string-list restriction (the anti-bulk-YAML
12//! guard, ported from OAP/aide `spec-types`). [`parse_frontmatter`] is the
13//! declared-nothing form, byte-compatible with pre-013 behavior.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::edges::{
20    CoAuthorityItem, ConstrainItem, ExtendItem, Origin, ReferenceItem, RefineItem, SupersedeItem,
21};
22use crate::error::{Error, Result};
23use crate::unit::Unit;
24
25/// Lifecycle status of a spec.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum Status {
29    Draft,
30    Approved,
31    Superseded,
32    Retired,
33}
34
35/// Risk level (optional descriptive metadata).
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Risk {
39    Low,
40    Medium,
41    High,
42    Critical,
43}
44
45/// Implementation progress (optional descriptive metadata).
46///
47/// The canonical "not applicable" spelling is `n-a` (kebab-case, the family the
48/// other variants share); `n/a` is accepted as a deserialize-only alias (spec
49/// 015) for the predecessor dialect, and normalizes back to `n-a` on emission.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum Implementation {
53    Pending,
54    InProgress,
55    Complete,
56    #[serde(rename = "n-a", alias = "n/a")]
57    Na,
58    Deferred,
59}
60
61/// A failed frontmatter parse, classified for the compiler's V-code mapping
62/// (spec 013 §3.3).
63#[derive(Clone, Debug)]
64pub enum FrontmatterIssue {
65    /// Malformed YAML or a grammar violation: the V-002 class.
66    Malformed(String),
67    /// A DECLARED extra key whose value JSON cannot represent (non-string
68    /// mapping key, YAML tag, non-finite number): the V-013 class.
69    UnrepresentableDeclared { key: String, detail: String },
70}
71
72impl From<FrontmatterIssue> for Error {
73    fn from(issue: FrontmatterIssue) -> Self {
74        match issue {
75            FrontmatterIssue::Malformed(m) => Error::Parse(m),
76            FrontmatterIssue::UnrepresentableDeclared { key, detail } => Error::Parse(format!(
77                "declared extra-frontmatter key '{key}' carries an unrepresentable YAML value: {detail}"
78            )),
79        }
80    }
81}
82
83/// Every frontmatter key modeled as a struct field. Keys outside this set
84/// overflow into `extra_frontmatter`.
85pub const KNOWN_KEYS: &[&str] = &[
86    // required + descriptive
87    "id",
88    "title",
89    "status",
90    "created",
91    "summary",
92    "authors",
93    "owner",
94    "kind",
95    "domain",
96    "risk",
97    "implementation",
98    "depends_on",
99    "code_aliases",
100    "feature_branch",
101    // typed edges (8)
102    "establishes",
103    "extends",
104    "refines",
105    "supersedes",
106    "amends",
107    "co_authority",
108    "constrains",
109    "references",
110    // lifecycle / amendment
111    "superseded_by",
112    "retirement_rationale",
113    "amends_sections",
114    "unamendable",
115    "amendment_record",
116    // bootstrap marker
117    "origin",
118];
119
120/// The typed, parsed frontmatter of a `spec.md`.
121///
122/// Field names are `snake_case` to match the authored YAML. Unknown keys are
123/// **not** captured by serde (unknown fields are ignored on deserialize); they
124/// are collected separately into `extra_frontmatter` by [`parse_frontmatter`].
125#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
126pub struct Frontmatter {
127    // --- required ---
128    pub id: String,
129    pub title: String,
130    pub status: Status,
131    pub created: String,
132    pub summary: String,
133
134    // --- optional descriptive ---
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub authors: Vec<String>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub owner: Option<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub kind: Option<String>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub domain: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub risk: Option<Risk>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub implementation: Option<Implementation>,
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub depends_on: Vec<String>,
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub code_aliases: Vec<String>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub feature_branch: Option<String>,
153
154    // --- typed edges (8) ---
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub establishes: Vec<Unit>,
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub extends: Vec<ExtendItem>,
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub refines: Vec<RefineItem>,
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub supersedes: Vec<SupersedeItem>,
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub amends: Vec<String>,
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub co_authority: Vec<CoAuthorityItem>,
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    pub constrains: Vec<ConstrainItem>,
169    #[serde(default, skip_serializing_if = "Vec::is_empty")]
170    pub references: Vec<ReferenceItem>,
171
172    // --- lifecycle / amendment ---
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub superseded_by: Option<String>,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub retirement_rationale: Option<String>,
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub amends_sections: Vec<String>,
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub unamendable: Vec<String>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub amendment_record: Option<String>,
183
184    // --- bootstrap marker ---
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub origin: Option<Origin>,
187
188    // --- overflow (populated by parse_frontmatter, never by serde) ---
189    #[serde(skip)]
190    pub extra_frontmatter: BTreeMap<String, serde_json::Value>,
191}
192
193/// Split a `spec.md` source into its `(frontmatter_yaml, body)` halves.
194///
195/// Strips a leading UTF-8 BOM, requires the file to open with a `---` fence, and
196/// reads to the next line that is exactly `---`. Line-ending agnostic (uses
197/// [`str::lines`], which handles both `\n` and `\r\n`). Returns owned strings.
198pub fn split_frontmatter(src: &str) -> Result<(String, String)> {
199    let src = src.strip_prefix('\u{feff}').unwrap_or(src);
200    let mut lines = src.lines();
201
202    match lines.next() {
203        Some(first) if first.trim_end() == "---" => {}
204        _ => {
205            return Err(Error::Parse(
206                "spec.md must begin with a YAML frontmatter block delimited by '---'".into(),
207            ));
208        }
209    }
210
211    let mut frontmatter = String::new();
212    let mut closed = false;
213    for line in lines.by_ref() {
214        if line.trim_end() == "---" {
215            closed = true;
216            break;
217        }
218        frontmatter.push_str(line);
219        frontmatter.push('\n');
220    }
221    if !closed {
222        return Err(Error::Parse(
223            "unterminated frontmatter block (missing closing '---')".into(),
224        ));
225    }
226
227    let mut body = String::new();
228    for line in lines {
229        body.push_str(line);
230        body.push('\n');
231    }
232
233    Ok((frontmatter, body))
234}
235
236/// Parse the frontmatter block of a `spec.md` into a typed [`Frontmatter`],
237/// treating every extra key as undeclared (pre-013 behavior, kept for
238/// config-free callers).
239///
240/// Returns [`Error::Parse`] for a malformed block, a missing required key, an
241/// invalid enum value, or a non-scalar value under an unknown (overflow) key.
242pub fn parse_frontmatter(src: &str) -> Result<Frontmatter> {
243    parse_frontmatter_with(src, &[]).map_err(Into::into)
244}
245
246/// Parse with declared-key awareness (spec 013): a key listed in `declared`
247/// (the adopter's `frontmatter.extra_known_keys`) carries any
248/// JSON-representable YAML value, transported verbatim; an undeclared key
249/// keeps the scalar / string-list restriction. A top-level `null` value drops
250/// the key on either path.
251pub fn parse_frontmatter_with(
252    src: &str,
253    declared: &[String],
254) -> std::result::Result<Frontmatter, FrontmatterIssue> {
255    let malformed = |m: String| FrontmatterIssue::Malformed(m);
256    let (yaml, _body) = split_frontmatter(src).map_err(|e| {
257        malformed(match e {
258            Error::Parse(m) => m,
259            other => other.to_string(),
260        })
261    })?;
262
263    let value: serde_yaml::Value = serde_yaml::from_str(&yaml)
264        .map_err(|e| malformed(format!("invalid YAML frontmatter: {e}")))?;
265
266    let mapping = value
267        .as_mapping()
268        .ok_or_else(|| malformed("frontmatter must be a YAML mapping".into()))?;
269
270    // Known keys (unknown keys are ignored here; collected below).
271    let mut frontmatter: Frontmatter = serde_yaml::from_value(value.clone())
272        .map_err(|e| malformed(format!("invalid frontmatter: {e}")))?;
273
274    // Overflow: every key not in KNOWN_KEYS becomes an extra_frontmatter entry.
275    for (k, v) in mapping {
276        let key = match k.as_str() {
277            Some(s) => s,
278            None => return Err(malformed("frontmatter keys must be strings".into())),
279        };
280        if KNOWN_KEYS.contains(&key) {
281            continue;
282        }
283        let json = if declared.iter().any(|d| d == key) {
284            yaml_to_json(v).map_err(|detail| FrontmatterIssue::UnrepresentableDeclared {
285                key: key.to_string(),
286                detail,
287            })?
288        } else {
289            yaml_to_extra(v).map_err(malformed)?
290        };
291        if json.is_null() {
292            continue;
293        }
294        frontmatter.extra_frontmatter.insert(key.to_string(), json);
295    }
296
297    // `paths:` sugar on extends/refines items (spec 014): expanded here, in
298    // the shared parse path, so every consumer (compile, index, lint, couple)
299    // sees only single-unit edges.
300    frontmatter.extends =
301        crate::edges::expand_extend_paths(std::mem::take(&mut frontmatter.extends))
302            .map_err(malformed)?;
303    frontmatter.refines =
304        crate::edges::expand_refine_paths(std::mem::take(&mut frontmatter.refines))
305            .map_err(malformed)?;
306    // Full-scope supersedes (`{ scope: full }` / bare id) collapse to the
307    // bare-string form so the wire stays byte-identical for full-only corpora
308    // (spec 019).
309    frontmatter.supersedes =
310        crate::edges::normalize_supersedes(std::mem::take(&mut frontmatter.supersedes));
311
312    Ok(frontmatter)
313}
314
315/// The UNDECLARED-key path: scalars and string lists only (`Null` drops the
316/// key); a nested map, mixed list, or tag is a grammar violation, exactly
317/// the pre-013 guard.
318fn yaml_to_extra(v: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
319    use serde_yaml::Value;
320    match v {
321        Value::Null => Ok(serde_json::Value::Null),
322        Value::Bool(b) => Ok(serde_json::Value::Bool(*b)),
323        Value::Number(n) => {
324            if let Some(i) = n.as_i64() {
325                Ok(serde_json::Value::from(i))
326            } else if let Some(f) = n.as_f64() {
327                serde_json::Number::from_f64(f)
328                    .map(serde_json::Value::Number)
329                    .ok_or_else(|| "unsupported numeric extra-frontmatter value".to_string())
330            } else {
331                Err("unsupported numeric extra-frontmatter value".to_string())
332            }
333        }
334        Value::String(s) => Ok(serde_json::Value::String(s.clone())),
335        Value::Sequence(seq) => {
336            let mut list = Vec::with_capacity(seq.len());
337            for item in seq {
338                match item.as_str() {
339                    Some(s) => list.push(serde_json::Value::String(s.to_string())),
340                    None => {
341                        return Err("extra-frontmatter lists must contain only strings".to_string());
342                    }
343                }
344            }
345            Ok(serde_json::Value::Array(list))
346        }
347        Value::Mapping(_) | Value::Tagged(_) => Err(
348            "extra-frontmatter values must be scalars or string lists, not nested maps".to_string(),
349        ),
350    }
351}
352
353/// The DECLARED-key path (spec 013 §3.2): full YAML → JSON conversion.
354/// Mappings require string keys; tags and non-finite numbers are
355/// unrepresentable. Map key order is canonicalized by the sorted
356/// `serde_json::Map` (authoring order is not preserved: the price of
357/// byte-identical registries).
358fn yaml_to_json(v: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
359    use serde_yaml::Value;
360    match v {
361        Value::Null => Ok(serde_json::Value::Null),
362        Value::Bool(b) => Ok(serde_json::Value::Bool(*b)),
363        Value::Number(n) => {
364            if let Some(i) = n.as_i64() {
365                Ok(serde_json::Value::from(i))
366            } else if let Some(u) = n.as_u64() {
367                Ok(serde_json::Value::from(u))
368            } else if let Some(f) = n.as_f64() {
369                serde_json::Number::from_f64(f)
370                    .map(serde_json::Value::Number)
371                    .ok_or_else(|| format!("non-finite number {f} is not JSON-representable"))
372            } else {
373                Err("unsupported YAML number".to_string())
374            }
375        }
376        Value::String(s) => Ok(serde_json::Value::String(s.clone())),
377        Value::Sequence(seq) => seq
378            .iter()
379            .map(yaml_to_json)
380            .collect::<std::result::Result<Vec<_>, _>>()
381            .map(serde_json::Value::Array),
382        Value::Mapping(map) => {
383            let mut out = serde_json::Map::new();
384            for (mk, mv) in map {
385                let Some(key) = mk.as_str() else {
386                    return Err("non-string mapping key is not JSON-representable".to_string());
387                };
388                out.insert(key.to_string(), yaml_to_json(mv)?);
389            }
390            Ok(serde_json::Value::Object(out))
391        }
392        Value::Tagged(tagged) => Err(format!(
393            "YAML tag '{}' is not JSON-representable",
394            tagged.tag
395        )),
396    }
397}