workflow-graph-shared 1.2.11

Core types and YAML parser for workflow-graph
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//! YAML workflow definition parser.
//!
//! Workflow files use a GitHub Actions-inspired format:
//!
//! ```yaml
//! name: CI Pipeline
//! on: push
//!
//! jobs:
//!   lint:
//!     name: Lint
//!     run: cargo clippy --all-targets
//!
//!   test:
//!     name: Unit Tests
//!     run: cargo test
//!
//!   build:
//!     name: Build
//!     needs: [lint, test]
//!     run: cargo build --release
//!
//!   deploy:
//!     name: Deploy
//!     needs: [build]
//!     steps:
//!       - name: Deploy DB
//!         run: ./scripts/migrate.sh
//!       - name: Deploy App
//!         run: ./scripts/deploy.sh
//! ```

use indexmap::IndexMap;
use serde::Deserialize;

use crate::{Job, JobStatus, Workflow};

/// Top-level YAML workflow definition.
#[derive(Debug, Deserialize)]
pub struct WorkflowDef {
    pub name: String,
    #[serde(rename = "on")]
    pub trigger: TriggerDef,
    #[serde(default)]
    pub env: IndexMap<String, String>,
    pub jobs: IndexMap<String, JobDef>,
}

/// Trigger can be a simple string or a structured definition.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum TriggerDef {
    Simple(String),
    List(Vec<String>),
    Structured(IndexMap<String, serde_yaml::Value>),
}

impl TriggerDef {
    pub fn display(&self) -> String {
        match self {
            TriggerDef::Simple(s) => format!("on: {s}"),
            TriggerDef::List(v) => format!("on: [{}]", v.join(", ")),
            TriggerDef::Structured(m) => {
                let keys: Vec<&str> = m.keys().map(|k| k.as_str()).collect();
                format!("on: [{}]", keys.join(", "))
            }
        }
    }
}

/// A single job definition in the workflow YAML.
#[derive(Debug, Deserialize)]
pub struct JobDef {
    /// Display name (defaults to the job key if not set).
    pub name: Option<String>,
    /// Job dependencies — other job IDs that must succeed first.
    #[serde(default)]
    pub needs: Needs,
    /// Shell command to run (simple single-command job).
    pub run: Option<String>,
    /// Multi-step job (used instead of `run`).
    #[serde(default)]
    pub steps: Vec<StepDef>,
    /// Per-job environment variables.
    #[serde(default)]
    pub env: IndexMap<String, String>,
    /// Timeout in seconds.
    pub timeout: Option<u64>,
    /// Condition for running this job (expression string).
    #[serde(rename = "if")]
    pub condition: Option<String>,
    /// Worker labels required to execute this job.
    #[serde(default)]
    pub labels: Vec<String>,
    /// Maximum number of retries on failure (default 0).
    #[serde(default)]
    pub retries: u32,
}

/// Dependencies can be a single string or a list.
#[derive(Debug, Default, Deserialize)]
#[serde(untagged)]
pub enum Needs {
    #[default]
    None,
    Single(String),
    List(Vec<String>),
}

impl Needs {
    pub fn to_vec(&self) -> Vec<String> {
        match self {
            Needs::None => vec![],
            Needs::Single(s) => vec![s.clone()],
            Needs::List(v) => v.clone(),
        }
    }
}

/// A single step within a job.
#[derive(Debug, Deserialize)]
pub struct StepDef {
    pub id: Option<String>,
    pub name: Option<String>,
    pub run: Option<String>,
    #[serde(rename = "if")]
    pub condition: Option<String>,
    #[serde(default)]
    pub env: IndexMap<String, String>,
}

