cuenv_core/
ci.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Workflow dispatch input definition for manual triggers
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
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/// GitHub Actions provider configuration
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
69#[serde(rename_all = "camelCase")]
70pub struct GitHubConfig {
71    /// Runner label(s) - single string or array of labels
72    pub runner: Option<StringOrVec>,
73    /// Cachix configuration for Nix caching
74    pub cachix: Option<CachixConfig>,
75    /// Artifact upload configuration
76    pub artifacts: Option<ArtifactsConfig>,
77    /// Paths to ignore for trigger conditions
78    pub paths_ignore: Option<Vec<String>>,
79    /// Workflow permissions
80    pub permissions: Option<HashMap<String, String>>,
81}
82
83/// Cachix caching configuration
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85#[serde(rename_all = "camelCase")]
86pub struct CachixConfig {
87    /// Cachix cache name
88    pub name: String,
89    /// Secret name for auth token (defaults to CACHIX_AUTH_TOKEN)
90    pub auth_token: Option<String>,
91    /// Push filter pattern
92    pub push_filter: Option<String>,
93}
94
95/// Artifact upload configuration
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
97#[serde(rename_all = "camelCase")]
98pub struct ArtifactsConfig {
99    /// Paths to upload as artifacts
100    pub paths: Option<Vec<String>>,
101    /// Behavior when no files found: "warn", "error", or "ignore"
102    pub if_no_files_found: Option<String>,
103}
104
105/// Buildkite provider configuration
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
107#[serde(rename_all = "camelCase")]
108pub struct BuildkiteConfig {
109    /// Default queue for agents
110    pub queue: Option<String>,
111    /// Enable emoji prefixes in step labels
112    pub use_emojis: Option<bool>,
113    /// Buildkite plugins
114    pub plugins: Option<Vec<BuildkitePlugin>>,
115}
116
117/// Buildkite plugin configuration
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119pub struct BuildkitePlugin {
120    /// Plugin name
121    pub name: String,
122    /// Plugin configuration (arbitrary JSON)
123    pub config: Option<serde_json::Value>,
124}
125
126/// GitLab CI provider configuration
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
128#[serde(rename_all = "camelCase")]
129pub struct GitLabConfig {
130    /// Docker image for jobs
131    pub image: Option<String>,
132    /// Runner tags
133    pub tags: Option<Vec<String>>,
134    /// Cache configuration
135    pub cache: Option<GitLabCacheConfig>,
136}
137
138/// GitLab cache configuration
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
140pub struct GitLabCacheConfig {
141    /// Cache key
142    pub key: Option<String>,
143    /// Paths to cache
144    pub paths: Option<Vec<String>>,
145}
146
147/// Provider-specific configuration container
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
149pub struct ProviderConfig {
150    /// GitHub Actions configuration
151    pub github: Option<GitHubConfig>,
152    /// Buildkite configuration
153    pub buildkite: Option<BuildkiteConfig>,
154    /// GitLab CI configuration
155    pub gitlab: Option<GitLabConfig>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159#[serde(rename_all = "camelCase")]
160pub struct Pipeline {
161    pub name: String,
162    /// Environment for secret resolution (e.g., "production")
163    pub environment: Option<String>,
164    pub when: Option<PipelineCondition>,
165    pub tasks: Vec<String>,
166    /// Whether to derive trigger paths from task inputs.
167    /// Defaults to true for branch/PR triggers, false for scheduled-only.
168    pub derive_paths: Option<bool>,
169    /// Pipeline-specific provider configuration (overrides CI-level defaults)
170    pub provider: Option<ProviderConfig>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
174pub struct CI {
175    pub pipelines: Vec<Pipeline>,
176    /// Global provider configuration defaults
177    pub provider: Option<ProviderConfig>,
178}
179
180impl CI {
181    /// Get merged GitHub config for a specific pipeline.
182    /// Pipeline-specific config overrides CI-level defaults.
183    pub fn github_config_for_pipeline(&self, pipeline_name: &str) -> GitHubConfig {
184        let global = self
185            .provider
186            .as_ref()
187            .and_then(|p| p.github.as_ref())
188            .cloned()
189            .unwrap_or_default();
190
191        let pipeline_config = self
192            .pipelines
193            .iter()
194            .find(|p| p.name == pipeline_name)
195            .and_then(|p| p.provider.as_ref())
196            .and_then(|p| p.github.as_ref());
197
198        match pipeline_config {
199            Some(pipeline) => GitHubConfig {
200                runner: pipeline.runner.clone().or(global.runner),
201                cachix: pipeline.cachix.clone().or(global.cachix),
202                artifacts: pipeline.artifacts.clone().or(global.artifacts),
203                paths_ignore: pipeline.paths_ignore.clone().or(global.paths_ignore),
204                permissions: pipeline.permissions.clone().or(global.permissions),
205            },
206            None => global,
207        }
208    }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212#[serde(untagged)]
213pub enum StringOrVec {
214    String(String),
215    Vec(Vec<String>),
216}
217
218impl StringOrVec {
219    /// Convert to a vector of strings
220    pub fn to_vec(&self) -> Vec<String> {
221        match self {
222            StringOrVec::String(s) => vec![s.clone()],
223            StringOrVec::Vec(v) => v.clone(),
224        }
225    }
226
227    /// Get as a single string (first element if vec)
228    pub fn as_single(&self) -> Option<&str> {
229        match self {
230            StringOrVec::String(s) => Some(s),
231            StringOrVec::Vec(v) => v.first().map(|s| s.as_str()),
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_github_config_merge() {
242        let ci = CI {
243            provider: Some(ProviderConfig {
244                github: Some(GitHubConfig {
245                    runner: Some(StringOrVec::String("ubuntu-latest".to_string())),
246                    cachix: Some(CachixConfig {
247                        name: "my-cache".to_string(),
248                        auth_token: None,
249                        push_filter: None,
250                    }),
251                    ..Default::default()
252                }),
253                ..Default::default()
254            }),
255            pipelines: vec![
256                Pipeline {
257                    name: "ci".to_string(),
258                    environment: None,
259                    when: None,
260                    tasks: vec!["test".to_string()],
261                    derive_paths: None,
262                    provider: Some(ProviderConfig {
263                        github: Some(GitHubConfig {
264                            runner: Some(StringOrVec::String("self-hosted".to_string())),
265                            ..Default::default()
266                        }),
267                        ..Default::default()
268                    }),
269                },
270                Pipeline {
271                    name: "release".to_string(),
272                    environment: None,
273                    when: None,
274                    tasks: vec!["deploy".to_string()],
275                    derive_paths: None,
276                    provider: None,
277                },
278            ],
279        };
280
281        // Pipeline with override
282        let ci_config = ci.github_config_for_pipeline("ci");
283        assert_eq!(
284            ci_config.runner,
285            Some(StringOrVec::String("self-hosted".to_string()))
286        );
287        assert!(ci_config.cachix.is_some()); // Inherited from global
288
289        // Pipeline without override
290        let release_config = ci.github_config_for_pipeline("release");
291        assert_eq!(
292            release_config.runner,
293            Some(StringOrVec::String("ubuntu-latest".to_string()))
294        );
295    }
296
297    #[test]
298    fn test_string_or_vec() {
299        let single = StringOrVec::String("value".to_string());
300        assert_eq!(single.to_vec(), vec!["value"]);
301        assert_eq!(single.as_single(), Some("value"));
302
303        let multi = StringOrVec::Vec(vec!["a".to_string(), "b".to_string()]);
304        assert_eq!(multi.to_vec(), vec!["a", "b"]);
305        assert_eq!(multi.as_single(), Some("a"));
306    }
307
308    #[test]
309    fn test_manual_trigger_bool() {
310        let json = r#"{"manual": true}"#;
311        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
312        assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(true))));
313
314        let json = r#"{"manual": false}"#;
315        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
316        assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(false))));
317    }
318
319    #[test]
320    fn test_manual_trigger_with_inputs() {
321        let json =
322            r#"{"manual": {"tag_name": {"description": "Tag to release", "required": true}}}"#;
323        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
324
325        match &cond.manual {
326            Some(ManualTrigger::WithInputs(inputs)) => {
327                assert!(inputs.contains_key("tag_name"));
328                let input = inputs.get("tag_name").unwrap();
329                assert_eq!(input.description, "Tag to release");
330                assert_eq!(input.required, Some(true));
331            }
332            _ => panic!("Expected WithInputs variant"),
333        }
334    }
335
336    #[test]
337    fn test_manual_trigger_helpers() {
338        let enabled = ManualTrigger::Enabled(true);
339        assert!(enabled.is_enabled());
340        assert!(enabled.inputs().is_none());
341
342        let disabled = ManualTrigger::Enabled(false);
343        assert!(!disabled.is_enabled());
344
345        let mut inputs = HashMap::new();
346        inputs.insert(
347            "tag".to_string(),
348            WorkflowDispatchInput {
349                description: "Tag name".to_string(),
350                required: Some(true),
351                default: None,
352                input_type: None,
353                options: None,
354            },
355        );
356        let with_inputs = ManualTrigger::WithInputs(inputs);
357        assert!(with_inputs.is_enabled());
358        assert!(with_inputs.inputs().is_some());
359    }
360
361    #[test]
362    fn test_scheduled_cron_expressions() {
363        // Single cron expression
364        let json = r#"{"scheduled": "0 0 * * 0"}"#;
365        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
366        match &cond.scheduled {
367            Some(StringOrVec::String(s)) => assert_eq!(s, "0 0 * * 0"),
368            _ => panic!("Expected single string"),
369        }
370
371        // Multiple cron expressions
372        let json = r#"{"scheduled": ["0 0 * * 0", "0 12 * * *"]}"#;
373        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
374        match &cond.scheduled {
375            Some(StringOrVec::Vec(v)) => {
376                assert_eq!(v.len(), 2);
377                assert_eq!(v[0], "0 0 * * 0");
378                assert_eq!(v[1], "0 12 * * *");
379            }
380            _ => panic!("Expected vec"),
381        }
382    }
383
384    #[test]
385    fn test_release_trigger() {
386        let json = r#"{"release": ["published", "created"]}"#;
387        let cond: PipelineCondition = serde_json::from_str(json).unwrap();
388        assert_eq!(
389            cond.release,
390            Some(vec!["published".to_string(), "created".to_string()])
391        );
392    }
393
394    #[test]
395    fn test_pipeline_derive_paths() {
396        let json = r#"{"name": "ci", "tasks": ["test"], "derivePaths": true}"#;
397        let pipeline: Pipeline = serde_json::from_str(json).unwrap();
398        assert_eq!(pipeline.derive_paths, Some(true));
399
400        let json = r#"{"name": "scheduled", "tasks": ["sync"], "derivePaths": false}"#;
401        let pipeline: Pipeline = serde_json::from_str(json).unwrap();
402        assert_eq!(pipeline.derive_paths, Some(false));
403
404        let json = r#"{"name": "default", "tasks": ["build"]}"#;
405        let pipeline: Pipeline = serde_json::from_str(json).unwrap();
406        assert_eq!(pipeline.derive_paths, None);
407    }
408}