use super::section_shape;
use crate::LoopConfig;
use serde_yaml::Value;
const SECTIONS: &[(&str, &str)] = &[
("information", "A. Information"),
("pre_execution", "B. Pre-execution"),
("goals", "C. Goals"),
("validations", "D. Validations"),
("success", "E. Success"),
("stop_gates", "F. Stop gates"),
("schedules", "G. Schedules"),
("constraints", "H. Constraints"),
("execution_guidelines", "I. Execution guidelines"),
("default_skills", "J. Default skills"),
("graph", "Graph"),
("providers", "Providers"),
("skills", "Skills"),
("context", "Context"),
];
const PREAMBLE: &[&str] = &["version", "description"];
#[cfg(test)]
fn covered_keys() -> Vec<&'static str> {
let mut v: Vec<&'static str> = SECTIONS.iter().map(|(k, _)| *k).collect();
v.extend_from_slice(PREAMBLE);
v.push("name");
v
}
pub fn render_md(cfg: &LoopConfig) -> String {
let Ok(Value::Mapping(root)) = serde_yaml::to_value(cfg) else {
return String::new();
};
let mut out = String::new();
let name = root
.get(Value::from("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(Value::from(*key)) {
if !is_blank(v) {
push_field(&mut out, key, v, 0);
}
}
}
if out.lines().count() > 2 {
out.push('\n');
}
for (key, heading) in SECTIONS {
let Some(value) = root.get(Value::from(*key)) else {
continue;
};
if is_blank(value) {
continue;
}
out.push_str(&format!("## {heading}\n\n"));
render_section(&mut out, key, 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 = m
.get(Value::from(key_field))
.and_then(|v| v.as_str())
.unwrap_or("unnamed");
out.push_str(&format!("### {heading}\n"));
for (k, v) in m {
let k = k.as_str().unwrap_or_default();
if k == key_field || is_blank(v) {
continue;
}
push_field(out, k, 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_top_level_config_key() {
let cfg = crate::parse_str(
r#"
name: t
goals: [{ name: g1, description: a sufficiently long goal description }]
validations:
- target: g1
name: v
mode: objective
statement: it exists
detector: { type: file_exists, path: out.txt }
"#,
"test",
)
.expect("parses");
let Ok(Value::Mapping(root)) = serde_yaml::to_value(&cfg) else {
panic!("a config serialises to a mapping");
};
let covered = covered_keys();
let missing: Vec<String> = root
.keys()
.filter_map(|k| k.as_str())
.filter(|k| !covered.contains(k))
.map(str::to_string)
.collect();
assert!(
missing.is_empty(),
"these top-level keys would be dropped by render_md; add them to \
SECTIONS or PREAMBLE: {missing:?}"
);
}
}