edda_conductor/plan/
parser.rs1use crate::plan::schema::{CheckSpec, Plan};
2use anyhow::{bail, Context, Result};
3use std::path::Path;
4
5pub fn load_plan(path: &Path) -> Result<Plan> {
7 let content =
8 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
9 parse_plan(&content)
10}
11
12pub fn parse_plan(yaml: &str) -> Result<Plan> {
14 let mut raw: serde_yml::Value = serde_yml::from_str(yaml).context("invalid YAML syntax")?;
16
17 normalize_checks(&mut raw)?;
19
20 expand_variables(&mut raw);
22
23 let plan: Plan = serde_yml::from_value(raw).context("plan schema validation failed")?;
25
26 validate_plan(&plan)?;
28
29 Ok(plan)
30}
31
32fn normalize_checks(raw: &mut serde_yml::Value) -> Result<()> {
37 let phases = match raw.get_mut("phases") {
38 Some(serde_yml::Value::Sequence(seq)) => seq,
39 _ => return Ok(()),
40 };
41
42 for phase in phases.iter_mut() {
43 let checks = match phase.get_mut("check") {
44 Some(serde_yml::Value::Sequence(seq)) => seq,
45 _ => continue,
46 };
47
48 for check in checks.iter_mut() {
49 if let Some(normalized) = normalize_one_check(check)? {
50 *check = normalized;
51 }
52 }
53 }
54 Ok(())
55}
56
57fn normalize_one_check(check: &serde_yml::Value) -> Result<Option<serde_yml::Value>> {
60 let map = match check.as_mapping() {
61 Some(m) => m,
62 None => bail!("check must be a mapping, got: {check:?}"),
63 };
64
65 if map.contains_key(serde_yml::Value::String("type".into())) {
67 return Ok(None);
68 }
69
70 if map.len() != 1 {
72 bail!(
73 "short-format check must have exactly one key, got {}",
74 map.len()
75 );
76 }
77
78 let (key, value) = map
79 .iter()
80 .next()
81 .context("short-format check map is empty")?;
82 let key_str = key
83 .as_str()
84 .ok_or_else(|| anyhow::anyhow!("check key must be a string"))?;
85
86 let mut out = serde_yml::Mapping::new();
87 out.insert(
88 serde_yml::Value::String("type".into()),
89 serde_yml::Value::String(key_str.into()),
90 );
91
92 match key_str {
93 "cmd_succeeds" => {
94 let cmd = value
95 .as_str()
96 .ok_or_else(|| anyhow::anyhow!("cmd_succeeds value must be a string"))?;
97 out.insert(
98 serde_yml::Value::String("cmd".into()),
99 serde_yml::Value::String(cmd.into()),
100 );
101 }
102 "file_exists" => {
103 let path = value
104 .as_str()
105 .ok_or_else(|| anyhow::anyhow!("file_exists value must be a string"))?;
106 out.insert(
107 serde_yml::Value::String("path".into()),
108 serde_yml::Value::String(path.into()),
109 );
110 }
111 "file_contains" => {
112 let inner = value
114 .as_mapping()
115 .ok_or_else(|| anyhow::anyhow!("file_contains value must be a mapping"))?;
116 for (k, v) in inner {
117 out.insert(k.clone(), v.clone());
118 }
119 }
120 "git_clean" => {
121 if let Some(m) = value.as_mapping() {
123 for (k, v) in m {
124 out.insert(k.clone(), v.clone());
125 }
126 }
127 }
129 "edda_event" => {
130 if let Some(m) = value.as_mapping() {
131 for (k, v) in m {
132 out.insert(k.clone(), v.clone());
133 }
134 } else {
135 let et = value
136 .as_str()
137 .ok_or_else(|| anyhow::anyhow!("edda_event value must be string or mapping"))?;
138 out.insert(
139 serde_yml::Value::String("event_type".into()),
140 serde_yml::Value::String(et.into()),
141 );
142 }
143 }
144 other => {
145 bail!(
146 "unknown check type: \"{other}\". Valid types: cmd_succeeds, file_exists, \
147 file_contains, git_clean, edda_event, wait_until"
148 );
149 }
150 }
151
152 Ok(Some(serde_yml::Value::Mapping(out)))
153}
154
155fn expand_variables(value: &mut serde_yml::Value) {
157 match value {
158 serde_yml::Value::String(s) => {
159 if s.contains("${{") {
160 *s = expand_env_vars(s);
161 }
162 }
163 serde_yml::Value::Mapping(m) => {
164 for (_, v) in m.iter_mut() {
165 expand_variables(v);
166 }
167 }
168 serde_yml::Value::Sequence(seq) => {
169 for v in seq.iter_mut() {
170 expand_variables(v);
171 }
172 }
173 _ => {}
174 }
175}
176
177fn expand_env_vars(s: &str) -> String {
179 let mut result = String::with_capacity(s.len());
180 let mut rest = s;
181
182 while let Some(start) = rest.find("${{") {
183 result.push_str(&rest[..start]);
184 let after_start = &rest[start + 3..];
185 if let Some(end) = after_start.find("}}") {
186 let expr = after_start[..end].trim();
187 if let Some(var_name) = expr.strip_prefix("env.") {
188 let val = std::env::var(var_name.trim()).unwrap_or_default();
189 result.push_str(&val);
190 } else {
191 result.push_str(&rest[start..start + 3 + end + 2]);
193 }
194 rest = &after_start[end + 2..];
195 } else {
196 result.push_str(&rest[start..]);
198 rest = "";
199 }
200 }
201 result.push_str(rest);
202 result
203}
204
205fn validate_plan(plan: &Plan) -> Result<()> {
207 if !is_kebab_case(&plan.name) {
209 bail!(
210 "plan name must be kebab-case (lowercase letters, digits, hyphens), got: \"{}\"",
211 plan.name
212 );
213 }
214
215 if plan.phases.is_empty() {
217 bail!("plan must have at least one phase");
218 }
219
220 let mut seen_ids = std::collections::HashSet::new();
222 for phase in &plan.phases {
223 if !seen_ids.insert(phase.id.as_str()) {
224 bail!("duplicate phase id: \"{}\"", phase.id);
225 }
226 }
227
228 for phase in &plan.phases {
230 for dep in &phase.depends_on {
231 if !seen_ids.contains(dep.as_str()) {
232 bail!(
233 "phase \"{}\" depends on \"{dep}\" which does not exist",
234 phase.id
235 );
236 }
237 }
238 }
239
240 for phase in &plan.phases {
243 for check in &phase.check {
244 validate_check_nesting(check)?;
245 }
246 }
247
248 Ok(())
249}
250
251fn validate_check_nesting(check: &CheckSpec) -> Result<()> {
252 if let CheckSpec::WaitUntil { check: inner, .. } = check {
253 if matches!(inner.as_ref(), CheckSpec::WaitUntil { .. }) {
254 bail!("wait_until cannot nest another wait_until");
255 }
256 }
257 Ok(())
258}
259
260fn is_kebab_case(s: &str) -> bool {
261 if s.is_empty() {
262 return false;
263 }
264 let first = s.as_bytes()[0];
266 if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
267 return false;
268 }
269 s.bytes()
270 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::plan::schema::OnFail;
277
278 #[test]
279 fn parse_minimal_plan() {
280 let yaml = r#"
281name: test
282phases:
283 - id: one
284 prompt: "hello"
285"#;
286 let plan = parse_plan(yaml).unwrap();
287 assert_eq!(plan.name, "test");
288 assert_eq!(plan.phases.len(), 1);
289 }
290
291 #[test]
292 fn short_format_cmd_succeeds() {
293 let yaml = r#"
294name: test
295phases:
296 - id: build
297 prompt: "build"
298 check:
299 - cmd_succeeds: "cargo build"
300"#;
301 let plan = parse_plan(yaml).unwrap();
302 assert!(matches!(
303 &plan.phases[0].check[0],
304 CheckSpec::CmdSucceeds { cmd, .. } if cmd == "cargo build"
305 ));
306 }
307
308 #[test]
309 fn short_format_file_exists() {
310 let yaml = r#"
311name: test
312phases:
313 - id: one
314 prompt: "x"
315 check:
316 - file_exists: "src/main.rs"
317"#;
318 let plan = parse_plan(yaml).unwrap();
319 assert!(matches!(
320 &plan.phases[0].check[0],
321 CheckSpec::FileExists { path } if path == "src/main.rs"
322 ));
323 }
324
325 #[test]
326 fn short_format_file_contains() {
327 let yaml = r#"
328name: test
329phases:
330 - id: one
331 prompt: "x"
332 check:
333 - file_contains:
334 path: "Cargo.toml"
335 pattern: "edda-core"
336"#;
337 let plan = parse_plan(yaml).unwrap();
338 assert!(matches!(
339 &plan.phases[0].check[0],
340 CheckSpec::FileContains { path, pattern }
341 if path == "Cargo.toml" && pattern == "edda-core"
342 ));
343 }
344
345 #[test]
346 fn short_format_git_clean() {
347 let yaml = r#"
348name: test
349phases:
350 - id: one
351 prompt: "x"
352 check:
353 - git_clean: true
354"#;
355 let plan = parse_plan(yaml).unwrap();
356 assert!(matches!(
357 &plan.phases[0].check[0],
358 CheckSpec::GitClean {
359 allow_untracked: false
360 }
361 ));
362 }
363
364 #[test]
365 fn tagged_format_passes_through() {
366 let yaml = r#"
367name: test
368phases:
369 - id: one
370 prompt: "x"
371 check:
372 - type: cmd_succeeds
373 cmd: "cargo test"
374 timeout_sec: 300
375"#;
376 let plan = parse_plan(yaml).unwrap();
377 assert!(matches!(
378 &plan.phases[0].check[0],
379 CheckSpec::CmdSucceeds { cmd, timeout_sec: 300 } if cmd == "cargo test"
380 ));
381 }
382
383 #[test]
384 fn variable_expansion() {
385 std::env::set_var("EDDA_TEST_VAR_12345", "hello-world");
386 let yaml = r#"
387name: test
388phases:
389 - id: one
390 prompt: "x"
391 env:
392 MY_VAR: "${{ env.EDDA_TEST_VAR_12345 }}"
393"#;
394 let plan = parse_plan(yaml).unwrap();
395 assert_eq!(plan.phases[0].env.get("MY_VAR").unwrap(), "hello-world");
396 std::env::remove_var("EDDA_TEST_VAR_12345");
397 }
398
399 #[test]
400 fn reject_non_kebab_name() {
401 let yaml = r#"
402name: MyPlan
403phases:
404 - id: one
405 prompt: "x"
406"#;
407 let err = parse_plan(yaml).unwrap_err();
408 assert!(err.to_string().contains("kebab-case"));
409 }
410
411 #[test]
412 fn reject_empty_phases() {
413 let yaml = r#"
414name: test
415phases: []
416"#;
417 let err = parse_plan(yaml).unwrap_err();
418 assert!(err.to_string().contains("at least one phase"));
419 }
420
421 #[test]
422 fn reject_duplicate_phase_id() {
423 let yaml = r#"
424name: test
425phases:
426 - id: same
427 prompt: "a"
428 - id: same
429 prompt: "b"
430"#;
431 let err = parse_plan(yaml).unwrap_err();
432 assert!(err.to_string().contains("duplicate"));
433 }
434
435 #[test]
436 fn reject_unknown_dependency() {
437 let yaml = r#"
438name: test
439phases:
440 - id: one
441 prompt: "x"
442 depends_on: [nonexistent]
443"#;
444 let err = parse_plan(yaml).unwrap_err();
445 assert!(err.to_string().contains("nonexistent"));
446 }
447
448 #[test]
449 fn reject_unknown_check_type() {
450 let yaml = r#"
451name: test
452phases:
453 - id: one
454 prompt: "x"
455 check:
456 - magic_check: "foo"
457"#;
458 let err = parse_plan(yaml).unwrap_err();
459 assert!(err.to_string().contains("unknown check type"));
460 }
461
462 #[test]
463 fn reject_nested_wait_until() {
464 let yaml = r#"
465name: test
466phases:
467 - id: one
468 prompt: "x"
469 check:
470 - type: wait_until
471 interval_sec: 5
472 timeout_sec: 30
473 check:
474 type: wait_until
475 interval_sec: 1
476 timeout_sec: 10
477 check:
478 type: file_exists
479 path: "x"
480"#;
481 let err = parse_plan(yaml).unwrap_err();
482 assert!(err.to_string().contains("wait_until cannot nest"));
483 }
484
485 #[test]
486 fn on_fail_variants_deserialize() {
487 for (input, expected) in [
488 ("auto_retry", OnFail::AutoRetry),
489 ("ask", OnFail::Ask),
490 ("skip", OnFail::Skip),
491 ("abort", OnFail::Abort),
492 ] {
493 let yaml = format!("name: test\non_fail: {input}\nphases:\n - id: a\n prompt: x\n");
494 let plan = parse_plan(&yaml).unwrap();
495 assert_eq!(plan.on_fail, expected, "failed for {input}");
496 }
497 }
498}