1use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fmt;
12use std::path::PathBuf;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Playbook {
21 pub version: String,
23
24 pub name: String,
26
27 #[serde(default)]
29 pub description: Option<String>,
30
31 #[serde(default)]
33 pub params: HashMap<String, serde_yaml_ng::Value>,
34
35 #[serde(default)]
37 pub targets: HashMap<String, Target>,
38
39 pub stages: IndexMap<String, Stage>,
41
42 #[serde(default)]
44 pub compliance: Option<Compliance>,
45
46 #[serde(default)]
48 pub policy: Policy,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct Stage {
58 #[serde(default)]
60 pub description: Option<String>,
61
62 pub cmd: String,
64
65 #[serde(default)]
67 pub deps: Vec<Dependency>,
68
69 #[serde(default)]
71 pub outs: Vec<Output>,
72
73 #[serde(default)]
75 pub after: Vec<String>,
76
77 #[serde(default)]
79 pub target: Option<String>,
80
81 #[serde(default)]
83 pub params: Option<Vec<String>>,
84
85 #[serde(default)]
87 pub parallel: Option<ParallelConfig>,
88
89 #[serde(default)]
91 pub retry: Option<RetryConfig>,
92
93 #[serde(default)]
95 pub resources: Option<ResourceConfig>,
96
97 #[serde(default)]
99 pub frozen: bool,
100
101 #[serde(default)]
103 pub shell: Option<ShellMode>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Dependency {
109 pub path: String,
111
112 #[serde(rename = "type", default)]
114 pub dep_type: Option<String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Output {
120 pub path: String,
122
123 #[serde(rename = "type", default)]
125 pub out_type: Option<String>,
126
127 #[serde(default)]
129 pub remote: Option<String>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Target {
139 #[serde(default)]
141 pub host: Option<String>,
142
143 #[serde(default)]
145 pub ssh_user: Option<String>,
146
147 #[serde(default)]
149 pub cores: Option<u32>,
150
151 #[serde(default)]
153 pub memory_gb: Option<u32>,
154
155 #[serde(default)]
157 pub workdir: Option<String>,
158
159 #[serde(default)]
161 pub env: HashMap<String, String>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171#[derive(Default)]
172pub enum FailurePolicy {
173 #[default]
175 StopOnFirst,
176 ContinueIndependent,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183#[derive(Default)]
184pub enum ValidationPolicy {
185 #[default]
187 Checksum,
188 None,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195#[derive(Default)]
196pub enum ConcurrencyPolicy {
197 #[default]
199 Wait,
200 Fail,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct Policy {
207 #[serde(default)]
209 pub failure: FailurePolicy,
210
211 #[serde(default)]
213 pub validation: ValidationPolicy,
214
215 #[serde(default = "Policy::default_lock_file")]
217 pub lock_file: bool,
218
219 #[serde(default)]
221 pub concurrency: Option<ConcurrencyPolicy>,
222
223 #[serde(default)]
225 pub work_dir: Option<PathBuf>,
226
227 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ParallelConfig {
258 pub strategy: String,
260
261 #[serde(default)]
263 pub glob: Option<String>,
264
265 #[serde(default)]
267 pub max_workers: Option<u32>,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct RetryConfig {
273 #[serde(default = "RetryConfig::default_limit")]
275 pub limit: u32,
276
277 #[serde(default = "RetryConfig::default_policy")]
279 pub policy: String,
280
281 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct BackoffConfig {
298 #[serde(default = "BackoffConfig::default_initial")]
300 pub initial_seconds: f64,
301
302 #[serde(default = "BackoffConfig::default_multiplier")]
304 pub multiplier: f64,
305
306 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ResourceConfig {
326 #[serde(default)]
328 pub cores: Option<u32>,
329
330 #[serde(default)]
332 pub memory_gb: Option<f64>,
333
334 #[serde(default)]
336 pub gpu: Option<u32>,
337
338 #[serde(default)]
340 pub timeout: Option<u64>,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
345#[serde(rename_all = "snake_case")]
346pub enum ShellMode {
347 Rash,
349 Raw,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct Compliance {
360 #[serde(default)]
362 pub pre_flight: Vec<ComplianceCheck>,
363
364 #[serde(default)]
366 pub post_flight: Vec<ComplianceCheck>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct ComplianceCheck {
372 #[serde(rename = "type")]
374 pub check_type: String,
375
376 #[serde(default)]
378 pub min_grade: Option<String>,
379
380 #[serde(default)]
382 pub path: Option<String>,
383
384 #[serde(default)]
386 pub min: Option<f64>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct LockFile {
396 pub schema: String,
398
399 pub playbook: String,
401
402 pub generated_at: String,
404
405 pub generator: String,
407
408 pub blake3_version: String,
410
411 #[serde(default)]
413 pub params_hash: Option<String>,
414
415 pub stages: IndexMap<String, StageLock>,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct StageLock {
422 pub status: StageStatus,
424
425 #[serde(default)]
427 pub started_at: Option<String>,
428
429 #[serde(default)]
431 pub completed_at: Option<String>,
432
433 #[serde(default)]
435 pub duration_seconds: Option<f64>,
436
437 #[serde(default)]
439 pub target: Option<String>,
440
441 #[serde(default)]
443 pub deps: Vec<DepLock>,
444
445 #[serde(default)]
447 pub params_hash: Option<String>,
448
449 #[serde(default)]
451 pub outs: Vec<OutLock>,
452
453 #[serde(default)]
455 pub cmd_hash: Option<String>,
456
457 #[serde(default)]
459 pub cache_key: Option<String>,
460}
461
462#[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#[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#[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 #[serde(default)]
497 pub remote: Option<String>,
498}
499
500#[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#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct TimestampedEvent {
554 pub ts: String,
556
557 #[serde(flatten)]
559 pub event: PipelineEvent,
560}
561
562#[derive(Debug, Clone, PartialEq, Eq)]
568pub enum InvalidationReason {
569 NoLockFile,
571 StageNotInLock,
573 PreviousRunIncomplete { status: String },
575 CmdChanged { old: String, new: String },
577 DepChanged { path: String, old_hash: String, new_hash: String },
579 ParamsChanged { old: String, new: String },
581 CacheKeyMismatch { old: String, new: String },
583 OutputMissing { path: String },
585 Forced,
587 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#[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
638pub 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#[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 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}