1use serde_json::{Map, Number, Value};
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42struct Scalar {
43 text: String,
44 quoted: bool,
45}
46
47#[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
70fn 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
97fn 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
111fn 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
135fn 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 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
244fn 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
291pub 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
313pub 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
319pub 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
345pub 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
355pub 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
395pub 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 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
455fn 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
483fn 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 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
524fn 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
569fn 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
602fn 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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}