1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12pub const IR_VERSION: &str = "1.4";
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct IntermediateRepresentation {
18 pub version: String,
20
21 pub pipeline: PipelineMetadata,
23
24 #[serde(default)]
26 pub runtimes: Vec<Runtime>,
27
28 #[serde(default, skip_serializing_if = "StageConfiguration::is_empty")]
30 pub stages: StageConfiguration,
31
32 pub tasks: Vec<Task>,
34}
35
36impl IntermediateRepresentation {
37 pub fn new(pipeline_name: impl Into<String>) -> Self {
39 Self {
40 version: IR_VERSION.to_string(),
41 pipeline: PipelineMetadata {
42 name: pipeline_name.into(),
43 environment: None,
44 requires_onepassword: false,
45 project_name: None,
46 trigger: None,
47 },
48 runtimes: Vec::new(),
49 stages: StageConfiguration::default(),
50 tasks: Vec::new(),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct PipelineMetadata {
58 pub name: String,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub environment: Option<String>,
64
65 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
67 pub requires_onepassword: bool,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub project_name: Option<String>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub trigger: Option<TriggerCondition>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
80#[serde(rename_all = "snake_case")]
81pub struct TriggerCondition {
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub branches: Vec<String>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub pull_request: Option<bool>,
89
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub scheduled: Vec<String>,
93
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub release: Vec<String>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub manual: Option<ManualTriggerConfig>,
101
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub paths: Vec<String>,
105
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub paths_ignore: Vec<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
113pub struct ManualTriggerConfig {
114 pub enabled: bool,
116
117 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
119 pub inputs: HashMap<String, WorkflowDispatchInputDef>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124#[serde(rename_all = "snake_case")]
125pub struct WorkflowDispatchInputDef {
126 pub description: String,
128
129 #[serde(default)]
131 pub required: bool,
132
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub default: Option<String>,
136
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub input_type: Option<String>,
140
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub options: Vec<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148pub struct Runtime {
149 pub id: String,
151
152 pub flake: String,
154
155 pub output: String,
157
158 pub system: String,
160
161 pub digest: String,
163
164 #[serde(default)]
166 pub purity: PurityMode,
167}
168
169#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
171#[serde(rename_all = "lowercase")]
172pub enum PurityMode {
173 Strict,
175
176 #[default]
178 Warning,
179
180 Override,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct Task {
187 pub id: String,
189
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub runtime: Option<String>,
193
194 pub command: Vec<String>,
196
197 #[serde(default)]
199 pub shell: bool,
200
201 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
203 pub env: HashMap<String, String>,
204
205 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
207 pub secrets: HashMap<String, SecretConfig>,
208
209 #[serde(skip_serializing_if = "Option::is_none")]
211 pub resources: Option<ResourceRequirements>,
212
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub concurrency_group: Option<String>,
216
217 #[serde(default)]
219 pub inputs: Vec<String>,
220
221 #[serde(default)]
223 pub outputs: Vec<OutputDeclaration>,
224
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub depends_on: Vec<String>,
228
229 #[serde(default)]
231 pub cache_policy: CachePolicy,
232
233 #[serde(default)]
235 pub deployment: bool,
236
237 #[serde(default)]
239 pub manual_approval: bool,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244pub struct SecretConfig {
245 pub source: String,
247
248 #[serde(default)]
250 pub cache_key: bool,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
255pub struct ResourceRequirements {
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub cpu: Option<String>,
259
260 #[serde(skip_serializing_if = "Option::is_none")]
262 pub memory: Option<String>,
263
264 #[serde(default, skip_serializing_if = "Vec::is_empty")]
266 pub tags: Vec<String>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
271pub struct OutputDeclaration {
272 pub path: String,
274
275 #[serde(rename = "type")]
277 pub output_type: OutputType,
278}
279
280#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
282#[serde(rename_all = "lowercase")]
283pub enum OutputType {
284 #[default]
286 Cas,
287
288 Orchestrator,
290}
291
292#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
294#[serde(rename_all = "lowercase")]
295pub enum CachePolicy {
296 #[default]
298 Normal,
299
300 Readonly,
302
303 Writeonly,
305
306 Disabled,
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
316#[serde(rename_all = "lowercase")]
317pub enum BuildStage {
318 Bootstrap,
320
321 Setup,
323
324 Success,
326
327 Failure,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
333#[serde(rename_all = "camelCase")]
334#[derive(Default)]
335pub struct StageTask {
336 pub id: String,
338
339 pub provider: String,
341
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub label: Option<String>,
345
346 pub command: Vec<String>,
348
349 #[serde(default)]
351 pub shell: bool,
352
353 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
355 pub env: HashMap<String, String>,
356
357 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
359 pub secrets: HashMap<String, SecretConfig>,
360
361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
363 pub depends_on: Vec<String>,
364
365 #[serde(default, skip_serializing_if = "is_zero")]
367 pub priority: i32,
368}
369
370#[allow(clippy::trivially_copy_pass_by_ref)]
373fn is_zero(v: &i32) -> bool {
374 *v == 0
375}
376
377#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
379pub struct StageConfiguration {
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
382 pub bootstrap: Vec<StageTask>,
383
384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
386 pub setup: Vec<StageTask>,
387
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
390 pub success: Vec<StageTask>,
391
392 #[serde(default, skip_serializing_if = "Vec::is_empty")]
394 pub failure: Vec<StageTask>,
395}
396
397impl StageConfiguration {
398 #[must_use]
400 pub fn is_empty(&self) -> bool {
401 self.bootstrap.is_empty()
402 && self.setup.is_empty()
403 && self.success.is_empty()
404 && self.failure.is_empty()
405 }
406
407 pub fn add(&mut self, stage: BuildStage, task: StageTask) {
409 match stage {
410 BuildStage::Bootstrap => self.bootstrap.push(task),
411 BuildStage::Setup => self.setup.push(task),
412 BuildStage::Success => self.success.push(task),
413 BuildStage::Failure => self.failure.push(task),
414 }
415 }
416
417 pub fn sort_by_priority(&mut self) {
419 self.bootstrap.sort_by_key(|t| t.priority);
420 self.setup.sort_by_key(|t| t.priority);
421 self.success.sort_by_key(|t| t.priority);
422 self.failure.sort_by_key(|t| t.priority);
423 }
424
425 #[must_use]
427 pub fn setup_task_ids(&self) -> Vec<String> {
428 self.bootstrap
429 .iter()
430 .chain(self.setup.iter())
431 .map(|t| t.id.clone())
432 .collect()
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn test_ir_version() {
442 let ir = IntermediateRepresentation::new("test-pipeline");
443 assert_eq!(ir.version, "1.4");
444 assert_eq!(ir.pipeline.name, "test-pipeline");
445 assert!(ir.runtimes.is_empty());
446 assert!(ir.stages.is_empty());
447 assert!(ir.tasks.is_empty());
448 }
449
450 #[test]
451 fn test_purity_mode_serialization() {
452 let strict = PurityMode::Strict;
453 let json = serde_json::to_string(&strict).unwrap();
454 assert_eq!(json, r#""strict""#);
455
456 let warning = PurityMode::Warning;
457 let json = serde_json::to_string(&warning).unwrap();
458 assert_eq!(json, r#""warning""#);
459
460 let override_mode = PurityMode::Override;
461 let json = serde_json::to_string(&override_mode).unwrap();
462 assert_eq!(json, r#""override""#);
463 }
464
465 #[test]
466 fn test_cache_policy_serialization() {
467 let normal = CachePolicy::Normal;
468 assert_eq!(serde_json::to_string(&normal).unwrap(), r#""normal""#);
469
470 let readonly = CachePolicy::Readonly;
471 assert_eq!(serde_json::to_string(&readonly).unwrap(), r#""readonly""#);
472
473 let writeonly = CachePolicy::Writeonly;
474 assert_eq!(serde_json::to_string(&writeonly).unwrap(), r#""writeonly""#);
475
476 let disabled = CachePolicy::Disabled;
477 assert_eq!(serde_json::to_string(&disabled).unwrap(), r#""disabled""#);
478 }
479
480 #[test]
481 fn test_output_type_serialization() {
482 let cas = OutputType::Cas;
483 assert_eq!(serde_json::to_string(&cas).unwrap(), r#""cas""#);
484
485 let orchestrator = OutputType::Orchestrator;
486 assert_eq!(
487 serde_json::to_string(&orchestrator).unwrap(),
488 r#""orchestrator""#
489 );
490 }
491
492 #[test]
493 fn test_task_minimal() {
494 let task = Task {
495 id: "test-task".to_string(),
496 runtime: None,
497 command: vec!["echo".to_string(), "hello".to_string()],
498 shell: false,
499 env: HashMap::new(),
500 secrets: HashMap::new(),
501 resources: None,
502 concurrency_group: None,
503 inputs: vec![],
504 outputs: vec![],
505 depends_on: vec![],
506 cache_policy: CachePolicy::Normal,
507 deployment: false,
508 manual_approval: false,
509 };
510
511 let json = serde_json::to_value(&task).unwrap();
512 assert_eq!(json["id"], "test-task");
513 assert_eq!(json["command"], serde_json::json!(["echo", "hello"]));
514 assert_eq!(json["shell"], false);
515 }
516
517 #[test]
518 fn test_task_with_deployment() {
519 let task = Task {
520 id: "deploy-prod".to_string(),
521 runtime: None,
522 command: vec!["deploy".to_string()],
523 shell: false,
524 env: HashMap::new(),
525 secrets: HashMap::new(),
526 resources: None,
527 concurrency_group: Some("production".to_string()),
528 inputs: vec![],
529 outputs: vec![],
530 depends_on: vec!["build".to_string()],
531 cache_policy: CachePolicy::Disabled,
532 deployment: true,
533 manual_approval: true,
534 };
535
536 let json = serde_json::to_value(&task).unwrap();
537 assert_eq!(json["deployment"], true);
538 assert_eq!(json["manual_approval"], true);
539 assert_eq!(json["cache_policy"], "disabled");
540 assert_eq!(json["concurrency_group"], "production");
541 }
542
543 #[test]
544 fn test_secret_config() {
545 let secret = SecretConfig {
546 source: "CI_API_KEY".to_string(),
547 cache_key: true,
548 };
549
550 let json = serde_json::to_value(&secret).unwrap();
551 assert_eq!(json["source"], "CI_API_KEY");
552 assert_eq!(json["cache_key"], true);
553 }
554
555 #[test]
556 fn test_runtime() {
557 let runtime = Runtime {
558 id: "nix-rust".to_string(),
559 flake: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
560 output: "devShells.x86_64-linux.default".to_string(),
561 system: "x86_64-linux".to_string(),
562 digest: "sha256:abc123".to_string(),
563 purity: PurityMode::Strict,
564 };
565
566 let json = serde_json::to_value(&runtime).unwrap();
567 assert_eq!(json["id"], "nix-rust");
568 assert_eq!(json["purity"], "strict");
569 }
570
571 #[test]
572 fn test_full_ir_serialization() {
573 let mut ir = IntermediateRepresentation::new("my-pipeline");
574 ir.pipeline.trigger = Some(TriggerCondition {
575 branches: vec!["main".to_string()],
576 ..Default::default()
577 });
578
579 ir.runtimes.push(Runtime {
580 id: "default".to_string(),
581 flake: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
582 output: "devShells.x86_64-linux.default".to_string(),
583 system: "x86_64-linux".to_string(),
584 digest: "sha256:def456".to_string(),
585 purity: PurityMode::Warning,
586 });
587
588 ir.tasks.push(Task {
589 id: "build".to_string(),
590 runtime: Some("default".to_string()),
591 command: vec!["cargo".to_string(), "build".to_string()],
592 shell: false,
593 env: HashMap::new(),
594 secrets: HashMap::new(),
595 resources: Some(ResourceRequirements {
596 cpu: Some("2".to_string()),
597 memory: Some("4Gi".to_string()),
598 tags: vec!["rust".to_string()],
599 }),
600 concurrency_group: None,
601 inputs: vec!["src/**/*.rs".to_string(), "Cargo.toml".to_string()],
602 outputs: vec![OutputDeclaration {
603 path: "target/release/binary".to_string(),
604 output_type: OutputType::Cas,
605 }],
606 depends_on: vec![],
607 cache_policy: CachePolicy::Normal,
608 deployment: false,
609 manual_approval: false,
610 });
611
612 let json = serde_json::to_string_pretty(&ir).unwrap();
613 assert!(json.contains(r#""version": "1.4""#));
614 assert!(json.contains(r#""name": "my-pipeline""#));
615 assert!(json.contains(r#""id": "build""#));
616 }
617
618 #[test]
623 fn test_build_stage_serialization() {
624 assert_eq!(
625 serde_json::to_string(&BuildStage::Bootstrap).unwrap(),
626 r#""bootstrap""#
627 );
628 assert_eq!(
629 serde_json::to_string(&BuildStage::Setup).unwrap(),
630 r#""setup""#
631 );
632 assert_eq!(
633 serde_json::to_string(&BuildStage::Success).unwrap(),
634 r#""success""#
635 );
636 assert_eq!(
637 serde_json::to_string(&BuildStage::Failure).unwrap(),
638 r#""failure""#
639 );
640 }
641
642 #[test]
643 fn test_stage_task_serialization() {
644 let task = StageTask {
645 id: "install-nix".to_string(),
646 provider: "nix".to_string(),
647 label: Some("Install Nix".to_string()),
648 command: vec!["curl -sSf https://install.determinate.systems/nix | sh".to_string()],
649 shell: true,
650 env: [(
651 "NIX_INSTALLER_DIAGNOSTIC_ENDPOINT".to_string(),
652 String::new(),
653 )]
654 .into_iter()
655 .collect(),
656 secrets: HashMap::new(),
657 depends_on: vec![],
658 priority: 0,
659 };
660
661 let json = serde_json::to_value(&task).unwrap();
662 assert_eq!(json["id"], "install-nix");
663 assert_eq!(json["provider"], "nix");
664 assert_eq!(json["label"], "Install Nix");
665 assert_eq!(json["shell"], true);
666 }
667
668 #[test]
669 fn test_stage_task_default() {
670 let task = StageTask::default();
671 assert!(task.id.is_empty());
672 assert!(task.provider.is_empty());
673 assert!(task.label.is_none());
674 assert!(task.command.is_empty());
675 assert!(!task.shell);
676 assert!(task.env.is_empty());
677 assert!(task.depends_on.is_empty());
678 assert_eq!(task.priority, 0);
679 }
680
681 #[test]
682 fn test_stage_configuration_empty() {
683 let config = StageConfiguration::default();
684 assert!(config.is_empty());
685 assert!(config.bootstrap.is_empty());
686 assert!(config.setup.is_empty());
687 assert!(config.success.is_empty());
688 assert!(config.failure.is_empty());
689 }
690
691 #[test]
692 fn test_stage_configuration_add() {
693 let mut config = StageConfiguration::default();
694
695 config.add(
696 BuildStage::Bootstrap,
697 StageTask {
698 id: "install-nix".to_string(),
699 provider: "nix".to_string(),
700 ..Default::default()
701 },
702 );
703
704 config.add(
705 BuildStage::Setup,
706 StageTask {
707 id: "setup-1password".to_string(),
708 provider: "1password".to_string(),
709 ..Default::default()
710 },
711 );
712
713 assert!(!config.is_empty());
714 assert_eq!(config.bootstrap.len(), 1);
715 assert_eq!(config.setup.len(), 1);
716 assert_eq!(config.bootstrap[0].id, "install-nix");
717 assert_eq!(config.setup[0].id, "setup-1password");
718 }
719
720 #[test]
721 fn test_stage_configuration_sort_by_priority() {
722 let mut config = StageConfiguration::default();
723
724 config.add(
725 BuildStage::Setup,
726 StageTask {
727 id: "setup-1password".to_string(),
728 priority: 20,
729 ..Default::default()
730 },
731 );
732 config.add(
733 BuildStage::Setup,
734 StageTask {
735 id: "setup-cachix".to_string(),
736 priority: 5,
737 ..Default::default()
738 },
739 );
740 config.add(
741 BuildStage::Setup,
742 StageTask {
743 id: "setup-cuenv".to_string(),
744 priority: 10,
745 ..Default::default()
746 },
747 );
748
749 config.sort_by_priority();
750
751 assert_eq!(config.setup[0].id, "setup-cachix");
752 assert_eq!(config.setup[1].id, "setup-cuenv");
753 assert_eq!(config.setup[2].id, "setup-1password");
754 }
755
756 #[test]
757 fn test_stage_configuration_setup_task_ids() {
758 let mut config = StageConfiguration::default();
759
760 config.add(
761 BuildStage::Bootstrap,
762 StageTask {
763 id: "install-nix".to_string(),
764 ..Default::default()
765 },
766 );
767 config.add(
768 BuildStage::Setup,
769 StageTask {
770 id: "setup-cuenv".to_string(),
771 ..Default::default()
772 },
773 );
774 config.add(
775 BuildStage::Success,
776 StageTask {
777 id: "notify".to_string(),
778 ..Default::default()
779 },
780 );
781
782 let ids = config.setup_task_ids();
783 assert_eq!(ids.len(), 2);
784 assert!(ids.contains(&"install-nix".to_string()));
785 assert!(ids.contains(&"setup-cuenv".to_string()));
786 assert!(!ids.contains(&"notify".to_string()));
788 }
789
790 #[test]
791 fn test_ir_with_stages() {
792 let mut ir = IntermediateRepresentation::new("ci-pipeline");
793
794 ir.stages.add(
795 BuildStage::Bootstrap,
796 StageTask {
797 id: "install-nix".to_string(),
798 provider: "nix".to_string(),
799 label: Some("Install Nix".to_string()),
800 command: vec!["curl -sSf https://install.determinate.systems/nix | sh".to_string()],
801 shell: true,
802 priority: 0,
803 ..Default::default()
804 },
805 );
806
807 ir.stages.add(
808 BuildStage::Setup,
809 StageTask {
810 id: "setup-1password".to_string(),
811 provider: "1password".to_string(),
812 label: Some("Setup 1Password".to_string()),
813 command: vec!["cuenv secrets setup onepassword".to_string()],
814 depends_on: vec!["install-nix".to_string()],
815 priority: 20,
816 ..Default::default()
817 },
818 );
819
820 let json = serde_json::to_string_pretty(&ir).unwrap();
821 assert!(json.contains(r#""version": "1.4""#));
822 assert!(json.contains("install-nix"));
823 assert!(json.contains("setup-1password"));
824 assert!(json.contains("1password"));
825 }
826}