Skip to main content

cuenv_github/workflow/
schema.rs

1//! GitHub Actions Workflow Schema Types
2//!
3//! Defines the data structures for GitHub Actions workflow YAML generation.
4//! See: <https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions>
5
6use indexmap::IndexMap;
7use serde::Serialize;
8
9/// A GitHub Actions workflow definition.
10///
11/// Represents the complete structure of a workflow file that can be committed
12/// to `.github/workflows/`.
13#[derive(Debug, Clone, Serialize)]
14pub struct Workflow {
15    /// Workflow name displayed in GitHub UI
16    pub name: String,
17
18    /// Trigger configuration
19    #[serde(rename = "on")]
20    pub on: WorkflowTriggers,
21
22    /// Concurrency settings to prevent duplicate runs
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub concurrency: Option<Concurrency>,
25
26    /// Default permissions for `GITHUB_TOKEN`
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub permissions: Option<Permissions>,
29
30    /// Environment variables available to all jobs
31    #[serde(skip_serializing_if = "IndexMap::is_empty")]
32    pub env: IndexMap<String, String>,
33
34    /// Job definitions (order preserved via `IndexMap`)
35    pub jobs: IndexMap<String, Job>,
36}
37
38impl Workflow {
39    /// Serialize workflow to YAML with generation header.
40    ///
41    /// # Errors
42    ///
43    /// Returns an error if YAML serialization fails.
44    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
45        let yaml = serde_yaml::to_string(self)?;
46        let header =
47            "# Generated by cuenv - do not edit manually\n# Regenerate with: cuenv sync ci\n\n";
48        Ok(format!("{header}{yaml}"))
49    }
50}
51
52/// Workflow trigger configuration.
53///
54/// Defines when the workflow should run.
55#[derive(Debug, Clone, Default, Serialize)]
56pub struct WorkflowTriggers {
57    /// Trigger on push events
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub push: Option<PushTrigger>,
60
61    /// Trigger on pull request events
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub pull_request: Option<PullRequestTrigger>,
64
65    /// Trigger on release events
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub release: Option<ReleaseTrigger>,
68
69    /// Manual trigger with optional inputs
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub workflow_dispatch: Option<WorkflowDispatchTrigger>,
72
73    /// Scheduled trigger (cron expressions)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub schedule: Option<Vec<ScheduleTrigger>>,
76}
77
78/// Push event trigger configuration.
79#[derive(Debug, Clone, Default, Serialize)]
80#[serde(rename_all = "kebab-case")]
81pub struct PushTrigger {
82    /// Branch patterns to trigger on
83    #[serde(skip_serializing_if = "Vec::is_empty")]
84    pub branches: Vec<String>,
85
86    /// Tag patterns to trigger on
87    #[serde(skip_serializing_if = "Vec::is_empty")]
88    pub tags: Vec<String>,
89
90    /// Path patterns that must be matched to trigger
91    #[serde(skip_serializing_if = "Vec::is_empty")]
92    pub paths: Vec<String>,
93
94    /// Path patterns to ignore
95    #[serde(skip_serializing_if = "Vec::is_empty")]
96    pub paths_ignore: Vec<String>,
97}
98
99/// Pull request event trigger configuration.
100#[derive(Debug, Clone, Default, Serialize)]
101#[serde(rename_all = "kebab-case")]
102pub struct PullRequestTrigger {
103    /// Branch patterns to trigger on (target branches)
104    #[serde(skip_serializing_if = "Vec::is_empty")]
105    pub branches: Vec<String>,
106
107    /// Activity types to trigger on (e.g., "opened", "synchronize")
108    #[serde(skip_serializing_if = "Vec::is_empty")]
109    pub types: Vec<String>,
110
111    /// Path patterns that must be matched to trigger
112    #[serde(skip_serializing_if = "Vec::is_empty")]
113    pub paths: Vec<String>,
114
115    /// Path patterns to ignore
116    #[serde(skip_serializing_if = "Vec::is_empty")]
117    pub paths_ignore: Vec<String>,
118}
119
120/// Release event trigger configuration.
121#[derive(Debug, Clone, Default, Serialize)]
122pub struct ReleaseTrigger {
123    /// Activity types to trigger on (e.g., "published", "created")
124    #[serde(skip_serializing_if = "Vec::is_empty")]
125    pub types: Vec<String>,
126}
127
128/// Manual workflow dispatch trigger configuration.
129#[derive(Debug, Clone, Default, Serialize)]
130pub struct WorkflowDispatchTrigger {
131    /// Input parameters for manual trigger
132    #[serde(skip_serializing_if = "IndexMap::is_empty")]
133    pub inputs: IndexMap<String, WorkflowInput>,
134}
135
136/// Input definition for `workflow_dispatch` triggers.
137#[derive(Debug, Clone, Serialize)]
138pub struct WorkflowInput {
139    /// Human-readable description of the input
140    pub description: String,
141
142    /// Whether the input is required
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub required: Option<bool>,
145
146    /// Default value for the input
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub default: Option<String>,
149
150    /// Input type (string, boolean, choice, environment)
151    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
152    pub input_type: Option<String>,
153
154    /// Options for choice-type inputs
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub options: Option<Vec<String>>,
157}
158
159/// Schedule trigger using cron expressions.
160#[derive(Debug, Clone, Serialize)]
161pub struct ScheduleTrigger {
162    /// Cron expression (e.g., "0 0 * * *" for daily at midnight)
163    pub cron: String,
164}
165
166/// Concurrency configuration to prevent duplicate workflow runs.
167#[derive(Debug, Clone, Serialize)]
168#[serde(rename_all = "kebab-case")]
169pub struct Concurrency {
170    /// Concurrency group name (use expressions like `${{ github.workflow }}`)
171    pub group: String,
172
173    /// Whether to cancel in-progress runs when a new run is triggered
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub cancel_in_progress: Option<bool>,
176}
177
178/// `GITHUB_TOKEN` permissions configuration.
179///
180/// Controls what the workflow can access using the automatic `GITHUB_TOKEN`.
181#[derive(Debug, Clone, Default, Serialize)]
182#[serde(rename_all = "kebab-case")]
183pub struct Permissions {
184    /// Repository contents permission
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub contents: Option<PermissionLevel>,
187
188    /// Check runs permission
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub checks: Option<PermissionLevel>,
191
192    /// Pull requests permission
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub pull_requests: Option<PermissionLevel>,
195
196    /// Issues permission
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub issues: Option<PermissionLevel>,
199
200    /// GitHub Packages permission
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub packages: Option<PermissionLevel>,
203
204    /// OIDC token permission (for cloud authentication)
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub id_token: Option<PermissionLevel>,
207
208    /// GitHub Actions permission
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub actions: Option<PermissionLevel>,
211}
212
213/// Permission level for `GITHUB_TOKEN` scopes.
214#[derive(Debug, Clone, Copy, Serialize)]
215#[serde(rename_all = "lowercase")]
216pub enum PermissionLevel {
217    /// Read-only access
218    Read,
219    /// Read and write access
220    Write,
221    /// No access
222    None,
223}
224
225/// Matrix strategy for running jobs with different configurations.
226#[derive(Debug, Clone, Serialize)]
227#[serde(rename_all = "kebab-case")]
228pub struct Strategy {
229    /// Matrix of job configurations
230    pub matrix: Matrix,
231
232    /// Whether to fail fast when one matrix job fails
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub fail_fast: Option<bool>,
235
236    /// Maximum parallel jobs
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub max_parallel: Option<u32>,
239}
240
241/// Matrix configuration for strategy.
242#[derive(Debug, Clone, Serialize)]
243pub struct Matrix {
244    /// Include specific combinations
245    #[serde(skip_serializing_if = "Vec::is_empty")]
246    pub include: Vec<IndexMap<String, serde_yaml::Value>>,
247}
248
249/// A job in a GitHub Actions workflow.
250///
251/// Jobs run in parallel by default unless `needs` dependencies are specified.
252#[derive(Debug, Clone, Serialize)]
253#[serde(rename_all = "kebab-case")]
254pub struct Job {
255    /// Job display name (shown in GitHub UI)
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub name: Option<String>,
258
259    /// Runner label(s) specifying where to run
260    pub runs_on: RunsOn,
261
262    /// Job dependencies (these jobs must complete first)
263    #[serde(skip_serializing_if = "Vec::is_empty")]
264    pub needs: Vec<String>,
265
266    /// Conditional execution expression
267    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
268    pub if_condition: Option<String>,
269
270    /// Matrix strategy for parallel job execution
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub strategy: Option<Strategy>,
273
274    /// Environment for deployment protection rules
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub environment: Option<Environment>,
277
278    /// Job-level environment variables
279    #[serde(skip_serializing_if = "IndexMap::is_empty")]
280    pub env: IndexMap<String, String>,
281
282    /// Job concurrency settings
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub concurrency: Option<Concurrency>,
285
286    /// Continue workflow if this job fails
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub continue_on_error: Option<bool>,
289
290    /// Job timeout in minutes
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub timeout_minutes: Option<u32>,
293
294    /// Job steps (executed sequentially)
295    pub steps: Vec<Step>,
296}
297
298/// Runner specification for where a job runs.
299#[derive(Debug, Clone, Serialize)]
300#[serde(untagged)]
301pub enum RunsOn {
302    /// Single runner label (e.g., "ubuntu-latest")
303    Label(String),
304    /// Multiple runner labels (job runs on runner matching all labels)
305    Labels(Vec<String>),
306}
307
308/// Environment for deployment protection rules.
309///
310/// Environments can require manual approval or have other protection rules.
311#[derive(Debug, Clone, Serialize)]
312#[serde(untagged)]
313pub enum Environment {
314    /// Simple environment name
315    Name(String),
316    /// Environment with deployment URL
317    WithUrl {
318        /// Environment name
319        name: String,
320        /// URL to the deployed environment
321        url: String,
322    },
323}
324
325/// A step in a job.
326///
327/// Steps can either `uses` an action or `run` a shell command.
328#[derive(Debug, Clone, Default, Serialize)]
329#[serde(rename_all = "kebab-case")]
330pub struct Step {
331    /// Step display name (shown in GitHub UI)
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub name: Option<String>,
334
335    /// Unique identifier for referencing step outputs
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub id: Option<String>,
338
339    /// Conditional execution expression
340    #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
341    pub if_condition: Option<String>,
342
343    /// Action to use (e.g., "actions/checkout@v4")
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub uses: Option<String>,
346
347    /// Shell command(s) to run
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub run: Option<String>,
350
351    /// Working directory for run commands
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub working_directory: Option<String>,
354
355    /// Shell to use for run commands (e.g., "bash", "pwsh")
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub shell: Option<String>,
358
359    /// Action inputs (for `uses` steps)
360    #[serde(rename = "with", skip_serializing_if = "IndexMap::is_empty")]
361    pub with_inputs: IndexMap<String, serde_yaml::Value>,
362
363    /// Step environment variables
364    #[serde(skip_serializing_if = "IndexMap::is_empty")]
365    pub env: IndexMap<String, String>,
366
367    /// Continue on error (don't fail the job)
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub continue_on_error: Option<bool>,
370
371    /// Step timeout in minutes
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub timeout_minutes: Option<u32>,
374}
375
376impl Step {
377    /// Create a step that uses an action
378    pub fn uses(action: impl Into<String>) -> Self {
379        Self {
380            uses: Some(action.into()),
381            ..Default::default()
382        }
383    }
384
385    /// Create a step that runs a shell command
386    pub fn run(command: impl Into<String>) -> Self {
387        Self {
388            run: Some(command.into()),
389            ..Default::default()
390        }
391    }
392
393    /// Set the step name
394    #[must_use]
395    pub fn with_name(mut self, name: impl Into<String>) -> Self {
396        self.name = Some(name.into());
397        self
398    }
399
400    /// Set the step ID
401    #[must_use]
402    pub fn with_id(mut self, id: impl Into<String>) -> Self {
403        self.id = Some(id.into());
404        self
405    }
406
407    /// Add a with input
408    #[must_use]
409    pub fn with_input(
410        mut self,
411        key: impl Into<String>,
412        value: impl Into<serde_yaml::Value>,
413    ) -> Self {
414        self.with_inputs.insert(key.into(), value.into());
415        self
416    }
417
418    /// Add an environment variable
419    #[must_use]
420    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
421        self.env.insert(key.into(), value.into());
422        self
423    }
424
425    /// Set a condition
426    #[must_use]
427    pub fn with_if(mut self, condition: impl Into<String>) -> Self {
428        self.if_condition = Some(condition.into());
429        self
430    }
431
432    /// Set working directory
433    #[must_use]
434    pub fn with_working_directory(mut self, dir: impl Into<String>) -> Self {
435        self.working_directory = Some(dir.into());
436        self
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_step_builder() {
446        let step = Step::uses("actions/checkout@v4")
447            .with_name("Checkout")
448            .with_input("fetch-depth", serde_yaml::Value::Number(2.into()));
449
450        assert_eq!(step.name, Some("Checkout".to_string()));
451        assert_eq!(step.uses, Some("actions/checkout@v4".to_string()));
452        assert!(step.with_inputs.contains_key("fetch-depth"));
453    }
454
455    #[test]
456    fn test_workflow_serialization() {
457        let workflow = Workflow {
458            name: "CI".to_string(),
459            on: WorkflowTriggers {
460                push: Some(PushTrigger {
461                    branches: vec!["main".to_string()],
462                    ..Default::default()
463                }),
464                ..Default::default()
465            },
466            concurrency: Some(Concurrency {
467                group: "${{ github.workflow }}-${{ github.ref }}".to_string(),
468                cancel_in_progress: Some(true),
469            }),
470            permissions: Some(Permissions {
471                contents: Some(PermissionLevel::Read),
472                ..Default::default()
473            }),
474            env: IndexMap::new(),
475            jobs: IndexMap::new(),
476        };
477
478        let yaml = serde_yaml::to_string(&workflow).unwrap();
479        assert!(yaml.contains("name: CI"));
480        assert!(yaml.contains("push:"));
481        assert!(yaml.contains("branches:"));
482        assert!(yaml.contains("- main"));
483    }
484
485    #[test]
486    fn test_job_with_needs() {
487        let job = Job {
488            name: Some("Test".to_string()),
489            runs_on: RunsOn::Label("ubuntu-latest".to_string()),
490            needs: vec!["build".to_string()],
491            if_condition: None,
492            strategy: None,
493            environment: None,
494            env: IndexMap::new(),
495            concurrency: None,
496            continue_on_error: None,
497            timeout_minutes: None,
498            steps: vec![],
499        };
500
501        let yaml = serde_yaml::to_string(&job).unwrap();
502        assert!(yaml.contains("name: Test"));
503        assert!(yaml.contains("runs-on: ubuntu-latest"));
504        assert!(yaml.contains("needs:"));
505        assert!(yaml.contains("- build"));
506    }
507
508    #[test]
509    fn test_environment_serialization() {
510        let env_simple = Environment::Name("production".to_string());
511        let yaml = serde_yaml::to_string(&env_simple).unwrap();
512        assert!(yaml.contains("production"));
513
514        let env_with_url = Environment::WithUrl {
515            name: "production".to_string(),
516            url: "https://example.com".to_string(),
517        };
518        let yaml = serde_yaml::to_string(&env_with_url).unwrap();
519        assert!(yaml.contains("name: production"));
520        assert!(yaml.contains("url:"));
521    }
522}