Skip to main content

batuta/playbook/
parser.rs

1//! Playbook YAML parsing and structural validation (PB-001)
2//!
3//! Parses YAML into typed `Playbook` and validates structural constraints:
4//! - version must be "1.0"
5//! - stages must have non-empty cmd
6//! - after references must point to valid stage names
7//! - param template references must resolve
8
9use super::types::*;
10use anyhow::{bail, Context, Result};
11use std::path::Path;
12
13/// Parse a playbook from a YAML file path
14pub fn parse_playbook_file(path: &Path) -> Result<Playbook> {
15    let content = std::fs::read_to_string(path)
16        .with_context(|| format!("failed to read {}", path.display()))?;
17    parse_playbook(&content).with_context(|| format!("failed to parse {}", path.display()))
18}
19
20/// Parse a playbook from a YAML string
21pub fn parse_playbook(yaml: &str) -> Result<Playbook> {
22    let pb: Playbook = serde_yaml_ng::from_str(yaml).context("invalid playbook YAML")?;
23    Ok(pb)
24}
25
26/// Validate a parsed playbook, returning warnings for non-fatal issues
27pub fn validate_playbook(pb: &Playbook) -> Result<Vec<ValidationWarning>> {
28    if pb.version != "1.0" {
29        bail!("unsupported playbook version '{}', expected '1.0'", pb.version);
30    }
31    if pb.name.is_empty() {
32        bail!("playbook name must not be empty");
33    }
34    if pb.stages.is_empty() {
35        bail!("playbook must have at least one stage");
36    }
37
38    let mut warnings = Vec::new();
39    for (name, stage) in &pb.stages {
40        validate_stage(name, stage, pb, &mut warnings)?;
41    }
42    Ok(warnings)
43}
44
45/// Validate a single stage within a playbook.
46fn validate_stage(
47    name: &str,
48    stage: &Stage,
49    pb: &Playbook,
50    warnings: &mut Vec<ValidationWarning>,
51) -> Result<()> {
52    if stage.cmd.trim().is_empty() {
53        bail!("stage '{}' has empty cmd", name);
54    }
55
56    for after_ref in &stage.after {
57        if !pb.stages.contains_key(after_ref) {
58            bail!("stage '{}' references unknown stage '{}' in after", name, after_ref);
59        }
60        if after_ref == name {
61            bail!("stage '{}' references itself in after", name);
62        }
63    }
64
65    if let Some(target_ref) = &stage.target {
66        if !pb.targets.contains_key(target_ref) && !target_ref.is_empty() {
67            warnings.push(ValidationWarning {
68                message: format!(
69                    "stage '{}' references target '{}' which is not defined in targets",
70                    name, target_ref
71                ),
72            });
73        }
74    }
75
76    validate_template_refs(&stage.cmd, &pb.params, &stage.deps, &stage.outs)
77        .with_context(|| format!("stage '{}' cmd template error", name))?;
78
79    if stage.outs.is_empty() {
80        warnings.push(ValidationWarning {
81            message: format!("stage '{}' has no outputs — will always re-run (no cache key)", name),
82        });
83    }
84
85    Ok(())
86}
87
88/// Validate a single template reference (e.g. "params.model", "deps[0].path")
89fn validate_single_ref(
90    ref_str: &str,
91    global_params: &std::collections::HashMap<String, serde_yaml_ng::Value>,
92    deps: &[Dependency],
93    outs: &[Output],
94) -> Result<()> {
95    if let Some(key) = ref_str.strip_prefix("params.") {
96        if !global_params.contains_key(key) {
97            bail!("template references undefined param '{}'", key);
98        }
99    } else if let Some(idx_str) =
100        ref_str.strip_prefix("deps[").and_then(|s| s.strip_suffix("].path"))
101    {
102        let idx: usize =
103            idx_str.parse().with_context(|| format!("invalid deps index '{}'", idx_str))?;
104        if idx >= deps.len() {
105            bail!("template references deps[{}] but only {} deps defined", idx, deps.len());
106        }
107    } else if let Some(idx_str) =
108        ref_str.strip_prefix("outs[").and_then(|s| s.strip_suffix("].path"))
109    {
110        let idx: usize =
111            idx_str.parse().with_context(|| format!("invalid outs index '{}'", idx_str))?;
112        if idx >= outs.len() {
113            bail!("template references outs[{}] but only {} outs defined", idx, outs.len());
114        }
115    }
116    Ok(())
117}
118
119/// Validate template references without resolving them (UTF-8 safe)
120fn validate_template_refs(
121    cmd: &str,
122    global_params: &std::collections::HashMap<String, serde_yaml_ng::Value>,
123    deps: &[Dependency],
124    outs: &[Output],
125) -> Result<()> {
126    let mut pos = 0;
127
128    while pos < cmd.len() {
129        if cmd[pos..].starts_with("{{") {
130            let start = pos + 2;
131            if let Some(end_offset) = cmd[start..].find("}}") {
132                let ref_str = cmd[start..start + end_offset].trim();
133                validate_single_ref(ref_str, global_params, deps, outs)?;
134                pos = start + end_offset + 2;
135            } else {
136                pos += 2;
137            }
138        } else {
139            let ch = cmd[pos..].chars().next().expect("iterator empty");
140            pos += ch.len_utf8();
141        }
142    }
143    Ok(())
144}
145
146#[cfg(test)]
147#[allow(non_snake_case)]
148mod tests {
149    use super::*;
150
151    fn minimal_yaml() -> String {
152        r#"
153version: "1.0"
154name: test
155params: {}
156targets: {}
157stages:
158  hello:
159    cmd: "echo hello"
160    deps: []
161    outs:
162      - path: /tmp/out.txt
163policy:
164  failure: stop_on_first
165  validation: checksum
166  lock_file: true
167"#
168        .to_string()
169    }
170
171    #[test]
172    fn test_PB001_parse_valid_playbook() {
173        let pb = parse_playbook(&minimal_yaml()).expect("unexpected failure");
174        assert_eq!(pb.version, "1.0");
175        assert_eq!(pb.name, "test");
176        assert_eq!(pb.stages.len(), 1);
177    }
178
179    #[test]
180    fn test_PB001_validate_valid_playbook() {
181        let pb = parse_playbook(&minimal_yaml()).expect("unexpected failure");
182        let warnings = validate_playbook(&pb).expect("unexpected failure");
183        assert!(warnings.is_empty());
184    }
185
186    #[test]
187    fn test_PB001_reject_bad_version() {
188        let yaml = minimal_yaml().replace("\"1.0\"", "\"2.0\"");
189        let pb = parse_playbook(&yaml).expect("unexpected failure");
190        let err = validate_playbook(&pb).unwrap_err();
191        assert!(err.to_string().contains("unsupported playbook version"));
192    }
193
194    #[test]
195    fn test_PB001_reject_empty_name() {
196        let yaml = minimal_yaml().replace("name: test", "name: \"\"");
197        let pb = parse_playbook(&yaml).expect("unexpected failure");
198        let err = validate_playbook(&pb).unwrap_err();
199        assert!(err.to_string().contains("name must not be empty"));
200    }
201
202    #[test]
203    fn test_PB001_reject_empty_cmd() {
204        let yaml = minimal_yaml().replace("echo hello", "  ");
205        let pb = parse_playbook(&yaml).expect("unexpected failure");
206        let err = validate_playbook(&pb).unwrap_err();
207        assert!(err.to_string().contains("empty cmd"));
208    }
209
210    #[test]
211    fn test_PB001_reject_invalid_after_ref() {
212        let yaml = r#"
213version: "1.0"
214name: test
215params: {}
216targets: {}
217stages:
218  hello:
219    cmd: "echo hello"
220    deps: []
221    outs:
222      - path: /tmp/out.txt
223    after:
224      - nonexistent
225policy:
226  failure: stop_on_first
227  validation: checksum
228  lock_file: true
229"#;
230        let pb = parse_playbook(yaml).expect("unexpected failure");
231        let err = validate_playbook(&pb).unwrap_err();
232        assert!(err.to_string().contains("unknown stage 'nonexistent'"));
233    }
234
235    #[test]
236    fn test_PB001_reject_self_reference() {
237        let yaml = r#"
238version: "1.0"
239name: test
240params: {}
241targets: {}
242stages:
243  hello:
244    cmd: "echo hello"
245    deps: []
246    outs:
247      - path: /tmp/out.txt
248    after:
249      - hello
250policy:
251  failure: stop_on_first
252  validation: checksum
253  lock_file: true
254"#;
255        let pb = parse_playbook(yaml).expect("unexpected failure");
256        let err = validate_playbook(&pb).unwrap_err();
257        assert!(err.to_string().contains("references itself"));
258    }
259
260    #[test]
261    fn test_PB001_warn_missing_outs() {
262        let yaml = r#"
263version: "1.0"
264name: test
265params: {}
266targets: {}
267stages:
268  hello:
269    cmd: "echo hello"
270    deps: []
271    outs: []
272policy:
273  failure: stop_on_first
274  validation: checksum
275  lock_file: true
276"#;
277        let pb = parse_playbook(yaml).expect("unexpected failure");
278        let warnings = validate_playbook(&pb).expect("unexpected failure");
279        assert_eq!(warnings.len(), 1);
280        assert!(warnings[0].message.contains("no outputs"));
281    }
282
283    #[test]
284    fn test_PB001_reject_undefined_param_ref() {
285        let yaml = r#"
286version: "1.0"
287name: test
288params: {}
289targets: {}
290stages:
291  hello:
292    cmd: "echo {{params.missing_key}}"
293    deps: []
294    outs:
295      - path: /tmp/out.txt
296policy:
297  failure: stop_on_first
298  validation: checksum
299  lock_file: true
300"#;
301        let pb = parse_playbook(yaml).expect("unexpected failure");
302        let err = validate_playbook(&pb).unwrap_err();
303        let msg = format!("{:#}", err);
304        assert!(msg.contains("undefined param"), "error was: {}", msg);
305    }
306
307    #[test]
308    fn test_PB001_accept_valid_param_ref() {
309        let yaml = r#"
310version: "1.0"
311name: test
312params:
313  model: "base"
314targets: {}
315stages:
316  hello:
317    cmd: "echo {{params.model}}"
318    deps: []
319    outs:
320      - path: /tmp/out.txt
321policy:
322  failure: stop_on_first
323  validation: checksum
324  lock_file: true
325"#;
326        let pb = parse_playbook(yaml).expect("unexpected failure");
327        let warnings = validate_playbook(&pb).expect("unexpected failure");
328        assert!(warnings.is_empty());
329    }
330
331    #[test]
332    fn test_PB001_reject_out_of_range_deps_ref() {
333        let yaml = r#"
334version: "1.0"
335name: test
336params: {}
337targets: {}
338stages:
339  hello:
340    cmd: "cat {{deps[5].path}}"
341    deps: []
342    outs:
343      - path: /tmp/out.txt
344policy:
345  failure: stop_on_first
346  validation: checksum
347  lock_file: true
348"#;
349        let pb = parse_playbook(yaml).expect("unexpected failure");
350        let err = validate_playbook(&pb).unwrap_err();
351        let msg = format!("{:#}", err);
352        assert!(msg.contains("deps[5]"), "error was: {}", msg);
353    }
354
355    #[test]
356    fn test_PB001_parse_invalid_yaml() {
357        let err = parse_playbook("not: valid: yaml: [[[").unwrap_err();
358        assert!(err.to_string().contains("invalid playbook YAML"));
359    }
360
361    #[test]
362    fn test_PB001_multistage_playbook() {
363        let yaml = r#"
364version: "1.0"
365name: multi
366params:
367  model: base
368targets: {}
369stages:
370  extract:
371    cmd: "extract --model {{params.model}}"
372    deps:
373      - path: /data/input.wav
374    outs:
375      - path: /data/audio.wav
376  transcribe:
377    cmd: "transcribe {{deps[0].path}}"
378    deps:
379      - path: /data/audio.wav
380    outs:
381      - path: /data/text.txt
382    after:
383      - extract
384policy:
385  failure: stop_on_first
386  validation: checksum
387  lock_file: true
388"#;
389        let pb = parse_playbook(yaml).expect("unexpected failure");
390        let warnings = validate_playbook(&pb).expect("unexpected failure");
391        assert!(warnings.is_empty());
392        assert_eq!(pb.stages.len(), 2);
393    }
394}