Skip to main content

loopsmith_core/config/
guidelines.rs

1//! Section I — execution guidelines.
2//!
3//! A guideline is a **phase**: a named stretch of the run with its own standing
4//! instruction, and its own place in an ordering. Nodes opt into a phase with
5//! `stage:`, and a node's phase must be active before it is dispatched.
6//!
7//! This is the layer the execution graph deliberately does not have. `graph`
8//! edges mean "this node reads that node's output" — a data dependency, and
9//! nothing else. Phases express the other kind of ordering, the one that is
10//! about method rather than data: *gather before you draft*, *land the tests
11//! before you refactor*. Overloading `depends_on` with both would make the
12//! critical path meaningless, because half the edges would not be real work
13//! dependencies at all.
14//!
15//! Ordering is written as arrows, because the thing being described is an
16//! ordering and a list of `depends_on` arrays reads like a data structure:
17//!
18//! ```yaml
19//! execution_guidelines:
20//!   items:
21//!     - name: gather
22//!       guideline: Collect sources. Write nothing yet.
23//!     - name: draft
24//!       guideline: Write only from what `gather` collected.
25//!   dependency:
26//!     - gather -> draft -> review
27//! ```
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
33#[serde(deny_unknown_fields)]
34pub struct ExecutionGuidelines {
35    #[serde(default)]
36    pub items: Vec<Guideline>,
37    /// Ordering, one chain per entry: `a -> b`, or `a -> b -> c`.
38    ///
39    /// Anything not named here has no predecessor and starts immediately, so
40    /// two guidelines with no arrow between them run in parallel. That is the
41    /// default on purpose: sequencing should be something you asked for.
42    #[serde(default)]
43    pub dependency: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
47#[serde(deny_unknown_fields)]
48pub struct Guideline {
49    pub name: String,
50    /// The standing instruction for this phase, injected into the prompt of
51    /// every node that declares `stage: <name>`.
52    pub guideline: String,
53    /// Optional note for the human reading the config.
54    #[serde(default)]
55    pub note: Option<String>,
56}
57
58/// A guideline resolved into a DAG node: its own name plus the phases it waits
59/// on, derived from the arrow list.
60#[derive(Debug, Clone, PartialEq)]
61pub struct Phase {
62    pub name: String,
63    pub guideline: String,
64    pub depends_on: Vec<String>,
65}
66
67impl ExecutionGuidelines {
68    pub fn is_empty(&self) -> bool {
69        self.items.is_empty()
70    }
71
72    pub fn get(&self, name: &str) -> Option<&Guideline> {
73        self.items.iter().find(|g| g.name == name)
74    }
75
76    pub fn names(&self) -> Vec<&str> {
77        self.items.iter().map(|g| g.name.as_str()).collect()
78    }
79
80    /// Every edge the arrow list declares, in order.
81    ///
82    /// Errors describe the offending line rather than the offending character,
83    /// because the author is looking at a line.
84    pub fn edges(&self) -> Result<Vec<(String, String)>, String> {
85        let mut out = Vec::new();
86        for line in &self.dependency {
87            out.extend(parse_chain(line)?);
88        }
89        Ok(out)
90    }
91
92    /// Resolve guidelines and arrows into DAG nodes.
93    ///
94    /// Does not check for cycles or unknown names — that is the scheduler's
95    /// job (it already has Kahn's algorithm) and the validator's.
96    pub fn phases(&self) -> Result<Vec<Phase>, String> {
97        let edges = self.edges()?;
98        Ok(self
99            .items
100            .iter()
101            .map(|g| Phase {
102                name: g.name.clone(),
103                guideline: g.guideline.clone(),
104                depends_on: edges
105                    .iter()
106                    .filter(|(_, to)| *to == g.name)
107                    .map(|(from, _)| from.clone())
108                    .collect(),
109            })
110            .collect())
111    }
112}
113
114/// `a -> b -> c` becomes `[(a, b), (b, c)]`.
115pub fn parse_chain(line: &str) -> Result<Vec<(String, String)>, String> {
116    let parts: Vec<&str> = line.split("->").map(str::trim).collect();
117    if parts.len() < 2 {
118        return Err(format!(
119            "`{line}` is not an ordering; write it as `earlier -> later`"
120        ));
121    }
122    if let Some(blank) = parts.iter().position(|p| p.is_empty()) {
123        return Err(format!(
124            "`{line}` has an empty name at position {}; every `->` needs a guideline on both sides",
125            blank + 1
126        ));
127    }
128    Ok(parts
129        .windows(2)
130        .map(|w| (w[0].to_string(), w[1].to_string()))
131        .collect())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn guidelines(items: &[&str], dependency: &[&str]) -> ExecutionGuidelines {
139        ExecutionGuidelines {
140            items: items
141                .iter()
142                .map(|n| Guideline {
143                    name: n.to_string(),
144                    guideline: format!("do the {n} work"),
145                    note: None,
146                })
147                .collect(),
148            dependency: dependency.iter().map(|s| s.to_string()).collect(),
149        }
150    }
151
152    #[test]
153    fn a_two_name_arrow_is_one_edge() {
154        assert_eq!(
155            parse_chain("gather -> draft").unwrap(),
156            vec![("gather".into(), "draft".into())]
157        );
158    }
159
160    #[test]
161    fn a_chain_becomes_consecutive_edges() {
162        assert_eq!(
163            parse_chain("a -> b -> c").unwrap(),
164            vec![("a".into(), "b".into()), ("b".into(), "c".into())]
165        );
166    }
167
168    #[test]
169    fn spacing_around_the_arrow_does_not_matter() {
170        assert_eq!(parse_chain("a->b").unwrap(), parse_chain("a  ->  b").unwrap());
171    }
172
173    #[test]
174    fn a_line_without_an_arrow_is_refused() {
175        let err = parse_chain("gather").unwrap_err();
176        assert!(err.contains("earlier -> later"), "got: {err}");
177    }
178
179    #[test]
180    fn a_dangling_arrow_is_refused() {
181        for line in ["a ->", "-> b", "a -> -> c"] {
182            let err = parse_chain(line).unwrap_err();
183            assert!(err.contains("empty name"), "for `{line}`, got: {err}");
184        }
185    }
186
187    #[test]
188    fn phases_carry_the_predecessors_the_arrows_named() {
189        let g = guidelines(&["gather", "draft", "review"], &["gather -> draft -> review"]);
190        let phases = g.phases().unwrap();
191        assert_eq!(phases[0].depends_on, Vec::<String>::new());
192        assert_eq!(phases[1].depends_on, vec!["gather"]);
193        assert_eq!(phases[2].depends_on, vec!["draft"]);
194    }
195
196    #[test]
197    fn guidelines_with_no_arrow_between_them_are_independent() {
198        // Two unrelated phases must not acquire an accidental ordering just by
199        // being written one after the other.
200        let g = guidelines(&["seo", "social"], &[]);
201        let phases = g.phases().unwrap();
202        assert!(phases.iter().all(|p| p.depends_on.is_empty()));
203    }
204
205    #[test]
206    fn one_phase_can_wait_on_several() {
207        let g = guidelines(
208            &["a", "b", "publish"],
209            &["a -> publish", "b -> publish"],
210        );
211        let publish = &g.phases().unwrap()[2];
212        assert_eq!(publish.depends_on, vec!["a", "b"]);
213    }
214}