Skip to main content

workflow_graph_shared/
lib.rs

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    /// Epoch milliseconds when the job started running (for live timer).
26    #[serde(default)]
27    pub started_at: Option<f64>,
28    pub depends_on: Vec<String>,
29    pub output: Option<String>,
30    /// Worker labels required to execute this job.
31    #[serde(default)]
32    pub required_labels: Vec<String>,
33    /// Maximum number of retries on failure.
34    #[serde(default)]
35    pub max_retries: u32,
36    /// Current attempt number (0-indexed).
37    #[serde(default)]
38    pub attempt: u32,
39    /// Arbitrary metadata for custom renderers (e.g., node_type, icon, color).
40    #[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    /// Returns a sample workflow matching the GitHub Actions screenshot.
54    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}