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}