1use crate::{error, JobId};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
6#[serde(untagged)]
7pub enum EnvironmentVariable {
8 String(String),
9 Number(f64),
10 Boolean(bool),
11}
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct UserCommandStep {
15 pub name: Option<String>,
16 pub image: Option<String>,
17 pub run: String,
18 #[serde(rename = "continue-on-error")]
19 pub continue_on_error: Option<bool>,
20 pub environments: Option<HashMap<String, EnvironmentVariable>>,
21 pub secrets: Option<Vec<String>>,
22 pub volumes: Option<Vec<String>>,
23 pub timeout: Option<String>,
24 #[serde(rename = "security-opts")]
25 pub security_opts: Option<Vec<String>>,
26}
27
28#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct UserActionStep {
30 pub name: Option<String>,
31 pub uses: String,
32 pub with: Option<serde_yaml::Value>,
33 #[serde(rename = "continue-on-error")]
34 pub continue_on_error: Option<bool>,
35 pub environments: Option<HashMap<String, EnvironmentVariable>>,
36 pub secrets: Option<Vec<String>>,
37 pub volumes: Option<Vec<String>>,
38 pub timeout: Option<String>,
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(untagged)]
43pub enum UserStep {
44 Command(UserCommandStep),
45 Action(UserActionStep),
46}
47
48#[derive(Serialize, Deserialize, Debug, Clone)]
49pub struct UserJob {
50 pub name: Option<String>,
51 pub image: Option<String>,
52 #[serde(rename = "working-directories")]
54 pub working_dirs: Option<Vec<String>>,
55 pub steps: Vec<UserStep>,
56 #[serde(rename = "depends-on")]
57 pub depends_on: Option<Vec<String>>,
58}
59
60#[derive(Serialize, Deserialize, Debug, Clone)]
61pub struct UserWorkflow {
62 pub name: Option<String>,
63 pub on: Option<WorkflowTriggerEvents>,
64 pub jobs: HashMap<JobId, UserJob>,
65}
66
67#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
68pub struct WorkflowPushEvent {
69 pub branches: Option<Vec<String>>,
70 pub tags: Option<Vec<String>>,
71 pub paths: Option<Vec<String>>,
72}
73
74#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
75pub struct WorkflowPullRequestEvent {
76 pub types: Option<Vec<String>>,
77 pub branches: Option<Vec<String>>,
78 pub paths: Option<Vec<String>>,
79}
80
81#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
82pub struct WorkflowLabelEvent {
83 pub types: Option<Vec<String>>,
84}
85
86#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
87#[serde(rename_all = "lowercase")]
88pub struct WorkflowTriggerEvents {
89 pub push: Option<WorkflowPushEvent>,
90 pub pull_request: Option<WorkflowPullRequestEvent>,
91 pub label: Option<WorkflowLabelEvent>,
92}
93
94impl UserWorkflow {
95 pub fn from_str(str: &str) -> crate::Result<Self> {
96 let workflow = serde_yaml::from_str(str).map_err(|e| {
97 error::Error::workflow_config_error(format!("Failed to parse workflow: {}", e))
98 })?;
99
100 Self::validate(&workflow)?;
101
102 Ok(workflow)
103 }
104
105 fn validate(workflow: &UserWorkflow) -> crate::Result<()> {
106 if workflow.jobs.is_empty() {
107 return Err(error::Error::workflow_config_error(
108 "Workflow must have at least one job",
109 ));
110 }
111
112 let mut is_all_jobs_has_dependencies = true;
113 for (job_name, job) in &workflow.jobs {
115 if let Some(depends_on) = &job.depends_on {
116 if !depends_on.is_empty() {
117 for depend_job_key in depends_on {
118 if !workflow.jobs.contains_key(depend_job_key) {
119 return Err(error::Error::workflow_config_error(format!(
120 "Job {} depends on job {}, but job {} is not defined",
121 job_name, depend_job_key, depend_job_key
122 )));
123 }
124 }
125 } else {
126 is_all_jobs_has_dependencies = false;
127 }
128 } else {
129 is_all_jobs_has_dependencies = false;
130 }
131
132 if job.steps.is_empty() {
133 return Err(error::Error::workflow_config_error(format!(
134 "Job `{}` must have at least one step",
135 job_name
136 )));
137 }
138 }
139
140 if is_all_jobs_has_dependencies {
141 return Err(error::Error::workflow_config_error(
142 "Cannot have all jobs has dependencies",
143 ));
144 }
145
146 Ok(())
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::{EnvironmentVariable, UserCommandStep, UserStep, UserWorkflow, WorkflowPushEvent};
153 use crate::{error::Error, user_config::WorkflowTriggerEvents};
154
155 #[test]
156 fn test_parse() {
157 let yaml = r#"
158name: Test Workflow
159on:
160 push:
161 branches:
162 - master
163
164jobs:
165 test-job:
166 name: Test Job
167 working-directories:
168 - /home/runner/work
169 steps:
170 - name: Test Step
171 continue-on-error: true
172 timeout: 10m
173 environments:
174 TEST_ENV: test
175 number: 1
176 boolean: true
177 run: echo "Hello World"
178 - name: Action step
179 uses: cache
180"#;
181
182 let workflow = UserWorkflow::from_str(yaml).unwrap();
183
184 assert_eq!(workflow.name, Some("Test Workflow".to_string()));
185
186 assert_eq!(
187 workflow.on,
188 Some(WorkflowTriggerEvents {
189 push: Some(WorkflowPushEvent {
190 branches: Some(vec!["master".to_string()]),
191 tags: None,
192 paths: None,
193 }),
194 pull_request: None,
195 label: None,
196 })
197 );
198
199 let job = workflow.jobs.get("test-job").unwrap();
200 assert_eq!(job.name, Some("Test Job".to_string()));
201 let step = job.steps.get(0).unwrap();
204
205 if let UserStep::Command(command_step) = step {
206 let UserCommandStep {
207 name,
208 environments,
209 run,
210 continue_on_error,
211 timeout,
212 ..
213 } = command_step;
214 assert_eq!(name.as_ref().unwrap(), "Test Step");
215 assert_eq!(timeout.as_ref().unwrap(), "10m");
217 assert_eq!(continue_on_error, &Some(true));
218
219 let environments = environments.clone().unwrap();
220 assert_eq!(
221 environments.get("TEST_ENV").unwrap(),
222 &EnvironmentVariable::String("test".to_string())
223 );
224 assert_eq!(
225 environments.get("number").unwrap(),
226 &EnvironmentVariable::Number(1.0)
227 );
228 assert_eq!(
229 environments.get("boolean").unwrap(),
230 &EnvironmentVariable::Boolean(true)
231 );
232
233 assert_eq!(run, "echo \"Hello World\"");
234 } else {
235 panic!("Step should be command step");
236 }
237 }
238
239 #[test]
240 fn test_empty_jobs() {
241 let yaml = r#"jobs:"#;
242
243 let res = UserWorkflow::from_str(yaml);
244
245 assert_eq!(
246 res.unwrap_err(),
247 Error::workflow_config_error("Workflow must have at least one job")
248 );
249 }
250
251 #[test]
252 fn test_job_depend_not_exist() {
253 let yaml = r#"
254jobs:
255 job1:
256 depends-on: [job2]
257 steps:
258 - run: echo "Hello World"
259"#;
260
261 let res = UserWorkflow::from_str(yaml);
262 assert_eq!(
263 res.unwrap_err(),
264 Error::workflow_config_error("Job job1 depends on job job2, but job job2 is not defined")
265 );
266 }
267
268 #[test]
269 fn test_job_dependencies() {
270 let yaml = r#"
271jobs:
272 job1:
273 depends-on: [job2]
274 steps:
275 - run: echo "Hello World"
276 job2:
277 depends-on: [job1]
278 steps:
279 - run: echo "Hello World"
280"#;
281
282 let res = UserWorkflow::from_str(yaml);
283 assert_eq!(
284 res.unwrap_err(),
285 Error::workflow_config_error("Cannot have all jobs has dependencies")
286 );
287 }
288
289 #[test]
290 fn test_empty_steps() {
291 let yaml = r#"
292jobs:
293 job1:
294 name: Test Job
295 steps:
296"#;
297
298 let res = UserWorkflow::from_str(yaml);
299 assert_eq!(
300 res.unwrap_err(),
301 Error::workflow_config_error("Job `job1` must have at least one step")
302 );
303 }
304}