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