Skip to main content

cuenv_core/
ci.rs

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