metrale_config/
recipe_yaml.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: A small YAML reader for recipe files; the workspace has no YAML dependency.
4//!
5//! Owner: config (recipe YAML), read by the server's `recipe` module and the bench gate's
6//! recipe closure (`gate::recipe_closure`).
7//! Invariants:
8//! - `parse` returns `Ok` only for a document whose top level is a mapping.
9//! - A map line with no `key:` or at an unexpected indent, a list line without
10//!   `- `, an empty key, or a key with neither a value nor a deeper block is an
11//!   error, never skipped: a skipped key would change what gets served.
12//!
13//! Supported, as the vendored recipes under `tests/fixtures/recipes` use them:
14//! `key: scalar`, nested maps by deeper indent, `key: |` and `key: |-` literal
15//! blocks, `- item` sequences, `{}` empty maps, `#` comments and blank lines.
16//! No other flow collection is parsed: `key: [a, b]` reads as the scalar
17//! `[a, b]`.
18
19use anyhow::{Result, bail};
20use std::collections::BTreeMap;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum Yaml {
24    Scalar(String),
25    List(Vec<Yaml>),
26    Map(BTreeMap<String, Yaml>),
27}
28
29impl Yaml {
30    pub fn as_str(&self) -> Option<&str> {
31        match self {
32            Yaml::Scalar(s) => Some(s),
33            _ => None,
34        }
35    }
36
37    pub fn as_map(&self) -> Option<&BTreeMap<String, Yaml>> {
38        match self {
39            Yaml::Map(m) => Some(m),
40            _ => None,
41        }
42    }
43}
44
45/// 2026-09-26: One significant line: its indent and its content, comments already gone.
46struct Line {
47    no: usize,
48    indent: usize,
49    text: String,
50}
51
52/// 2026-09-26: Parse a whole document. The top level must be a mapping.
53pub fn parse(input: &str) -> Result<Yaml> {
54    let lines = significant(input)?;
55    let (value, end) = parse_block(&lines, 0, 0)?;
56    if end != lines.len() {
57        let l = &lines[end];
58        bail!("line {}: unexpected indentation at {:?}", l.no, l.text);
59    }
60    match value {
61        Yaml::Map(_) => Ok(value),
62        _ => bail!("the document must be a mapping"),
63    }
64}
65
66/// 2026-09-26: Strip comments and blanks, but never inside a literal block: a `#`
67/// there is content.
68fn significant(input: &str) -> Result<Vec<Line>> {
69    let mut out = Vec::new();
70    let mut raw = input.lines().enumerate().peekable();
71    while let Some((i, line)) = raw.next() {
72        let no = i + 1;
73        let indent = line.len() - line.trim_start().len();
74        let trimmed = line.trim_start();
75        if trimmed.is_empty() || trimmed.starts_with('#') {
76            continue;
77        }
78        let text = strip_comment(trimmed);
79        if text.is_empty() {
80            continue;
81        }
82        let is_literal = text.ends_with(": |") || text.ends_with(": |-");
83        if !is_literal {
84            out.push(Line { no, indent, text });
85            continue;
86        }
87        // 2026-09-26: Consume the block verbatim: every following line indented deeper
88        // than the key, comments and blanks included.
89        let mut body: Vec<String> = Vec::new();
90        let mut block_indent = None;
91        while let Some((_, peek)) = raw.peek() {
92            let peek_indent = peek.len() - peek.trim_start().len();
93            if !peek.trim().is_empty() && peek_indent <= indent {
94                break;
95            }
96            let (_, l) = raw.next().expect("peeked");
97            if l.trim().is_empty() {
98                body.push(String::new());
99                continue;
100            }
101            let base = *block_indent.get_or_insert(peek_indent);
102            body.push(l.chars().skip(base).collect());
103        }
104        while body.last().is_some_and(|l| l.is_empty()) {
105            body.pop();
106        }
107        // 2026-09-26: Re-emit the key with the block's lines joined by `\n` after a
108        // NUL marker, so parse_block knows.
109        let key = text.trim_end_matches(['|', '-', ' ']).trim_end_matches(':');
110        out.push(Line {
111            no,
112            indent,
113            text: format!("{key}:\u{0}{}", body.join("\n")),
114        });
115    }
116    Ok(out)
117}
118
119/// 2026-09-26: Remove a trailing `# comment` (a `#` at the start or after a space),
120/// respecting double quotes.
121fn strip_comment(s: &str) -> String {
122    let mut in_quotes = false;
123    for (i, c) in s.char_indices() {
124        match c {
125            '"' => in_quotes = !in_quotes,
126            '#' if !in_quotes && (i == 0 || s.as_bytes()[i - 1] == b' ') => {
127                return s[..i].trim_end().to_string();
128            }
129            _ => {}
130        }
131    }
132    s.trim_end().to_string()
133}
134
135/// 2026-09-26: Parse the run of lines at `indent`, returning the value and the next index.
136fn parse_block(lines: &[Line], start: usize, indent: usize) -> Result<(Yaml, usize)> {
137    if lines.get(start).is_some_and(|l| l.text.starts_with("- ")) {
138        return parse_list(lines, start, indent);
139    }
140    let mut map = BTreeMap::new();
141    let mut i = start;
142    while i < lines.len() {
143        let line = &lines[i];
144        if line.indent < indent {
145            break;
146        }
147        if line.indent > indent {
148            bail!(
149                "line {}: unexpected indentation at {:?}",
150                line.no,
151                line.text
152            );
153        }
154        let Some((key, rest)) = line.text.split_once(':') else {
155            bail!(
156                "line {}: expected `key: value`, found {:?}",
157                line.no,
158                line.text
159            );
160        };
161        let key = key.trim().to_string();
162        if key.is_empty() {
163            bail!("line {}: empty key", line.no);
164        }
165        // 2026-09-26: A `|` literal block, already joined and NUL-marked by `significant`.
166        if let Some(body) = rest.strip_prefix('\u{0}') {
167            map.insert(key, Yaml::Scalar(body.to_string()));
168            i += 1;
169            continue;
170        }
171        let rest = rest.trim();
172        if rest == "{}" {
173            map.insert(key, Yaml::Map(BTreeMap::new()));
174            i += 1;
175            continue;
176        }
177        if !rest.is_empty() {
178            map.insert(key, Yaml::Scalar(unquote(rest)));
179            i += 1;
180            continue;
181        }
182        // 2026-09-26: Nested block: whatever is indented deeper.
183        let next_indent = lines.get(i + 1).map(|l| l.indent).unwrap_or(0);
184        if next_indent <= indent {
185            bail!(
186                "line {}: {key:?} has no value and no indented block",
187                line.no
188            );
189        }
190        let (value, next) = parse_block(lines, i + 1, next_indent)?;
191        map.insert(key, value);
192        i = next;
193    }
194    Ok((Yaml::Map(map), i))
195}
196
197fn parse_list(lines: &[Line], start: usize, indent: usize) -> Result<(Yaml, usize)> {
198    let mut items = Vec::new();
199    let mut i = start;
200    while i < lines.len() {
201        let line = &lines[i];
202        if line.indent < indent {
203            break;
204        }
205        let Some(item) = line.text.strip_prefix("- ") else {
206            bail!(
207                "line {}: expected a `- ` item, found {:?}",
208                line.no,
209                line.text
210            );
211        };
212        items.push(Yaml::Scalar(unquote(item.trim())));
213        i += 1;
214    }
215    Ok((Yaml::List(items), i))
216}
217
218/// 2026-09-26: `"2"` and `'x'` become `2` and `x`. Every scalar is text here, so a
219/// quote only delimits.
220fn unquote(s: &str) -> String {
221    let b = s.as_bytes();
222    if b.len() >= 2
223        && (b[0] == b'"' && b[b.len() - 1] == b'"' || b[0] == b'\'' && b[b.len() - 1] == b'\'')
224    {
225        return s[1..s.len() - 1].to_string();
226    }
227    s.to_string()
228}
229
230#[cfg(test)]
231#[path = "recipe_yaml_tests.rs"]
232mod tests;