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)]
36#[serde(rename_all = "snake_case")]
37pub enum FieldType {
38 Text,
39 Textarea,
40 Select,
41 Toggle,
42 Badge,
43 Slider,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct FieldDef {
49 pub key: String,
51 pub field_type: FieldType,
53 pub label: String,
55 #[serde(default)]
57 pub options: Vec<String>,
58 #[serde(default)]
60 pub default_value: Option<serde_json::Value>,
61 #[serde(default)]
63 pub min: Option<f64>,
64 #[serde(default)]
66 pub max: Option<f64>,
67}
68
69#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct NodeDefinition {
76 pub node_type: String,
79 pub label: String,
81 #[serde(default)]
83 pub icon: String,
84 #[serde(default)]
86 pub header_color: String,
87 #[serde(default)]
89 pub category: String,
90 #[serde(default)]
92 pub fields: Vec<FieldDef>,
93 #[serde(default)]
95 pub inputs: Vec<Port>,
96 #[serde(default)]
98 pub outputs: Vec<Port>,
99}
100
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum JobStatus {
104 Queued,
105 Running,
106 Success,
107 Failure,
108 Skipped,
109 Cancelled,
110}
111
112#[derive(Clone, Debug, Serialize, Deserialize)]
113pub struct Job {
114 pub id: String,
115 pub name: String,
116 pub status: JobStatus,
117 pub command: String,
118 pub duration_secs: Option<u64>,
119 #[serde(default)]
121 pub started_at: Option<f64>,
122 pub depends_on: Vec<String>,
123 pub output: Option<String>,
124 #[serde(default)]
126 pub required_labels: Vec<String>,
127 #[serde(default)]
129 pub max_retries: u32,
130 #[serde(default)]
132 pub attempt: u32,
133 #[serde(default)]
135 pub metadata: HashMap<String, serde_json::Value>,
136 #[serde(default)]
138 pub ports: Vec<Port>,
139 #[serde(default)]
143 pub children: Option<Vec<Job>>,
144 #[serde(default)]
146 pub collapsed: bool,
147}
148
149#[derive(Clone, Debug, Serialize, Deserialize)]
150pub struct Workflow {
151 pub id: String,
152 pub name: String,
153 pub trigger: String,
154 pub jobs: Vec<Job>,
155}
156
157impl Workflow {
158 pub fn sample() -> Self {
160 Workflow {
161 id: "ci-1".into(),
162 name: "ci.yml".into(),
163 trigger: "on: push".into(),
164 jobs: vec![
165 Job {
166 id: "unit-tests".into(),
167 name: "Unit Tests".into(),
168 status: JobStatus::Queued,
169 command: "echo 'Running unit tests' && sleep 2".into(),
170 duration_secs: None,
171 depends_on: vec![],
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 children: None,
180 collapsed: false,
181 },
182 Job {
183 id: "lint".into(),
184 name: "Lint".into(),
185 status: JobStatus::Queued,
186 command: "echo 'Running linter' && sleep 1".into(),
187 duration_secs: None,
188 depends_on: vec![],
189 started_at: None,
190 output: None,
191 required_labels: vec![],
192 max_retries: 0,
193 attempt: 0,
194 metadata: HashMap::new(),
195 ports: vec![],
196 children: None,
197 collapsed: false,
198 },
199 Job {
200 id: "typecheck".into(),
201 name: "Typecheck".into(),
202 status: JobStatus::Queued,
203 command: "echo 'Running typecheck' && sleep 2".into(),
204 duration_secs: None,
205 depends_on: vec![],
206 started_at: None,
207 output: None,
208 required_labels: vec![],
209 max_retries: 0,
210 attempt: 0,
211 metadata: HashMap::new(),
212 ports: vec![],
213 children: None,
214 collapsed: false,
215 },
216 Job {
217 id: "build".into(),
218 name: "Build".into(),
219 status: JobStatus::Queued,
220 command: "echo 'Building project' && sleep 3".into(),
221 duration_secs: None,
222 depends_on: vec!["unit-tests".into(), "lint".into(), "typecheck".into()],
223 started_at: None,
224 output: None,
225 required_labels: vec![],
226 max_retries: 0,
227 attempt: 0,
228 metadata: HashMap::new(),
229 ports: vec![],
230 children: None,
231 collapsed: false,
232 },
233 Job {
234 id: "deploy-db".into(),
235 name: "Deploy DB Migrations".into(),
236 status: JobStatus::Queued,
237 command: "echo 'Deploying DB migrations' && sleep 1".into(),
238 duration_secs: None,
239 depends_on: vec!["build".into()],
240 started_at: None,
241 output: None,
242 required_labels: vec![],
243 max_retries: 0,
244 attempt: 0,
245 metadata: HashMap::new(),
246 ports: vec![],
247 children: None,
248 collapsed: false,
249 },
250 Job {
251 id: "e2e-tests".into(),
252 name: "E2E Tests".into(),
253 status: JobStatus::Queued,
254 command: "echo 'Running E2E tests' && sleep 5".into(),
255 duration_secs: None,
256 depends_on: vec!["build".into()],
257 started_at: None,
258 output: None,
259 required_labels: vec![],
260 max_retries: 0,
261 attempt: 0,
262 metadata: HashMap::new(),
263 ports: vec![],
264 children: None,
265 collapsed: false,
266 },
267 Job {
268 id: "deploy-preview".into(),
269 name: "Deploy Preview".into(),
270 status: JobStatus::Queued,
271 command: "echo 'Deploying preview' && sleep 1".into(),
272 duration_secs: None,
273 depends_on: vec!["build".into()],
274 started_at: None,
275 output: None,
276 required_labels: vec![],
277 max_retries: 0,
278 attempt: 0,
279 metadata: HashMap::new(),
280 ports: vec![],
281 children: None,
282 collapsed: false,
283 },
284 Job {
285 id: "deploy-web".into(),
286 name: "Deploy Web".into(),
287 status: JobStatus::Queued,
288 command: "echo 'Deploying to production' && sleep 3".into(),
289 duration_secs: None,
290 depends_on: vec!["deploy-db".into()],
291 started_at: None,
292 output: None,
293 required_labels: vec![],
294 max_retries: 0,
295 attempt: 0,
296 metadata: HashMap::new(),
297 ports: vec![],
298 children: None,
299 collapsed: false,
300 },
301 ],
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn make_job(id: &str, metadata: HashMap<String, serde_json::Value>) -> Job {
311 Job {
312 id: id.into(),
313 name: id.into(),
314 status: JobStatus::Queued,
315 command: "echo test".into(),
316 duration_secs: None,
317 started_at: None,
318 depends_on: vec![],
319 output: None,
320 required_labels: vec![],
321 max_retries: 0,
322 attempt: 0,
323 metadata,
324 ports: vec![],
325 children: None,
326 collapsed: false,
327 }
328 }
329
330 #[test]
331 fn job_metadata_serializes_roundtrip() {
332 let mut meta = HashMap::new();
333 meta.insert("node_type".into(), serde_json::json!("deploy"));
334 meta.insert("icon".into(), serde_json::json!("rocket"));
335 meta.insert("priority".into(), serde_json::json!(42));
336
337 let job = make_job("deploy-1", meta);
338 let json = serde_json::to_string(&job).unwrap();
339 let deserialized: Job = serde_json::from_str(&json).unwrap();
340
341 assert_eq!(deserialized.metadata.len(), 3);
342 assert_eq!(
343 deserialized.metadata["node_type"],
344 serde_json::json!("deploy")
345 );
346 assert_eq!(deserialized.metadata["icon"], serde_json::json!("rocket"));
347 assert_eq!(deserialized.metadata["priority"], serde_json::json!(42));
348 }
349
350 #[test]
351 fn job_metadata_defaults_to_empty() {
352 let json = r#"{
353 "id": "test",
354 "name": "Test",
355 "status": "queued",
356 "command": "echo hi",
357 "depends_on": []
358 }"#;
359 let job: Job = serde_json::from_str(json).unwrap();
360 assert!(job.metadata.is_empty());
361 }
362
363 #[test]
364 fn job_metadata_with_nested_values() {
365 let mut meta = HashMap::new();
366 meta.insert(
367 "config".into(),
368 serde_json::json!({"timeout": 30, "retries": true}),
369 );
370 meta.insert("tags".into(), serde_json::json!(["ci", "deploy"]));
371
372 let job = make_job("complex", meta);
373 let json = serde_json::to_string(&job).unwrap();
374 let deserialized: Job = serde_json::from_str(&json).unwrap();
375
376 assert_eq!(
377 deserialized.metadata["config"],
378 serde_json::json!({"timeout": 30, "retries": true})
379 );
380 assert_eq!(
381 deserialized.metadata["tags"],
382 serde_json::json!(["ci", "deploy"])
383 );
384 }
385
386 #[test]
387 fn job_metadata_from_json_string() {
388 let json = r##"{
389 "id": "styled",
390 "name": "Styled Node",
391 "status": "running",
392 "command": "echo hi",
393 "depends_on": [],
394 "metadata": {
395 "color": "#ff0000",
396 "weight": 1.5,
397 "visible": true
398 }
399 }"##;
400 let job: Job = serde_json::from_str(json).unwrap();
401 assert_eq!(job.metadata.len(), 3);
402 assert_eq!(job.metadata["color"], serde_json::json!("#ff0000"));
403 assert_eq!(job.metadata["weight"], serde_json::json!(1.5));
404 assert_eq!(job.metadata["visible"], serde_json::json!(true));
405 }
406
407 #[test]
408 fn workflow_sample_has_empty_metadata() {
409 let wf = Workflow::sample();
410 for job in &wf.jobs {
411 assert!(
412 job.metadata.is_empty(),
413 "Expected empty metadata for job '{}'",
414 job.id
415 );
416 }
417 }
418}