impl WorkflowDef {
    /// Parse a YAML string into a workflow definition.
    pub fn from_yaml(yaml: &str) -> Result<Self, String> {
        serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))
    }

    /// Parse a JSON string into a workflow definition.
    pub fn from_json(json: &str) -> Result<Self, String> {
        serde_json::from_str(json).map_err(|e| format!("JSON parse error: {e}"))
    }

    /// Auto-detect format and parse. Tries JSON first (stricter), falls back to YAML.
    pub fn parse(input: &str) -> Result<Self, String> {
        let trimmed = input.trim_start();
        if trimmed.starts_with('{') {
            Self::from_json(input)
        } else {
            Self::from_yaml(input)
        }
    }

    /// Auto-detect format based on file extension.
    pub fn from_file_contents(contents: &str, filename: &str) -> Result<Self, String> {
        if filename.ends_with(".json") {
            Self::from_json(contents)
        } else if filename.ends_with(".yml") || filename.ends_with(".yaml") {
            Self::from_yaml(contents)
        } else {
            Self::parse(contents)
        }
    }

    /// Convert to the runtime `Workflow` model.
    ///
    /// For jobs with `steps`, the steps are joined into a single shell script
    /// separated by `&&`. For jobs with `run`, that command is used directly.
    pub fn into_workflow(self, id: &str) -> Result<Workflow, String> {
        let trigger = self.trigger.display();
        let mut jobs = Vec::with_capacity(self.jobs.len());

        for (job_id, job_def) in &self.jobs {
            let name = job_def.name.clone().unwrap_or_else(|| job_id.clone());

            let command = build_command(job_def, &self.env)?;
            let depends_on = job_def.needs.to_vec();

            // Validate dependencies exist
            for dep in &depends_on {
                if !self.jobs.contains_key(dep) {
                    return Err(format!(
                        "Job '{job_id}' depends on '{dep}', which doesn't exist"
                    ));
                }
            }

            jobs.push(Job {
                id: job_id.clone(),
                name,
                status: JobStatus::Queued,
                command,
                duration_secs: None,
                started_at: None,
                depends_on,
                output: None,
                required_labels: job_def.labels.clone(),
                max_retries: job_def.retries,
                attempt: 0,
                metadata: std::collections::HashMap::new(),
                ports: vec![],
                children: None,
                collapsed: false,
            });
        }

        Ok(Workflow {
            id: id.to_string(),
            name: self.name,
            trigger,
            jobs,
        })
    }
}

/// Build the shell command for a job, combining env vars and steps/run.
fn build_command(job: &JobDef, global_env: &IndexMap<String, String>) -> Result<String, String> {
    // Collect env var exports
    let mut env_exports = Vec::new();
    for (k, v) in global_env {
        env_exports.push(format!("export {k}={}", shell_quote(v)));
    }
    for (k, v) in &job.env {
        env_exports.push(format!("export {k}={}", shell_quote(v)));
    }

    let commands = if !job.steps.is_empty() {
        // Multi-step: join step commands
        let step_cmds: Result<Vec<String>, String> = job
            .steps
            .iter()
            .enumerate()
            .filter_map(|(i, step)| {
                step.run.as_ref().map(|cmd| {
                    let mut parts = Vec::new();
                    // Per-step env
                    for (k, v) in &step.env {
                        parts.push(format!("export {k}={}", shell_quote(v)));
                    }
                    let default_label = format!("step {}", i + 1);
                    let label = step
                        .name
                        .as_deref()
                        .or(step.id.as_deref())
                        .unwrap_or(&default_label);
                    parts.push(format!("echo '=== {label} ==='"));
                    parts.push(cmd.trim().to_string());
                    Ok(parts.join(" && "))
                })
            })
            .collect();
        step_cmds?
    } else if let Some(run) = &job.run {
        vec![run.trim().to_string()]
    } else {
        return Err("Job must have either 'run' or 'steps'".to_string());
    };

    let mut full = env_exports;
    full.extend(commands);
    Ok(full.join(" && "))
}

fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_simple_workflow() {
        let yaml = r#"
name: CI
on: push

jobs:
  lint:
    name: Lint
    run: cargo clippy

  test:
    name: Test
    run: cargo test

  build:
    name: Build
    needs: [lint, test]
    run: cargo build --release
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let wf = def.into_workflow("ci-1").unwrap();

        assert_eq!(wf.name, "CI");
        assert_eq!(wf.trigger, "on: push");
        assert_eq!(wf.jobs.len(), 3);
        assert_eq!(wf.jobs[2].depends_on, vec!["lint", "test"]);
    }

    #[test]
    fn parse_steps_workflow() {
        let yaml = r#"
name: Deploy
on: push

jobs:
  deploy:
    name: Deploy All
    steps:
      - name: Migrate DB
        run: ./migrate.sh
      - name: Deploy App
        run: ./deploy.sh
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let wf = def.into_workflow("deploy-1").unwrap();

        assert_eq!(wf.jobs.len(), 1);
        assert!(wf.jobs[0].command.contains("Migrate DB"));
        assert!(wf.jobs[0].command.contains("./deploy.sh"));
    }

    #[test]
    fn invalid_dependency_errors() {
        let yaml = r#"
name: Bad
on: push

jobs:
  build:
    needs: [nonexistent]
    run: echo hi
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let result = def.into_workflow("bad-1");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("nonexistent"));
    }

    #[test]
    fn job_without_run_or_steps_errors() {
        let yaml = r#"
name: Bad
on: push

jobs:
  empty:
    name: Empty Job
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let result = def.into_workflow("bad-2");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .contains("must have either 'run' or 'steps'")
        );
    }

    #[test]
    fn empty_jobs_map() {
        let yaml = r#"
name: Empty
on: push

jobs: {}
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let wf = def.into_workflow("empty-1").unwrap();
        assert_eq!(wf.jobs.len(), 0);
    }

    #[test]
    fn single_string_dependency() {
        let yaml = r#"
name: Single Dep
on: push

jobs:
  a:
    run: echo a
  b:
    needs: a
    run: echo b
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let wf = def.into_workflow("single-1").unwrap();
        assert_eq!(wf.jobs[1].depends_on, vec!["a"]);
    }

    #[test]
    fn special_characters_in_job_names() {
        let yaml = r#"
name: Special Chars
on: push

jobs:
  build-linux_x86:
    name: "Build (Linux x86_64)"
    run: echo "building"
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let wf = def.into_workflow("special-1").unwrap();
        assert_eq!(wf.jobs[0].id, "build-linux_x86");
        assert_eq!(wf.jobs[0].name, "Build (Linux x86_64)");
    }

    #[test]
    fn labels_and_retries_parsed() {
        let yaml = r#"
name: Config
on: push

jobs:
  deploy:
    name: Deploy
    run: ./deploy.sh
    labels: [linux, aws]
    retries: 3
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let wf = def.into_workflow("config-1").unwrap();
        assert_eq!(wf.jobs[0].required_labels, vec!["linux", "aws"]);
        assert_eq!(wf.jobs[0].max_retries, 3);
    }

    #[test]
    fn env_vars_in_command() {
        let yaml = r#"
name: Env
on: push

env:
  GLOBAL: "value"

jobs:
  test:
    run: echo test
    env:
      LOCAL: "local_value"
"#;
        let def = WorkflowDef::from_yaml(yaml).unwrap();
        let wf = def.into_workflow("env-1").unwrap();
        assert!(wf.jobs[0].command.contains("export GLOBAL="));
        assert!(wf.jobs[0].command.contains("export LOCAL="));
    }

    #[test]
    fn json_format_parsing() {
        let json = r#"{
            "name": "JSON Workflow",
            "on": "push",
            "jobs": {
                "test": {
                    "run": "echo test"
                }
            }
        }"#;
        let def = WorkflowDef::from_json(json).unwrap();
        let wf = def.into_workflow("json-1").unwrap();
        assert_eq!(wf.name, "JSON Workflow");
        assert_eq!(wf.jobs.len(), 1);
    }

    #[test]
    fn malformed_yaml_returns_error() {
        let yaml = "this is not valid yaml: [[[";
        assert!(WorkflowDef::from_yaml(yaml).is_err());
    }

    #[test]
    fn shell_quote_handles_single_quotes() {
        let result = super::shell_quote("it's a test");
        assert_eq!(result, "'it'\\''s a test'");
    }
}