cuenv_core/
ci.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap};
3
4/// Workflow dispatch input definition for manual triggers
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(rename_all = "camelCase")]
7pub struct WorkflowDispatchInput {
8    /// Description shown in the GitHub UI
9    pub description: String,
10    /// Whether this input is required
11    pub required: Option<bool>,
12    /// Default value for the input
13    pub default: Option<String>,
14    /// Input type: "string", "boolean", "choice", or "environment"
15    #[serde(rename = "type")]
16    pub input_type: Option<String>,
17    /// Options for choice-type inputs
18    pub options: Option<Vec<String>>,
19}
20
21/// Manual trigger configuration - can be a simple bool or include inputs
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(untagged)]
24pub enum ManualTrigger {
25    /// Simple enabled/disabled flag
26    Enabled(bool),
27    /// Workflow dispatch with input definitions
28    WithInputs(HashMap<String, WorkflowDispatchInput>),
29}
30
31impl ManualTrigger {
32    /// Check if manual trigger is enabled (either directly or via inputs)
33    pub fn is_enabled(&self) -> bool {
34        match self {
35            ManualTrigger::Enabled(enabled) => *enabled,
36            ManualTrigger::WithInputs(inputs) => !inputs.is_empty(),
37        }
38    }
39
40    /// Get the inputs if configured
41    pub fn inputs(&self) -> Option<&HashMap<String, WorkflowDispatchInput>> {
42        match self {
43            ManualTrigger::Enabled(_) => None,
44            ManualTrigger::WithInputs(inputs) => Some(inputs),
45        }
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50#[serde(rename_all = "camelCase")]
51pub struct PipelineCondition {
52    pub pull_request: Option<bool>,
53    #[serde(default)]
54    pub branch: Option<StringOrVec>,
55    #[serde(default)]
56    pub tag: Option<StringOrVec>,
57    pub default_branch: Option<bool>,
58    /// Cron expression(s) for scheduled runs
59    #[serde(default)]
60    pub scheduled: Option<StringOrVec>,
61    /// Manual trigger configuration (bool or with inputs)
62    pub manual: Option<ManualTrigger>,
63    /// Release event types (e.g., ["published"])
64    pub release: Option<Vec<String>>,
65}
66
67/// Runner mapping for matrix dimensions
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
69pub struct RunnerMapping {
70    /// Architecture to runner mapping (e.g., "linux-x64" -> "ubuntu-latest")
71    pub arch: Option<HashMap<String, String>>,
72}
73
74/// Artifact download configuration for pipeline tasks
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase")]
77pub struct ArtifactDownload {
78    /// Source task name (must have outputs)
79    pub from: String,
80    /// Base directory to download artifacts into
81    pub to: String,
82    /// Glob pattern to filter matrix variants (e.g., "*stable")
83    #[serde(default)]
84    pub filter: String,
85}
86
87/// Matrix task configuration for pipeline
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "camelCase")]
90pub struct MatrixTask {
91    /// Task name to run
92    pub task: String,
93    /// Matrix dimensions (e.g., arch: ["linux-x64", "darwin-arm64"])
94    pub matrix: BTreeMap<String, Vec<String>>,
95    /// Artifacts to download before running
96    #[serde(default)]
97    pub artifacts: Option<Vec<ArtifactDownload>>,
98    /// Parameters to pass to the task
99    #[serde(default)]
100    pub params: Option<BTreeMap<String, String>>,
101}
102
103/// Pipeline task reference - either a simple task name or a matrix task
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(untagged)]
106pub enum PipelineTask {
107    /// Simple task reference by name
108    Simple(String),
109    /// Matrix task with dimensions and optional artifacts/params
110    Matrix(MatrixTask),
111}
112
113impl PipelineTask {
114    /// Get the task name regardless of variant
115    pub fn task_name(&self) -> &str {
116        match self {
117            PipelineTask::Simple(name) => name,
118            PipelineTask::Matrix(matrix) => &matrix.task,
119        }
120    }
121
122    /// Check if this is a matrix task (Matrix variant, regardless of dimensions)
123    pub fn is_matrix(&self) -> bool {
124        matches!(self, PipelineTask::Matrix(_))
125    }
126
127    /// Check if this task has actual matrix dimensions that require expansion.
128    ///
129    /// Returns true only for Matrix tasks with non-empty matrix map.
130    /// Aggregation tasks (empty matrix with artifacts) return false.
131    pub fn has_matrix_dimensions(&self) -> bool {
132        match self {
133            PipelineTask::Simple(_) => false,
134            PipelineTask::Matrix(m) => !m.matrix.is_empty(),
135        }
136    }
137
138    /// Get matrix dimensions if this is a matrix task
139    pub fn matrix(&self) -> Option<&BTreeMap<String, Vec<String>>> {
140        match self {
141            PipelineTask::Simple(_) => None,
142            PipelineTask::Matrix(m) => Some(&m.matrix),
143        }
144    }
145}
146
147/// Provider-specific configuration container.
148///
149/// This is a dynamic map of provider name to provider-specific configuration.
150/// Each provider crate (cuenv-github, cuenv-buildkite, cuenv-gitlab) defines
151/// its own typed configuration and deserializes from this map.
152///
153/// Example CUE configuration:
154/// ```cue
155/// provider: {
156///     github: {
157///         runner: "ubuntu-latest"
158///         cachix: { name: "my-cache" }
159///     }
160/// }
161/// ```
162pub type ProviderConfig = HashMap<String, serde_json::Value>;
163
164/// GitHub Action configuration for setup steps
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166#[serde(rename_all = "camelCase")]
167pub struct GitHubActionConfig {
168    /// Action reference (e.g., "Mozilla-Actions/sccache-action@v0.2")
169    pub uses: String,
170
171    /// Action inputs (optional)
172    #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "with")]
173    pub inputs: HashMap<String, serde_json::Value>,
174}
175
176/// Pipeline generation mode
177///
178/// Controls how the CI workflow is generated:
179/// - `Thin`: Minimal workflow with cuenv orchestration (default)
180/// - `Expanded`: Full workflow with all tasks as individual jobs/steps
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
182#[serde(rename_all = "lowercase")]
183pub enum PipelineMode {
184    /// Generate minimal workflow with cuenv ci orchestration
185    /// Structure: bootstrap contributors → cuenv ci --pipeline <name> → finalizer contributors
186    #[default]
187    Thin,
188    /// Generate full workflow with all tasks as individual jobs/steps
189    /// Structure: All tasks expanded inline with proper dependencies
190    Expanded,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
194#[serde(rename_all = "camelCase")]
195pub struct Pipeline {
196    /// Generation mode for this pipeline (default: thin)
197    #[serde(default)]
198    pub mode: PipelineMode,
199    /// CI providers to emit workflows for (overrides global ci.providers for this pipeline).
200    /// If specified, completely replaces the global providers list.
201    #[serde(default, skip_serializing_if = "Vec::is_empty")]
202    pub providers: Vec<String>,
203    /// Environment for secret resolution (e.g., "production")
204    pub environment: Option<String>,
205    pub when: Option<PipelineCondition>,
206    /// Tasks to run - can be simple task names or matrix task objects
207    #[serde(default)]
208    pub tasks: Vec<PipelineTask>,
209    /// Whether to derive trigger paths from task inputs.
210    /// Defaults to true for branch/PR triggers, false for scheduled-only.
211    pub derive_paths: Option<bool>,
212    /// Pipeline-specific provider configuration (overrides CI-level defaults)
213    pub provider: Option<ProviderConfig>,
214}
215
216// =============================================================================
217// Contributors
218// =============================================================================
219
220/// Execution condition for contributor tasks
221///
222/// Determines when a task runs based on the outcome of prior tasks.
223/// Used by emitters to generate conditional execution logic.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum TaskCondition {
227    /// Run only if all prior tasks succeeded (default for success phase)
228    OnSuccess,
229
230    /// Run only if any prior task failed (default for failure phase)
231    OnFailure,
232
233    /// Run regardless of prior task outcomes
234    Always,
235}
236
237/// Activation condition for contributors
238/// All specified conditions must be true (AND logic)
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
240#[serde(rename_all = "camelCase")]
241pub struct ActivationCondition {
242    /// Always active (no conditions)
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub always: Option<bool>,
245
246    /// Workspace membership detection (active if project is member of these workspace types)
247    /// Values: "npm", "bun", "pnpm", "yarn", "cargo", "deno"
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub workspace_member: Vec<String>,
250
251    /// Runtime type detection (active if project uses any of these runtime types)
252    /// Values: "nix", "devenv", "container", "dagger", "oci", "tools"
253    #[serde(default, skip_serializing_if = "Vec::is_empty")]
254    pub runtime_type: Vec<String>,
255
256    /// Cuenv source mode detection (for cuenv installation strategy)
257    /// Values: "git", "nix", "homebrew", "release", "native", "artifact"
258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
259    pub cuenv_source: Vec<String>,
260
261    /// Secrets provider detection (active if environment uses any of these providers)
262    /// Values: "onepassword", "aws", "vault", "azure", "gcp"
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub secrets_provider: Vec<String>,
265
266    /// Provider configuration detection (active if these config paths are set)
267    /// Path format: "github.cachix", "github.trustedPublishing.cratesIo"
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub provider_config: Vec<String>,
270
271    /// Task command detection (active if any task uses these commands)
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub task_command: Vec<String>,
274
275    /// Task label detection (active if any task has these labels)
276    #[serde(default, skip_serializing_if = "Vec::is_empty")]
277    pub task_labels: Vec<String>,
278
279    /// Environment name matching (active only in these environments)
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub environment: Vec<String>,
282}
283
284/// Secret reference for contributor tasks
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
286#[serde(untagged)]
287pub enum SecretRef {
288    /// Simple secret name (string)
289    Simple(String),
290    /// Detailed secret configuration
291    Detailed(SecretRefConfig),
292}
293
294/// Detailed secret configuration
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296#[serde(rename_all = "camelCase")]
297pub struct SecretRefConfig {
298    /// CI secret name (e.g., "CACHIX_AUTH_TOKEN")
299    pub source: String,
300    /// Include in cache key via salted HMAC
301    #[serde(default)]
302    pub cache_key: bool,
303}
304
305/// Provider-specific task configuration
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
307#[serde(rename_all = "camelCase")]
308pub struct TaskProviderConfig {
309    /// GitHub Action to use instead of shell command
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub github: Option<GitHubActionConfig>,
312}
313
314/// Auto-association rules for contributors
315/// Defines how user tasks are automatically connected to contributor tasks
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
317#[serde(rename_all = "camelCase")]
318pub struct AutoAssociate {
319    /// Commands that trigger auto-association (e.g., ["bun", "bunx"])
320    #[serde(default, skip_serializing_if = "Vec::is_empty")]
321    pub command: Vec<String>,
322
323    /// Task to inject as dependency (e.g., "cuenv:contributor:bun.workspace.setup")
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub inject_dependency: Option<String>,
326}
327
328/// A task contributed to the DAG by a contributor (CUE-defined)
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
330#[serde(rename_all = "camelCase")]
331pub struct ContributorTask {
332    /// Task identifier (e.g., "bun.workspace.install")
333    /// Will be prefixed with "cuenv:contributor:" when injected
334    pub id: String,
335
336    /// Human-readable display name
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub label: Option<String>,
339
340    /// Human-readable description
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub description: Option<String>,
343
344    /// Shell command to execute
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub command: Option<String>,
347
348    /// Command arguments
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub args: Vec<String>,
351
352    /// Multi-line script (alternative to command)
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub script: Option<String>,
355
356    /// Wrap command in shell
357    #[serde(default)]
358    pub shell: bool,
359
360    /// Environment variables
361    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
362    pub env: HashMap<String, String>,
363
364    /// Secret references (key=env var name)
365    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
366    pub secrets: HashMap<String, SecretRef>,
367
368    /// Input files/patterns for caching
369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
370    pub inputs: Vec<String>,
371
372    /// Output files/patterns for caching
373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
374    pub outputs: Vec<String>,
375
376    /// Whether task requires hermetic execution
377    #[serde(default)]
378    pub hermetic: bool,
379
380    /// Dependencies on other tasks
381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
382    pub depends_on: Vec<String>,
383
384    /// Ordering priority (lower = earlier)
385    #[serde(default = "default_priority")]
386    pub priority: i32,
387
388    /// Execution condition (on_success, on_failure, always)
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub condition: Option<TaskCondition>,
391
392    /// Provider-specific overrides (e.g., GitHub Actions)
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub provider: Option<TaskProviderConfig>,
395}
396
397const fn default_priority() -> i32 {
398    10
399}
400
401/// Contributor definition (CUE-defined)
402/// Contributors inject tasks into the DAG based on activation conditions
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
404#[serde(rename_all = "camelCase")]
405pub struct Contributor {
406    /// Contributor identifier (e.g., "bun.workspace", "nix", "1password")
407    pub id: String,
408
409    /// Activation condition (defaults to always active)
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub when: Option<ActivationCondition>,
412
413    /// Tasks to contribute when active
414    pub tasks: Vec<ContributorTask>,
415
416    /// Auto-association rules for user tasks
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub auto_associate: Option<AutoAssociate>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
422pub struct CI {
423    /// CI providers to emit workflows for (e.g., `["github", "buildkite"]`).
424    /// If not specified, no workflows are emitted (explicit configuration required).
425    /// Per-pipeline providers can override this global setting.
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub providers: Vec<String>,
428    #[serde(default)]
429    pub pipelines: BTreeMap<String, Pipeline>,
430    /// Global provider configuration defaults
431    pub provider: Option<ProviderConfig>,
432    /// Contributors that inject tasks into build phases
433    #[serde(default, skip_serializing_if = "Vec::is_empty")]
434    pub contributors: Vec<Contributor>,
435}
436
437impl CI {
438    /// Get effective providers for a pipeline.
439    ///
440    /// Per-pipeline providers completely override global providers.
441    /// Returns an empty slice if no providers are configured (emit nothing).
442    #[must_use]
443    pub fn providers_for_pipeline(&self, pipeline_name: &str) -> &[String] {
444        self.pipelines
445            .get(pipeline_name)
446            .filter(|p| !p.providers.is_empty())
447            .map(|p| p.providers.as_slice())
448            .unwrap_or(&self.providers)
449    }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
453#[serde(untagged)]
454pub enum StringOrVec {
455    String(String),
456    Vec(Vec<String>),
457}
458
459impl StringOrVec {
460    /// Convert to a vector of strings
461    pub fn to_vec(&self) -> Vec<String> {
462        match self {
463            StringOrVec::String(s) => vec![s.clone()],
464            StringOrVec::Vec(v) => v.clone(),
465        }
466    }
467
468    /// Get as a single string (first element if vec)
469    pub fn as_single(&self) -> Option<&str> {
470        match self {
471            StringOrVec::String(s) => Some(s),
472            StringOrVec::Vec(v) => v.first().map(|s| s.as_str()),
473        }
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn test_string_or_vec() {
483        let single = StringOrVec::String("value".to_string());
484        assert_eq!(single.to_vec(), vec!["value"]);
485        assert_eq!(single.as_single(), Some("value"));
486
487        let multi = StringOrVec::Vec(vec!["a".to_string(), "b".to_string()]);
488        assert_eq!(multi.to_vec(), vec!["a", "b"]);
489        assert_eq!(multi.as_single(), Some("a"));
490    }
491
492    #[test]
493    fn test_manual_trigger_bool() {
494        let json = r#"{"manual": true}"#;
495        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
496        assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(true))));
497
498        let json = r#"{"manual": false}"#;
499        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
500        assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(false))));
501    }
502
503    #[test]
504    fn test_manual_trigger_with_inputs() {
505        let json =
506            r#"{"manual": {"tag_name": {"description": "Tag to release", "required": true}}}"#;
507        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
508
509        match &cond.manual {
510            Some(ManualTrigger::WithInputs(inputs)) => {
511                assert!(inputs.contains_key("tag_name"));
512                let input = inputs.get("tag_name").unwrap();
513                assert_eq!(input.description, "Tag to release");
514                assert_eq!(input.required, Some(true));
515            }
516            _ => panic!("Expected WithInputs variant"),
517        }
518    }
519
520    #[test]
521    fn test_manual_trigger_helpers() {
522        let enabled = ManualTrigger::Enabled(true);
523        assert!(enabled.is_enabled());
524        assert!(enabled.inputs().is_none());
525
526        let disabled = ManualTrigger::Enabled(false);
527        assert!(!disabled.is_enabled());
528
529        let mut inputs = HashMap::new();
530        inputs.insert(
531            "tag".to_string(),
532            WorkflowDispatchInput {
533                description: "Tag name".to_string(),
534                required: Some(true),
535                default: None,
536                input_type: None,
537                options: None,
538            },
539        );
540        let with_inputs = ManualTrigger::WithInputs(inputs);
541        assert!(with_inputs.is_enabled());
542        assert!(with_inputs.inputs().is_some());
543    }
544
545    #[test]
546    fn test_scheduled_cron_expressions() {
547        // Single cron expression
548        let json = r#"{"scheduled": "0 0 * * 0"}"#;
549        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
550        match &cond.scheduled {
551            Some(StringOrVec::String(s)) => assert_eq!(s, "0 0 * * 0"),
552            _ => panic!("Expected single string"),
553        }
554
555        // Multiple cron expressions
556        let json = r#"{"scheduled": ["0 0 * * 0", "0 12 * * *"]}"#;
557        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
558        match &cond.scheduled {
559            Some(StringOrVec::Vec(v)) => {
560                assert_eq!(v.len(), 2);
561                assert_eq!(v[0], "0 0 * * 0");
562                assert_eq!(v[1], "0 12 * * *");
563            }
564            _ => panic!("Expected vec"),
565        }
566    }
567
568    #[test]
569    fn test_release_trigger() {
570        let json = r#"{"release": ["published", "created"]}"#;
571        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
572        assert_eq!(
573            cond.release,
574            Some(vec!["published".to_string(), "created".to_string()])
575        );
576    }
577
578    #[test]
579    fn test_pipeline_derive_paths() {
580        let json = r#"{"tasks": ["test"], "derivePaths": true}"#;
581        let pipeline: Pipeline = serde_json::from_str(json).unwrap();
582        assert_eq!(pipeline.derive_paths, Some(true));
583
584        let json = r#"{"tasks": ["sync"], "derivePaths": false}"#;
585        let pipeline: Pipeline = serde_json::from_str(json).unwrap();
586        assert_eq!(pipeline.derive_paths, Some(false));
587
588        let json = r#"{"tasks": ["build"]}"#;
589        let pipeline: Pipeline = serde_json::from_str(json).unwrap();
590        assert_eq!(pipeline.derive_paths, None);
591    }
592
593    #[test]
594    fn test_pipeline_task_simple() {
595        let json = r#""build""#;
596        let task: PipelineTask = serde_json::from_str(json).unwrap();
597        assert!(matches!(task, PipelineTask::Simple(ref s) if s == "build"));
598        assert_eq!(task.task_name(), "build");
599        assert!(!task.is_matrix());
600        assert!(task.matrix().is_none());
601    }
602
603    #[test]
604    fn test_pipeline_task_matrix() {
605        let json =
606            r#"{"task": "release.build", "matrix": {"arch": ["linux-x64", "darwin-arm64"]}}"#;
607        let task: PipelineTask = serde_json::from_str(json).unwrap();
608        assert!(task.is_matrix());
609        assert_eq!(task.task_name(), "release.build");
610
611        let matrix = task.matrix().unwrap();
612        assert!(matrix.contains_key("arch"));
613        assert_eq!(matrix["arch"], vec!["linux-x64", "darwin-arm64"]);
614    }
615
616    #[test]
617    fn test_pipeline_task_matrix_with_artifacts() {
618        let json = r#"{
619            "task": "release.publish",
620            "matrix": {},
621            "artifacts": [{"from": "release.build", "to": "dist", "filter": "*stable"}],
622            "params": {"tag": "v1.0.0"}
623        }"#;
624        let task: PipelineTask = serde_json::from_str(json).unwrap();
625
626        if let PipelineTask::Matrix(m) = task {
627            assert_eq!(m.task, "release.publish");
628            let artifacts = m.artifacts.unwrap();
629            assert_eq!(artifacts.len(), 1);
630            assert_eq!(artifacts[0].from, "release.build");
631            assert_eq!(artifacts[0].to, "dist");
632            assert_eq!(artifacts[0].filter, "*stable");
633
634            let params = m.params.unwrap();
635            assert_eq!(params.get("tag"), Some(&"v1.0.0".to_string()));
636        } else {
637            panic!("Expected Matrix variant");
638        }
639    }
640
641    #[test]
642    fn test_pipeline_mixed_tasks() {
643        let json = r#"{
644            "tasks": [
645                {"task": "release.build", "matrix": {"arch": ["linux-x64", "darwin-arm64"]}},
646                "release.publish:github",
647                "docs.deploy"
648            ]
649        }"#;
650        let pipeline: Pipeline = serde_json::from_str(json).unwrap();
651        assert_eq!(pipeline.tasks.len(), 3);
652        assert!(pipeline.tasks[0].is_matrix());
653        assert!(!pipeline.tasks[1].is_matrix());
654        assert!(!pipeline.tasks[2].is_matrix());
655    }
656
657    #[test]
658    fn test_runner_mapping() {
659        let json = r#"{"arch": {"linux-x64": "ubuntu-latest", "darwin-arm64": "macos-14"}}"#;
660        let mapping: RunnerMapping = serde_json::from_str(json).unwrap();
661        let arch = mapping.arch.unwrap();
662        assert_eq!(arch.get("linux-x64"), Some(&"ubuntu-latest".to_string()));
663        assert_eq!(arch.get("darwin-arm64"), Some(&"macos-14".to_string()));
664    }
665
666    #[test]
667    fn test_contributor_task_with_command_and_args() {
668        let json = r#"{
669            "id": "bun.workspace.install",
670            "command": "bun",
671            "args": ["install", "--frozen-lockfile"],
672            "inputs": ["package.json", "bun.lock"],
673            "outputs": ["node_modules"]
674        }"#;
675        let task: ContributorTask = serde_json::from_str(json).unwrap();
676        assert_eq!(task.id, "bun.workspace.install");
677        assert_eq!(task.command, Some("bun".to_string()));
678        assert_eq!(task.args, vec!["install", "--frozen-lockfile"]);
679        assert_eq!(task.inputs, vec!["package.json", "bun.lock"]);
680        assert_eq!(task.outputs, vec!["node_modules"]);
681    }
682
683    #[test]
684    fn test_contributor_task_with_script() {
685        let json = r#"{
686            "id": "nix.install",
687            "command": "sh",
688            "args": ["-c", "curl -sSL https://install.determinate.systems/nix | sh"]
689        }"#;
690        let task: ContributorTask = serde_json::from_str(json).unwrap();
691        assert_eq!(task.id, "nix.install");
692        assert_eq!(task.command, Some("sh".to_string()));
693        assert_eq!(
694            task.args,
695            vec![
696                "-c",
697                "curl -sSL https://install.determinate.systems/nix | sh"
698            ]
699        );
700    }
701
702    #[test]
703    fn test_contributor_with_auto_associate() {
704        let json = r#"{
705            "id": "bun.workspace",
706            "when": {"workspaceMember": ["bun"]},
707            "tasks": [{
708                "id": "bun.workspace.install",
709                "command": "bun",
710                "args": ["install"]
711            }],
712            "autoAssociate": {
713                "command": ["bun", "bunx"],
714                "injectDependency": "cuenv:contributor:bun.workspace.setup"
715            }
716        }"#;
717        let contributor: Contributor = serde_json::from_str(json).unwrap();
718        assert_eq!(contributor.id, "bun.workspace");
719
720        let when = contributor.when.unwrap();
721        assert_eq!(when.workspace_member, vec!["bun"]);
722
723        let auto = contributor.auto_associate.unwrap();
724        assert_eq!(auto.command, vec!["bun", "bunx"]);
725        assert_eq!(
726            auto.inject_dependency,
727            Some("cuenv:contributor:bun.workspace.setup".to_string())
728        );
729    }
730
731    #[test]
732    fn test_activation_condition_workspace_member() {
733        let json = r#"{"workspaceMember": ["npm", "bun"]}"#;
734        let cond: ActivationCondition = serde_json::from_str(json).unwrap();
735        assert_eq!(cond.workspace_member, vec!["npm", "bun"]);
736    }
737
738    #[test]
739    fn test_providers_for_pipeline_global() {
740        let ci = CI {
741            providers: vec!["github".to_string()],
742            pipelines: BTreeMap::from([(
743                "ci".to_string(),
744                Pipeline {
745                    providers: vec![],
746                    mode: PipelineMode::default(),
747                    environment: None,
748                    when: None,
749                    tasks: vec![],
750                    derive_paths: None,
751                    provider: None,
752                },
753            )]),
754            ..Default::default()
755        };
756        assert_eq!(ci.providers_for_pipeline("ci"), &["github"]);
757    }
758
759    #[test]
760    fn test_providers_for_pipeline_override() {
761        let ci = CI {
762            providers: vec!["github".to_string()],
763            pipelines: BTreeMap::from([(
764                "release".to_string(),
765                Pipeline {
766                    providers: vec!["buildkite".to_string()],
767                    mode: PipelineMode::default(),
768                    environment: None,
769                    when: None,
770                    tasks: vec![],
771                    derive_paths: None,
772                    provider: None,
773                },
774            )]),
775            ..Default::default()
776        };
777        assert_eq!(ci.providers_for_pipeline("release"), &["buildkite"]);
778    }
779
780    #[test]
781    fn test_providers_for_pipeline_empty() {
782        let ci = CI::default();
783        assert!(ci.providers_for_pipeline("any").is_empty());
784    }
785
786    #[test]
787    fn test_providers_for_pipeline_nonexistent() {
788        let ci = CI {
789            providers: vec!["github".to_string()],
790            ..Default::default()
791        };
792        // Non-existent pipeline falls back to global
793        assert_eq!(ci.providers_for_pipeline("nonexistent"), &["github"]);
794    }
795}