1pub mod yaml;
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum JobStatus {
10 Queued,
11 Running,
12 Success,
13 Failure,
14 Skipped,
15 Cancelled,
16}
17
18#[derive(Clone, Debug, Serialize, Deserialize)]
19pub struct Job {
20 pub id: String,
21 pub name: String,
22 pub status: JobStatus,
23 pub command: String,
24 pub duration_secs: Option<u64>,
25 #[serde(default)]
27 pub started_at: Option<f64>,
28 pub depends_on: Vec<String>,
29 pub output: Option<String>,
30 #[serde(default)]
32 pub required_labels: Vec<String>,
33 #[serde(default)]
35 pub max_retries: u32,
36 #[serde(default)]
38 pub attempt: u32,
39 #[serde(default)]
41 pub metadata: HashMap<String, serde_json::Value>,
42}
43
44#[derive(Clone, Debug, Serialize, Deserialize)]
45pub struct Workflow {
46 pub id: String,
47 pub name: String,
48 pub trigger: String,
49 pub jobs: Vec<Job>,
50}
51
52impl Workflow {
53 pub fn sample() -> Self {
55 Workflow {
56 id: "ci-1".into(),
57 name: "ci.yml".into(),
58 trigger: "on: push".into(),
59 jobs: vec![
60 Job {
61 id: "unit-tests".into(),
62 name: "Unit Tests".into(),
63 status: JobStatus::Queued,
64 command: "echo 'Running unit tests' && sleep 2".into(),
65 duration_secs: None,
66 depends_on: vec![],
67 started_at: None,
68 output: None,
69 required_labels: vec![],
70 max_retries: 0,
71 attempt: 0,
72 metadata: HashMap::new(),
73 },
74 Job {
75 id: "lint".into(),
76 name: "Lint".into(),
77 status: JobStatus::Queued,
78 command: "echo 'Running linter' && sleep 1".into(),
79 duration_secs: None,
80 depends_on: vec![],
81 started_at: None,
82 output: None,
83 required_labels: vec![],
84 max_retries: 0,
85 attempt: 0,
86 metadata: HashMap::new(),
87 },
88 Job {
89 id: "typecheck".into(),
90 name: "Typecheck".into(),
91 status: JobStatus::Queued,
92 command: "echo 'Running typecheck' && sleep 2".into(),
93 duration_secs: None,
94 depends_on: vec![],
95 started_at: None,
96 output: None,
97 required_labels: vec![],
98 max_retries: 0,
99 attempt: 0,
100 metadata: HashMap::new(),
101 },
102 Job {
103 id: "build".into(),
104 name: "Build".into(),
105 status: JobStatus::Queued,
106 command: "echo 'Building project' && sleep 3".into(),
107 duration_secs: None,
108 depends_on: vec!["unit-tests".into(), "lint".into(), "typecheck".into()],
109 started_at: None,
110 output: None,
111 required_labels: vec![],
112 max_retries: 0,
113 attempt: 0,
114 metadata: HashMap::new(),
115 },
116 Job {
117 id: "deploy-db".into(),
118 name: "Deploy DB Migrations".into(),
119 status: JobStatus::Queued,
120 command: "echo 'Deploying DB migrations' && sleep 1".into(),
121 duration_secs: None,
122 depends_on: vec!["build".into()],
123 started_at: None,
124 output: None,
125 required_labels: vec![],
126 max_retries: 0,
127 attempt: 0,
128 metadata: HashMap::new(),
129 },
130 Job {
131 id: "e2e-tests".into(),
132 name: "E2E Tests".into(),
133 status: JobStatus::Queued,
134 command: "echo 'Running E2E tests' && sleep 5".into(),
135 duration_secs: None,
136 depends_on: vec!["build".into()],
137 started_at: None,
138 output: None,
139 required_labels: vec![],
140 max_retries: 0,
141 attempt: 0,
142 metadata: HashMap::new(),
143 },
144 Job {
145 id: "deploy-preview".into(),
146 name: "Deploy Preview".into(),
147 status: JobStatus::Queued,
148 command: "echo 'Deploying preview' && sleep 1".into(),
149 duration_secs: None,
150 depends_on: vec!["build".into()],
151 started_at: None,
152 output: None,
153 required_labels: vec![],
154 max_retries: 0,
155 attempt: 0,
156 metadata: HashMap::new(),
157 },
158 Job {
159 id: "deploy-web".into(),
160 name: "Deploy Web".into(),
161 status: JobStatus::Queued,
162 command: "echo 'Deploying to production' && sleep 3".into(),
163 duration_secs: None,
164 depends_on: vec!["deploy-db".into()],
165 started_at: None,
166 output: None,
167 required_labels: vec![],
168 max_retries: 0,
169 attempt: 0,
170 metadata: HashMap::new(),
171 },
172 ],
173 }
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn make_job(id: &str, metadata: HashMap<String, serde_json::Value>) -> Job {
182 Job {
183 id: id.into(),
184 name: id.into(),
185 status: JobStatus::Queued,
186 command: "echo test".into(),
187 duration_secs: None,
188 started_at: None,
189 depends_on: vec![],
190 output: None,
191 required_labels: vec![],
192 max_retries: 0,
193 attempt: 0,
194 metadata,
195 }
196 }
197
198 #[test]
199 fn job_metadata_serializes_roundtrip() {
200 let mut meta = HashMap::new();
201 meta.insert("node_type".into(), serde_json::json!("deploy"));
202 meta.insert("icon".into(), serde_json::json!("rocket"));
203 meta.insert("priority".into(), serde_json::json!(42));
204
205 let job = make_job("deploy-1", meta);
206 let json = serde_json::to_string(&job).unwrap();
207 let deserialized: Job = serde_json::from_str(&json).unwrap();
208
209 assert_eq!(deserialized.metadata.len(), 3);
210 assert_eq!(
211 deserialized.metadata["node_type"],
212 serde_json::json!("deploy")
213 );
214 assert_eq!(deserialized.metadata["icon"], serde_json::json!("rocket"));
215 assert_eq!(deserialized.metadata["priority"], serde_json::json!(42));
216 }
217
218 #[test]
219 fn job_metadata_defaults_to_empty() {
220 let json = r#"{
221 "id": "test",
222 "name": "Test",
223 "status": "queued",
224 "command": "echo hi",
225 "depends_on": []
226 }"#;
227 let job: Job = serde_json::from_str(json).unwrap();
228 assert!(job.metadata.is_empty());
229 }
230
231 #[test]
232 fn job_metadata_with_nested_values() {
233 let mut meta = HashMap::new();
234 meta.insert(
235 "config".into(),
236 serde_json::json!({"timeout": 30, "retries": true}),
237 );
238 meta.insert("tags".into(), serde_json::json!(["ci", "deploy"]));
239
240 let job = make_job("complex", meta);
241 let json = serde_json::to_string(&job).unwrap();
242 let deserialized: Job = serde_json::from_str(&json).unwrap();
243
244 assert_eq!(
245 deserialized.metadata["config"],
246 serde_json::json!({"timeout": 30, "retries": true})
247 );
248 assert_eq!(
249 deserialized.metadata["tags"],
250 serde_json::json!(["ci", "deploy"])
251 );
252 }
253
254 #[test]
255 fn job_metadata_from_json_string() {
256 let json = r##"{
257 "id": "styled",
258 "name": "Styled Node",
259 "status": "running",
260 "command": "echo hi",
261 "depends_on": [],
262 "metadata": {
263 "color": "#ff0000",
264 "weight": 1.5,
265 "visible": true
266 }
267 }"##;
268 let job: Job = serde_json::from_str(json).unwrap();
269 assert_eq!(job.metadata.len(), 3);
270 assert_eq!(job.metadata["color"], serde_json::json!("#ff0000"));
271 assert_eq!(job.metadata["weight"], serde_json::json!(1.5));
272 assert_eq!(job.metadata["visible"], serde_json::json!(true));
273 }
274
275 #[test]
276 fn workflow_sample_has_empty_metadata() {
277 let wf = Workflow::sample();
278 for job in &wf.jobs {
279 assert!(
280 job.metadata.is_empty(),
281 "Expected empty metadata for job '{}'",
282 job.id
283 );
284 }
285 }
286}