1use indexmap::IndexMap;
7use serde::Serialize;
8
9#[derive(Debug, Clone, Serialize)]
14pub struct Workflow {
15 pub name: String,
17
18 #[serde(rename = "on")]
20 pub on: WorkflowTriggers,
21
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub concurrency: Option<Concurrency>,
25
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub permissions: Option<Permissions>,
29
30 #[serde(skip_serializing_if = "IndexMap::is_empty")]
32 pub env: IndexMap<String, String>,
33
34 pub jobs: IndexMap<String, Job>,
36}
37
38impl Workflow {
39 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#[derive(Debug, Clone, Default, Serialize)]
56pub struct WorkflowTriggers {
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub push: Option<PushTrigger>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub pull_request: Option<PullRequestTrigger>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub release: Option<ReleaseTrigger>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub workflow_dispatch: Option<WorkflowDispatchTrigger>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub schedule: Option<Vec<ScheduleTrigger>>,
76}
77
78#[derive(Debug, Clone, Default, Serialize)]
80#[serde(rename_all = "kebab-case")]
81pub struct PushTrigger {
82 #[serde(skip_serializing_if = "Vec::is_empty")]
84 pub branches: Vec<String>,
85
86 #[serde(skip_serializing_if = "Vec::is_empty")]
88 pub tags: Vec<String>,
89
90 #[serde(skip_serializing_if = "Vec::is_empty")]
92 pub paths: Vec<String>,
93
94 #[serde(skip_serializing_if = "Vec::is_empty")]
96 pub paths_ignore: Vec<String>,
97}
98
99#[derive(Debug, Clone, Default, Serialize)]
101#[serde(rename_all = "kebab-case")]
102pub struct PullRequestTrigger {
103 #[serde(skip_serializing_if = "Vec::is_empty")]
105 pub branches: Vec<String>,
106
107 #[serde(skip_serializing_if = "Vec::is_empty")]
109 pub types: Vec<String>,
110
111 #[serde(skip_serializing_if = "Vec::is_empty")]
113 pub paths: Vec<String>,
114
115 #[serde(skip_serializing_if = "Vec::is_empty")]
117 pub paths_ignore: Vec<String>,
118}
119
120#[derive(Debug, Clone, Default, Serialize)]
122pub struct ReleaseTrigger {
123 #[serde(skip_serializing_if = "Vec::is_empty")]
125 pub types: Vec<String>,
126}
127
128#[derive(Debug, Clone, Default, Serialize)]
130pub struct WorkflowDispatchTrigger {
131 #[serde(skip_serializing_if = "IndexMap::is_empty")]
133 pub inputs: IndexMap<String, WorkflowInput>,
134}
135
136#[derive(Debug, Clone, Serialize)]
138pub struct WorkflowInput {
139 pub description: String,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub required: Option<bool>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub default: Option<String>,
149
150 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
152 pub input_type: Option<String>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub options: Option<Vec<String>>,
157}
158
159#[derive(Debug, Clone, Serialize)]
161pub struct ScheduleTrigger {
162 pub cron: String,
164}
165
166#[derive(Debug, Clone, Serialize)]
168#[serde(rename_all = "kebab-case")]
169pub struct Concurrency {
170 pub group: String,
172
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub cancel_in_progress: Option<bool>,
176}
177
178#[derive(Debug, Clone, Default, Serialize)]
182#[serde(rename_all = "kebab-case")]
183pub struct Permissions {
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub contents: Option<PermissionLevel>,
187
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub checks: Option<PermissionLevel>,
191
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub pull_requests: Option<PermissionLevel>,
195
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub issues: Option<PermissionLevel>,
199
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub packages: Option<PermissionLevel>,
203
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub id_token: Option<PermissionLevel>,
207
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub actions: Option<PermissionLevel>,
211}
212
213#[derive(Debug, Clone, Copy, Serialize)]
215#[serde(rename_all = "lowercase")]
216pub enum PermissionLevel {
217 Read,
219 Write,
221 None,
223}
224
225#[derive(Debug, Clone, Serialize)]
227#[serde(rename_all = "kebab-case")]
228pub struct Strategy {
229 pub matrix: Matrix,
231
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub fail_fast: Option<bool>,
235
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub max_parallel: Option<u32>,
239}
240
241#[derive(Debug, Clone, Serialize)]
243pub struct Matrix {
244 #[serde(skip_serializing_if = "Vec::is_empty")]
246 pub include: Vec<IndexMap<String, serde_yaml::Value>>,
247}
248
249#[derive(Debug, Clone, Serialize)]
253#[serde(rename_all = "kebab-case")]
254pub struct Job {
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub name: Option<String>,
258
259 pub runs_on: RunsOn,
261
262 #[serde(skip_serializing_if = "Vec::is_empty")]
264 pub needs: Vec<String>,
265
266 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
268 pub if_condition: Option<String>,
269
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub strategy: Option<Strategy>,
273
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub environment: Option<Environment>,
277
278 #[serde(skip_serializing_if = "IndexMap::is_empty")]
280 pub env: IndexMap<String, String>,
281
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub concurrency: Option<Concurrency>,
285
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub continue_on_error: Option<bool>,
289
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub timeout_minutes: Option<u32>,
293
294 pub steps: Vec<Step>,
296}
297
298#[derive(Debug, Clone, Serialize)]
300#[serde(untagged)]
301pub enum RunsOn {
302 Label(String),
304 Labels(Vec<String>),
306}
307
308#[derive(Debug, Clone, Serialize)]
312#[serde(untagged)]
313pub enum Environment {
314 Name(String),
316 WithUrl {
318 name: String,
320 url: String,
322 },
323}
324
325#[derive(Debug, Clone, Default, Serialize)]
329#[serde(rename_all = "kebab-case")]
330pub struct Step {
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub name: Option<String>,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub id: Option<String>,
338
339 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
341 pub if_condition: Option<String>,
342
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub uses: Option<String>,
346
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub run: Option<String>,
350
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub working_directory: Option<String>,
354
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub shell: Option<String>,
358
359 #[serde(rename = "with", skip_serializing_if = "IndexMap::is_empty")]
361 pub with_inputs: IndexMap<String, serde_yaml::Value>,
362
363 #[serde(skip_serializing_if = "IndexMap::is_empty")]
365 pub env: IndexMap<String, String>,
366
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub continue_on_error: Option<bool>,
370
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub timeout_minutes: Option<u32>,
374}
375
376impl Step {
377 pub fn uses(action: impl Into<String>) -> Self {
379 Self {
380 uses: Some(action.into()),
381 ..Default::default()
382 }
383 }
384
385 pub fn run(command: impl Into<String>) -> Self {
387 Self {
388 run: Some(command.into()),
389 ..Default::default()
390 }
391 }
392
393 #[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 #[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 #[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 #[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 #[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 #[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}