Skip to main content

cuenv_ci/ir/
schema.rs

1//! IR v1.5 Schema Types
2//!
3//! JSON schema for the intermediate representation used by the CI pipeline compiler.
4//!
5//! ## Version History
6//! - v1.5: Unified task model - phase tasks have `phase` field instead of separate `stages`
7//! - v1.4: Added `stages` field for provider-injected setup tasks (deprecated in v1.5)
8//! - v1.3: Initial stable version
9
10use cuenv_core::ci::{PipelineMode, PipelineTask};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13
14/// IR version identifier
15pub const IR_VERSION: &str = "1.5";
16
17/// Root IR document
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct IntermediateRepresentation {
20    /// IR version (always "1.5")
21    pub version: String,
22
23    /// Pipeline metadata
24    pub pipeline: PipelineMetadata,
25
26    /// Runtime environment definitions
27    #[serde(default)]
28    pub runtimes: Vec<Runtime>,
29
30    /// Task definitions (includes both regular tasks and phase tasks)
31    pub tasks: Vec<Task>,
32}
33
34impl IntermediateRepresentation {
35    /// Create a new IR document
36    pub fn new(pipeline_name: impl Into<String>) -> Self {
37        Self {
38            version: IR_VERSION.to_string(),
39            pipeline: PipelineMetadata {
40                name: pipeline_name.into(),
41                mode: PipelineMode::default(),
42                environment: None,
43                requires_onepassword: false,
44                project_name: None,
45                trigger: None,
46                pipeline_tasks: Vec::new(),
47                pipeline_task_defs: Vec::new(),
48            },
49            runtimes: Vec::new(),
50            tasks: Vec::new(),
51        }
52    }
53
54    /// Get all tasks belonging to a specific phase (unified model).
55    ///
56    /// Returns an iterator over tasks that have `phase` set to the given stage.
57    /// These are tasks contributed by CUE contributors.
58    pub fn phase_tasks(&self, stage: BuildStage) -> impl Iterator<Item = &Task> {
59        self.tasks.iter().filter(move |t| t.phase == Some(stage))
60    }
61
62    /// Get all regular tasks (not phase tasks).
63    ///
64    /// Returns an iterator over tasks that have no phase set.
65    /// These are the main pipeline tasks defined in the project.
66    pub fn regular_tasks(&self) -> impl Iterator<Item = &Task> {
67        self.tasks.iter().filter(|t| t.phase.is_none())
68    }
69
70    /// Get phase tasks sorted by priority (lower = earlier).
71    ///
72    /// Collects phase tasks into a Vec and sorts them by priority.
73    /// Uses priority 10 as default if not specified.
74    #[must_use]
75    pub fn sorted_phase_tasks(&self, stage: BuildStage) -> Vec<&Task> {
76        let mut tasks: Vec<_> = self.phase_tasks(stage).collect();
77        tasks.sort_by_key(|t| t.priority.unwrap_or(10));
78        tasks
79    }
80}
81
82/// Pipeline metadata and trigger configuration
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
84pub struct PipelineMetadata {
85    /// Pipeline name
86    pub name: String,
87
88    /// Generation mode (thin or expanded)
89    #[serde(default)]
90    pub mode: PipelineMode,
91
92    /// Environment for secret resolution (e.g., "production")
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub environment: Option<String>,
95
96    /// Whether this pipeline requires 1Password for secret resolution
97    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
98    pub requires_onepassword: bool,
99
100    /// Project name (for monorepo prefixing)
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub project_name: Option<String>,
103
104    /// Trigger conditions
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub trigger: Option<TriggerCondition>,
107
108    /// Task IDs that this pipeline runs (for contributor filtering)
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub pipeline_tasks: Vec<String>,
111
112    /// Full pipeline task definitions with matrix configurations preserved
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub pipeline_task_defs: Vec<PipelineTask>,
115}
116
117/// Trigger conditions for pipeline execution
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
119#[serde(rename_all = "snake_case")]
120pub struct TriggerCondition {
121    /// Branch patterns to trigger on
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub branches: Vec<String>,
124
125    /// Enable pull request triggers
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub pull_request: Option<bool>,
128
129    /// Cron expressions for scheduled runs
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub scheduled: Vec<String>,
132
133    /// Release event types (e.g., `["published"]`)
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub release: Vec<String>,
136
137    /// Manual trigger configuration
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub manual: Option<ManualTriggerConfig>,
140
141    /// Path patterns derived from task inputs (triggers on these paths)
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub paths: Vec<String>,
144
145    /// Path patterns to ignore (from provider config)
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub paths_ignore: Vec<String>,
148}
149
150/// Manual trigger (`workflow_dispatch`) configuration
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
152pub struct ManualTriggerConfig {
153    /// Whether manual trigger is enabled
154    pub enabled: bool,
155
156    /// Input definitions for `workflow_dispatch`
157    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
158    pub inputs: BTreeMap<String, WorkflowDispatchInputDef>,
159}
160
161/// Workflow dispatch input definition
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "snake_case")]
164pub struct WorkflowDispatchInputDef {
165    /// Human-readable description
166    pub description: String,
167
168    /// Whether the input is required
169    #[serde(default)]
170    pub required: bool,
171
172    /// Default value
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub default: Option<String>,
175
176    /// Input type (string, boolean, choice, environment)
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub input_type: Option<String>,
179
180    /// Options for choice-type inputs
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub options: Vec<String>,
183}
184
185/// Runtime environment definition (Nix flake-based)
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187pub struct Runtime {
188    /// Unique runtime identifier
189    pub id: String,
190
191    /// Nix flake reference (e.g., "github:NixOS/nixpkgs/nixos-unstable")
192    pub flake: String,
193
194    /// Flake output path (e.g., "devShells.x86_64-linux.default")
195    pub output: String,
196
197    /// System architecture (e.g., "x86_64-linux", "aarch64-darwin")
198    pub system: String,
199
200    /// Runtime digest for caching (computed from flake.lock + output)
201    pub digest: String,
202
203    /// Purity enforcement mode
204    #[serde(default)]
205    pub purity: PurityMode,
206}
207
208/// Purity enforcement for Nix flakes
209#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
210#[serde(rename_all = "lowercase")]
211pub enum PurityMode {
212    /// Reject unlocked flakes (strict mode)
213    Strict,
214
215    /// Warn on unlocked flakes, inject UUID into digest (default)
216    #[default]
217    Warning,
218
219    /// Allow manual input pinning at compile time
220    Override,
221}
222
223/// Task definition in the IR
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct Task {
226    /// Unique task identifier
227    pub id: String,
228
229    /// Runtime environment ID
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub runtime: Option<String>,
232
233    /// Command to execute (array form for direct execve)
234    pub command: Vec<String>,
235
236    /// Shell execution mode (false = direct execve, true = wrap in /bin/sh -c)
237    #[serde(default)]
238    pub shell: bool,
239
240    /// Environment variables
241    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
242    pub env: BTreeMap<String, String>,
243
244    /// Secret configurations
245    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
246    pub secrets: BTreeMap<String, SecretConfig>,
247
248    /// Resource requirements (for scheduling)
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub resources: Option<ResourceRequirements>,
251
252    /// Concurrency group for serialized execution
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub concurrency_group: Option<String>,
255
256    /// Input file globs (expanded at compile time)
257    #[serde(default)]
258    pub inputs: Vec<String>,
259
260    /// Output declarations
261    #[serde(default)]
262    pub outputs: Vec<OutputDeclaration>,
263
264    /// Task dependencies (must complete before this task runs)
265    #[serde(default, skip_serializing_if = "Vec::is_empty")]
266    pub depends_on: Vec<String>,
267
268    /// Cache policy
269    #[serde(default)]
270    pub cache_policy: CachePolicy,
271
272    /// Deployment flag (if true, `cache_policy` is forced to disabled)
273    #[serde(default)]
274    pub deployment: bool,
275
276    /// Manual approval required before execution
277    #[serde(default)]
278    pub manual_approval: bool,
279
280    /// Matrix configuration for parallel job expansion
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub matrix: Option<MatrixConfig>,
283
284    /// Artifacts to download before running this task
285    #[serde(default, skip_serializing_if = "Vec::is_empty")]
286    pub artifact_downloads: Vec<ArtifactDownload>,
287
288    /// Parameters to pass to the task command
289    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
290    pub params: BTreeMap<String, String>,
291
292    // ==========================================================================
293    // Phase task fields (for unified task model)
294    // ==========================================================================
295    /// Phase this task belongs to (None = regular task, Some = phase task)
296    ///
297    /// Phase tasks are contributed by CUE contributors and run at specific
298    /// lifecycle points: bootstrap, setup, success, or failure.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub phase: Option<BuildStage>,
301
302    /// Human-readable label for display (primarily for phase tasks)
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub label: Option<String>,
305
306    /// Priority within phase (lower = earlier, default 10)
307    ///
308    /// Only meaningful for phase tasks. Used for ordering when multiple
309    /// contributors add tasks to the same phase.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub priority: Option<i32>,
312
313    /// Contributor that added this task (e.g., "nix", "codecov")
314    ///
315    /// Set when this task was contributed by a CUE contributor.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub contributor: Option<String>,
318
319    /// Execution condition for phase tasks
320    ///
321    /// Determines when the task runs relative to other task outcomes:
322    /// - `OnSuccess`: Run only if all prior tasks succeeded
323    /// - `OnFailure`: Run only if any prior task failed
324    /// - `Always`: Run regardless of prior task outcomes
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub condition: Option<TaskCondition>,
327
328    /// Provider-specific hints (e.g., GitHub Action specs)
329    ///
330    /// Opaque JSON value that provider-specific emitters can interpret.
331    /// For GitHub, may contain `{ "github_action": { "uses": "...", "with": {...} } }`.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub provider_hints: Option<serde_json::Value>,
334}
335
336impl Task {
337    /// Get the display label for this task, falling back to the ID.
338    ///
339    /// Used by renderers when generating step names in CI workflows.
340    #[must_use]
341    pub fn label(&self) -> String {
342        self.label.clone().unwrap_or_else(|| self.id.clone())
343    }
344
345    /// Get the command as a single string (for shell execution).
346    ///
347    /// Joins the command array with spaces.
348    #[must_use]
349    pub fn command_string(&self) -> String {
350        self.command.join(" ")
351    }
352
353    /// Create a synthetic task for artifact aggregation.
354    ///
355    /// Used when converting `MatrixTask` (with artifacts/params but no matrix dimensions)
356    /// into an IR `Task` for the emitter.
357    #[must_use]
358    pub fn synthetic_aggregation(
359        id: impl Into<String>,
360        artifact_downloads: Vec<ArtifactDownload>,
361        params: BTreeMap<String, String>,
362    ) -> Self {
363        Self {
364            id: id.into(),
365            runtime: None,
366            command: vec![],
367            shell: false,
368            env: BTreeMap::new(),
369            secrets: BTreeMap::new(),
370            resources: None,
371            concurrency_group: None,
372            inputs: vec![],
373            outputs: vec![],
374            depends_on: vec![],
375            cache_policy: CachePolicy::Normal,
376            deployment: false,
377            manual_approval: false,
378            matrix: None,
379            artifact_downloads,
380            params,
381            // Phase task fields (not applicable for synthetic tasks)
382            phase: None,
383            label: None,
384            priority: None,
385            contributor: None,
386            condition: None,
387            provider_hints: None,
388        }
389    }
390
391    /// Create a synthetic task for matrix expansion.
392    ///
393    /// Used when converting `MatrixTask` (with matrix dimensions)
394    /// into an IR `Task` for the emitter.
395    #[must_use]
396    pub fn synthetic_matrix(id: impl Into<String>, matrix: MatrixConfig) -> Self {
397        Self {
398            id: id.into(),
399            runtime: None,
400            command: vec![],
401            shell: false,
402            env: BTreeMap::new(),
403            secrets: BTreeMap::new(),
404            resources: None,
405            concurrency_group: None,
406            inputs: vec![],
407            outputs: vec![],
408            depends_on: vec![],
409            cache_policy: CachePolicy::Normal,
410            deployment: false,
411            manual_approval: false,
412            matrix: Some(matrix),
413            artifact_downloads: vec![],
414            params: BTreeMap::new(),
415            // Phase task fields (not applicable for synthetic tasks)
416            phase: None,
417            label: None,
418            priority: None,
419            contributor: None,
420            condition: None,
421            provider_hints: None,
422        }
423    }
424}
425
426/// Matrix configuration for parallel job expansion
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
428pub struct MatrixConfig {
429    /// Matrix dimensions (e.g., `{"arch": ["linux-x64", "darwin-arm64"]}`)
430    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
431    pub dimensions: BTreeMap<String, Vec<String>>,
432
433    /// Exclude specific combinations
434    #[serde(default, skip_serializing_if = "Vec::is_empty")]
435    pub exclude: Vec<BTreeMap<String, String>>,
436
437    /// Include additional combinations
438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
439    pub include: Vec<BTreeMap<String, String>>,
440
441    /// Maximum parallel jobs (0 = unlimited)
442    #[serde(default)]
443    pub max_parallel: usize,
444
445    /// Fail-fast behavior (stop all jobs on first failure)
446    #[serde(default = "default_fail_fast")]
447    pub fail_fast: bool,
448}
449
450const fn default_fail_fast() -> bool {
451    true
452}
453
454/// Artifact download configuration
455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
456pub struct ArtifactDownload {
457    /// Name pattern for the artifact (can include matrix variables like `build-${{ matrix.arch }}`)
458    pub name: String,
459
460    /// Directory to download artifacts into
461    pub path: String,
462
463    /// Optional filter pattern for matrix variants (e.g., `"*stable"`)
464    #[serde(default, skip_serializing_if = "String::is_empty")]
465    pub filter: String,
466}
467
468/// Secret configuration for a task
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
470pub struct SecretConfig {
471    /// Source reference (e.g., CI variable name, 1Password reference)
472    pub source: String,
473
474    /// Include secret in cache key via salted HMAC
475    #[serde(default)]
476    pub cache_key: bool,
477}
478
479/// Resource requirements for task execution
480#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
481pub struct ResourceRequirements {
482    /// CPU request/limit (e.g., "2", "1000m")
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub cpu: Option<String>,
485
486    /// Memory request/limit (e.g., "2Gi", "512Mi")
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub memory: Option<String>,
489
490    /// Agent/runner tags for scheduling
491    #[serde(default, skip_serializing_if = "Vec::is_empty")]
492    pub tags: Vec<String>,
493}
494
495/// Output artifact declaration
496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
497pub struct OutputDeclaration {
498    /// Path to output file/directory
499    pub path: String,
500
501    /// Storage type
502    #[serde(rename = "type")]
503    pub output_type: OutputType,
504}
505
506/// Output storage type
507#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
508#[serde(rename_all = "lowercase")]
509pub enum OutputType {
510    /// Store in Content Addressable Store (default)
511    #[default]
512    Cas,
513
514    /// Upload via orchestrator (e.g., GitLab artifacts, Buildkite artifacts)
515    Orchestrator,
516}
517
518/// Cache policy for task execution
519#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
520#[serde(rename_all = "lowercase")]
521pub enum CachePolicy {
522    /// Read from cache, write on miss (default)
523    #[default]
524    Normal,
525
526    /// Read from cache only, never write (fork PRs)
527    Readonly,
528
529    /// Always execute, write results (cache warming)
530    Writeonly,
531
532    /// No cache interaction (deployments)
533    Disabled,
534}
535
536// =============================================================================
537// Stage Configuration (v1.4)
538// =============================================================================
539
540/// Build stages that providers can inject tasks into
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
542#[serde(rename_all = "lowercase")]
543pub enum BuildStage {
544    /// Environment bootstrap (e.g., install Nix)
545    Bootstrap,
546
547    /// Provider setup (e.g., 1Password, Cachix, AWS credentials)
548    Setup,
549
550    /// Post-success actions (e.g., notifications, cache push)
551    Success,
552
553    /// Post-failure actions (e.g., alerts, debugging)
554    Failure,
555}
556
557/// Execution condition for phase tasks
558///
559/// Determines when a phase task runs based on the outcome of prior tasks.
560/// Used by emitters to generate conditional execution logic (e.g., `if: failure()` in GitHub Actions).
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
562#[serde(rename_all = "snake_case")]
563pub enum TaskCondition {
564    /// Run only if all prior tasks succeeded (default for success phase)
565    OnSuccess,
566
567    /// Run only if any prior task failed (default for failure phase)
568    OnFailure,
569
570    /// Run regardless of prior task outcomes
571    Always,
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[test]
579    fn test_ir_version() {
580        let ir = IntermediateRepresentation::new("test-pipeline");
581        assert_eq!(ir.version, "1.5");
582        assert_eq!(ir.pipeline.name, "test-pipeline");
583        assert!(ir.runtimes.is_empty());
584        assert!(ir.tasks.is_empty());
585    }
586
587    #[test]
588    fn test_purity_mode_serialization() {
589        let strict = PurityMode::Strict;
590        let json = serde_json::to_string(&strict).unwrap();
591        assert_eq!(json, r#""strict""#);
592
593        let warning = PurityMode::Warning;
594        let json = serde_json::to_string(&warning).unwrap();
595        assert_eq!(json, r#""warning""#);
596
597        let override_mode = PurityMode::Override;
598        let json = serde_json::to_string(&override_mode).unwrap();
599        assert_eq!(json, r#""override""#);
600    }
601
602    #[test]
603    fn test_cache_policy_serialization() {
604        let normal = CachePolicy::Normal;
605        assert_eq!(serde_json::to_string(&normal).unwrap(), r#""normal""#);
606
607        let readonly = CachePolicy::Readonly;
608        assert_eq!(serde_json::to_string(&readonly).unwrap(), r#""readonly""#);
609
610        let writeonly = CachePolicy::Writeonly;
611        assert_eq!(serde_json::to_string(&writeonly).unwrap(), r#""writeonly""#);
612
613        let disabled = CachePolicy::Disabled;
614        assert_eq!(serde_json::to_string(&disabled).unwrap(), r#""disabled""#);
615    }
616
617    #[test]
618    fn test_output_type_serialization() {
619        let cas = OutputType::Cas;
620        assert_eq!(serde_json::to_string(&cas).unwrap(), r#""cas""#);
621
622        let orchestrator = OutputType::Orchestrator;
623        assert_eq!(
624            serde_json::to_string(&orchestrator).unwrap(),
625            r#""orchestrator""#
626        );
627    }
628
629    #[test]
630    fn test_task_minimal() {
631        let task = Task {
632            id: "test-task".to_string(),
633            runtime: None,
634            command: vec!["echo".to_string(), "hello".to_string()],
635            shell: false,
636            env: BTreeMap::new(),
637            secrets: BTreeMap::new(),
638            resources: None,
639            concurrency_group: None,
640            inputs: vec![],
641            outputs: vec![],
642            depends_on: vec![],
643            cache_policy: CachePolicy::Normal,
644            deployment: false,
645            manual_approval: false,
646            matrix: None,
647            artifact_downloads: vec![],
648            params: BTreeMap::new(),
649            phase: None,
650            label: None,
651            priority: None,
652            contributor: None,
653            condition: None,
654            provider_hints: None,
655        };
656
657        let json = serde_json::to_value(&task).unwrap();
658        assert_eq!(json["id"], "test-task");
659        assert_eq!(json["command"], serde_json::json!(["echo", "hello"]));
660        assert_eq!(json["shell"], false);
661    }
662
663    #[test]
664    fn test_task_with_deployment() {
665        let task = Task {
666            id: "deploy-prod".to_string(),
667            runtime: None,
668            command: vec!["deploy".to_string()],
669            shell: false,
670            env: BTreeMap::new(),
671            secrets: BTreeMap::new(),
672            resources: None,
673            concurrency_group: Some("production".to_string()),
674            inputs: vec![],
675            outputs: vec![],
676            depends_on: vec!["build".to_string()],
677            cache_policy: CachePolicy::Disabled,
678            deployment: true,
679            manual_approval: true,
680            matrix: None,
681            artifact_downloads: vec![],
682            params: BTreeMap::new(),
683            phase: None,
684            label: None,
685            priority: None,
686            contributor: None,
687            condition: None,
688            provider_hints: None,
689        };
690
691        let json = serde_json::to_value(&task).unwrap();
692        assert_eq!(json["deployment"], true);
693        assert_eq!(json["manual_approval"], true);
694        assert_eq!(json["cache_policy"], "disabled");
695        assert_eq!(json["concurrency_group"], "production");
696    }
697
698    #[test]
699    fn test_task_with_matrix() {
700        let task = Task {
701            id: "build-matrix".to_string(),
702            runtime: None,
703            command: vec!["cargo".to_string(), "build".to_string()],
704            shell: false,
705            env: BTreeMap::new(),
706            secrets: BTreeMap::new(),
707            resources: None,
708            concurrency_group: None,
709            inputs: vec![],
710            outputs: vec![],
711            depends_on: vec![],
712            cache_policy: CachePolicy::Normal,
713            deployment: false,
714            manual_approval: false,
715            matrix: Some(MatrixConfig {
716                dimensions: [(
717                    "arch".to_string(),
718                    vec!["x64".to_string(), "arm64".to_string()],
719                )]
720                .into_iter()
721                .collect(),
722                ..Default::default()
723            }),
724            artifact_downloads: vec![],
725            params: BTreeMap::new(),
726            phase: None,
727            label: None,
728            priority: None,
729            contributor: None,
730            condition: None,
731            provider_hints: None,
732        };
733
734        let json = serde_json::to_value(&task).unwrap();
735        assert_eq!(
736            json["matrix"]["dimensions"]["arch"],
737            serde_json::json!(["x64", "arm64"])
738        );
739    }
740
741    #[test]
742    fn test_artifact_download() {
743        let artifact = ArtifactDownload {
744            name: "build-${{ matrix.arch }}".to_string(),
745            path: "./artifacts".to_string(),
746            filter: "*stable".to_string(),
747        };
748
749        let json = serde_json::to_value(&artifact).unwrap();
750        assert_eq!(json["name"], "build-${{ matrix.arch }}");
751        assert_eq!(json["path"], "./artifacts");
752        assert_eq!(json["filter"], "*stable");
753    }
754
755    #[test]
756    fn test_secret_config() {
757        let secret = SecretConfig {
758            source: "CI_API_KEY".to_string(),
759            cache_key: true,
760        };
761
762        let json = serde_json::to_value(&secret).unwrap();
763        assert_eq!(json["source"], "CI_API_KEY");
764        assert_eq!(json["cache_key"], true);
765    }
766
767    #[test]
768    fn test_runtime() {
769        let runtime = Runtime {
770            id: "nix-rust".to_string(),
771            flake: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
772            output: "devShells.x86_64-linux.default".to_string(),
773            system: "x86_64-linux".to_string(),
774            digest: "sha256:abc123".to_string(),
775            purity: PurityMode::Strict,
776        };
777
778        let json = serde_json::to_value(&runtime).unwrap();
779        assert_eq!(json["id"], "nix-rust");
780        assert_eq!(json["purity"], "strict");
781    }
782
783    #[test]
784    fn test_full_ir_serialization() {
785        let mut ir = IntermediateRepresentation::new("my-pipeline");
786        ir.pipeline.trigger = Some(TriggerCondition {
787            branches: vec!["main".to_string()],
788            ..Default::default()
789        });
790
791        ir.runtimes.push(Runtime {
792            id: "default".to_string(),
793            flake: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
794            output: "devShells.x86_64-linux.default".to_string(),
795            system: "x86_64-linux".to_string(),
796            digest: "sha256:def456".to_string(),
797            purity: PurityMode::Warning,
798        });
799
800        ir.tasks.push(Task {
801            id: "build".to_string(),
802            runtime: Some("default".to_string()),
803            command: vec!["cargo".to_string(), "build".to_string()],
804            shell: false,
805            env: BTreeMap::new(),
806            secrets: BTreeMap::new(),
807            resources: Some(ResourceRequirements {
808                cpu: Some("2".to_string()),
809                memory: Some("4Gi".to_string()),
810                tags: vec!["rust".to_string()],
811            }),
812            concurrency_group: None,
813            inputs: vec!["src/**/*.rs".to_string(), "Cargo.toml".to_string()],
814            outputs: vec![OutputDeclaration {
815                path: "target/release/binary".to_string(),
816                output_type: OutputType::Cas,
817            }],
818            depends_on: vec![],
819            cache_policy: CachePolicy::Normal,
820            deployment: false,
821            manual_approval: false,
822            matrix: None,
823            artifact_downloads: vec![],
824            params: BTreeMap::new(),
825            phase: None,
826            label: None,
827            priority: None,
828            contributor: None,
829            condition: None,
830            provider_hints: None,
831        });
832
833        let json = serde_json::to_string_pretty(&ir).unwrap();
834        assert!(json.contains(r#""version": "1.5""#));
835        assert!(json.contains(r#""name": "my-pipeline""#));
836        assert!(json.contains(r#""id": "build""#));
837    }
838
839    // =============================================================================
840    // Stage Configuration Tests (v1.4)
841    // =============================================================================
842
843    #[test]
844    fn test_build_stage_serialization() {
845        assert_eq!(
846            serde_json::to_string(&BuildStage::Bootstrap).unwrap(),
847            r#""bootstrap""#
848        );
849        assert_eq!(
850            serde_json::to_string(&BuildStage::Setup).unwrap(),
851            r#""setup""#
852        );
853        assert_eq!(
854            serde_json::to_string(&BuildStage::Success).unwrap(),
855            r#""success""#
856        );
857        assert_eq!(
858            serde_json::to_string(&BuildStage::Failure).unwrap(),
859            r#""failure""#
860        );
861    }
862
863    // =============================================================================
864    // Phase Task Filtering and Sorting Tests (v1.5)
865    // =============================================================================
866
867    /// Helper to create a minimal task for testing
868    fn make_test_task(id: &str) -> Task {
869        Task {
870            id: id.to_string(),
871            runtime: None,
872            command: vec!["echo".to_string()],
873            shell: false,
874            env: BTreeMap::new(),
875            secrets: BTreeMap::new(),
876            resources: None,
877            concurrency_group: None,
878            inputs: vec![],
879            outputs: vec![],
880            depends_on: vec![],
881            cache_policy: CachePolicy::Disabled,
882            deployment: false,
883            manual_approval: false,
884            matrix: None,
885            artifact_downloads: vec![],
886            params: BTreeMap::new(),
887            phase: None,
888            label: None,
889            priority: None,
890            contributor: None,
891            condition: None,
892            provider_hints: None,
893        }
894    }
895
896    #[test]
897    fn test_phase_tasks_filters_by_phase() {
898        let mut ir = IntermediateRepresentation::new("test");
899
900        // Add regular task (no phase)
901        ir.tasks.push(make_test_task("regular-task"));
902
903        // Add bootstrap phase task
904        let mut bootstrap_task = make_test_task("install-nix");
905        bootstrap_task.phase = Some(BuildStage::Bootstrap);
906        ir.tasks.push(bootstrap_task);
907
908        // Add setup phase task
909        let mut setup_task = make_test_task("setup-cuenv");
910        setup_task.phase = Some(BuildStage::Setup);
911        ir.tasks.push(setup_task);
912
913        // Verify phase_tasks filters correctly
914        let bootstrap_tasks: Vec<_> = ir.phase_tasks(BuildStage::Bootstrap).collect();
915        assert_eq!(bootstrap_tasks.len(), 1);
916        assert_eq!(bootstrap_tasks[0].id, "install-nix");
917
918        let setup_tasks: Vec<_> = ir.phase_tasks(BuildStage::Setup).collect();
919        assert_eq!(setup_tasks.len(), 1);
920        assert_eq!(setup_tasks[0].id, "setup-cuenv");
921
922        // Success phase should be empty
923        let success_tasks: Vec<_> = ir.phase_tasks(BuildStage::Success).collect();
924        assert!(success_tasks.is_empty());
925    }
926
927    #[test]
928    fn test_regular_tasks_excludes_phase_tasks() {
929        let mut ir = IntermediateRepresentation::new("test");
930
931        // Add regular tasks
932        ir.tasks.push(make_test_task("build"));
933        ir.tasks.push(make_test_task("test"));
934
935        // Add phase task
936        let mut phase_task = make_test_task("install-nix");
937        phase_task.phase = Some(BuildStage::Bootstrap);
938        ir.tasks.push(phase_task);
939
940        // Verify regular_tasks excludes phase tasks
941        let regular: Vec<_> = ir.regular_tasks().collect();
942        assert_eq!(regular.len(), 2);
943        assert!(regular.iter().any(|t| t.id == "build"));
944        assert!(regular.iter().any(|t| t.id == "test"));
945        assert!(!regular.iter().any(|t| t.id == "install-nix"));
946    }
947
948    #[test]
949    fn test_sorted_phase_tasks_orders_by_priority() {
950        let mut ir = IntermediateRepresentation::new("test");
951
952        // Add tasks with different priorities (lower = earlier)
953        let mut task_high_priority = make_test_task("first");
954        task_high_priority.phase = Some(BuildStage::Setup);
955        task_high_priority.priority = Some(1);
956        ir.tasks.push(task_high_priority);
957
958        let mut task_low_priority = make_test_task("last");
959        task_low_priority.phase = Some(BuildStage::Setup);
960        task_low_priority.priority = Some(100);
961        ir.tasks.push(task_low_priority);
962
963        let mut task_medium_priority = make_test_task("middle");
964        task_medium_priority.phase = Some(BuildStage::Setup);
965        task_medium_priority.priority = Some(50);
966        ir.tasks.push(task_medium_priority);
967
968        // Verify sorted order
969        let sorted = ir.sorted_phase_tasks(BuildStage::Setup);
970        assert_eq!(sorted.len(), 3);
971        assert_eq!(sorted[0].id, "first");
972        assert_eq!(sorted[1].id, "middle");
973        assert_eq!(sorted[2].id, "last");
974    }
975
976    #[test]
977    fn test_sorted_phase_tasks_uses_default_priority() {
978        let mut ir = IntermediateRepresentation::new("test");
979
980        // Task with explicit low priority
981        let mut explicit_task = make_test_task("explicit");
982        explicit_task.phase = Some(BuildStage::Setup);
983        explicit_task.priority = Some(5);
984        ir.tasks.push(explicit_task);
985
986        // Task with no priority (defaults to 10)
987        let mut default_task = make_test_task("default");
988        default_task.phase = Some(BuildStage::Setup);
989        ir.tasks.push(default_task);
990
991        // Task with high priority (> 10)
992        let mut high_task = make_test_task("high");
993        high_task.phase = Some(BuildStage::Setup);
994        high_task.priority = Some(20);
995        ir.tasks.push(high_task);
996
997        // Verify: explicit (5) < default (10) < high (20)
998        let sorted = ir.sorted_phase_tasks(BuildStage::Setup);
999        assert_eq!(sorted[0].id, "explicit");
1000        assert_eq!(sorted[1].id, "default");
1001        assert_eq!(sorted[2].id, "high");
1002    }
1003}