batuta/playbook/
parser.rs1use super::types::*;
10use anyhow::{bail, Context, Result};
11use std::path::Path;
12
13pub 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
20pub 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
26pub 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
45fn 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
88fn 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
119fn 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}