Skip to main content

edda_conductor/plan/
parser.rs

1use crate::plan::schema::{CheckSpec, Plan};
2use anyhow::{bail, Context, Result};
3use std::path::Path;
4
5/// Load and validate a plan from a YAML file.
6pub 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
12/// Parse and validate a plan from a YAML string.
13pub fn parse_plan(yaml: &str) -> Result<Plan> {
14    // Step 1: Parse into raw Value for short-format normalization
15    let mut raw: serde_yml::Value = serde_yml::from_str(yaml).context("invalid YAML syntax")?;
16
17    // Step 2: Normalize short-format checks
18    normalize_checks(&mut raw)?;
19
20    // Step 3: Expand variables
21    expand_variables(&mut raw);
22
23    // Step 4: Deserialize into typed Plan
24    let plan: Plan = serde_yml::from_value(raw).context("plan schema validation failed")?;
25
26    // Step 5: Validate constraints
27    validate_plan(&plan)?;
28
29    Ok(plan)
30}
31
32/// Normalize short-format checks into tagged objects.
33///
34/// Short: `cmd_succeeds: "cargo test"`
35/// Long:  `{ type: cmd_succeeds, cmd: "cargo test" }`
36fn 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
57/// Try to normalize a single short-format check.
58/// Returns Some(normalized) if it was short format, None if already tagged.
59fn 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    // Already tagged format (has "type" key)
66    if map.contains_key(serde_yml::Value::String("type".into())) {
67        return Ok(None);
68    }
69
70    // Short format: single key = check type, value = argument
71    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            // file_contains is a map: { path: ..., pattern: ... }
113            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            // git_clean: true  or  git_clean: { allow_untracked: true }
122            if let Some(m) = value.as_mapping() {
123                for (k, v) in m {
124                    out.insert(k.clone(), v.clone());
125                }
126            }
127            // git_clean: true → no extra fields needed
128        }
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
155/// Expand `${{ env.VAR_NAME }}` patterns in string values.
156fn 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
177/// Replace `${{ env.VAR_NAME }}` with the environment variable value.
178fn 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                // Unknown expression — keep as-is
192                result.push_str(&rest[start..start + 3 + end + 2]);
193            }
194            rest = &after_start[end + 2..];
195        } else {
196            // Unclosed — keep as-is
197            result.push_str(&rest[start..]);
198            rest = "";
199        }
200    }
201    result.push_str(rest);
202    result
203}
204
205/// Validate plan constraints that can't be expressed in serde.
206fn validate_plan(plan: &Plan) -> Result<()> {
207    // Rule 1: name must be kebab-case
208    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    // Rule 2: at least one phase
216    if plan.phases.is_empty() {
217        bail!("plan must have at least one phase");
218    }
219
220    // Rule 3: phase IDs must be unique
221    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    // Rule 4: depends_on must reference existing phase IDs
229    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    // Rule 5: no cycles (delegated to topo module, checked separately)
241    // Rule 6: wait_until cannot nest another wait_until
242    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    // Must start with lowercase letter or digit
265    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}