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