cuenv_ci/ir/
schema.rs

1//! IR v1.4 Schema Types
2//!
3//! JSON schema for the intermediate representation used by the CI pipeline compiler.
4//!
5//! ## Version History
6//! - v1.4: Added `stages` field for provider-injected setup tasks
7//! - v1.3: Initial stable version
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// IR version identifier
13pub const IR_VERSION: &str = "1.4";
14
15/// Root IR document
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct IntermediateRepresentation {
18    /// IR version (always "1.4")
19    pub version: String,
20
21    /// Pipeline metadata
22    pub pipeline: PipelineMetadata,
23
24    /// Runtime environment definitions
25    #[serde(default)]
26    pub runtimes: Vec<Runtime>,
27
28    /// Stage configuration for provider-injected tasks (bootstrap, setup, etc.)
29    #[serde(default, skip_serializing_if = "StageConfiguration::is_empty")]
30    pub stages: StageConfiguration,
31
32    /// Task definitions
33    pub tasks: Vec<Task>,
34}
35
36impl IntermediateRepresentation {
37    /// Create a new IR document
38    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/// Pipeline metadata and trigger configuration
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct PipelineMetadata {
58    /// Pipeline name
59    pub name: String,
60
61    /// Environment for secret resolution (e.g., "production")
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub environment: Option<String>,
64
65    /// Whether this pipeline requires 1Password for secret resolution
66    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
67    pub requires_onepassword: bool,
68
69    /// Project name (for monorepo prefixing)
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub project_name: Option<String>,
72
73    /// Trigger conditions
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub trigger: Option<TriggerCondition>,
76}
77
78/// Trigger conditions for pipeline execution
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
80#[serde(rename_all = "snake_case")]
81pub struct TriggerCondition {
82    /// Branch patterns to trigger on
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub branches: Vec<String>,
85
86    /// Enable pull request triggers
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub pull_request: Option<bool>,
89
90    /// Cron expressions for scheduled runs
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub scheduled: Vec<String>,
93
94    /// Release event types (e.g., `["published"]`)
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub release: Vec<String>,
97
98    /// Manual trigger configuration
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub manual: Option<ManualTriggerConfig>,
101
102    /// Path patterns derived from task inputs (triggers on these paths)
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub paths: Vec<String>,
105
106    /// Path patterns to ignore (from provider config)
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub paths_ignore: Vec<String>,
109}
110
111/// Manual trigger (`workflow_dispatch`) configuration
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
113pub struct ManualTriggerConfig {
114    /// Whether manual trigger is enabled
115    pub enabled: bool,
116
117    /// Input definitions for `workflow_dispatch`
118    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
119    pub inputs: HashMap<String, WorkflowDispatchInputDef>,
120}
121
122/// Workflow dispatch input definition
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124#[serde(rename_all = "snake_case")]
125pub struct WorkflowDispatchInputDef {
126    /// Human-readable description
127    pub description: String,
128
129    /// Whether the input is required
130    #[serde(default)]
131    pub required: bool,
132
133    /// Default value
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub default: Option<String>,
136
137    /// Input type (string, boolean, choice, environment)
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub input_type: Option<String>,
140
141    /// Options for choice-type inputs
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub options: Vec<String>,
144}
145
146/// Runtime environment definition (Nix flake-based)
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148pub struct Runtime {
149    /// Unique runtime identifier
150    pub id: String,
151
152    /// Nix flake reference (e.g., "github:NixOS/nixpkgs/nixos-unstable")
153    pub flake: String,
154
155    /// Flake output path (e.g., "devShells.x86_64-linux.default")
156    pub output: String,
157
158    /// System architecture (e.g., "x86_64-linux", "aarch64-darwin")
159    pub system: String,
160
161    /// Runtime digest for caching (computed from flake.lock + output)
162    pub digest: String,
163
164    /// Purity enforcement mode
165    #[serde(default)]
166    pub purity: PurityMode,
167}
168
169/// Purity enforcement for Nix flakes
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
171#[serde(rename_all = "lowercase")]
172pub enum PurityMode {
173    /// Reject unlocked flakes (strict mode)
174    Strict,
175
176    /// Warn on unlocked flakes, inject UUID into digest (default)
177    #[default]
178    Warning,
179
180    /// Allow manual input pinning at compile time
181    Override,
182}
183
184/// Task definition in the IR
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct Task {
187    /// Unique task identifier
188    pub id: String,
189
190    /// Runtime environment ID
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub runtime: Option<String>,
193
194    /// Command to execute (array form for direct execve)
195    pub command: Vec<String>,
196
197    /// Shell execution mode (false = direct execve, true = wrap in /bin/sh -c)
198    #[serde(default)]
199    pub shell: bool,
200
201    /// Environment variables
202    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
203    pub env: HashMap<String, String>,
204
205    /// Secret configurations
206    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
207    pub secrets: HashMap<String, SecretConfig>,
208
209    /// Resource requirements (for scheduling)
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub resources: Option<ResourceRequirements>,
212
213    /// Concurrency group for serialized execution
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub concurrency_group: Option<String>,
216
217    /// Input file globs (expanded at compile time)
218    #[serde(default)]
219    pub inputs: Vec<String>,
220
221    /// Output declarations
222    #[serde(default)]
223    pub outputs: Vec<OutputDeclaration>,
224
225    /// Task dependencies (must complete before this task runs)
226    #[serde(default, skip_serializing_if = "Vec::is_empty")]
227    pub depends_on: Vec<String>,
228
229    /// Cache policy
230    #[serde(default)]
231    pub cache_policy: CachePolicy,
232
233    /// Deployment flag (if true, `cache_policy` is forced to disabled)
234    #[serde(default)]
235    pub deployment: bool,
236
237    /// Manual approval required before execution
238    #[serde(default)]
239    pub manual_approval: bool,
240}
241
242/// Secret configuration for a task
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244pub struct SecretConfig {
245    /// Source reference (e.g., CI variable name, 1Password reference)
246    pub source: String,
247
248    /// Include secret in cache key via salted HMAC
249    #[serde(default)]
250    pub cache_key: bool,
251}
252
253/// Resource requirements for task execution
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
255pub struct ResourceRequirements {
256    /// CPU request/limit (e.g., "2", "1000m")
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub cpu: Option<String>,
259
260    /// Memory request/limit (e.g., "2Gi", "512Mi")
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub memory: Option<String>,
263
264    /// Agent/runner tags for scheduling
265    #[serde(default, skip_serializing_if = "Vec::is_empty")]
266    pub tags: Vec<String>,
267}
268
269/// Output artifact declaration
270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
271pub struct OutputDeclaration {
272    /// Path to output file/directory
273    pub path: String,
274
275    /// Storage type
276    #[serde(rename = "type")]
277    pub output_type: OutputType,
278}
279
280/// Output storage type
281#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
282#[serde(rename_all = "lowercase")]
283pub enum OutputType {
284    /// Store in Content Addressable Store (default)
285    #[default]
286    Cas,
287
288    /// Upload via orchestrator (e.g., GitLab artifacts, Buildkite artifacts)
289    Orchestrator,
290}
291
292/// Cache policy for task execution
293#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
294#[serde(rename_all = "lowercase")]
295pub enum CachePolicy {
296    /// Read from cache, write on miss (default)
297    #[default]
298    Normal,
299
300    /// Read from cache only, never write (fork PRs)
301    Readonly,
302
303    /// Always execute, write results (cache warming)
304    Writeonly,
305
306    /// No cache interaction (deployments)
307    Disabled,
308}
309
310// =============================================================================
311// Stage Configuration (v1.4)
312// =============================================================================
313
314/// Build stages that providers can inject tasks into
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
316#[serde(rename_all = "lowercase")]
317pub enum BuildStage {
318    /// Environment bootstrap (e.g., install Nix)
319    Bootstrap,
320
321    /// Provider setup (e.g., 1Password, Cachix, AWS credentials)
322    Setup,
323
324    /// Post-success actions (e.g., notifications, cache push)
325    Success,
326
327    /// Post-failure actions (e.g., alerts, debugging)
328    Failure,
329}
330
331/// A task contributed by a stage provider
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
333#[serde(rename_all = "camelCase")]
334#[derive(Default)]
335pub struct StageTask {
336    /// Unique task identifier within the stage
337    pub id: String,
338
339    /// Provider that contributed this task (e.g., "nix", "1password", "cachix")
340    pub provider: String,
341
342    /// Human-readable label for display
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub label: Option<String>,
345
346    /// Command to execute
347    pub command: Vec<String>,
348
349    /// Shell execution mode (false = direct execve, true = wrap in /bin/sh -c)
350    #[serde(default)]
351    pub shell: bool,
352
353    /// Environment variables
354    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
355    pub env: HashMap<String, String>,
356
357    /// Secret configurations
358    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
359    pub secrets: HashMap<String, SecretConfig>,
360
361    /// Dependencies on other stage tasks (by ID)
362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
363    pub depends_on: Vec<String>,
364
365    /// Priority within the stage (lower = earlier, default 0)
366    #[serde(default, skip_serializing_if = "is_zero")]
367    pub priority: i32,
368}
369
370/// Helper to skip serializing zero priority
371/// Serde's `skip_serializing_if` requires a reference parameter
372#[allow(clippy::trivially_copy_pass_by_ref)]
373fn is_zero(v: &i32) -> bool {
374    *v == 0
375}
376
377/// Stage configuration containing all provider-injected tasks
378#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
379pub struct StageConfiguration {
380    /// Bootstrap tasks (environment setup, runs first)
381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
382    pub bootstrap: Vec<StageTask>,
383
384    /// Setup tasks (provider configuration, runs after bootstrap)
385    #[serde(default, skip_serializing_if = "Vec::is_empty")]
386    pub setup: Vec<StageTask>,
387
388    /// Success tasks (post-success actions)
389    #[serde(default, skip_serializing_if = "Vec::is_empty")]
390    pub success: Vec<StageTask>,
391
392    /// Failure tasks (post-failure actions)
393    #[serde(default, skip_serializing_if = "Vec::is_empty")]
394    pub failure: Vec<StageTask>,
395}
396
397impl StageConfiguration {
398    /// Check if all stages are empty
399    #[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    /// Add a task to the appropriate stage
408    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    /// Sort all stages by priority (lower priority = earlier)
418    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    /// Get all task IDs from bootstrap and setup stages (for task dependencies)
426    #[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    // =============================================================================
619    // Stage Configuration Tests (v1.4)
620    // =============================================================================
621
622    #[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        // Success stage should not be included
787        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}