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