use super::{section_shape, SECTION_PATHS};
use crate::LoopConfig;
use serde_yaml::Value;
const PREAMBLE: &[&str] = &["version", "description", "environment", "features"];
fn at<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = root;
for part in path.split('.') {
cur = cur.get(part)?;
}
Some(cur)
}
#[cfg(test)]
fn covered_paths() -> Vec<&'static str> {
let mut v: Vec<&'static str> = SECTION_PATHS.iter().map(|(_, p, _)| *p).collect();
v.extend_from_slice(PREAMBLE);
v.push("name");
v
}
pub fn render_md(cfg: &LoopConfig) -> String {
let Ok(root) = serde_yaml::to_value(cfg) else {
return String::new();
};
if !root.is_mapping() {
return String::new();
}
let mut out = String::new();
let name = root
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unnamed-loop");
out.push_str(&format!("# {name}\n\n"));
for key in PREAMBLE {
if let Some(v) = root.get(*key) {
if !is_blank(v) {
push_field(&mut out, key, v, 0);
}
}
}
if out.lines().count() > 2 {
out.push('\n');
}
for (_, path, heading) in SECTION_PATHS {
let Some(value) = at(&root, path) else {
continue;
};
if is_blank(value) {
continue;
}
out.push_str(&format!("## {heading}\n\n"));
render_section(&mut out, path, value);
out.push('\n');
}
out
}
fn render_section(out: &mut String, key: &str, value: &Value) {
let shape = section_shape(key);
match (value, shape) {
(Value::Sequence(items), Some(s)) => {
for item in items {
render_entry(out, item, s.key_field);
}
}
(Value::Mapping(m), Some(s)) => {
let list_field = s.list_field;
for (k, v) in m {
let k = k.as_str().unwrap_or_default();
if Some(k) == list_field || is_blank(v) {
continue;
}
push_field(out, k, v, 0);
}
if let Some(field) = list_field {
if let Some(Value::Sequence(items)) = m.get(Value::from(field)) {
if !items.is_empty() {
out.push('\n');
}
for item in items {
render_entry(out, item, s.key_field);
}
}
}
}
(Value::Mapping(m), None) => {
for (k, v) in m {
if is_blank(v) {
continue;
}
push_field(out, k.as_str().unwrap_or_default(), v, 0);
}
}
_ => push_value_inline(out, value, 0),
}
}
fn render_entry(out: &mut String, item: &Value, key_field: &str) {
let Value::Mapping(m) = item else {
push_value_inline(out, item, 0);
return;
};
let heading = super::nested_get(m, key_field)
.and_then(|v| v.as_str())
.unwrap_or("unnamed");
out.push_str(&format!("### {heading}\n"));
let mut rest = m.clone();
super::nested_remove(&mut rest, key_field);
for (k, v) in &rest {
if is_blank(v) {
continue;
}
push_field(out, k.as_str().unwrap_or_default(), v, 0);
}
out.push('\n');
}
fn push_field(out: &mut String, key: &str, value: &Value, indent: usize) {
let pad = " ".repeat(indent);
match value {
Value::Mapping(m) => {
out.push_str(&format!("{pad}- {key}:\n"));
for (k, v) in m {
if is_blank(v) {
continue;
}
push_field(out, k.as_str().unwrap_or_default(), v, indent + 2);
}
}
Value::Sequence(items) if items.iter().all(is_scalar) => {
out.push_str(&format!("{pad}- {key}: {}\n", flow(value)));
}
Value::Sequence(_) => {
out.push_str(&format!("{pad}- {key}: {}\n", flow(value)));
}
Value::String(s) => push_string(out, key, s, indent),
other => out.push_str(&format!("{pad}- {key}: {}\n", flow(other))),
}
}
fn push_string(out: &mut String, key: &str, s: &str, indent: usize) {
let pad = " ".repeat(indent);
let trimmed = s.trim_end();
if trimmed.contains('\n') {
let cont = " ".repeat(indent + 4);
let mut lines = trimmed.lines();
out.push_str(&format!(
"{pad}- {key}: {}\n",
lines.next().unwrap_or_default()
));
for line in lines {
out.push_str(&format!("{cont}{}\n", line.trim()));
}
return;
}
let needs_quoting = trimmed.is_empty()
|| !matches!(
serde_yaml::from_str::<Value>(trimmed),
Ok(Value::String(ref got)) if got == trimmed
);
if needs_quoting {
out.push_str(&format!("{pad}- {key}: {}\n", flow(&Value::from(trimmed))));
} else {
out.push_str(&format!("{pad}- {key}: {trimmed}\n"));
}
}
fn push_value_inline(out: &mut String, value: &Value, indent: usize) {
out.push_str(&format!("{}- {}\n", " ".repeat(indent), flow(value)));
}
fn flow(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".into())
}
fn is_scalar(v: &Value) -> bool {
!matches!(v, Value::Mapping(_) | Value::Sequence(_))
}
fn is_blank(v: &Value) -> bool {
match v {
Value::Null => true,
Value::Sequence(s) => s.is_empty(),
Value::Mapping(m) => m.is_empty() || m.values().all(is_blank),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_renderer_knows_about_every_config_section() {
let cfg = crate::parse_str(
r#"
name: t
intent:
goals: [{ name: g1, description: a sufficiently long goal description }]
safety:
checks:
- target: g1
name: v
mode: objective
statement: it exists
detector: { type: file_exists, path: out.txt }
"#,
"test",
)
.expect("parses");
let root = serde_yaml::to_value(&cfg).expect("a config serialises");
let covered = covered_paths();
let mut missing: Vec<String> = Vec::new();
for (key, value) in root.as_mapping().expect("a mapping") {
let Some(key) = key.as_str() else { continue };
if covered.contains(&key) {
continue;
}
let Some(inner) = value.as_mapping() else {
missing.push(key.to_string());
continue;
};
for sub in inner.keys().filter_map(|k| k.as_str()) {
let path = format!("{key}.{sub}");
if !covered.contains(&path.as_str()) {
missing.push(path);
}
}
}
assert!(
missing.is_empty(),
"these sections would be dropped by render_md; add them to \
SECTION_PATHS or PREAMBLE: {missing:?}"
);
}
}