Skip to main content

cuenv_core/
ci.rs

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