1use super::section_shape;
12use crate::LoopConfig;
13use serde_yaml::Value;
14
15const SECTIONS: &[(&str, &str)] = &[
18 ("information", "A. Information"),
19 ("pre_execution", "B. Pre-execution"),
20 ("goals", "C. Goals"),
21 ("validations", "D. Validations"),
22 ("success", "E. Success"),
23 ("stop_gates", "F. Stop gates"),
24 ("schedules", "G. Schedules"),
25 ("constraints", "H. Constraints"),
26 ("execution_guidelines", "I. Execution guidelines"),
27 ("default_skills", "J. Default skills"),
28 ("graph", "Graph"),
29 ("providers", "Providers"),
30 ("skills", "Skills"),
31 ("context", "Context"),
32];
33
34const PREAMBLE: &[&str] = &["version", "description"];
36
37#[cfg(test)]
41fn covered_keys() -> Vec<&'static str> {
42 let mut v: Vec<&'static str> = SECTIONS.iter().map(|(k, _)| *k).collect();
43 v.extend_from_slice(PREAMBLE);
44 v.push("name");
45 v
46}
47
48pub fn render_md(cfg: &LoopConfig) -> String {
49 let Ok(Value::Mapping(root)) = serde_yaml::to_value(cfg) else {
50 return String::new();
53 };
54
55 let mut out = String::new();
56 let name = root
57 .get(Value::from("name"))
58 .and_then(|v| v.as_str())
59 .unwrap_or("unnamed-loop");
60 out.push_str(&format!("# {name}\n\n"));
61
62 for key in PREAMBLE {
63 if let Some(v) = root.get(Value::from(*key)) {
64 if !is_blank(v) {
65 push_field(&mut out, key, v, 0);
66 }
67 }
68 }
69 if out.lines().count() > 2 {
70 out.push('\n');
71 }
72
73 for (key, heading) in SECTIONS {
74 let Some(value) = root.get(Value::from(*key)) else {
75 continue;
76 };
77 if is_blank(value) {
78 continue;
79 }
80 out.push_str(&format!("## {heading}\n\n"));
81 render_section(&mut out, key, value);
82 out.push('\n');
83 }
84 out
85}
86
87fn render_section(out: &mut String, key: &str, value: &Value) {
88 let shape = section_shape(key);
89
90 match (value, shape) {
91 (Value::Sequence(items), Some(s)) => {
93 for item in items {
94 render_entry(out, item, s.key_field);
95 }
96 }
97 (Value::Mapping(m), Some(s)) => {
99 let list_field = s.list_field;
100 for (k, v) in m {
101 let k = k.as_str().unwrap_or_default();
102 if Some(k) == list_field || is_blank(v) {
103 continue;
104 }
105 push_field(out, k, v, 0);
106 }
107 if let Some(field) = list_field {
108 if let Some(Value::Sequence(items)) = m.get(Value::from(field)) {
109 if !items.is_empty() {
110 out.push('\n');
111 }
112 for item in items {
113 render_entry(out, item, s.key_field);
114 }
115 }
116 }
117 }
118 (Value::Mapping(m), None) => {
120 for (k, v) in m {
121 if is_blank(v) {
122 continue;
123 }
124 push_field(out, k.as_str().unwrap_or_default(), v, 0);
125 }
126 }
127 _ => push_value_inline(out, value, 0),
128 }
129}
130
131fn render_entry(out: &mut String, item: &Value, key_field: &str) {
133 let Value::Mapping(m) = item else {
134 push_value_inline(out, item, 0);
135 return;
136 };
137 let heading = m
138 .get(Value::from(key_field))
139 .and_then(|v| v.as_str())
140 .unwrap_or("unnamed");
141 out.push_str(&format!("### {heading}\n"));
142 for (k, v) in m {
143 let k = k.as_str().unwrap_or_default();
144 if k == key_field || is_blank(v) {
145 continue;
146 }
147 push_field(out, k, v, 0);
148 }
149 out.push('\n');
150}
151
152fn push_field(out: &mut String, key: &str, value: &Value, indent: usize) {
153 let pad = " ".repeat(indent);
154 match value {
155 Value::Mapping(m) => {
156 out.push_str(&format!("{pad}- {key}:\n"));
157 for (k, v) in m {
158 if is_blank(v) {
159 continue;
160 }
161 push_field(out, k.as_str().unwrap_or_default(), v, indent + 2);
162 }
163 }
164 Value::Sequence(items) if items.iter().all(is_scalar) => {
165 out.push_str(&format!("{pad}- {key}: {}\n", flow(value)));
166 }
167 Value::Sequence(_) => {
168 out.push_str(&format!("{pad}- {key}: {}\n", flow(value)));
172 }
173 Value::String(s) => push_string(out, key, s, indent),
174 other => out.push_str(&format!("{pad}- {key}: {}\n", flow(other))),
175 }
176}
177
178fn push_string(out: &mut String, key: &str, s: &str, indent: usize) {
181 let pad = " ".repeat(indent);
182 let trimmed = s.trim_end();
183
184 if trimmed.contains('\n') {
185 let cont = " ".repeat(indent + 4);
186 let mut lines = trimmed.lines();
187 out.push_str(&format!(
188 "{pad}- {key}: {}\n",
189 lines.next().unwrap_or_default()
190 ));
191 for line in lines {
192 out.push_str(&format!("{cont}{}\n", line.trim()));
193 }
194 return;
195 }
196
197 let needs_quoting = trimmed.is_empty()
198 || !matches!(
199 serde_yaml::from_str::<Value>(trimmed),
200 Ok(Value::String(ref got)) if got == trimmed
201 );
202 if needs_quoting {
203 out.push_str(&format!("{pad}- {key}: {}\n", flow(&Value::from(trimmed))));
204 } else {
205 out.push_str(&format!("{pad}- {key}: {trimmed}\n"));
206 }
207}
208
209fn push_value_inline(out: &mut String, value: &Value, indent: usize) {
210 out.push_str(&format!("{}- {}\n", " ".repeat(indent), flow(value)));
211}
212
213fn flow(value: &Value) -> String {
216 serde_json::to_string(value).unwrap_or_else(|_| "null".into())
217}
218
219fn is_scalar(v: &Value) -> bool {
220 !matches!(v, Value::Mapping(_) | Value::Sequence(_))
221}
222
223
224
225fn is_blank(v: &Value) -> bool {
233 match v {
234 Value::Null => true,
235 Value::Sequence(s) => s.is_empty(),
236 Value::Mapping(m) => m.is_empty() || m.values().all(is_blank),
237 _ => false,
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn the_renderer_knows_about_every_top_level_config_key() {
247 let cfg = crate::parse_str(
251 r#"
252name: t
253goals: [{ name: g1, description: a sufficiently long goal description }]
254validations:
255 - target: g1
256 name: v
257 mode: objective
258 statement: it exists
259 detector: { type: file_exists, path: out.txt }
260"#,
261 "test",
262 )
263 .expect("parses");
264
265 let Ok(Value::Mapping(root)) = serde_yaml::to_value(&cfg) else {
266 panic!("a config serialises to a mapping");
267 };
268 let covered = covered_keys();
269 let missing: Vec<String> = root
270 .keys()
271 .filter_map(|k| k.as_str())
272 .filter(|k| !covered.contains(k))
273 .map(str::to_string)
274 .collect();
275 assert!(
276 missing.is_empty(),
277 "these top-level keys would be dropped by render_md; add them to \
278 SECTIONS or PREAMBLE: {missing:?}"
279 );
280 }
281}