rust_actions/
runner.rs

1use crate::expr::{evaluate_assertion, evaluate_value, ExprContext, JobOutputs};
2use crate::hooks::HookRegistry;
3use crate::matrix::{expand_matrix, format_matrix_suffix, MatrixCombination};
4use crate::parser::{parse_workflow_file, parse_workflows, Job, Step, Workflow};
5use crate::registry::{ErasedStepFn, StepRegistry};
6use crate::workflow_registry::{is_file_ref, parse_file_ref, WorkflowRegistry};
7use crate::world::World;
8use crate::{Error, Result};
9use colored::Colorize;
10use serde_json::Value;
11use std::any::Any;
12use std::collections::{HashMap, HashSet};
13use std::marker::PhantomData;
14use std::path::PathBuf;
15use std::time::{Duration, Instant};
16
17#[derive(Debug, Clone)]
18pub enum StepResult {
19    Passed(Duration),
20    Failed(Duration, String),
21    Skipped,
22}
23
24impl StepResult {
25    pub fn is_passed(&self) -> bool {
26        matches!(self, StepResult::Passed(_))
27    }
28
29    pub fn is_failed(&self) -> bool {
30        matches!(self, StepResult::Failed(_, _))
31    }
32}
33
34#[derive(Debug)]
35pub struct JobResult {
36    pub name: String,
37    pub matrix_suffix: String,
38    pub steps: Vec<(String, StepResult)>,
39    pub outputs: JobOutputs,
40    pub duration: Duration,
41}
42
43impl JobResult {
44    pub fn passed(&self) -> bool {
45        self.steps.iter().all(|(_, r)| r.is_passed())
46    }
47
48    pub fn steps_passed(&self) -> usize {
49        self.steps.iter().filter(|(_, r)| r.is_passed()).count()
50    }
51
52    pub fn steps_failed(&self) -> usize {
53        self.steps.iter().filter(|(_, r)| r.is_failed()).count()
54    }
55}
56
57#[derive(Debug)]
58pub struct WorkflowResult {
59    pub name: String,
60    pub jobs: Vec<JobResult>,
61    pub duration: Duration,
62}
63
64impl WorkflowResult {
65    pub fn passed(&self) -> bool {
66        self.jobs.iter().all(|j| j.passed())
67    }
68
69    pub fn jobs_passed(&self) -> usize {
70        self.jobs.iter().filter(|j| j.passed()).count()
71    }
72
73    pub fn jobs_failed(&self) -> usize {
74        self.jobs.iter().filter(|j| !j.passed()).count()
75    }
76
77    pub fn total_steps_passed(&self) -> usize {
78        self.jobs.iter().map(|j| j.steps_passed()).sum()
79    }
80
81    pub fn total_steps_failed(&self) -> usize {
82        self.jobs.iter().map(|j| j.steps_failed()).sum()
83    }
84}
85
86pub struct RustActions<W: World + 'static> {
87    workflows_path: PathBuf,
88    single_workflow: Option<PathBuf>,
89    steps: StepRegistry,
90    hooks: HookRegistry<W>,
91    _phantom: PhantomData<W>,
92}
93
94impl<W: World + 'static> RustActions<W> {
95    pub fn new() -> Self {
96        let mut steps = StepRegistry::new();
97        steps.collect_for::<W>();
98
99        Self {
100            workflows_path: PathBuf::from("tests/workflows"),
101            single_workflow: None,
102            steps,
103            hooks: HookRegistry::new(),
104            _phantom: PhantomData,
105        }
106    }
107
108    pub fn workflows(mut self, path: impl Into<PathBuf>) -> Self {
109        self.workflows_path = path.into();
110        self
111    }
112
113    pub fn features(self, path: impl Into<PathBuf>) -> Self {
114        self.workflows(path)
115    }
116
117    pub fn workflow(mut self, path: impl Into<PathBuf>) -> Self {
118        self.single_workflow = Some(path.into());
119        self
120    }
121
122    pub fn register_step(mut self, name: impl Into<String>, func: ErasedStepFn) -> Self {
123        self.steps.register(name, func);
124        self
125    }
126
127    pub async fn run(self) {
128        let registry = if self.single_workflow.is_some() {
129            None
130        } else {
131            match WorkflowRegistry::build(&self.workflows_path) {
132                Ok(r) => Some(r),
133                Err(e) => {
134                    eprintln!(
135                        "{} Failed to build workflow registry: {}",
136                        "Error:".red().bold(),
137                        e
138                    );
139                    std::process::exit(1);
140                }
141            }
142        };
143
144        let workflows: Vec<(PathBuf, Workflow)> = if let Some(ref path) = self.single_workflow {
145            match parse_workflow_file(path) {
146                Ok(w) => vec![w],
147                Err(e) => {
148                    eprintln!("{} Failed to parse workflow: {}", "Error:".red().bold(), e);
149                    std::process::exit(1);
150                }
151            }
152        } else {
153            match parse_workflows(&self.workflows_path) {
154                Ok(w) => w.into_iter().filter(|(_, w)| !w.is_reusable()).collect(),
155                Err(e) => {
156                    eprintln!(
157                        "{} Failed to parse workflows: {}",
158                        "Error:".red().bold(),
159                        e
160                    );
161                    std::process::exit(1);
162                }
163            }
164        };
165
166        self.hooks.run_before_all().await;
167
168        let mut all_results = Vec::new();
169        let mut total_passed = 0;
170        let mut total_failed = 0;
171
172        for (path, workflow) in workflows {
173            let result = self.run_workflow(&path, workflow, registry.as_ref()).await;
174            total_passed += result.jobs_passed();
175            total_failed += result.jobs_failed();
176            all_results.push(result);
177        }
178
179        self.hooks.run_after_all().await;
180
181        println!();
182        let total_jobs = total_passed + total_failed;
183        let total_steps_passed: usize = all_results.iter().map(|r| r.total_steps_passed()).sum();
184        let total_steps_failed: usize = all_results.iter().map(|r| r.total_steps_failed()).sum();
185        let total_steps = total_steps_passed + total_steps_failed;
186
187        if total_failed == 0 {
188            println!(
189                "{} {} ({} passed)",
190                format!("{} jobs", total_jobs).green(),
191                "✓".green(),
192                total_passed
193            );
194        } else {
195            println!(
196                "{} ({} passed, {} failed)",
197                format!("{} jobs", total_jobs).yellow(),
198                total_passed,
199                total_failed
200            );
201        }
202
203        println!(
204            "{} ({} passed, {} failed)",
205            format!("{} steps", total_steps),
206            total_steps_passed,
207            total_steps_failed
208        );
209
210        if total_failed > 0 {
211            std::process::exit(1);
212        }
213    }
214
215    async fn run_workflow(
216        &self,
217        _path: &PathBuf,
218        workflow: Workflow,
219        registry: Option<&WorkflowRegistry>,
220    ) -> WorkflowResult {
221        let start = Instant::now();
222        println!("\n{} {}", "Workflow:".bold(), workflow.name);
223
224        let job_order = match toposort_jobs(&workflow.jobs) {
225            Ok(order) => order,
226            Err(e) => {
227                eprintln!("{} {}", "Error:".red().bold(), e);
228                return WorkflowResult {
229                    name: workflow.name,
230                    jobs: vec![],
231                    duration: start.elapsed(),
232                };
233            }
234        };
235
236        let mut job_outputs: HashMap<String, JobOutputs> = HashMap::new();
237        let mut job_results = Vec::new();
238
239        for job_name in job_order {
240            let job = &workflow.jobs[&job_name];
241
242            if let Some(uses) = &job.uses {
243                if is_file_ref(uses) {
244                    if let Some(reg) = registry {
245                        match self
246                            .run_file_ref_job(&job_name, uses, job, reg, &job_outputs)
247                            .await
248                        {
249                            Ok(result) => {
250                                job_outputs.insert(job_name.clone(), result.outputs.clone());
251                                job_results.push(result);
252                            }
253                            Err(e) => {
254                                eprintln!(
255                                    "  {} {} ({})",
256                                    "✗".red(),
257                                    job_name,
258                                    e
259                                );
260                            }
261                        }
262                    }
263                    continue;
264                }
265            }
266
267            let matrix_combos = job
268                .strategy
269                .as_ref()
270                .map(|s| expand_matrix(s))
271                .unwrap_or_else(|| vec![HashMap::new()]);
272
273            for matrix_values in matrix_combos {
274                let result = self
275                    .run_job(&job_name, job, &workflow.env, &job_outputs, &matrix_values)
276                    .await;
277                job_outputs.insert(job_name.clone(), result.outputs.clone());
278                job_results.push(result);
279            }
280        }
281
282        WorkflowResult {
283            name: workflow.name,
284            jobs: job_results,
285            duration: start.elapsed(),
286        }
287    }
288
289    async fn run_file_ref_job(
290        &self,
291        job_name: &str,
292        uses: &str,
293        _job: &Job,
294        registry: &WorkflowRegistry,
295        parent_outputs: &HashMap<String, JobOutputs>,
296    ) -> Result<JobResult> {
297        let start = Instant::now();
298        let file_path = parse_file_ref(uses)?;
299        let ref_workflow = registry.resolve_file_ref(uses)?;
300
301        println!(
302            "  {} {} (via @file:{})",
303            "Job:".dimmed(),
304            job_name,
305            file_path
306        );
307
308        let mut combined_outputs = JobOutputs::new();
309
310        let ref_job_order = toposort_jobs(&ref_workflow.jobs)?;
311
312        let mut ref_job_outputs: HashMap<String, JobOutputs> = HashMap::new();
313        let mut all_step_results = Vec::new();
314
315        for ref_job_name in ref_job_order {
316            let ref_job = &ref_workflow.jobs[&ref_job_name];
317
318            let mut world = match W::new().await {
319                Ok(w) => w,
320                Err(_) => {
321                    return Ok(JobResult {
322                        name: job_name.to_string(),
323                        matrix_suffix: String::new(),
324                        steps: vec![],
325                        outputs: JobOutputs::new(),
326                        duration: start.elapsed(),
327                    });
328                }
329            };
330
331            let mut ctx = ExprContext::new();
332            ctx.env = ref_workflow.env.clone();
333
334            for (dep_name, dep_outputs) in &ref_job_outputs {
335                ctx.needs.insert(dep_name.clone(), dep_outputs.clone());
336            }
337            for (dep_name, dep_outputs) in parent_outputs {
338                ctx.needs.insert(dep_name.clone(), dep_outputs.clone());
339            }
340
341            #[allow(unused_variables)]
342            let step_outputs: HashMap<String, Value> = HashMap::new();
343
344            for step in &ref_job.steps {
345                let result = self.run_step(&mut world, step, &mut ctx).await;
346                let step_name = step.name.clone().unwrap_or_else(|| step.uses.clone());
347
348                match &result {
349                    StepResult::Passed(_) => {
350                        println!("    {} {}", "✓".green(), step_name);
351                    }
352                    StepResult::Failed(_, msg) => {
353                        println!("    {} {}", "✗".red(), step_name);
354                        println!("      {}: {}", "Error".red(), msg);
355                    }
356                    StepResult::Skipped => {
357                        println!("    {} {} (skipped)", "○".dimmed(), step_name);
358                    }
359                }
360
361                all_step_results.push((step_name, result));
362            }
363
364            let mut ref_job_output = JobOutputs::new();
365            for (key, expr) in &ref_job.outputs {
366                if let Ok(value) = evaluate_value(&Value::String(expr.clone()), &ctx) {
367                    ref_job_output.insert(key.clone(), value);
368                }
369            }
370            ref_job_outputs.insert(ref_job_name.clone(), ref_job_output.clone());
371        }
372
373        if let Some(trigger) = &ref_workflow.on {
374            if let Some(call_config) = &trigger.workflow_call {
375                for (key, output_def) in &call_config.outputs {
376                    let mut eval_ctx = ExprContext::new();
377                    for (job_name, outputs) in &ref_job_outputs {
378                        eval_ctx.jobs.insert(job_name.clone(), outputs.clone());
379                    }
380                    if let Ok(value) =
381                        evaluate_value(&Value::String(output_def.value.clone()), &eval_ctx)
382                    {
383                        combined_outputs.insert(key.clone(), value);
384                    }
385                }
386            }
387        }
388
389        Ok(JobResult {
390            name: job_name.to_string(),
391            matrix_suffix: String::new(),
392            steps: all_step_results,
393            outputs: combined_outputs,
394            duration: start.elapsed(),
395        })
396    }
397
398    async fn run_job(
399        &self,
400        job_name: &str,
401        job: &Job,
402        workflow_env: &HashMap<String, String>,
403        parent_outputs: &HashMap<String, JobOutputs>,
404        matrix_values: &MatrixCombination,
405    ) -> JobResult {
406        let start = Instant::now();
407        let matrix_suffix = format_matrix_suffix(matrix_values);
408
409        let mut world = match W::new().await {
410            Ok(w) => w,
411            Err(e) => {
412                println!(
413                    "  {} {}{} (world init failed: {})",
414                    "✗".red(),
415                    job_name,
416                    matrix_suffix,
417                    e
418                );
419                return JobResult {
420                    name: job_name.to_string(),
421                    matrix_suffix,
422                    steps: vec![],
423                    outputs: JobOutputs::new(),
424                    duration: start.elapsed(),
425                };
426            }
427        };
428
429        self.hooks.run_before_scenario(&mut world).await;
430
431        let mut ctx = ExprContext::new();
432        ctx.env = workflow_env.clone();
433        ctx.env.extend(job.env.clone());
434        ctx.matrix = matrix_values.clone();
435
436        for need in job.needs.as_vec() {
437            if let Some(outputs) = parent_outputs.get(&need) {
438                ctx.needs.insert(need.clone(), outputs.clone());
439            }
440        }
441
442        let mut step_results = Vec::new();
443        let mut should_skip = false;
444
445        for step in &job.steps {
446            let step_name = step.name.clone().unwrap_or_else(|| step.uses.clone());
447
448            if should_skip {
449                step_results.push((step_name, StepResult::Skipped));
450                continue;
451            }
452
453            self.hooks.run_before_step(&mut world, step).await;
454
455            let result = self.run_step(&mut world, step, &mut ctx).await;
456
457            self.hooks.run_after_step(&mut world, step, &result).await;
458
459            if result.is_failed() && !step.continue_on_error {
460                should_skip = true;
461            }
462
463            step_results.push((step_name, result));
464        }
465
466        self.hooks.run_after_scenario(&mut world).await;
467
468        let duration = start.elapsed();
469        let all_passed = step_results.iter().all(|(_, r)| r.is_passed());
470
471        if all_passed {
472            println!(
473                "  {} {}{} ({:?})",
474                "✓".green(),
475                job_name,
476                matrix_suffix,
477                duration
478            );
479        } else {
480            println!(
481                "  {} {}{} ({:?})",
482                "✗".red(),
483                job_name,
484                matrix_suffix,
485                duration
486            );
487        }
488
489        for (name, result) in &step_results {
490            match result {
491                StepResult::Passed(_) => {
492                    println!("    {} {}", "✓".green(), name);
493                }
494                StepResult::Failed(_, msg) => {
495                    println!("    {} {}", "✗".red(), name);
496                    println!("      {}: {}", "Error".red(), msg);
497                }
498                StepResult::Skipped => {
499                    println!("    {} {} (skipped)", "○".dimmed(), name);
500                }
501            }
502        }
503
504        let mut outputs = JobOutputs::new();
505        for (key, expr) in &job.outputs {
506            if let Ok(value) = evaluate_value(&Value::String(expr.clone()), &ctx) {
507                outputs.insert(key.clone(), value);
508            }
509        }
510
511        JobResult {
512            name: job_name.to_string(),
513            matrix_suffix,
514            steps: step_results,
515            outputs,
516            duration,
517        }
518    }
519
520    async fn run_step(&self, world: &mut W, step: &Step, ctx: &mut ExprContext) -> StepResult {
521        let start = Instant::now();
522
523        for assertion in &step.pre_assert {
524            match evaluate_assertion(assertion, ctx) {
525                Ok(true) => {}
526                Ok(false) => {
527                    return StepResult::Failed(
528                        start.elapsed(),
529                        format!("Pre-assertion failed: {}", assertion),
530                    );
531                }
532                Err(e) => {
533                    return StepResult::Failed(
534                        start.elapsed(),
535                        format!("Pre-assertion error: {}", e),
536                    );
537                }
538            }
539        }
540
541        let step_fn = match self.steps.get(&step.uses) {
542            Some(f) => f,
543            None => {
544                return StepResult::Failed(
545                    start.elapsed(),
546                    format!("Step not found: {}", step.uses),
547                );
548            }
549        };
550
551        let evaluated_args = match step
552            .with
553            .iter()
554            .map(|(k, v)| evaluate_value(v, ctx).map(|ev| (k.clone(), ev)))
555            .collect::<Result<HashMap<_, _>>>()
556        {
557            Ok(args) => args,
558            Err(e) => {
559                return StepResult::Failed(
560                    start.elapsed(),
561                    format!("Args evaluation failed: {}", e),
562                );
563            }
564        };
565
566        let world_any: &mut dyn Any = world;
567        let outputs = match step_fn(world_any, evaluated_args).await {
568            Ok(outputs) => outputs,
569            Err(e) => return StepResult::Failed(start.elapsed(), e.to_string()),
570        };
571
572        if let Some(id) = &step.id {
573            ctx.steps.insert(id.clone(), outputs.clone());
574        }
575
576        if !step.post_assert.is_empty() {
577            let assert_ctx = ctx.with_outputs(outputs);
578
579            for assertion in &step.post_assert {
580                match evaluate_assertion(assertion, &assert_ctx) {
581                    Ok(true) => {}
582                    Ok(false) => {
583                        return StepResult::Failed(
584                            start.elapsed(),
585                            format!("Post-assertion failed: {}", assertion),
586                        );
587                    }
588                    Err(e) => {
589                        return StepResult::Failed(
590                            start.elapsed(),
591                            format!("Post-assertion error: {}", e),
592                        );
593                    }
594                }
595            }
596        }
597
598        StepResult::Passed(start.elapsed())
599    }
600}
601
602impl<W: World + 'static> Default for RustActions<W> {
603    fn default() -> Self {
604        Self::new()
605    }
606}
607
608fn toposort_jobs(jobs: &HashMap<String, Job>) -> Result<Vec<String>> {
609    let mut result = Vec::new();
610    let mut visited = HashSet::new();
611    let mut temp_visited = HashSet::new();
612
613    fn visit(
614        name: &str,
615        jobs: &HashMap<String, Job>,
616        visited: &mut HashSet<String>,
617        temp_visited: &mut HashSet<String>,
618        result: &mut Vec<String>,
619        path: &mut Vec<String>,
620    ) -> Result<()> {
621        if temp_visited.contains(name) {
622            path.push(name.to_string());
623            return Err(Error::CircularDependency {
624                chain: path.join(" -> "),
625            });
626        }
627
628        if visited.contains(name) {
629            return Ok(());
630        }
631
632        temp_visited.insert(name.to_string());
633        path.push(name.to_string());
634
635        if let Some(job) = jobs.get(name) {
636            for dep in job.needs.as_vec() {
637                if !jobs.contains_key(&dep) {
638                    return Err(Error::JobDependencyNotFound {
639                        job: name.to_string(),
640                        dependency: dep.clone(),
641                    });
642                }
643                visit(&dep, jobs, visited, temp_visited, result, path)?;
644            }
645        }
646
647        path.pop();
648        temp_visited.remove(name);
649        visited.insert(name.to_string());
650        result.push(name.to_string());
651
652        Ok(())
653    }
654
655    let job_names: Vec<String> = jobs.keys().cloned().collect();
656    for name in &job_names {
657        let mut path = Vec::new();
658        visit(name, jobs, &mut visited, &mut temp_visited, &mut result, &mut path)?;
659    }
660
661    Ok(result)
662}