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}