loopsmith_core/md/mod.rs
1//! Markdown-native config: the same A–J model, written as a document.
2//!
3//! A `.md` config is not YAML wearing a markdown hat. Headings are sections,
4//! `###` headings are entries, bullets are fields, and any prose at column 0 is
5//! documentation that the parser ignores. That means a loop config can explain
6//! itself in place — the reason a goal exists sits next to the goal.
7//!
8//! # Shape
9//!
10//! ```markdown
11//! # my-loop
12//!
13//! - version: 0.1.0
14//! - description: what this loop is for
15//!
16//! Prose at the left margin is ignored. Put the reasoning here.
17//!
18//! ## C. Goals
19//!
20//! ### ship-it
21//! - description: the thing is shipped and the suite is green
22//! - priority: 1
23//!
24//! ## F. Stop gates
25//! - max_iterations: 12
26//! - max_cost_usd: 10.0
27//! ```
28//!
29//! # How it works
30//!
31//! The parser does **not** know about `Goal` or `StopGates`. It turns the
32//! document into a `serde_yaml::Value` and hands that to the same `Deserialize`
33//! impls the YAML path uses. Every default, alias, and `deny_unknown_fields`
34//! rule therefore applies identically, and a new config field needs no parser
35//! change at all.
36//!
37//! The renderer is the exact inverse, over `serde_yaml::to_value`. Round-trip
38//! is a property test rather than a hope.
39
40mod parse;
41mod render;
42
43pub use parse::parse_md;
44pub use render::render_md;
45
46/// Where a `###` heading's text goes, per section.
47///
48/// This is the only place the markdown layer knows anything section-specific,
49/// and it exists because `### ship-it` has to become `name: ship-it` for a goal
50/// but `id: ship-it` for a node. Sections absent from this table take no `###`
51/// entries — they are plain field bags like `stop_gates`.
52pub(crate) struct SectionShape {
53 /// Field inside the section that holds the list, or `None` when the
54 /// section *is* the list.
55 pub list_field: Option<&'static str>,
56 /// Field a `###` heading fills in.
57 pub key_field: &'static str,
58}
59
60pub(crate) fn section_shape(section: &str) -> Option<SectionShape> {
61 let (list_field, key_field) = match section {
62 "information" => (None, "key"),
63 "pre_execution" => (None, "step"),
64 "goals" => (None, "name"),
65 "validations" => (None, "name"),
66 "success" => (None, "name"),
67 "schedules" => (None, "type"),
68 "execution_guidelines" => (Some("items"), "name"),
69 "default_skills" => (None, "name"),
70 "graph" => (Some("nodes"), "id"),
71 "providers" => (Some("providers"), "id"),
72 _ => return None,
73 };
74 Some(SectionShape {
75 list_field,
76 key_field,
77 })
78}
79
80/// Normalise a heading into a config key: `A. Pre-execution` → `pre_execution`.
81///
82/// Section letters are navigation aids for humans, not part of the grammar, so
83/// they are stripped. Writing the raw key (`## pre_execution`) works too.
84pub(crate) fn heading_to_key(heading: &str) -> String {
85 let h = heading.trim();
86 // Drop a leading section letter: "A.", "B)", "J -", "10." all count.
87 let h = match h.find(['.', ')', '-']) {
88 Some(i) if i <= 2 && h[..i].chars().all(|c| c.is_ascii_alphanumeric()) => &h[i + 1..],
89 _ => h,
90 };
91 h.trim()
92 .to_lowercase()
93 .split_whitespace()
94 .collect::<Vec<_>>()
95 .join("_")
96 .replace('-', "_")
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn headings_normalise_to_config_keys() {
105 for (heading, key) in [
106 ("A. Information", "information"),
107 ("B. Pre-execution", "pre_execution"),
108 ("F. Stop gates", "stop_gates"),
109 ("I. Execution guidelines", "execution_guidelines"),
110 ("J. Default skills", "default_skills"),
111 ("Providers", "providers"),
112 ("stop_gates", "stop_gates"),
113 (" Graph ", "graph"),
114 ] {
115 assert_eq!(heading_to_key(heading), key, "for heading `{heading}`");
116 }
117 }
118
119 #[test]
120 fn a_hyphenated_word_is_not_mistaken_for_a_section_letter() {
121 // "Pre-execution" has a hyphen at index 3, past the letter window, so
122 // the whole word survives.
123 assert_eq!(heading_to_key("Pre-execution"), "pre_execution");
124 }
125}