use std::fmt::Write;
#[derive(Debug, Clone)]
pub enum Value {
String(String),
Int(i64),
Bool(bool),
Null,
Array(Vec<Value>),
Map(Vec<(String, Value)>),
BlockScalar(String),
}
impl Value {
pub fn s(v: impl Into<String>) -> Self { Self::String(v.into()) }
pub fn i(v: i64) -> Self { Self::Int(v) }
pub fn b(v: bool) -> Self { Self::Bool(v) }
pub fn arr(vs: impl IntoIterator<Item = Value>) -> Self {
Self::Array(vs.into_iter().collect())
}
pub fn map() -> Self { Self::Map(Vec::new()) }
pub fn block(v: impl Into<String>) -> Self { Self::BlockScalar(v.into()) }
pub fn insert(&mut self, k: impl Into<String>, v: Value) -> &mut Self {
if let Self::Map(items) = self {
let key = k.into();
if let Some(idx) = items.iter().position(|(ek, _)| ek == &key) {
items[idx].1 = v;
} else {
items.push((key, v));
}
}
self
}
}
fn render_scalar(s: &str) -> String {
let needs_quote = s.is_empty()
|| s.starts_with(|c: char| c.is_ascii_whitespace())
|| s.ends_with(|c: char| c.is_ascii_whitespace())
|| s.chars().any(|c| matches!(c, ':' | '#' | '&' | '*' | '[' | ']' | '{' | '}' | '|' | '>' | '\'' | '"'))
|| matches!(s, "true" | "false" | "null" | "yes" | "no")
|| s.parse::<f64>().is_ok();
if needs_quote {
let escaped: String = s
.chars()
.flat_map(|c| match c {
'"' => "\\\"".chars().collect::<Vec<_>>().into_iter(),
'\\' => "\\\\".chars().collect::<Vec<_>>().into_iter(),
c => vec![c].into_iter(),
})
.collect();
let mut out = String::from("\"");
out.push_str(&escaped);
out.push('"');
out
} else {
s.to_string()
}
}
fn render_at(v: &Value, indent: usize, out: &mut String) {
match v {
Value::String(s) => out.push_str(&render_scalar(s)),
Value::Int(n) => { let _ = write!(out, "{n}"); }
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Null => out.push_str("null"),
Value::Array(items) if items.is_empty() => out.push_str("[]"),
Value::Array(items) => {
for v in items {
out.push('\n');
push_indent(out, indent);
out.push_str("- ");
match v {
Value::Map(inner) if !inner.is_empty() => {
for (i, (k, mv)) in inner.iter().enumerate() {
if i > 0 {
out.push('\n');
push_indent(out, indent + 1);
}
out.push_str(&render_scalar(k));
out.push(':');
match mv {
Value::Map(im) if !im.is_empty() => {
render_at(mv, indent + 2, out);
}
Value::Array(ia) if !ia.is_empty() => {
render_at(mv, indent + 2, out);
}
_ => {
out.push(' ');
render_at(mv, indent + 1, out);
}
}
}
}
Value::Array(_) => render_at(v, indent + 1, out),
_ => render_at(v, indent, out),
}
}
}
Value::Map(items) if items.is_empty() => out.push_str("{}"),
Value::Map(items) => {
for (k, v) in items {
out.push('\n');
push_indent(out, indent);
out.push_str(&render_scalar(k));
out.push(':');
match v {
Value::Map(inner) if !inner.is_empty() => render_at(v, indent + 1, out),
Value::Array(inner) if !inner.is_empty() => render_at(v, indent + 1, out),
Value::BlockScalar(_) => render_at(v, indent + 1, out),
_ => {
out.push(' ');
render_at(v, indent, out);
}
}
}
}
Value::BlockScalar(s) => {
out.push_str(" |");
for line in s.lines() {
out.push('\n');
push_indent(out, indent);
out.push_str(line);
}
}
}
}
fn push_indent(out: &mut String, depth: usize) {
for _ in 0..depth { out.push_str(" "); }
}
pub fn render(v: &Value) -> String {
let mut out = String::new();
match v {
Value::Map(_) | Value::Array(_) => {
render_at(v, 0, &mut out);
if out.starts_with('\n') {
out.remove(0);
}
}
_ => render_at(v, 0, &mut out),
}
if !out.ends_with('\n') { out.push('\n'); }
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scalar_with_colon_gets_quoted() {
assert!(render_scalar("foo:bar").starts_with('"'));
assert!(!render_scalar("plain").starts_with('"'));
}
#[test]
fn map_renders_with_indent() {
let mut m = Value::map();
m.insert("name", Value::s("demo"));
m.insert("version", Value::s("0.1.0"));
let out = render(&m);
assert!(out.contains("name: demo"));
assert!(out.contains("version: "));
}
#[test]
fn nested_map_indented() {
let mut outer = Value::map();
let mut inner = Value::map();
inner.insert("type", Value::s("git"));
outer.insert("repository", inner);
let s = render(&outer);
assert!(s.contains("repository:"));
assert!(s.contains(" type: git"));
}
#[test]
fn array_of_maps_inlines_first_key_on_dash_line() {
let mut r1 = Value::map();
r1.insert("name", Value::s("a"));
r1.insert("ecosystem", Value::s("x"));
let mut r2 = Value::map();
r2.insert("name", Value::s("b"));
let out = render(&Value::arr([r1, r2]));
assert!(out.contains("- name: a\n"),
"first map key should be inline on dash line; got: {out}");
assert!(out.contains(" ecosystem: x\n"),
"subsequent map keys should indent at depth+1; got: {out}");
assert!(out.contains("- name: b\n"),
"second map entry should also inline; got: {out}");
}
}