Skip to main content

majordomus_cli/metadata/
yaml.rs

1//! The layer's YAML subset, as `docs/SCHEMAS.md` and the shell tool's `mj_yaml_flatten`
2//! define it: `key: value`, nested maps by two-space indent, block lists (`- item`), lists
3//! of maps (`- key: value` plus indented keys), inline lists `[a, b]`, single or double
4//! quotes, comments. Tabs, odd indentation, anchors, multi-line scalars and flow maps are
5//! refused with the line named (the shell parser reads an anchor or alias as text; this one
6//! refuses it, which is the stricter reading of the same contract). A scalar is a string unless it is an unquoted integer or
7//! `true`/`false`; nothing else is interpreted.
8//!
9//! ```
10//! use majordomus_cli::metadata::yaml;
11//!
12//! let text = "\
13//! version: 1
14//! sources:
15//!   - id: policy
16//!     kind: policy
17//!     required: true
18//! tags: [rust, quality]
19//! ";
20//! let map = yaml::parse_mapping(text).unwrap();
21//! assert_eq!(map["version"], 1, "an unquoted integer is a number");
22//! assert_eq!(map["sources"][0]["kind"], "policy");
23//! assert_eq!(map["sources"][0]["required"], true);
24//! assert_eq!(map["tags"][1], "quality", "an inline list is a list");
25//!
26//! // every key path, which is how an unknown key is found and reported
27//! assert!(yaml::key_paths(&map).contains(&"sources.0.kind".to_string()));
28//!
29//! // what the subset refuses, it refuses with the line named rather than guessing
30//! assert!(yaml::parse_mapping("a: &anchor 1\n").is_err());
31//! assert!(yaml::parse_mapping("a:\n\tb: 1\n").is_err(), "tabs are not indentation");
32//! ```
33//!
34//! This is not a general YAML parser and does not try to be: the repository's files are
35//! written in this subset so that a person, an awk script and this executable read them
36//! identically, and a construct outside it is a mistake in the file, not a gap here.
37
38use serde_json::{Map, Number, Value};
39
40/// A parsed scalar with the fact of its quoting kept, so that `"1"` stays a string.
41#[derive(Debug, Clone, PartialEq, Eq)]
42struct Scalar {
43    text: String,
44    quoted: bool,
45}
46
47/// One flattened line: a dotted key path and its scalar, or an empty inline list.
48#[derive(Debug, Clone, PartialEq, Eq)]
49enum Flat {
50    Value(String, Scalar),
51    EmptyList(String),
52}
53
54fn is_key(s: &str) -> Option<(&str, &str)> {
55    let (k, rest) = s.split_once(':')?;
56    let mut chars = k.chars();
57    let first = chars.next()?;
58    if !(first.is_ascii_alphabetic() || first == '_') {
59        return None;
60    }
61    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
62        return None;
63    }
64    if !(rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t')) {
65        return None;
66    }
67    Some((k, rest.trim_start_matches([' ', '\t'])))
68}
69
70/// Strip a scalar's matching surrounding quotes. Inside single quotes YAML has exactly one
71/// escape, a doubled quote for one quote (`'the repository''s test'`), and every other
72/// parser reads it that way; keeping the pair published `repository''s` on the site.
73fn unquote(v: &str) -> Scalar {
74    let v = v.trim();
75    if v.len() >= 2 && v.starts_with('\'') && v.ends_with('\'') {
76        return Scalar {
77            text: v[1..v.len() - 1].replace("''", "'"),
78            quoted: true,
79        };
80    }
81    if v.len() >= 2 && v.starts_with('"') && v.ends_with('"') {
82        return Scalar {
83            text: v[1..v.len() - 1].to_string(),
84            quoted: true,
85        };
86    }
87    let mut text = v;
88    if let Some(pos) = find_comment(text) {
89        text = &text[..pos];
90    }
91    Scalar {
92        text: text.trim().to_string(),
93        quoted: false,
94    }
95}
96
97/// Position of a ` #` that starts a trailing comment, as the shell's `[ \t]+#.*$`.
98fn find_comment(s: &str) -> Option<usize> {
99    let bytes = s.as_bytes();
100    (1..bytes.len()).find(|&i| bytes[i] == b'#' && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t'))
101}
102
103fn join(a: &str, b: &str) -> String {
104    if a.is_empty() {
105        b.to_string()
106    } else {
107        format!("{a}.{b}")
108    }
109}
110
111/// An unquoted scalar starting with `&`, `*`, `|` or `>` is an anchor, an alias or a
112/// block scalar: outside the subset, and refused rather than read as text.
113fn is_unsupported_construct(v: &str) -> bool {
114    let v = v.trim();
115    !(v.starts_with('"') || v.starts_with('\''))
116        && (v.starts_with('&') || v.starts_with('*') || v.starts_with('|') || v.starts_with('>'))
117}
118
119fn emit(out: &mut Vec<Flat>, path: &str, v: &str) {
120    let v = v.trim();
121    if v.starts_with('[') && v.ends_with(']') {
122        let inner = v[1..v.len() - 1].trim();
123        if inner.is_empty() {
124            out.push(Flat::EmptyList(path.to_string()));
125            return;
126        }
127        for (i, part) in inner.split(',').enumerate() {
128            out.push(Flat::Value(format!("{path}.{i}"), unquote(part)));
129        }
130        return;
131    }
132    out.push(Flat::Value(path.to_string(), unquote(v)));
133}
134
135/// Flatten text into dotted paths, mirroring `mj_yaml_flatten`.
136fn flatten(text: &str) -> Result<Vec<Flat>, String> {
137    use std::collections::BTreeMap;
138    let mut ctx: BTreeMap<usize, String> = BTreeMap::new();
139    let mut pend: BTreeMap<usize, String> = BTreeMap::new();
140    let mut cnt: BTreeMap<String, usize> = BTreeMap::new();
141    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
142    let mut out = Vec::new();
143    ctx.insert(0, String::new());
144    for (n, raw) in text.lines().enumerate() {
145        let n = n + 1;
146        if raw.contains('\t') {
147            return Err(format!("tab character on line {n}"));
148        }
149        let t = raw.trim_start_matches(' ');
150        if t.is_empty() || t.starts_with('#') || t.trim_end() == "---" {
151            continue;
152        }
153        let ind = raw.len() - t.len();
154        if ind % 2 != 0 {
155            return Err(format!("odd indentation on line {n}"));
156        }
157        let s = t;
158        if let Some(item) = s.strip_prefix("- ") {
159            let Some(parent) = pend.get(&ind).cloned() else {
160                return Err(format!("list item without a parent key on line {n}"));
161            };
162            let idx = *cnt.get(&parent).unwrap_or(&0);
163            cnt.insert(parent.clone(), idx + 1);
164            let item = item.trim();
165            let ip = format!("{parent}.{idx}");
166            if let Some((k, v)) = is_key(item) {
167                ctx.retain(|kk, _| *kk <= ind + 2);
168                ctx.insert(ind + 2, ip.clone());
169                if v.is_empty() {
170                    let p = join(&ip, k);
171                    pend.insert(ind + 2, p.clone());
172                    pend.insert(ind + 4, p.clone());
173                    ctx.insert(ind + 4, p);
174                } else {
175                    if is_unsupported_construct(v) {
176                        return Err(format!("unsupported YAML construct on line {n}: {v}"));
177                    }
178                    emit(&mut out, &join(&ip, k), v);
179                }
180            } else {
181                if is_unsupported_construct(item) {
182                    return Err(format!("unsupported YAML construct on line {n}: {item}"));
183                }
184                emit(&mut out, &ip, item);
185            }
186            continue;
187        }
188        let Some((k, v)) = is_key(s) else {
189            return Err(format!("cannot parse line {n}: {s}"));
190        };
191        let Some(base) = ctx.get(&ind).cloned() else {
192            return Err(format!("unexpected indentation on line {n}"));
193        };
194        ctx.retain(|kk, _| *kk <= ind);
195        pend.retain(|kk, _| *kk < ind);
196        let p = join(&base, k);
197        if !seen.insert(p.clone()) {
198            return Err(format!("'{p}' is given twice, on line {n}"));
199        }
200        if v.is_empty() {
201            pend.insert(ind, p.clone());
202            pend.insert(ind + 2, p.clone());
203            ctx.insert(ind + 2, p);
204        } else {
205            if is_unsupported_construct(v) {
206                return Err(format!("unsupported YAML construct on line {n}: {v}"));
207            }
208            emit(&mut out, &p, v);
209        }
210    }
211    Ok(out)
212}
213
214fn typed(s: &Scalar) -> Value {
215    if s.quoted {
216        return Value::String(s.text.clone());
217    }
218    match s.text.as_str() {
219        "true" => Value::Bool(true),
220        "false" => Value::Bool(false),
221        t if !t.is_empty()
222            && t.trim_start_matches('-')
223                .bytes()
224                .all(|b| b.is_ascii_digit())
225            && t != "-" =>
226        {
227            t.parse::<i64>()
228                .map(|n| Value::Number(Number::from(n)))
229                .unwrap_or_else(|_| Value::String(t.to_string()))
230        }
231        // a decimal with digits on both sides of one point (0.5, -1.25); anything else
232        // that merely looks numeric (1e3, .5, 1., 0x10) stays text, as the shell tool
233        // reads it
234        t if is_decimal(t) => t
235            .parse::<f64>()
236            .ok()
237            .and_then(Number::from_f64)
238            .map(Value::Number)
239            .unwrap_or_else(|| Value::String(t.to_string())),
240        t => Value::String(t.to_string()),
241    }
242}
243
244/// `-?[0-9]+\.[0-9]+`, and nothing wider: the one decimal form the subset types as a number.
245fn is_decimal(t: &str) -> bool {
246    let body = t.strip_prefix('-').unwrap_or(t);
247    match body.split_once('.') {
248        Some((int, frac)) => {
249            !int.is_empty()
250                && !frac.is_empty()
251                && int.bytes().all(|b| b.is_ascii_digit())
252                && frac.bytes().all(|b| b.is_ascii_digit())
253        }
254        None => false,
255    }
256}
257
258fn insert(root: &mut Map<String, Value>, path: &str, leaf: Value) -> Result<(), String> {
259    let segments: Vec<&str> = path.split('.').collect();
260    let mut node: &mut Value = root.entry(segments[0].to_string()).or_insert(Value::Null);
261    for seg in &segments[1..] {
262        if seg.bytes().all(|b| b.is_ascii_digit()) {
263            if node.is_null() {
264                *node = Value::Array(Vec::new());
265            }
266            let Value::Array(arr) = node else {
267                return Err(format!("'{path}' mixes a list with a mapping"));
268            };
269            let i: usize = seg.parse().map_err(|_| format!("bad index in '{path}'"))?;
270            while arr.len() <= i {
271                arr.push(Value::Null);
272            }
273            node = &mut arr[i];
274        } else {
275            if node.is_null() {
276                *node = Value::Object(Map::new());
277            }
278            let Value::Object(map) = node else {
279                return Err(format!("'{path}' mixes a mapping with a scalar or list"));
280            };
281            node = map.entry(seg.to_string()).or_insert(Value::Null);
282        }
283    }
284    if !node.is_null() {
285        return Err(format!("'{path}' is given twice"));
286    }
287    *node = leaf;
288    Ok(())
289}
290
291/// Parse a document of the subset into an ordered mapping.
292///
293/// ```
294/// use majordomus_cli::metadata::yaml::parse_mapping;
295/// use serde_json::json;
296/// let m = parse_mapping("id: project.x\nversion: 1\ntags: [a, b]\nnote: colons: are text\n").unwrap();
297/// assert_eq!(m["version"], json!(1));
298/// assert_eq!(m["tags"], json!(["a", "b"]));
299/// assert_eq!(m["note"], json!("colons: are text"));
300/// assert_eq!(parse_mapping("a:\tb\n").unwrap_err(), "tab character on line 1");
301/// ```
302pub fn parse_mapping(text: &str) -> Result<Map<String, Value>, String> {
303    let mut root = Map::new();
304    for flat in flatten(text)? {
305        match flat {
306            Flat::Value(path, scalar) => insert(&mut root, &path, typed(&scalar))?,
307            Flat::EmptyList(path) => insert(&mut root, &path, Value::Array(Vec::new()))?,
308        }
309    }
310    Ok(root)
311}
312
313/// Parse and deserialize into a typed value.
314pub fn parse_into<T: serde::de::DeserializeOwned>(text: &str) -> Result<T, String> {
315    let map = parse_mapping(text)?;
316    serde_json::from_value(Value::Object(map)).map_err(|e| e.to_string())
317}
318
319/// Every leaf key path of a value, in document order, as the allow-lists are written:
320/// `a.b.0.c`. An empty list is itself a leaf, so that `tags: []` flattens to `tags`.
321pub fn key_paths(map: &Map<String, Value>) -> Vec<String> {
322    let mut out = Vec::new();
323    for (k, v) in map {
324        walk(k, v, &mut out);
325    }
326    out
327}
328
329fn walk(prefix: &str, v: &Value, out: &mut Vec<String>) {
330    match v {
331        Value::Object(m) if !m.is_empty() => {
332            for (k, v) in m {
333                walk(&format!("{prefix}.{k}"), v, out);
334            }
335        }
336        Value::Array(a) if !a.is_empty() => {
337            for (i, v) in a.iter().enumerate() {
338                walk(&format!("{prefix}.{i}"), v, out);
339            }
340        }
341        _ => out.push(prefix.to_string()),
342    }
343}
344
345/// A scalar as the string an identity or title uses; `None` for a list or mapping.
346pub fn scalar_string(v: &Value) -> Option<String> {
347    match v {
348        Value::String(s) => Some(s.clone()),
349        Value::Number(n) => Some(n.to_string()),
350        Value::Bool(b) => Some(b.to_string()),
351        _ => None,
352    }
353}
354
355// ---------------------------------------------------------------- rendering
356
357/// Render a JSON value as a YAML document in the layer's subset wherever the value fits
358/// it, and in a conservative quoted style wherever it does not.
359///
360/// The reader above is the contract this writer aims at: a document it produces from a
361/// value whose keys are identifiers and whose scalars are strings, integers and booleans
362/// parses back to the same value through [`parse_mapping`]. Values outside the subset —
363/// a key like `$ref` or `/api/v1/peers`, a floating exponent, a null — are still written,
364/// quoted so that a general YAML 1.2 parser reads them back, because the generated
365/// OpenAPI and registry documents carry them and a projection that silently dropped them
366/// would be a lie.
367///
368/// The style is fixed so that the output is byte-deterministic: two-space indent, block
369/// mappings and block sequences, `{}` and `[]` for the empty collections, keys bare when
370/// they are identifiers and double-quoted otherwise, and scalars plain only when reading
371/// them back cannot change their type.
372///
373/// ```
374/// use majordomus_cli::metadata::yaml::{render, parse_mapping};
375/// use serde_json::json;
376/// let doc = json!({ "id": "peers.list", "tags": ["peers", "coordination"], "cached": true });
377/// assert_eq!(render(&doc), "id: peers.list\ntags:\n  - peers\n  - coordination\ncached: true\n");
378/// assert_eq!(serde_json::Value::Object(parse_mapping(&render(&doc)).unwrap()), doc);
379/// ```
380pub fn render(value: &Value) -> String {
381    let mut out = String::new();
382    match value {
383        Value::Object(map) if map.is_empty() => out.push_str("{}\n"),
384        Value::Object(map) => write_mapping(map, 0, &mut out),
385        Value::Array(items) if items.is_empty() => out.push_str("[]\n"),
386        Value::Array(items) => write_sequence(items, 0, &mut out),
387        scalar => {
388            out.push_str(&scalar_literal(scalar));
389            out.push('\n');
390        }
391    }
392    out
393}
394
395/// Render a value as a YAML document under a comment banner, one `# ` line per line of
396/// `banner`. The banner is a comment and never part of the data.
397pub fn render_with_banner(value: &Value, banner: &str) -> String {
398    let mut out = String::new();
399    for line in banner.lines() {
400        if line.is_empty() {
401            out.push_str("#\n");
402        } else {
403            out.push_str("# ");
404            out.push_str(line);
405            out.push('\n');
406        }
407    }
408    out.push_str(&render(value));
409    out
410}
411
412fn indent(depth: usize, out: &mut String) {
413    for _ in 0..depth {
414        out.push_str("  ");
415    }
416}
417
418fn write_mapping(map: &Map<String, Value>, depth: usize, out: &mut String) {
419    for (key, value) in map {
420        indent(depth, out);
421        out.push_str(&key_literal(key));
422        out.push(':');
423        write_child(value, depth, out);
424    }
425}
426
427fn write_sequence(items: &[Value], depth: usize, out: &mut String) {
428    for item in items {
429        indent(depth, out);
430        out.push('-');
431        match item {
432            Value::Object(map) if !map.is_empty() => {
433                // `- key: value`, the remaining keys aligned under it
434                let mut nested = String::new();
435                write_mapping(map, depth + 1, &mut nested);
436                let body = nested
437                    .strip_prefix(&"  ".repeat(depth + 1))
438                    .unwrap_or(&nested);
439                out.push(' ');
440                out.push_str(body);
441            }
442            Value::Array(items) if !items.is_empty() => {
443                out.push('\n');
444                write_sequence(items, depth + 1, out);
445            }
446            other => {
447                out.push(' ');
448                out.push_str(&inline(other));
449                out.push('\n');
450            }
451        }
452    }
453}
454
455/// The right-hand side of `key:`: inline for a scalar and for an empty collection, a
456/// block on the following lines for a non-empty one.
457fn write_child(value: &Value, depth: usize, out: &mut String) {
458    match value {
459        Value::Object(map) if !map.is_empty() => {
460            out.push('\n');
461            write_mapping(map, depth + 1, out);
462        }
463        Value::Array(items) if !items.is_empty() => {
464            out.push('\n');
465            write_sequence(items, depth + 1, out);
466        }
467        other => {
468            out.push(' ');
469            out.push_str(&inline(other));
470            out.push('\n');
471        }
472    }
473}
474
475fn inline(value: &Value) -> String {
476    match value {
477        Value::Object(_) => "{}".into(),
478        Value::Array(_) => "[]".into(),
479        scalar => scalar_literal(scalar),
480    }
481}
482
483/// A YAML key: bare when it is an identifier the reader accepts, double-quoted otherwise.
484/// The reader flattens nested keys into dotted paths, so a key holding a `.` is quoted
485/// too — quoting does not make it readable by the subset, and it does make the document
486/// readable by a general parser.
487fn key_literal(key: &str) -> String {
488    if is_plain_key(key) {
489        key.to_string()
490    } else {
491        quoted(key)
492    }
493}
494
495fn is_plain_key(key: &str) -> bool {
496    // a YAML 1.1 parser reads these bare as a boolean or null, so the key `on` of a schema
497    // became `true` in docs/generated/openapi.yaml while the JSON kept `on`
498    if matches!(
499        key.to_ascii_lowercase().as_str(),
500        "true" | "false" | "null" | "yes" | "no" | "on" | "off"
501    ) {
502        return false;
503    }
504    let mut chars = key.chars();
505    match chars.next() {
506        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
507        _ => return false,
508    }
509    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
510}
511
512fn scalar_literal(value: &Value) -> String {
513    match value {
514        Value::Null => "null".into(),
515        Value::Bool(true) => "true".into(),
516        Value::Bool(false) => "false".into(),
517        Value::Number(n) => n.to_string(),
518        Value::String(s) if is_plain_scalar(s) => s.clone(),
519        Value::String(s) => quoted(s),
520        other => other.to_string(),
521    }
522}
523
524/// A string may be written unquoted only when reading it back yields the same string:
525/// not empty, not a word or a number the reader types, no leading or trailing space, no
526/// character that starts a construct, and no ` #` that the reader would cut as a comment.
527fn is_plain_scalar(s: &str) -> bool {
528    if s.is_empty() || s.trim() != s {
529        return false;
530    }
531    if matches!(
532        s,
533        "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off"
534    ) {
535        return false;
536    }
537    if looks_numeric(s) || looks_temporal(s) {
538        return false;
539    }
540    let first = s.as_bytes()[0];
541    if !(first.is_ascii_alphanumeric() || first == b'_' || first == b'/' || first == b'.') {
542        return false;
543    }
544    if s.contains(": ") || s.ends_with(':') || s.contains(" #") || s.contains('\t') {
545        return false;
546    }
547    s.chars().all(|c| {
548        !c.is_control()
549            && !matches!(
550                c,
551                '"' | '\''
552                    | '\\'
553                    | '['
554                    | ']'
555                    | '{'
556                    | '}'
557                    | ','
558                    | '&'
559                    | '*'
560                    | '|'
561                    | '>'
562                    | '%'
563                    | '@'
564                    | '`'
565            )
566    })
567}
568
569/// A date or a timestamp, in the shape a YAML 1.1 parser types as a value of its own.
570///
571/// The reader in this module treats every unquoted non-numeric scalar as a string, but
572/// the encodings are also read by parsers this repository does not own — Psych and
573/// PyYAML among them — and those turn a plain `2026-09-08T07:10:37Z` into a time, not
574/// into the string that was written. The JSON sibling of the same document has a string
575/// there, so leaving it plain makes the two encodings different documents. Quoting it is
576/// what keeps them one value rendered twice.
577///
578/// The test is the date prefix alone: everything a YAML 1.1 timestamp can be begins with
579/// `YYYY-MM-DD`, and nothing else this repository writes does.
580fn looks_temporal(s: &str) -> bool {
581    let b = s.as_bytes();
582    b.len() >= 10
583        && b[..4].iter().all(u8::is_ascii_digit)
584        && b[4] == b'-'
585        && b[5..7].iter().all(u8::is_ascii_digit)
586        && b[7] == b'-'
587        && b[8..10].iter().all(u8::is_ascii_digit)
588}
589
590fn looks_numeric(s: &str) -> bool {
591    let body = s.strip_prefix('-').unwrap_or(s);
592    !body.is_empty()
593        && body
594            .chars()
595            .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
596        && body
597            .chars()
598            .next()
599            .is_some_and(|c| c.is_ascii_digit() || c == '.')
600}
601
602/// A double-quoted YAML scalar. YAML 1.2's double-quoted style takes JSON's escapes, so
603/// the JSON encoding of the string is a correct YAML scalar and needs no second rule.
604fn quoted(s: &str) -> String {
605    Value::String(s.to_string()).to_string()
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use serde_json::json;
612
613    #[test]
614    fn a_date_or_a_timestamp_is_quoted_so_a_foreign_parser_reads_a_string_back() {
615        // A YAML 1.1 parser types a plain `2026-09-08T07:10:37Z` as a time; the JSON
616        // sibling of the same document has a string there, and test case 52 compares the
617        // two with a parser this repository does not own.
618        let doc = json!({ "date": "2026-09-08T07:10:37Z", "day": "2026-09-08" });
619        assert_eq!(
620            render(&doc),
621            "date: \"2026-09-08T07:10:37Z\"\nday: \"2026-09-08\"\n"
622        );
623        assert_eq!(Value::Object(parse_mapping(&render(&doc)).unwrap()), doc);
624        // a string that merely starts with a year is still plain
625        assert!(is_plain_scalar("2026-releases"));
626    }
627
628    #[test]
629    fn unquoted_integers_and_decimals_are_numbers_and_everything_else_is_text() {
630        let m = parse_mapping(
631            "a: 10\nb: -3\nc: 0.5\nd: -1.25\ne: \"0.5\"\nf: 1e3\ng: .5\nh: 1.\ni: 0x10\nj: 1.2.3\n",
632        )
633        .unwrap();
634        assert_eq!(m["a"], json!(10));
635        assert_eq!(m["b"], json!(-3));
636        assert_eq!(m["c"], json!(0.5));
637        assert_eq!(m["d"], json!(-1.25));
638        for k in ["e", "f", "g", "h", "i", "j"] {
639            assert!(m[k].is_string(), "{k} = {:?}", m[k]);
640        }
641    }
642
643    #[test]
644    fn a_doubled_quote_inside_single_quotes_is_one_quote() {
645        let m =
646            parse_mapping("a: 'the repository''s test'\nb: \"it''s\"\nc: ''''\nd: ['x''y', z]\n")
647                .unwrap();
648        assert_eq!(m["a"], json!("the repository's test"));
649        // the escape is single-quoted YAML's alone; a double-quoted scalar keeps the pair
650        assert_eq!(m["b"], json!("it''s"));
651        assert_eq!(m["c"], json!("'"));
652        assert_eq!(m["d"], json!(["x'y", "z"]));
653    }
654
655    #[test]
656    fn flattens_like_the_shell_tool() {
657        let m = parse_mapping("id: a\nversion: 1\ndepends_on: [x@1, y@2]\ntags: []\nx-majordomus:\n  validator: v\n  tests: [t]\n").unwrap();
658        assert_eq!(
659            key_paths(&m),
660            vec![
661                "id",
662                "version",
663                "depends_on.0",
664                "depends_on.1",
665                "tags",
666                "x-majordomus.validator",
667                "x-majordomus.tests.0"
668            ]
669        );
670        assert_eq!(m["version"], json!(1));
671        assert_eq!(m["depends_on"], json!(["x@1", "y@2"]));
672    }
673
674    #[test]
675    fn colons_and_backticks_inside_scalars_are_text() {
676        let m = parse_mapping("description: The name is the only thing: no paths, no quotes.\nnote: `git status` stays clean\n").unwrap();
677        assert_eq!(
678            m["description"],
679            json!("The name is the only thing: no paths, no quotes.")
680        );
681        assert_eq!(m["note"], json!("`git status` stays clean"));
682    }
683
684    #[test]
685    fn block_lists_and_lists_of_maps() {
686        let text = "scope:\n  - lib/a\n  - Every surface agrees: the CLI and the site\nevidence:\n  - covers: x\n    type: test\n  - covers: y\n    type: manual\n";
687        let m = parse_mapping(text).unwrap();
688        assert_eq!(
689            m["scope"],
690            json!(["lib/a", "Every surface agrees: the CLI and the site"])
691        );
692        assert_eq!(
693            m["evidence"],
694            json!([{ "covers": "x", "type": "test" }, { "covers": "y", "type": "manual" }])
695        );
696    }
697
698    #[test]
699    fn list_items_at_parent_indent() {
700        let m = parse_mapping("sources:\n- id: a\n  kind: k\n- id: b\n  kind: k2\n").unwrap();
701        assert_eq!(
702            m["sources"],
703            json!([{ "id": "a", "kind": "k" }, { "id": "b", "kind": "k2" }])
704        );
705    }
706
707    #[test]
708    fn quotes_comments_and_typing() {
709        let m = parse_mapping(
710            "a: \"1\"   \nb: 1 # a comment\nc: 'x: y'\nd: true\ne: 15m\nf: -3\ng: -\n",
711        )
712        .unwrap();
713        assert_eq!(m["a"], json!("1"));
714        assert_eq!(m["b"], json!(1));
715        assert_eq!(m["c"], json!("x: y"));
716        assert_eq!(m["d"], json!(true));
717        assert_eq!(m["e"], json!("15m"));
718        assert_eq!(m["f"], json!(-3));
719        assert_eq!(m["g"], json!("-"));
720    }
721
722    #[test]
723    fn refusals_name_the_line() {
724        assert_eq!(
725            parse_mapping("a:\tb\n").unwrap_err(),
726            "tab character on line 1"
727        );
728        assert_eq!(
729            parse_mapping("a:\n   b: 1\n").unwrap_err(),
730            "odd indentation on line 2"
731        );
732        assert_eq!(
733            parse_mapping("- a\n").unwrap_err(),
734            "list item without a parent key on line 1"
735        );
736        assert_eq!(
737            parse_mapping("- a: [b\n").unwrap_err(),
738            "list item without a parent key on line 1"
739        );
740        assert_eq!(
741            parse_mapping("a: &x b\nc: *x\n").unwrap_err(),
742            "unsupported YAML construct on line 1: &x b"
743        );
744        assert_eq!(
745            parse_mapping("a: |\n  text\n").unwrap_err(),
746            "unsupported YAML construct on line 1: |"
747        );
748        assert_eq!(parse_mapping("a: '*x'\n").unwrap()["a"], json!("*x"));
749        assert_eq!(
750            parse_mapping("a: 1\n  b: 2\n").unwrap_err(),
751            "unexpected indentation on line 2"
752        );
753        assert!(parse_mapping("a: 1\na: 2\n").unwrap_err().contains("twice"));
754        assert!(parse_mapping("tags:\n  - a\ntags:\n  - b\n")
755            .unwrap_err()
756            .contains("'tags' is given twice"));
757        assert!(parse_mapping("x:\n  a: 1\n  a: 2\n")
758            .unwrap_err()
759            .contains("'x.a' is given twice"));
760    }
761
762    // ------------------------------------------------------------ rendering
763
764    /// Anything the writer emits from a value inside the subset must read back identical:
765    /// this is the one property the two halves of this module owe each other.
766    fn round_trips(doc: Value) {
767        let text = render(&doc);
768        let back = parse_mapping(&text).unwrap_or_else(|e| panic!("{e}\n--- rendered ---\n{text}"));
769        assert_eq!(Value::Object(back), doc, "--- rendered ---\n{text}");
770    }
771
772    #[test]
773    fn renders_scalars_maps_and_sequences() {
774        assert_eq!(
775            render(&json!({ "id": "peers.list", "count": 2, "cached": true })),
776            "id: peers.list\ncount: 2\ncached: true\n"
777        );
778        assert_eq!(
779            render(&json!({ "tags": ["a", "b"] })),
780            "tags:\n  - a\n  - b\n"
781        );
782        assert_eq!(
783            render(&json!({ "x": { "y": { "z": 1 } } })),
784            "x:\n  y:\n    z: 1\n"
785        );
786        assert_eq!(
787            render(&json!({ "items": [{ "id": "a", "n": 1 }, { "id": "b", "n": 2 }] })),
788            "items:\n  - id: a\n    n: 1\n  - id: b\n    n: 2\n"
789        );
790        assert_eq!(render(&json!({})), "{}\n");
791        assert_eq!(render(&json!([])), "[]\n");
792        assert_eq!(
793            render(&json!({ "empty": [], "none": {} })),
794            "empty: []\nnone: {}\n"
795        );
796    }
797
798    #[test]
799    fn quotes_whatever_would_read_back_as_something_else() {
800        // a string that looks like a number, a boolean or nothing at all
801        assert_eq!(render(&json!({ "a": "1" })), "a: \"1\"\n");
802        assert_eq!(render(&json!({ "a": "true" })), "a: \"true\"\n");
803        assert_eq!(render(&json!({ "a": "" })), "a: \"\"\n");
804        assert_eq!(render(&json!({ "a": " x " })), "a: \" x \"\n");
805        // a string carrying what the reader treats as syntax
806        assert_eq!(render(&json!({ "a": "k: v" })), "a: \"k: v\"\n");
807        assert_eq!(render(&json!({ "a": "x #c" })), "a: \"x #c\"\n");
808        assert_eq!(render(&json!({ "a": "[a, b]" })), "a: \"[a, b]\"\n");
809        assert_eq!(render(&json!({ "a": "&anchor" })), "a: \"&anchor\"\n");
810        assert_eq!(
811            render(&json!({ "a": "line\nbreak" })),
812            "a: \"line\\nbreak\"\n"
813        );
814        // a key outside the reader's identifier form is quoted rather than dropped
815        assert_eq!(render(&json!({ "$ref": "#/x" })), "\"$ref\": \"#/x\"\n");
816        assert_eq!(
817            render(&json!({ "/api/v1/peers": 1 })),
818            "\"/api/v1/peers\": 1\n"
819        );
820        assert_eq!(render(&json!({ "a.b": 1 })), "\"a.b\": 1\n");
821        // a key a YAML 1.1 parser would read as a boolean or null keeps its name
822        assert_eq!(render(&json!({ "on": [1] })), "\"on\":\n  - 1\n");
823        assert_eq!(render(&json!({ "No": 1 })), "\"No\": 1\n");
824        assert_eq!(render(&json!({ "one": 1 })), "one: 1\n");
825        // null has no place in the subset and is still written
826        assert_eq!(render(&json!({ "a": Value::Null })), "a: null\n");
827    }
828
829    #[test]
830    fn every_shape_of_the_subset_round_trips() {
831        round_trips(json!({ "id": "x", "n": 0, "neg": -3, "t": true, "f": false }));
832        round_trips(json!({ "s": "1", "b": "true", "e": "", "pad": " x ", "colon": "k: v" }));
833        round_trips(json!({ "tags": ["a", "b"], "empty": [] }));
834        round_trips(json!({ "outer": { "inner": { "deep": "value" } } }));
835        round_trips(json!({ "items": [{ "id": "a", "tags": ["x"] }, { "id": "b", "tags": [] }] }));
836        round_trips(json!({ "path": "docs/generated/registry.json", "sha": "0a1b2c" }));
837        round_trips(json!({ "text": "a sentence, with punctuation - and a dash" }));
838        round_trips(json!({ "dec": 1.5, "negdec": -0.25 }));
839    }
840
841    /// A sequence whose items are themselves collections: the item opens on its own line
842    /// and its body is indented under the dash. Outside the reader's subset — it flattens
843    /// by dotted path and has no shape for a list of lists — and written correctly anyway,
844    /// because the OpenAPI document has them.
845    #[test]
846    fn sequences_of_collections_open_under_their_dash() {
847        assert_eq!(
848            render(&json!({ "m": [[1, 2], [3]] })),
849            "m:\n  -\n    - 1\n    - 2\n  -\n    - 3\n"
850        );
851        assert_eq!(
852            render(&json!({ "m": [{ "a": [1] }, {}] })),
853            "m:\n  - a:\n      - 1\n  - {}\n"
854        );
855        assert_eq!(render(&json!({ "m": [[], {}] })), "m:\n  - []\n  - {}\n");
856    }
857
858    /// A document that is not a mapping at all: the writer renders the value it was given
859    /// rather than inventing a key to hang it on.
860    #[test]
861    fn a_document_that_is_not_a_mapping_is_still_a_document() {
862        assert_eq!(render(&json!([1, "two"])), "- 1\n- two\n");
863        assert_eq!(render(&json!("bare")), "bare\n");
864        assert_eq!(render(&json!(7)), "7\n");
865        assert_eq!(render(&json!(true)), "true\n");
866        assert_eq!(render(&Value::Null), "null\n");
867    }
868
869    /// The one shape the two halves disagree on, stated rather than hidden: an empty
870    /// mapping. The reader has no `{}` — an empty document and a key with no children are
871    /// both nothing to it — while the writer must emit `{}` or hand a general parser a
872    /// null. Every generated document this repository writes has keys, so the divergence
873    /// is at the edge of the subset and never in an artifact.
874    #[test]
875    fn the_empty_mapping_is_the_edge_of_the_subset() {
876        assert_eq!(render(&json!({})), "{}\n");
877        assert!(parse_mapping("{}").is_err());
878        assert!(parse_mapping("").unwrap().is_empty());
879        assert_eq!(render(&json!({ "a": {} })), "a: {}\n");
880        assert_eq!(
881            parse_mapping("a: {}\n").unwrap().get("a"),
882            Some(&Value::String("{}".into()))
883        );
884    }
885
886    #[test]
887    fn a_banner_is_a_comment_and_never_data() {
888        let doc = json!({ "a": 1 });
889        let text = render_with_banner(&doc, "GENERATED\n\nsource: nowhere");
890        assert_eq!(text, "# GENERATED\n#\n# source: nowhere\na: 1\n");
891        assert_eq!(Value::Object(parse_mapping(&text).unwrap()), doc);
892    }
893
894    #[test]
895    fn empty_document_is_empty_mapping() {
896        assert!(parse_mapping("").unwrap().is_empty());
897        assert!(parse_mapping("# only a comment\n---\n").unwrap().is_empty());
898    }
899
900    #[test]
901    fn nested_maps_and_pending_keys_without_children_vanish() {
902        let m = parse_mapping("context:\n  task: true\n  depth: 0\nempty:\nnext: 1\n").unwrap();
903        assert_eq!(
904            m,
905            serde_json::from_value::<serde_json::Map<String, Value>>(
906                json!({ "context": { "task": true, "depth": 0 }, "next": 1 })
907            )
908            .unwrap()
909        );
910    }
911}