Skip to main content

loopsmith_core/md/
render.rs

1//! `LoopConfig` → markdown document.
2//!
3//! The exact inverse of [`super::parse_md`], written against
4//! `serde_yaml::Value` for the same reason: it stays correct when the config
5//! model grows.
6//!
7//! One thing markdown cannot carry is trailing whitespace inside a value — a
8//! bullet ends where the line ends. Values are therefore emitted `trim_end`ed.
9//! Nothing else is lost.
10
11use super::section_shape;
12use crate::LoopConfig;
13use serde_yaml::Value;
14
15/// Section order and headings, matching the template so a rendered config and
16/// `LOOP-TEMPLATE.md` read the same way. Absent sections are skipped.
17const 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
34/// Top-level keys that are not sections; they render as preamble bullets.
35const PREAMBLE: &[&str] = &["version", "description"];
36
37/// Every top-level key must be either a section, a preamble field, or `name`.
38/// A key in none of those lists is silently dropped on render, which is how the
39/// `context` section went missing until a round-trip test caught it.
40#[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        // `LoopConfig` always serializes to a mapping; this arm exists so the
51        // function has no panic in it.
52        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        // The section *is* the list: every element becomes a `###` entry.
92        (Value::Sequence(items), Some(s)) => {
93            for item in items {
94                render_entry(out, item, s.key_field);
95            }
96        }
97        // The section is a mapping that holds a list under a named field.
98        (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        // A plain field bag: `stop_gates`, `constraints`, `skills`.
119        (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
131/// One `###` entry: its key field becomes the heading, the rest become bullets.
132fn 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            // A nested list of objects has no bullet form that survives a
169            // round trip, so it is emitted as inline flow — still valid YAML,
170            // which is exactly what the parser feeds a scalar to.
171            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
178/// A string that YAML would read back as something other than a string has to
179/// be quoted; everything else is written bare so prose stays readable.
180fn 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
213/// YAML flow style, borrowed from JSON — JSON is a subset of YAML, so this
214/// round-trips through the parser's scalar reader unchanged.
215fn 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
225/// Empty collections and nulls are omitted: a config full of `- skills: []` is
226/// noise, and the defaults put them back on the way in.
227///
228/// An empty **string** is not blank. `value: ""` is a value the author chose,
229/// and on a required field dropping it produces a document that no longer
230/// parses — which is exactly what happened to `information[].value` before this
231/// distinction existed.
232fn 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        // Serialise a default-ish config and check that every key it produces
248        // has somewhere to go. Without this, adding a section to `LoopConfig`
249        // and forgetting `SECTIONS` loses it silently on the markdown path.
250        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}