Skip to main content

batuta/playbook/
types.rs

1//! Playbook types — all serde types from spec §7.2
2//!
3//! These types represent the YAML schema for deterministic pipeline orchestration.
4//! Types for Phase 2+ features (parallel, retry, resources, compliance) are defined
5//! here so YAML with those features parses correctly, but they are not executed
6//! until their respective phases.
7
8use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fmt;
12use std::path::PathBuf;
13
14// ============================================================================
15// Playbook root
16// ============================================================================
17
18/// Top-level playbook definition
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Playbook {
21    /// Schema version (must be "1.0")
22    pub version: String,
23
24    /// Human-readable pipeline name
25    pub name: String,
26
27    /// Optional description
28    #[serde(default)]
29    pub description: Option<String>,
30
31    /// Global parameters (key-value pairs, supports strings and numbers)
32    #[serde(default)]
33    pub params: HashMap<String, serde_yaml_ng::Value>,
34
35    /// Named execution targets (machines)
36    #[serde(default)]
37    pub targets: HashMap<String, Target>,
38
39    /// Pipeline stages (order-preserving)
40    pub stages: IndexMap<String, Stage>,
41
42    /// Compliance gates (parsed, not executed in Phase 1)
43    #[serde(default)]
44    pub compliance: Option<Compliance>,
45
46    /// Execution policy
47    #[serde(default)]
48    pub policy: Policy,
49}
50
51// ============================================================================
52// Stages
53// ============================================================================
54
55/// A single pipeline stage
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Stage {
58    /// Human-readable description
59    #[serde(default)]
60    pub description: Option<String>,
61
62    /// Shell command (supports template substitution)
63    pub cmd: String,
64
65    /// Input dependencies
66    #[serde(default)]
67    pub deps: Vec<Dependency>,
68
69    /// Output artifacts
70    #[serde(default)]
71    pub outs: Vec<Output>,
72
73    /// Explicit ordering constraints (stage names)
74    #[serde(default)]
75    pub after: Vec<String>,
76
77    /// Execution target name (from playbook.targets)
78    #[serde(default)]
79    pub target: Option<String>,
80
81    /// Param keys this stage depends on (for granular invalidation)
82    #[serde(default)]
83    pub params: Option<Vec<String>>,
84
85    /// Parallel fan-out configuration (Phase 2)
86    #[serde(default)]
87    pub parallel: Option<ParallelConfig>,
88
89    /// Retry configuration (Phase 2)
90    #[serde(default)]
91    pub retry: Option<RetryConfig>,
92
93    /// Resource requirements (Phase 4)
94    #[serde(default)]
95    pub resources: Option<ResourceConfig>,
96
97    /// Frozen stage — never re-execute (Phase 4)
98    #[serde(default)]
99    pub frozen: bool,
100
101    /// Shell mode override
102    #[serde(default)]
103    pub shell: Option<ShellMode>,
104}
105
106/// Input dependency reference
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Dependency {
109    /// File or directory path
110    pub path: String,
111
112    /// Dependency type (e.g., "file", "directory")
113    #[serde(rename = "type", default)]
114    pub dep_type: Option<String>,
115}
116
117/// Output artifact reference
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Output {
120    /// File or directory path
121    pub path: String,
122
123    /// Output type
124    #[serde(rename = "type", default)]
125    pub out_type: Option<String>,
126
127    /// Remote target name (if output is on a remote machine)
128    #[serde(default)]
129    pub remote: Option<String>,
130}
131
132// ============================================================================
133// Targets
134// ============================================================================
135
136/// Named execution target (machine)
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Target {
139    /// Hostname (e.g., "localhost", "gpu-box.local")
140    #[serde(default)]
141    pub host: Option<String>,
142
143    /// SSH user for remote targets
144    #[serde(default)]
145    pub ssh_user: Option<String>,
146
147    /// CPU cores available
148    #[serde(default)]
149    pub cores: Option<u32>,
150
151    /// Memory in GB
152    #[serde(default)]
153    pub memory_gb: Option<u32>,
154
155    /// Working directory on target
156    #[serde(default)]
157    pub workdir: Option<String>,
158
159    /// Environment variables
160    #[serde(default)]
161    pub env: HashMap<String, String>,
162}
163
164// ============================================================================
165// Policy
166// ============================================================================
167
168/// Failure handling policy
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171#[derive(Default)]
172pub enum FailurePolicy {
173    /// Stop pipeline on first stage failure (Jidoka)
174    #[default]
175    StopOnFirst,
176    /// Continue running independent stages
177    ContinueIndependent,
178}
179
180/// Validation policy
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183#[derive(Default)]
184pub enum ValidationPolicy {
185    /// BLAKE3 checksum validation
186    #[default]
187    Checksum,
188    /// No validation
189    None,
190}
191
192/// Concurrency policy for lock file access
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195#[derive(Default)]
196pub enum ConcurrencyPolicy {
197    /// Wait for lock
198    #[default]
199    Wait,
200    /// Fail if locked
201    Fail,
202}
203
204/// Execution policy configuration
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct Policy {
207    /// Failure handling strategy
208    #[serde(default)]
209    pub failure: FailurePolicy,
210
211    /// Validation strategy
212    #[serde(default)]
213    pub validation: ValidationPolicy,
214
215    /// Whether to maintain a lock file
216    #[serde(default = "Policy::default_lock_file")]
217    pub lock_file: bool,
218
219    /// Concurrency policy (Phase 2)
220    #[serde(default)]
221    pub concurrency: Option<ConcurrencyPolicy>,
222
223    /// Working directory isolation mode (Phase 2)
224    #[serde(default)]
225    pub work_dir: Option<PathBuf>,
226
227    /// Clean work directory on success (Phase 2)
228    #[serde(default)]
229    pub clean_on_success: Option<bool>,
230}
231
232impl Policy {
233    fn default_lock_file() -> bool {
234        true
235    }
236}
237
238impl Default for Policy {
239    fn default() -> Self {
240        Self {
241            failure: FailurePolicy::default(),
242            validation: ValidationPolicy::default(),
243            lock_file: Self::default_lock_file(),
244            concurrency: None,
245            work_dir: None,
246            clean_on_success: None,
247        }
248    }
249}
250
251// ============================================================================
252// Phase 2+ types (parsed, not executed)
253// ============================================================================
254
255/// Parallel fan-out configuration
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ParallelConfig {
258    /// Strategy (e.g., "per_file")
259    pub strategy: String,
260
261    /// Glob pattern for file discovery
262    #[serde(default)]
263    pub glob: Option<String>,
264
265    /// Maximum concurrent workers
266    #[serde(default)]
267    pub max_workers: Option<u32>,
268}
269
270/// Retry configuration
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct RetryConfig {
273    /// Maximum retry attempts
274    #[serde(default = "RetryConfig::default_limit")]
275    pub limit: u32,
276
277    /// Retry policy
278    #[serde(default = "RetryConfig::default_policy")]
279    pub policy: String,
280
281    /// Backoff configuration
282    #[serde(default)]
283    pub backoff: Option<BackoffConfig>,
284}
285
286impl RetryConfig {
287    fn default_limit() -> u32 {
288        3
289    }
290    fn default_policy() -> String {
291        "on_failure".to_string()
292    }
293}
294
295/// Exponential backoff configuration
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct BackoffConfig {
298    /// Initial delay in seconds
299    #[serde(default = "BackoffConfig::default_initial")]
300    pub initial_seconds: f64,
301
302    /// Backoff multiplier
303    #[serde(default = "BackoffConfig::default_multiplier")]
304    pub multiplier: f64,
305
306    /// Maximum delay in seconds
307    #[serde(default = "BackoffConfig::default_max")]
308    pub max_seconds: f64,
309}
310
311impl BackoffConfig {
312    fn default_initial() -> f64 {
313        1.0
314    }
315    fn default_multiplier() -> f64 {
316        2.0
317    }
318    fn default_max() -> f64 {
319        60.0
320    }
321}
322
323/// Resource requirements
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ResourceConfig {
326    /// CPU cores
327    #[serde(default)]
328    pub cores: Option<u32>,
329
330    /// Memory in GB
331    #[serde(default)]
332    pub memory_gb: Option<f64>,
333
334    /// GPU devices (0 = CPU only)
335    #[serde(default)]
336    pub gpu: Option<u32>,
337
338    /// Timeout in seconds
339    #[serde(default)]
340    pub timeout: Option<u64>,
341}
342
343/// Shell execution mode
344#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum ShellMode {
347    /// Purified via bashrs/rash (Phase 2)
348    Rash,
349    /// Raw sh -c execution
350    Raw,
351}
352
353// ============================================================================
354// Compliance (parsed, not executed in Phase 1)
355// ============================================================================
356
357/// PMAT compliance gate configuration
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct Compliance {
360    /// Gates to run before pipeline stages
361    #[serde(default)]
362    pub pre_flight: Vec<ComplianceCheck>,
363
364    /// Gates to run after pipeline stages
365    #[serde(default)]
366    pub post_flight: Vec<ComplianceCheck>,
367}
368
369/// A single compliance check
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct ComplianceCheck {
372    /// Check type (e.g., "tdg", "quality_gate", "coverage")
373    #[serde(rename = "type")]
374    pub check_type: String,
375
376    /// Minimum acceptable grade
377    #[serde(default)]
378    pub min_grade: Option<String>,
379
380    /// Target path
381    #[serde(default)]
382    pub path: Option<String>,
383
384    /// Minimum threshold value
385    #[serde(default)]
386    pub min: Option<f64>,
387}
388
389// ============================================================================
390// Lock file types
391// ============================================================================
392
393/// Lock file representing cached pipeline state
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct LockFile {
396    /// Schema version
397    pub schema: String,
398
399    /// Playbook name
400    pub playbook: String,
401
402    /// When the lock was generated
403    pub generated_at: String,
404
405    /// Generator version string
406    pub generator: String,
407
408    /// BLAKE3 version used
409    pub blake3_version: String,
410
411    /// Hash of global params
412    #[serde(default)]
413    pub params_hash: Option<String>,
414
415    /// Per-stage lock data (order-preserving for deterministic YAML output)
416    pub stages: IndexMap<String, StageLock>,
417}
418
419/// Per-stage lock data
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct StageLock {
422    /// Completion status
423    pub status: StageStatus,
424
425    /// When the stage started
426    #[serde(default)]
427    pub started_at: Option<String>,
428
429    /// When the stage completed
430    #[serde(default)]
431    pub completed_at: Option<String>,
432
433    /// Duration in seconds
434    #[serde(default)]
435    pub duration_seconds: Option<f64>,
436
437    /// Target name
438    #[serde(default)]
439    pub target: Option<String>,
440
441    /// Dependency hashes
442    #[serde(default)]
443    pub deps: Vec<DepLock>,
444
445    /// Parameters hash for this stage
446    #[serde(default)]
447    pub params_hash: Option<String>,
448
449    /// Output hashes
450    #[serde(default)]
451    pub outs: Vec<OutLock>,
452
453    /// Hash of the resolved command
454    #[serde(default)]
455    pub cmd_hash: Option<String>,
456
457    /// Composite cache key
458    #[serde(default)]
459    pub cache_key: Option<String>,
460}
461
462/// Stage completion status
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(rename_all = "snake_case")]
465pub enum StageStatus {
466    Completed,
467    Failed,
468    Cached,
469    Running,
470    Pending,
471    Hashing,
472    Validating,
473}
474
475/// Dependency hash entry in lock file
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct DepLock {
478    pub path: String,
479    pub hash: String,
480    #[serde(default)]
481    pub file_count: Option<u64>,
482    #[serde(default)]
483    pub total_bytes: Option<u64>,
484}
485
486/// Output hash entry in lock file
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct OutLock {
489    pub path: String,
490    pub hash: String,
491    #[serde(default)]
492    pub file_count: Option<u64>,
493    #[serde(default)]
494    pub total_bytes: Option<u64>,
495    /// Remote target name (if output is on remote machine)
496    #[serde(default)]
497    pub remote: Option<String>,
498}
499
500// ============================================================================
501// Pipeline events (JSONL event log)
502// ============================================================================
503
504/// Pipeline execution event
505#[derive(Debug, Clone, Serialize, Deserialize)]
506#[serde(tag = "event", rename_all = "snake_case")]
507pub enum PipelineEvent {
508    RunStarted {
509        playbook: String,
510        run_id: String,
511        batuta_version: String,
512    },
513    RunCompleted {
514        playbook: String,
515        run_id: String,
516        stages_run: u32,
517        stages_cached: u32,
518        stages_failed: u32,
519        total_seconds: f64,
520    },
521    RunFailed {
522        playbook: String,
523        run_id: String,
524        error: String,
525    },
526    StageCached {
527        stage: String,
528        cache_key: String,
529        reason: String,
530    },
531    StageStarted {
532        stage: String,
533        target: String,
534        cache_miss_reason: String,
535    },
536    StageCompleted {
537        stage: String,
538        duration_seconds: f64,
539        #[serde(default)]
540        outs_hash: Option<String>,
541    },
542    StageFailed {
543        stage: String,
544        exit_code: Option<i32>,
545        error: String,
546        #[serde(default)]
547        retry_attempt: Option<u32>,
548    },
549}
550
551/// Timestamped event wrapper
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct TimestampedEvent {
554    /// ISO 8601 timestamp
555    pub ts: String,
556
557    /// The event payload (flattened)
558    #[serde(flatten)]
559    pub event: PipelineEvent,
560}
561
562// ============================================================================
563// Cache invalidation
564// ============================================================================
565
566/// Reason why a stage cache was invalidated
567#[derive(Debug, Clone, PartialEq, Eq)]
568pub enum InvalidationReason {
569    /// No lock file exists
570    NoLockFile,
571    /// Stage not found in lock file
572    StageNotInLock,
573    /// Previous run did not complete successfully
574    PreviousRunIncomplete { status: String },
575    /// Command changed
576    CmdChanged { old: String, new: String },
577    /// Dependency hash changed
578    DepChanged { path: String, old_hash: String, new_hash: String },
579    /// Parameters hash changed
580    ParamsChanged { old: String, new: String },
581    /// Cache key mismatch
582    CacheKeyMismatch { old: String, new: String },
583    /// Output file missing
584    OutputMissing { path: String },
585    /// Forced re-run
586    Forced,
587    /// Upstream stage was re-run
588    UpstreamRerun { stage: String },
589}
590
591impl fmt::Display for InvalidationReason {
592    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
593        match self {
594            Self::NoLockFile => write!(f, "no lock file found"),
595            Self::StageNotInLock => write!(f, "stage not found in lock file"),
596            Self::PreviousRunIncomplete { status } => {
597                write!(f, "previous run status: {}", status)
598            }
599            Self::CmdChanged { old, new } => {
600                write!(f, "cmd_hash changed: {} → {}", old, new)
601            }
602            Self::DepChanged { path, old_hash, new_hash } => {
603                write!(f, "dep '{}' hash changed: {} → {}", path, old_hash, new_hash)
604            }
605            Self::ParamsChanged { old, new } => {
606                write!(f, "params_hash changed: {} → {}", old, new)
607            }
608            Self::CacheKeyMismatch { old, new } => {
609                write!(f, "cache_key mismatch: {} → {}", old, new)
610            }
611            Self::OutputMissing { path } => {
612                write!(f, "output '{}' is missing", path)
613            }
614            Self::Forced => write!(f, "forced re-run (--force)"),
615            Self::UpstreamRerun { stage } => {
616                write!(f, "upstream stage '{}' was re-run", stage)
617            }
618        }
619    }
620}
621
622// ============================================================================
623// Validation
624// ============================================================================
625
626/// Validation warning (non-fatal)
627#[derive(Debug, Clone)]
628pub struct ValidationWarning {
629    pub message: String,
630}
631
632impl fmt::Display for ValidationWarning {
633    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
634        write!(f, "{}", self.message)
635    }
636}
637
638// ============================================================================
639// Helpers
640// ============================================================================
641
642/// Convert a serde_yaml_ng::Value to a string for template resolution
643pub fn yaml_value_to_string(val: &serde_yaml_ng::Value) -> String {
644    match val {
645        serde_yaml_ng::Value::String(s) => s.clone(),
646        serde_yaml_ng::Value::Number(n) => n.to_string(),
647        serde_yaml_ng::Value::Bool(b) => b.to_string(),
648        serde_yaml_ng::Value::Null => String::new(),
649        other => format!("{:?}", other),
650    }
651}
652
653// ============================================================================
654// Tests
655// ============================================================================
656
657#[cfg(test)]
658#[allow(non_snake_case)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn test_PB001_playbook_serde_roundtrip() {
664        let yaml = r#"
665version: "1.0"
666name: test-pipeline
667params:
668  model: "base"
669  chunk_size: 512
670targets: {}
671stages:
672  hello:
673    cmd: "echo hello"
674    deps: []
675    outs:
676      - path: /tmp/out.txt
677policy:
678  failure: stop_on_first
679  validation: checksum
680  lock_file: true
681"#;
682        let pb: Playbook = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
683        assert_eq!(pb.version, "1.0");
684        assert_eq!(pb.name, "test-pipeline");
685        assert_eq!(yaml_value_to_string(pb.params.get("model").expect("key not found")), "base");
686        // Numeric params now work
687        assert_eq!(
688            yaml_value_to_string(pb.params.get("chunk_size").expect("key not found")),
689            "512"
690        );
691        assert_eq!(pb.stages.len(), 1);
692        assert!(pb.stages.contains_key("hello"));
693    }
694
695    #[test]
696    fn test_PB001_numeric_params() {
697        let yaml = r#"
698version: "1.0"
699name: numeric
700params:
701  chunk_size: 512
702  bm25_weight: 0.3
703  enabled: true
704targets: {}
705stages:
706  test:
707    cmd: "echo test"
708    deps: []
709    outs: []
710policy:
711  failure: stop_on_first
712  validation: checksum
713  lock_file: true
714"#;
715        let pb: Playbook = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
716        assert_eq!(
717            yaml_value_to_string(pb.params.get("chunk_size").expect("key not found")),
718            "512"
719        );
720        assert_eq!(
721            yaml_value_to_string(pb.params.get("bm25_weight").expect("key not found")),
722            "0.3"
723        );
724        assert_eq!(yaml_value_to_string(pb.params.get("enabled").expect("key not found")), "true");
725    }
726
727    #[test]
728    fn test_PB001_stage_defaults() {
729        let yaml = r#"
730cmd: "echo test"
731deps: []
732outs: []
733"#;
734        let stage: Stage = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
735        assert!(stage.description.is_none());
736        assert!(stage.target.is_none());
737        assert!(stage.after.is_empty());
738        assert!(stage.params.is_none());
739        assert!(stage.parallel.is_none());
740        assert!(stage.retry.is_none());
741        assert!(stage.resources.is_none());
742        assert!(!stage.frozen);
743        assert!(stage.shell.is_none());
744    }
745
746    #[test]
747    fn test_PB001_stage_params_list() {
748        let yaml = r#"
749cmd: "echo {{params.model}}"
750deps: []
751outs: []
752params:
753  - model
754  - chunk_size
755"#;
756        let stage: Stage = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
757        let params = stage.params.expect("unexpected failure");
758        assert_eq!(params, vec!["model", "chunk_size"]);
759    }
760
761    #[test]
762    fn test_PB001_policy_defaults() {
763        let policy = Policy::default();
764        assert_eq!(policy.failure, FailurePolicy::StopOnFirst);
765        assert_eq!(policy.validation, ValidationPolicy::Checksum);
766        assert!(policy.lock_file);
767        assert!(policy.concurrency.is_none());
768    }
769
770    #[test]
771    fn test_PB001_policy_enum_serde() {
772        let yaml = r#"
773failure: stop_on_first
774validation: checksum
775lock_file: true
776"#;
777        let policy: Policy = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
778        assert_eq!(policy.failure, FailurePolicy::StopOnFirst);
779
780        let yaml2 = r#"
781failure: continue_independent
782validation: none
783lock_file: false
784"#;
785        let policy2: Policy = serde_yaml_ng::from_str(yaml2).expect("yaml deserialize failed");
786        assert_eq!(policy2.failure, FailurePolicy::ContinueIndependent);
787        assert_eq!(policy2.validation, ValidationPolicy::None);
788        assert!(!policy2.lock_file);
789    }
790
791    #[test]
792    fn test_PB001_stage_with_phase2_fields() {
793        let yaml = r#"
794cmd: "echo test"
795deps: []
796outs: []
797parallel:
798  strategy: per_file
799  glob: "*.txt"
800  max_workers: 4
801retry:
802  limit: 3
803  policy: on_failure
804resources:
805  cores: 4
806  memory_gb: 8.0
807  gpu: 2
808  timeout: 3600
809"#;
810        let stage: Stage = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
811        let par = stage.parallel.expect("unexpected failure");
812        assert_eq!(par.strategy, "per_file");
813        assert_eq!(par.glob.expect("unexpected failure"), "*.txt");
814        assert_eq!(par.max_workers.expect("unexpected failure"), 4);
815
816        let retry = stage.retry.expect("unexpected failure");
817        assert_eq!(retry.limit, 3);
818
819        let res = stage.resources.expect("unexpected failure");
820        assert_eq!(res.cores.expect("unexpected failure"), 4);
821        assert_eq!(res.memory_gb.expect("unexpected failure"), 8.0);
822        assert_eq!(res.gpu.expect("unexpected failure"), 2);
823        assert_eq!(res.timeout.expect("unexpected failure"), 3600);
824    }
825
826    #[test]
827    fn test_PB001_lock_file_serde_roundtrip() {
828        let lock = LockFile {
829            schema: "1.0".to_string(),
830            playbook: "test".to_string(),
831            generated_at: "2026-02-16T14:00:00Z".to_string(),
832            generator: "batuta 0.6.5".to_string(),
833            blake3_version: "1.8".to_string(),
834            params_hash: Some("blake3:abc123".to_string()),
835            stages: IndexMap::from([(
836                "hello".to_string(),
837                StageLock {
838                    status: StageStatus::Completed,
839                    started_at: Some("2026-02-16T14:00:00Z".to_string()),
840                    completed_at: Some("2026-02-16T14:00:01Z".to_string()),
841                    duration_seconds: Some(1.0),
842                    target: None,
843                    deps: vec![DepLock {
844                        path: "/tmp/in.txt".to_string(),
845                        hash: "blake3:def456".to_string(),
846                        file_count: Some(1),
847                        total_bytes: Some(100),
848                    }],
849                    params_hash: Some("blake3:aaa".to_string()),
850                    outs: vec![OutLock {
851                        path: "/tmp/out.txt".to_string(),
852                        hash: "blake3:ghi789".to_string(),
853                        file_count: Some(1),
854                        total_bytes: Some(200),
855                        remote: None,
856                    }],
857                    cmd_hash: Some("blake3:cmd111".to_string()),
858                    cache_key: Some("blake3:key222".to_string()),
859                },
860            )]),
861        };
862
863        let yaml = serde_yaml_ng::to_string(&lock).expect("yaml serialize failed");
864        let lock2: LockFile = serde_yaml_ng::from_str(&yaml).expect("yaml deserialize failed");
865        assert_eq!(lock2.playbook, "test");
866        assert_eq!(lock2.stages["hello"].status, StageStatus::Completed);
867    }
868
869    #[test]
870    fn test_PB001_stage_status_serde() {
871        let statuses = vec![
872            (StageStatus::Completed, "\"completed\""),
873            (StageStatus::Failed, "\"failed\""),
874            (StageStatus::Cached, "\"cached\""),
875            (StageStatus::Running, "\"running\""),
876            (StageStatus::Pending, "\"pending\""),
877            (StageStatus::Hashing, "\"hashing\""),
878            (StageStatus::Validating, "\"validating\""),
879        ];
880        for (status, expected) in statuses {
881            let json = serde_json::to_string(&status).expect("json serialize failed");
882            assert_eq!(json, expected);
883            let parsed: StageStatus = serde_json::from_str(&json).expect("json deserialize failed");
884            assert_eq!(parsed, status);
885        }
886    }
887
888    #[test]
889    fn test_PB001_invalidation_reason_display() {
890        assert_eq!(InvalidationReason::NoLockFile.to_string(), "no lock file found");
891        assert_eq!(InvalidationReason::Forced.to_string(), "forced re-run (--force)");
892        assert_eq!(
893            InvalidationReason::PreviousRunIncomplete { status: "failed".to_string() }.to_string(),
894            "previous run status: failed"
895        );
896    }
897
898    #[test]
899    fn test_PB001_pipeline_event_serde() {
900        let event = PipelineEvent::RunStarted {
901            playbook: "test".to_string(),
902            run_id: "r-abc123".to_string(),
903            batuta_version: "0.6.5".to_string(),
904        };
905        let json = serde_json::to_string(&event).expect("json serialize failed");
906        assert!(json.contains("\"event\":\"run_started\""));
907        assert!(json.contains("\"run_id\":\"r-abc123\""));
908    }
909
910    #[test]
911    fn test_PB001_run_completed_has_stages_failed() {
912        let event = PipelineEvent::RunCompleted {
913            playbook: "test".to_string(),
914            run_id: "r-abc".to_string(),
915            stages_run: 3,
916            stages_cached: 1,
917            stages_failed: 1,
918            total_seconds: 5.0,
919        };
920        let json = serde_json::to_string(&event).expect("json serialize failed");
921        assert!(json.contains("\"stages_failed\":1"));
922        assert!(json.contains("\"total_seconds\":5.0"));
923    }
924
925    #[test]
926    fn test_PB001_timestamped_event_serde() {
927        let te = TimestampedEvent {
928            ts: "2026-02-16T14:00:00Z".to_string(),
929            event: PipelineEvent::StageCached {
930                stage: "hello".to_string(),
931                cache_key: "blake3:abc".to_string(),
932                reason: "cache_key matches lock".to_string(),
933            },
934        };
935        let json = serde_json::to_string(&te).expect("json serialize failed");
936        assert!(json.contains("\"ts\":"));
937        assert!(json.contains("\"event\":\"stage_cached\""));
938    }
939
940    #[test]
941    fn test_PB001_compliance_parse() {
942        let yaml = r#"
943pre_flight:
944  - type: tdg
945    min_grade: B
946    path: src/
947post_flight:
948  - type: coverage
949    min: 85.0
950"#;
951        let compliance: Compliance =
952            serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
953        assert_eq!(compliance.pre_flight.len(), 1);
954        assert_eq!(compliance.pre_flight[0].check_type, "tdg");
955        assert_eq!(compliance.post_flight.len(), 1);
956        assert_eq!(compliance.post_flight[0].min.expect("unexpected failure"), 85.0);
957    }
958
959    #[test]
960    fn test_PB001_indexmap_preserves_stage_order() {
961        let yaml = r#"
962version: "1.0"
963name: ordered
964params: {}
965targets: {}
966stages:
967  alpha:
968    cmd: "echo alpha"
969    deps: []
970    outs: []
971  beta:
972    cmd: "echo beta"
973    deps: []
974    outs: []
975  gamma:
976    cmd: "echo gamma"
977    deps: []
978    outs: []
979policy:
980  failure: stop_on_first
981  validation: checksum
982  lock_file: true
983"#;
984        let pb: Playbook = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
985        let keys: Vec<&String> = pb.stages.keys().collect();
986        assert_eq!(keys, vec!["alpha", "beta", "gamma"]);
987    }
988
989    #[test]
990    fn test_PB001_target_with_spec_fields() {
991        let yaml = r#"
992host: "gpu-box.local"
993ssh_user: noah
994cores: 32
995memory_gb: 288
996workdir: "/data/pipeline"
997env:
998  CUDA_VISIBLE_DEVICES: "0,1"
999"#;
1000        let target: Target = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
1001        assert_eq!(target.host.as_deref(), Some("gpu-box.local"));
1002        assert_eq!(target.ssh_user.as_deref(), Some("noah"));
1003        assert_eq!(target.cores, Some(32));
1004        assert_eq!(target.memory_gb, Some(288));
1005    }
1006
1007    #[test]
1008    fn test_PB001_dep_and_output_with_type() {
1009        let yaml = r#"
1010path: /data/input.wav
1011type: file
1012"#;
1013        let dep: Dependency = serde_yaml_ng::from_str(yaml).expect("yaml deserialize failed");
1014        assert_eq!(dep.path, "/data/input.wav");
1015        assert_eq!(dep.dep_type.as_deref(), Some("file"));
1016
1017        let yaml2 = r#"
1018path: /data/output/
1019type: directory
1020remote: intel
1021"#;
1022        let out: Output = serde_yaml_ng::from_str(yaml2).expect("yaml deserialize failed");
1023        assert_eq!(out.path, "/data/output/");
1024        assert_eq!(out.remote.as_deref(), Some("intel"));
1025    }
1026}