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
38#[derive(Debug, Clone, Default, Serialize)]
42pub struct WorkflowTriggers {
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub push: Option<PushTrigger>,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub pull_request: Option<PullRequestTrigger>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub release: Option<ReleaseTrigger>,
54
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub workflow_dispatch: Option<WorkflowDispatchTrigger>,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub schedule: Option<Vec<ScheduleTrigger>>,
62}
63
64#[derive(Debug, Clone, Default, Serialize)]
66#[serde(rename_all = "kebab-case")]
67pub struct PushTrigger {
68 #[serde(skip_serializing_if = "Vec::is_empty")]
70 pub branches: Vec<String>,
71
72 #[serde(skip_serializing_if = "Vec::is_empty")]
74 pub tags: Vec<String>,
75
76 #[serde(skip_serializing_if = "Vec::is_empty")]
78 pub paths: Vec<String>,
79
80 #[serde(skip_serializing_if = "Vec::is_empty")]
82 pub paths_ignore: Vec<String>,
83}
84
85#[derive(Debug, Clone, Default, Serialize)]
87#[serde(rename_all = "kebab-case")]
88pub struct PullRequestTrigger {
89 #[serde(skip_serializing_if = "Vec::is_empty")]
91 pub branches: Vec<String>,
92
93 #[serde(skip_serializing_if = "Vec::is_empty")]
95 pub types: Vec<String>,
96
97 #[serde(skip_serializing_if = "Vec::is_empty")]
99 pub paths: Vec<String>,
100
101 #[serde(skip_serializing_if = "Vec::is_empty")]
103 pub paths_ignore: Vec<String>,
104}
105
106#[derive(Debug, Clone, Default, Serialize)]
108pub struct ReleaseTrigger {
109 #[serde(skip_serializing_if = "Vec::is_empty")]
111 pub types: Vec<String>,
112}
113
114#[derive(Debug, Clone, Default, Serialize)]
116pub struct WorkflowDispatchTrigger {
117 #[serde(skip_serializing_if = "IndexMap::is_empty")]
119 pub inputs: IndexMap<String, WorkflowInput>,
120}
121
122#[derive(Debug, Clone, Serialize)]
124pub struct WorkflowInput {
125 pub description: String,
127
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub required: Option<bool>,
131
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub default: Option<String>,
135
136 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
138 pub input_type: Option<String>,
139
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub options: Option<Vec<String>>,
143}
144
145#[derive(Debug, Clone, Serialize)]
147pub struct ScheduleTrigger {
148 pub cron: String,
150}
151
152#[derive(Debug, Clone, Serialize)]
154#[serde(rename_all = "kebab-case")]
155pub struct Concurrency {
156 pub group: String,
158
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub cancel_in_progress: Option<bool>,
162}
163
164#[derive(Debug, Clone, Default, Serialize)]
168#[serde(rename_all = "kebab-case")]
169pub struct Permissions {
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub contents: Option<PermissionLevel>,
173
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub checks: Option<PermissionLevel>,
177
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub pull_requests: Option<PermissionLevel>,
181
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub issues: Option<PermissionLevel>,
185
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub packages: Option<PermissionLevel>,
189
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub id_token: Option<PermissionLevel>,
193
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub actions: Option<PermissionLevel>,
197}
198
199#[derive(Debug, Clone, Copy, Serialize)]
201#[serde(rename_all = "lowercase")]
202pub enum PermissionLevel {
203 Read,
205 Write,
207 None,
209}
210
211#[derive(Debug, Clone, Serialize)]
215#[serde(rename_all = "kebab-case")]
216pub struct Job {
217 #[serde(skip_serializing_if = "Option::is_none")]
219 pub name: Option<String>,
220
221 pub runs_on: RunsOn,
223
224 #[serde(skip_serializing_if = "Vec::is_empty")]
226 pub needs: Vec<String>,
227
228 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
230 pub if_condition: Option<String>,
231
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub environment: Option<Environment>,
235
236 #[serde(skip_serializing_if = "IndexMap::is_empty")]
238 pub env: IndexMap<String, String>,
239
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub concurrency: Option<Concurrency>,
243
244 #[serde(skip_serializing_if = "Option::is_none")]
246 pub continue_on_error: Option<bool>,
247
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub timeout_minutes: Option<u32>,
251
252 pub steps: Vec<Step>,
254}
255
256#[derive(Debug, Clone, Serialize)]
258#[serde(untagged)]
259pub enum RunsOn {
260 Label(String),
262 Labels(Vec<String>),
264}
265
266#[derive(Debug, Clone, Serialize)]
270#[serde(untagged)]
271pub enum Environment {
272 Name(String),
274 WithUrl {
276 name: String,
278 url: String,
280 },
281}
282
283#[derive(Debug, Clone, Default, Serialize)]
287#[serde(rename_all = "kebab-case")]
288pub struct Step {
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub name: Option<String>,
292
293 #[serde(skip_serializing_if = "Option::is_none")]
295 pub id: Option<String>,
296
297 #[serde(rename = "if", skip_serializing_if = "Option::is_none")]
299 pub if_condition: Option<String>,
300
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub uses: Option<String>,
304
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub run: Option<String>,
308
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub working_directory: Option<String>,
312
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub shell: Option<String>,
316
317 #[serde(rename = "with", skip_serializing_if = "IndexMap::is_empty")]
319 pub with_inputs: IndexMap<String, serde_yaml::Value>,
320
321 #[serde(skip_serializing_if = "IndexMap::is_empty")]
323 pub env: IndexMap<String, String>,
324
325 #[serde(skip_serializing_if = "Option::is_none")]
327 pub continue_on_error: Option<bool>,
328
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub timeout_minutes: Option<u32>,
332}
333
334impl Step {
335 pub fn uses(action: impl Into<String>) -> Self {
337 Self {
338 uses: Some(action.into()),
339 ..Default::default()
340 }
341 }
342
343 pub fn run(command: impl Into<String>) -> Self {
345 Self {
346 run: Some(command.into()),
347 ..Default::default()
348 }
349 }
350
351 #[must_use]
353 pub fn with_name(mut self, name: impl Into<String>) -> Self {
354 self.name = Some(name.into());
355 self
356 }
357
358 #[must_use]
360 pub fn with_id(mut self, id: impl Into<String>) -> Self {
361 self.id = Some(id.into());
362 self
363 }
364
365 #[must_use]
367 pub fn with_input(
368 mut self,
369 key: impl Into<String>,
370 value: impl Into<serde_yaml::Value>,
371 ) -> Self {
372 self.with_inputs.insert(key.into(), value.into());
373 self
374 }
375
376 #[must_use]
378 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
379 self.env.insert(key.into(), value.into());
380 self
381 }
382
383 #[must_use]
385 pub fn with_if(mut self, condition: impl Into<String>) -> Self {
386 self.if_condition = Some(condition.into());
387 self
388 }
389
390 #[must_use]
392 pub fn with_working_directory(mut self, dir: impl Into<String>) -> Self {
393 self.working_directory = Some(dir.into());
394 self
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn test_step_builder() {
404 let step = Step::uses("actions/checkout@v4")
405 .with_name("Checkout")
406 .with_input("fetch-depth", serde_yaml::Value::Number(2.into()));
407
408 assert_eq!(step.name, Some("Checkout".to_string()));
409 assert_eq!(step.uses, Some("actions/checkout@v4".to_string()));
410 assert!(step.with_inputs.contains_key("fetch-depth"));
411 }
412
413 #[test]
414 fn test_workflow_serialization() {
415 let workflow = Workflow {
416 name: "CI".to_string(),
417 on: WorkflowTriggers {
418 push: Some(PushTrigger {
419 branches: vec!["main".to_string()],
420 ..Default::default()
421 }),
422 ..Default::default()
423 },
424 concurrency: Some(Concurrency {
425 group: "${{ github.workflow }}-${{ github.ref }}".to_string(),
426 cancel_in_progress: Some(true),
427 }),
428 permissions: Some(Permissions {
429 contents: Some(PermissionLevel::Read),
430 ..Default::default()
431 }),
432 env: IndexMap::new(),
433 jobs: IndexMap::new(),
434 };
435
436 let yaml = serde_yaml::to_string(&workflow).unwrap();
437 assert!(yaml.contains("name: CI"));
438 assert!(yaml.contains("push:"));
439 assert!(yaml.contains("branches:"));
440 assert!(yaml.contains("- main"));
441 }
442
443 #[test]
444 fn test_job_with_needs() {
445 let job = Job {
446 name: Some("Test".to_string()),
447 runs_on: RunsOn::Label("ubuntu-latest".to_string()),
448 needs: vec!["build".to_string()],
449 if_condition: None,
450 environment: None,
451 env: IndexMap::new(),
452 concurrency: None,
453 continue_on_error: None,
454 timeout_minutes: None,
455 steps: vec![],
456 };
457
458 let yaml = serde_yaml::to_string(&job).unwrap();
459 assert!(yaml.contains("name: Test"));
460 assert!(yaml.contains("runs-on: ubuntu-latest"));
461 assert!(yaml.contains("needs:"));
462 assert!(yaml.contains("- build"));
463 }
464
465 #[test]
466 fn test_environment_serialization() {
467 let env_simple = Environment::Name("production".to_string());
468 let yaml = serde_yaml::to_string(&env_simple).unwrap();
469 assert!(yaml.contains("production"));
470
471 let env_with_url = Environment::WithUrl {
472 name: "production".to_string(),
473 url: "https://example.com".to_string(),
474 };
475 let yaml = serde_yaml::to_string(&env_with_url).unwrap();
476 assert!(yaml.contains("name: production"));
477 assert!(yaml.contains("url:"));
478 }
479}