wrkflw_executor/
engine.rs

1#[allow(unused_imports)]
2use bollard::Docker;
3use futures::future;
4use regex;
5use serde_yaml::Value;
6use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9use std::process::Command;
10use thiserror::Error;
11
12use crate::dependency;
13use crate::docker;
14use crate::environment;
15use crate::podman;
16use wrkflw_logging;
17use wrkflw_matrix::MatrixCombination;
18use wrkflw_models::gitlab::Pipeline;
19use wrkflw_parser::gitlab::{self, parse_pipeline};
20use wrkflw_parser::workflow::{self, parse_workflow, ActionInfo, Job, WorkflowDefinition};
21use wrkflw_runtime::container::ContainerRuntime;
22use wrkflw_runtime::emulation;
23
24#[allow(unused_variables, unused_assignments)]
25/// Execute a GitHub Actions workflow file locally
26pub async fn execute_workflow(
27    workflow_path: &Path,
28    config: ExecutionConfig,
29) -> Result<ExecutionResult, ExecutionError> {
30    wrkflw_logging::info(&format!("Executing workflow: {}", workflow_path.display()));
31    wrkflw_logging::info(&format!("Runtime: {:?}", config.runtime_type));
32
33    // Determine if this is a GitLab CI/CD pipeline or GitHub Actions workflow
34    let is_gitlab = is_gitlab_pipeline(workflow_path);
35
36    if is_gitlab {
37        execute_gitlab_pipeline(workflow_path, config.clone()).await
38    } else {
39        execute_github_workflow(workflow_path, config.clone()).await
40    }
41}
42
43/// Determine if a file is a GitLab CI/CD pipeline
44fn is_gitlab_pipeline(path: &Path) -> bool {
45    // Check the file name
46    if let Some(file_name) = path.file_name() {
47        if let Some(file_name_str) = file_name.to_str() {
48            return file_name_str == ".gitlab-ci.yml" || file_name_str.ends_with("gitlab-ci.yml");
49        }
50    }
51
52    // If file name check fails, try to read and determine by content
53    if let Ok(content) = fs::read_to_string(path) {
54        // GitLab CI/CD pipelines typically have stages, before_script, after_script at the top level
55        if content.contains("stages:")
56            || content.contains("before_script:")
57            || content.contains("after_script:")
58        {
59            // Check for GitHub Actions specific keys that would indicate it's not GitLab
60            if !content.contains("on:")
61                && !content.contains("runs-on:")
62                && !content.contains("uses:")
63            {
64                return true;
65            }
66        }
67    }
68
69    false
70}
71
72/// Execute a GitHub Actions workflow file locally
73async fn execute_github_workflow(
74    workflow_path: &Path,
75    config: ExecutionConfig,
76) -> Result<ExecutionResult, ExecutionError> {
77    // 1. Parse workflow file
78    let workflow = parse_workflow(workflow_path)?;
79
80    // 2. Resolve job dependencies and create execution plan
81    let execution_plan = dependency::resolve_dependencies(&workflow)?;
82
83    // 3. Initialize appropriate runtime
84    let runtime = initialize_runtime(
85        config.runtime_type.clone(),
86        config.preserve_containers_on_failure,
87    )?;
88
89    // Create a temporary workspace directory
90    let workspace_dir = tempfile::tempdir()
91        .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?;
92
93    // 4. Set up GitHub-like environment
94    let mut env_context = environment::create_github_context(&workflow, workspace_dir.path());
95
96    // Add runtime mode to environment
97    env_context.insert(
98        "WRKFLW_RUNTIME_MODE".to_string(),
99        match config.runtime_type {
100            RuntimeType::Emulation => "emulation".to_string(),
101            RuntimeType::Docker => "docker".to_string(),
102            RuntimeType::Podman => "podman".to_string(),
103        },
104    );
105
106    // Add flag to hide GitHub action messages when in emulation mode
107    env_context.insert(
108        "WRKFLW_HIDE_ACTION_MESSAGES".to_string(),
109        "true".to_string(),
110    );
111
112    // Setup GitHub environment files
113    environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| {
114        ExecutionError::Execution(format!("Failed to setup GitHub env files: {}", e))
115    })?;
116
117    // 5. Execute jobs according to the plan
118    let mut results = Vec::new();
119    let mut has_failures = false;
120    let mut failure_details = String::new();
121
122    for job_batch in execution_plan {
123        // Execute jobs in parallel if they don't depend on each other
124        let job_results = execute_job_batch(
125            &job_batch,
126            &workflow,
127            runtime.as_ref(),
128            &env_context,
129            config.verbose,
130        )
131        .await?;
132
133        // Check for job failures and collect details
134        for job_result in &job_results {
135            if job_result.status == JobStatus::Failure {
136                has_failures = true;
137                failure_details.push_str(&format!("\nāŒ Job failed: {}\n", job_result.name));
138
139                // Add step details for failed jobs
140                for step in &job_result.steps {
141                    if step.status == StepStatus::Failure {
142                        failure_details.push_str(&format!("  āŒ {}: {}\n", step.name, step.output));
143                    }
144                }
145            }
146        }
147
148        results.extend(job_results);
149    }
150
151    // If there were failures, add detailed failure information to the result
152    if has_failures {
153        wrkflw_logging::error(&format!("Workflow execution failed:{}", failure_details));
154    }
155
156    Ok(ExecutionResult {
157        jobs: results,
158        failure_details: if has_failures {
159            Some(failure_details)
160        } else {
161            None
162        },
163    })
164}
165
166/// Execute a GitLab CI/CD pipeline locally
167async fn execute_gitlab_pipeline(
168    pipeline_path: &Path,
169    config: ExecutionConfig,
170) -> Result<ExecutionResult, ExecutionError> {
171    wrkflw_logging::info("Executing GitLab CI/CD pipeline");
172
173    // 1. Parse the GitLab pipeline file
174    let pipeline = parse_pipeline(pipeline_path)
175        .map_err(|e| ExecutionError::Parse(format!("Failed to parse GitLab pipeline: {}", e)))?;
176
177    // 2. Convert the GitLab pipeline to a format compatible with the workflow executor
178    let workflow = gitlab::convert_to_workflow_format(&pipeline);
179
180    // 3. Resolve job dependencies based on stages
181    let execution_plan = resolve_gitlab_dependencies(&pipeline, &workflow)?;
182
183    // 4. Initialize appropriate runtime
184    let runtime = initialize_runtime(
185        config.runtime_type.clone(),
186        config.preserve_containers_on_failure,
187    )?;
188
189    // Create a temporary workspace directory
190    let workspace_dir = tempfile::tempdir()
191        .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?;
192
193    // 5. Set up GitLab-like environment
194    let mut env_context = create_gitlab_context(&pipeline, workspace_dir.path());
195
196    // Add runtime mode to environment
197    env_context.insert(
198        "WRKFLW_RUNTIME_MODE".to_string(),
199        match config.runtime_type {
200            RuntimeType::Emulation => "emulation".to_string(),
201            RuntimeType::Docker => "docker".to_string(),
202            RuntimeType::Podman => "podman".to_string(),
203        },
204    );
205
206    // Setup environment files
207    environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| {
208        ExecutionError::Execution(format!("Failed to setup environment files: {}", e))
209    })?;
210
211    // 6. Execute jobs according to the plan
212    let mut results = Vec::new();
213    let mut has_failures = false;
214    let mut failure_details = String::new();
215
216    for job_batch in execution_plan {
217        // Execute jobs in parallel if they don't depend on each other
218        let job_results = execute_job_batch(
219            &job_batch,
220            &workflow,
221            runtime.as_ref(),
222            &env_context,
223            config.verbose,
224        )
225        .await?;
226
227        // Check for job failures and collect details
228        for job_result in &job_results {
229            if job_result.status == JobStatus::Failure {
230                has_failures = true;
231                failure_details.push_str(&format!("\nāŒ Job failed: {}\n", job_result.name));
232
233                // Add step details for failed jobs
234                for step in &job_result.steps {
235                    if step.status == StepStatus::Failure {
236                        failure_details.push_str(&format!("  āŒ {}: {}\n", step.name, step.output));
237                    }
238                }
239            }
240        }
241
242        results.extend(job_results);
243    }
244
245    // If there were failures, add detailed failure information to the result
246    if has_failures {
247        wrkflw_logging::error(&format!("Pipeline execution failed:{}", failure_details));
248    }
249
250    Ok(ExecutionResult {
251        jobs: results,
252        failure_details: if has_failures {
253            Some(failure_details)
254        } else {
255            None
256        },
257    })
258}
259
260/// Create an environment context for GitLab CI/CD pipeline execution
261fn create_gitlab_context(pipeline: &Pipeline, workspace_dir: &Path) -> HashMap<String, String> {
262    let mut env_context = HashMap::new();
263
264    // Add GitLab CI/CD environment variables
265    env_context.insert("CI".to_string(), "true".to_string());
266    env_context.insert("GITLAB_CI".to_string(), "true".to_string());
267
268    // Add custom environment variable to indicate use in wrkflw
269    env_context.insert("WRKFLW_CI".to_string(), "true".to_string());
270
271    // Add workspace directory
272    env_context.insert(
273        "CI_PROJECT_DIR".to_string(),
274        workspace_dir.to_string_lossy().to_string(),
275    );
276
277    // Also add the workspace as the GitHub workspace for compatibility with emulation runtime
278    env_context.insert(
279        "GITHUB_WORKSPACE".to_string(),
280        workspace_dir.to_string_lossy().to_string(),
281    );
282
283    // Add global variables from the pipeline
284    if let Some(variables) = &pipeline.variables {
285        for (key, value) in variables {
286            env_context.insert(key.clone(), value.clone());
287        }
288    }
289
290    env_context
291}
292
293/// Resolve GitLab CI/CD pipeline dependencies
294fn resolve_gitlab_dependencies(
295    pipeline: &Pipeline,
296    workflow: &WorkflowDefinition,
297) -> Result<Vec<Vec<String>>, ExecutionError> {
298    // For GitLab CI/CD pipelines, jobs within the same stage can run in parallel,
299    // but jobs in different stages run sequentially
300
301    // Get stages from the pipeline or create a default one
302    let stages = match &pipeline.stages {
303        Some(defined_stages) => defined_stages.clone(),
304        None => vec![
305            "build".to_string(),
306            "test".to_string(),
307            "deploy".to_string(),
308        ],
309    };
310
311    // Create an execution plan based on stages
312    let mut execution_plan = Vec::new();
313
314    // For each stage, collect the jobs that belong to it
315    for stage in stages {
316        let mut stage_jobs = Vec::new();
317
318        for (job_name, job) in &pipeline.jobs {
319            // Skip template jobs
320            if let Some(true) = job.template {
321                continue;
322            }
323
324            // Get the job's stage, or assume "test" if not specified
325            let default_stage = "test".to_string();
326            let job_stage = job.stage.as_ref().unwrap_or(&default_stage);
327
328            // If the job belongs to the current stage, add it to the batch
329            if job_stage == &stage {
330                stage_jobs.push(job_name.clone());
331            }
332        }
333
334        if !stage_jobs.is_empty() {
335            execution_plan.push(stage_jobs);
336        }
337    }
338
339    // Also create a batch for jobs without a stage
340    let mut stageless_jobs = Vec::new();
341
342    for (job_name, job) in &pipeline.jobs {
343        // Skip template jobs
344        if let Some(true) = job.template {
345            continue;
346        }
347
348        if job.stage.is_none() {
349            stageless_jobs.push(job_name.clone());
350        }
351    }
352
353    if !stageless_jobs.is_empty() {
354        execution_plan.push(stageless_jobs);
355    }
356
357    Ok(execution_plan)
358}
359
360// Determine if Docker/Podman is available or fall back to emulation
361fn initialize_runtime(
362    runtime_type: RuntimeType,
363    preserve_containers_on_failure: bool,
364) -> Result<Box<dyn ContainerRuntime>, ExecutionError> {
365    match runtime_type {
366        RuntimeType::Docker => {
367            if docker::is_available() {
368                // Handle the Result returned by DockerRuntime::new()
369                match docker::DockerRuntime::new_with_config(preserve_containers_on_failure) {
370                    Ok(docker_runtime) => Ok(Box::new(docker_runtime)),
371                    Err(e) => {
372                        wrkflw_logging::error(&format!(
373                            "Failed to initialize Docker runtime: {}, falling back to emulation mode",
374                            e
375                        ));
376                        Ok(Box::new(emulation::EmulationRuntime::new()))
377                    }
378                }
379            } else {
380                wrkflw_logging::error("Docker not available, falling back to emulation mode");
381                Ok(Box::new(emulation::EmulationRuntime::new()))
382            }
383        }
384        RuntimeType::Podman => {
385            if podman::is_available() {
386                // Handle the Result returned by PodmanRuntime::new()
387                match podman::PodmanRuntime::new_with_config(preserve_containers_on_failure) {
388                    Ok(podman_runtime) => Ok(Box::new(podman_runtime)),
389                    Err(e) => {
390                        wrkflw_logging::error(&format!(
391                            "Failed to initialize Podman runtime: {}, falling back to emulation mode",
392                            e
393                        ));
394                        Ok(Box::new(emulation::EmulationRuntime::new()))
395                    }
396                }
397            } else {
398                wrkflw_logging::error("Podman not available, falling back to emulation mode");
399                Ok(Box::new(emulation::EmulationRuntime::new()))
400            }
401        }
402        RuntimeType::Emulation => Ok(Box::new(emulation::EmulationRuntime::new())),
403    }
404}
405
406#[derive(Debug, Clone, PartialEq)]
407pub enum RuntimeType {
408    Docker,
409    Podman,
410    Emulation,
411}
412
413#[derive(Debug, Clone)]
414pub struct ExecutionConfig {
415    pub runtime_type: RuntimeType,
416    pub verbose: bool,
417    pub preserve_containers_on_failure: bool,
418}
419
420pub struct ExecutionResult {
421    pub jobs: Vec<JobResult>,
422    pub failure_details: Option<String>,
423}
424
425pub struct JobResult {
426    pub name: String,
427    pub status: JobStatus,
428    pub steps: Vec<StepResult>,
429    pub logs: String,
430}
431
432#[derive(Debug, Clone, PartialEq)]
433#[allow(dead_code)]
434pub enum JobStatus {
435    Success,
436    Failure,
437    Skipped,
438}
439
440#[derive(Debug, Clone)]
441pub struct StepResult {
442    pub name: String,
443    pub status: StepStatus,
444    pub output: String,
445}
446
447#[derive(Debug, Clone, PartialEq)]
448#[allow(dead_code)]
449pub enum StepStatus {
450    Success,
451    Failure,
452    Skipped,
453}
454
455#[derive(Error, Debug)]
456pub enum ExecutionError {
457    #[error("Parse error: {0}")]
458    Parse(String),
459
460    #[error("Runtime error: {0}")]
461    Runtime(String),
462
463    #[error("Execution error: {0}")]
464    Execution(String),
465
466    #[error("IO error: {0}")]
467    Io(#[from] std::io::Error),
468}
469
470// Convert errors from other modules
471impl From<String> for ExecutionError {
472    fn from(err: String) -> Self {
473        ExecutionError::Parse(err)
474    }
475}
476
477// Add Action preparation functions
478async fn prepare_action(
479    action: &ActionInfo,
480    runtime: &dyn ContainerRuntime,
481) -> Result<String, ExecutionError> {
482    if action.is_docker {
483        // Docker action: pull the image
484        let image = action.repository.trim_start_matches("docker://");
485
486        runtime
487            .pull_image(image)
488            .await
489            .map_err(|e| ExecutionError::Runtime(format!("Failed to pull Docker image: {}", e)))?;
490
491        return Ok(image.to_string());
492    }
493
494    if action.is_local {
495        // Local action: build from local directory
496        let action_dir = Path::new(&action.repository);
497
498        if !action_dir.exists() {
499            return Err(ExecutionError::Execution(format!(
500                "Local action directory not found: {}",
501                action_dir.display()
502            )));
503        }
504
505        let dockerfile = action_dir.join("Dockerfile");
506        if dockerfile.exists() {
507            // It's a Docker action, build it
508            let tag = format!("wrkflw-local-action:{}", uuid::Uuid::new_v4());
509
510            runtime
511                .build_image(&dockerfile, &tag)
512                .await
513                .map_err(|e| ExecutionError::Runtime(format!("Failed to build image: {}", e)))?;
514
515            return Ok(tag);
516        } else {
517            // It's a JavaScript or composite action
518            // For simplicity, we'll use node to run it (this would need more work for full support)
519            return Ok("node:16-buster-slim".to_string());
520        }
521    }
522
523    // GitHub action: use standard runner image
524    // In a real implementation, you'd need to clone the repo at the specified version
525    Ok("node:16-buster-slim".to_string())
526}
527
528async fn execute_job_batch(
529    jobs: &[String],
530    workflow: &WorkflowDefinition,
531    runtime: &dyn ContainerRuntime,
532    env_context: &HashMap<String, String>,
533    verbose: bool,
534) -> Result<Vec<JobResult>, ExecutionError> {
535    // Execute jobs in parallel
536    let futures = jobs
537        .iter()
538        .map(|job_name| execute_job_with_matrix(job_name, workflow, runtime, env_context, verbose));
539
540    let result_arrays = future::join_all(futures).await;
541
542    // Flatten the results from all jobs and their matrix combinations
543    let mut results = Vec::new();
544    for result_array in result_arrays {
545        match result_array {
546            Ok(job_results) => results.extend(job_results),
547            Err(e) => return Err(e),
548        }
549    }
550
551    Ok(results)
552}
553
554// Before execute_job_with_matrix implementation, add this struct
555struct JobExecutionContext<'a> {
556    job_name: &'a str,
557    workflow: &'a WorkflowDefinition,
558    runtime: &'a dyn ContainerRuntime,
559    env_context: &'a HashMap<String, String>,
560    verbose: bool,
561}
562
563/// Execute a job, expanding matrix if present
564async fn execute_job_with_matrix(
565    job_name: &str,
566    workflow: &WorkflowDefinition,
567    runtime: &dyn ContainerRuntime,
568    env_context: &HashMap<String, String>,
569    verbose: bool,
570) -> Result<Vec<JobResult>, ExecutionError> {
571    // Get the job definition
572    let job = workflow.jobs.get(job_name).ok_or_else(|| {
573        ExecutionError::Execution(format!("Job '{}' not found in workflow", job_name))
574    })?;
575
576    // Evaluate job condition if present
577    if let Some(if_condition) = &job.if_condition {
578        let should_run = evaluate_job_condition(if_condition, env_context, workflow);
579        if !should_run {
580            wrkflw_logging::info(&format!(
581                "ā­ļø Skipping job '{}' due to condition: {}",
582                job_name, if_condition
583            ));
584            // Return a skipped job result
585            return Ok(vec![JobResult {
586                name: job_name.to_string(),
587                status: JobStatus::Skipped,
588                steps: Vec::new(),
589                logs: String::new(),
590            }]);
591        }
592    }
593
594    // Check if this is a matrix job
595    if let Some(matrix_config) = &job.matrix {
596        // Expand the matrix into combinations
597        let combinations = wrkflw_matrix::expand_matrix(matrix_config)
598            .map_err(|e| ExecutionError::Execution(format!("Failed to expand matrix: {}", e)))?;
599
600        if combinations.is_empty() {
601            wrkflw_logging::info(&format!(
602                "Matrix job '{}' has no valid combinations",
603                job_name
604            ));
605            // Return empty result for jobs with no valid combinations
606            return Ok(Vec::new());
607        }
608
609        wrkflw_logging::info(&format!(
610            "Matrix job '{}' expanded to {} combinations",
611            job_name,
612            combinations.len()
613        ));
614
615        // Set maximum parallel jobs
616        let max_parallel = matrix_config.max_parallel.unwrap_or_else(|| {
617            // If not specified, use a reasonable default based on CPU cores
618            std::cmp::max(1, num_cpus::get())
619        });
620
621        // Execute matrix combinations
622        execute_matrix_combinations(MatrixExecutionContext {
623            job_name,
624            job_template: job,
625            combinations: &combinations,
626            max_parallel,
627            fail_fast: matrix_config.fail_fast.unwrap_or(true),
628            workflow,
629            runtime,
630            env_context,
631            verbose,
632        })
633        .await
634    } else {
635        // Regular job, no matrix
636        let ctx = JobExecutionContext {
637            job_name,
638            workflow,
639            runtime,
640            env_context,
641            verbose,
642        };
643        let result = execute_job(ctx).await?;
644        Ok(vec![result])
645    }
646}
647
648#[allow(unused_variables, unused_assignments)]
649async fn execute_job(ctx: JobExecutionContext<'_>) -> Result<JobResult, ExecutionError> {
650    // Get job definition
651    let job = ctx.workflow.jobs.get(ctx.job_name).ok_or_else(|| {
652        ExecutionError::Execution(format!("Job '{}' not found in workflow", ctx.job_name))
653    })?;
654
655    // Clone context and add job-specific variables
656    let mut job_env = ctx.env_context.clone();
657
658    // Add job-level environment variables
659    for (key, value) in &job.env {
660        job_env.insert(key.clone(), value.clone());
661    }
662
663    // Execute job steps
664    let mut step_results = Vec::new();
665    let mut job_logs = String::new();
666
667    // Create a temporary directory for this job execution
668    let job_dir = tempfile::tempdir()
669        .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?;
670
671    // Get the current project directory
672    let current_dir = std::env::current_dir().map_err(|e| {
673        ExecutionError::Execution(format!("Failed to get current directory: {}", e))
674    })?;
675
676    // Copy project files to the job workspace directory
677    wrkflw_logging::info(&format!(
678        "Copying project files to job workspace: {}",
679        job_dir.path().display()
680    ));
681    copy_directory_contents(&current_dir, job_dir.path())?;
682
683    wrkflw_logging::info(&format!("Executing job: {}", ctx.job_name));
684
685    let mut job_success = true;
686
687    // Execute job steps
688    for (idx, step) in job.steps.iter().enumerate() {
689        let step_result = execute_step(StepExecutionContext {
690            step,
691            step_idx: idx,
692            job_env: &job_env,
693            working_dir: job_dir.path(),
694            runtime: ctx.runtime,
695            workflow: ctx.workflow,
696            runner_image: &get_runner_image(&job.runs_on),
697            verbose: ctx.verbose,
698            matrix_combination: &None,
699        })
700        .await;
701
702        match step_result {
703            Ok(result) => {
704                // Check if step was successful
705                if result.status == StepStatus::Failure {
706                    job_success = false;
707                }
708
709                // Add step output to logs only in verbose mode or if there's an error
710                if ctx.verbose || result.status == StepStatus::Failure {
711                    job_logs.push_str(&format!(
712                        "\n=== Output from step '{}' ===\n{}\n=== End output ===\n\n",
713                        result.name, result.output
714                    ));
715                } else {
716                    // In non-verbose mode, just record that the step ran but don't include output
717                    job_logs.push_str(&format!(
718                        "Step '{}' completed with status: {:?}\n",
719                        result.name, result.status
720                    ));
721                }
722
723                step_results.push(result);
724            }
725            Err(e) => {
726                job_success = false;
727                job_logs.push_str(&format!("\n=== ERROR in step {} ===\n{}\n", idx + 1, e));
728
729                // Record the error as a failed step
730                step_results.push(StepResult {
731                    name: step
732                        .name
733                        .clone()
734                        .unwrap_or_else(|| format!("Step {}", idx + 1)),
735                    status: StepStatus::Failure,
736                    output: format!("Error: {}", e),
737                });
738
739                // Stop executing further steps
740                break;
741            }
742        }
743    }
744
745    Ok(JobResult {
746        name: ctx.job_name.to_string(),
747        status: if job_success {
748            JobStatus::Success
749        } else {
750            JobStatus::Failure
751        },
752        steps: step_results,
753        logs: job_logs,
754    })
755}
756
757// Before the execute_matrix_combinations function, add this struct
758struct MatrixExecutionContext<'a> {
759    job_name: &'a str,
760    job_template: &'a Job,
761    combinations: &'a [MatrixCombination],
762    max_parallel: usize,
763    fail_fast: bool,
764    workflow: &'a WorkflowDefinition,
765    runtime: &'a dyn ContainerRuntime,
766    env_context: &'a HashMap<String, String>,
767    verbose: bool,
768}
769
770/// Execute a set of matrix combinations
771async fn execute_matrix_combinations(
772    ctx: MatrixExecutionContext<'_>,
773) -> Result<Vec<JobResult>, ExecutionError> {
774    let mut results = Vec::new();
775    let mut any_failed = false;
776
777    // Process combinations in chunks limited by max_parallel
778    for chunk in ctx.combinations.chunks(ctx.max_parallel) {
779        // Skip processing if fail-fast is enabled and a previous job failed
780        if ctx.fail_fast && any_failed {
781            // Add skipped results for remaining combinations
782            for combination in chunk {
783                let combination_name =
784                    wrkflw_matrix::format_combination_name(ctx.job_name, combination);
785                results.push(JobResult {
786                    name: combination_name,
787                    status: JobStatus::Skipped,
788                    steps: Vec::new(),
789                    logs: "Job skipped due to previous matrix job failure".to_string(),
790                });
791            }
792            continue;
793        }
794
795        // Process this chunk of combinations in parallel
796        let chunk_futures = chunk.iter().map(|combination| {
797            execute_matrix_job(
798                ctx.job_name,
799                ctx.job_template,
800                combination,
801                ctx.workflow,
802                ctx.runtime,
803                ctx.env_context,
804                ctx.verbose,
805            )
806        });
807
808        let chunk_results = future::join_all(chunk_futures).await;
809
810        // Process results from this chunk
811        for result in chunk_results {
812            match result {
813                Ok(job_result) => {
814                    if job_result.status == JobStatus::Failure {
815                        any_failed = true;
816                    }
817                    results.push(job_result);
818                }
819                Err(e) => {
820                    // On error, mark as failed and continue if not fail-fast
821                    any_failed = true;
822                    wrkflw_logging::error(&format!("Matrix job failed: {}", e));
823
824                    if ctx.fail_fast {
825                        return Err(e);
826                    }
827                }
828            }
829        }
830    }
831
832    Ok(results)
833}
834
835/// Execute a single matrix job combination
836async fn execute_matrix_job(
837    job_name: &str,
838    job_template: &Job,
839    combination: &MatrixCombination,
840    workflow: &WorkflowDefinition,
841    runtime: &dyn ContainerRuntime,
842    base_env_context: &HashMap<String, String>,
843    verbose: bool,
844) -> Result<JobResult, ExecutionError> {
845    // Create the matrix-specific job name
846    let matrix_job_name = wrkflw_matrix::format_combination_name(job_name, combination);
847
848    wrkflw_logging::info(&format!("Executing matrix job: {}", matrix_job_name));
849
850    // Clone the environment and add matrix-specific values
851    let mut job_env = base_env_context.clone();
852    environment::add_matrix_context(&mut job_env, combination);
853
854    // Add job-level environment variables
855    for (key, value) in &job_template.env {
856        // TODO: Substitute matrix variable references in env values
857        job_env.insert(key.clone(), value.clone());
858    }
859
860    // Execute the job steps
861    let mut step_results = Vec::new();
862    let mut job_logs = String::new();
863
864    // Create a temporary directory for this job execution
865    let job_dir = tempfile::tempdir()
866        .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?;
867
868    // Get the current project directory
869    let current_dir = std::env::current_dir().map_err(|e| {
870        ExecutionError::Execution(format!("Failed to get current directory: {}", e))
871    })?;
872
873    // Copy project files to the job workspace directory
874    wrkflw_logging::info(&format!(
875        "Copying project files to job workspace: {}",
876        job_dir.path().display()
877    ));
878    copy_directory_contents(&current_dir, job_dir.path())?;
879
880    let job_success = if job_template.steps.is_empty() {
881        wrkflw_logging::warning(&format!("Job '{}' has no steps", matrix_job_name));
882        true
883    } else {
884        // Execute each step
885        for (idx, step) in job_template.steps.iter().enumerate() {
886            match execute_step(StepExecutionContext {
887                step,
888                step_idx: idx,
889                job_env: &job_env,
890                working_dir: job_dir.path(),
891                runtime,
892                workflow,
893                runner_image: &get_runner_image(&job_template.runs_on),
894                verbose,
895                matrix_combination: &Some(combination.values.clone()),
896            })
897            .await
898            {
899                Ok(result) => {
900                    job_logs.push_str(&format!("Step: {}\n", result.name));
901                    job_logs.push_str(&format!("Status: {:?}\n", result.status));
902
903                    // Only include step output in verbose mode or if there's an error
904                    if verbose || result.status == StepStatus::Failure {
905                        job_logs.push_str(&result.output);
906                        job_logs.push_str("\n\n");
907                    } else {
908                        job_logs.push('\n');
909                        job_logs.push('\n');
910                    }
911
912                    step_results.push(result.clone());
913
914                    if result.status != StepStatus::Success {
915                        // Step failed, abort job
916                        return Ok(JobResult {
917                            name: matrix_job_name,
918                            status: JobStatus::Failure,
919                            steps: step_results,
920                            logs: job_logs,
921                        });
922                    }
923                }
924                Err(e) => {
925                    // Log the error and abort the job
926                    job_logs.push_str(&format!("Step execution error: {}\n\n", e));
927                    return Ok(JobResult {
928                        name: matrix_job_name,
929                        status: JobStatus::Failure,
930                        steps: step_results,
931                        logs: job_logs,
932                    });
933                }
934            }
935        }
936
937        true
938    };
939
940    // Return job result
941    Ok(JobResult {
942        name: matrix_job_name,
943        status: if job_success {
944            JobStatus::Success
945        } else {
946            JobStatus::Failure
947        },
948        steps: step_results,
949        logs: job_logs,
950    })
951}
952
953// Before the execute_step function, add this struct
954struct StepExecutionContext<'a> {
955    step: &'a workflow::Step,
956    step_idx: usize,
957    job_env: &'a HashMap<String, String>,
958    working_dir: &'a Path,
959    runtime: &'a dyn ContainerRuntime,
960    workflow: &'a WorkflowDefinition,
961    runner_image: &'a str,
962    verbose: bool,
963    #[allow(dead_code)]
964    matrix_combination: &'a Option<HashMap<String, Value>>,
965}
966
967async fn execute_step(ctx: StepExecutionContext<'_>) -> Result<StepResult, ExecutionError> {
968    let step_name = ctx
969        .step
970        .name
971        .clone()
972        .unwrap_or_else(|| format!("Step {}", ctx.step_idx + 1));
973
974    if ctx.verbose {
975        wrkflw_logging::info(&format!("  Executing step: {}", step_name));
976    }
977
978    // Prepare step environment
979    let mut step_env = ctx.job_env.clone();
980
981    // Add step-level environment variables
982    for (key, value) in &ctx.step.env {
983        step_env.insert(key.clone(), value.clone());
984    }
985
986    // Execute the step based on its type
987    let step_result = if let Some(uses) = &ctx.step.uses {
988        // Action step
989        let action_info = ctx.workflow.resolve_action(uses);
990
991        // Check if this is the checkout action
992        if uses.starts_with("actions/checkout") {
993            // Get the current directory (assumes this is where your project is)
994            let current_dir = std::env::current_dir().map_err(|e| {
995                ExecutionError::Execution(format!("Failed to get current dir: {}", e))
996            })?;
997
998            // Copy the project files to the workspace
999            copy_directory_contents(&current_dir, ctx.working_dir)?;
1000
1001            // Add info for logs
1002            let output = if ctx.verbose {
1003                let mut detailed_output =
1004                    "Emulated checkout: Copied current directory to workspace\n\n".to_string();
1005
1006                // Add checkout action details
1007                detailed_output.push_str("Checkout Details:\n");
1008                detailed_output.push_str("  - Source: Local directory\n");
1009                detailed_output
1010                    .push_str(&format!("  - Destination: {}\n", ctx.working_dir.display()));
1011
1012                // Add list of top-level files/directories that were copied (limit to 10)
1013                detailed_output.push_str("\nTop-level files/directories copied:\n");
1014                if let Ok(entries) = std::fs::read_dir(&current_dir) {
1015                    for (i, entry) in entries.take(10).enumerate() {
1016                        if let Ok(entry) = entry {
1017                            let file_type = if entry.path().is_dir() {
1018                                "directory"
1019                            } else {
1020                                "file"
1021                            };
1022                            detailed_output.push_str(&format!(
1023                                "  - {} ({})\n",
1024                                entry.file_name().to_string_lossy(),
1025                                file_type
1026                            ));
1027                        }
1028
1029                        if i >= 9 {
1030                            detailed_output.push_str("  - ... (more items not shown)\n");
1031                            break;
1032                        }
1033                    }
1034                }
1035
1036                detailed_output
1037            } else {
1038                "Emulated checkout: Copied current directory to workspace".to_string()
1039            };
1040
1041            if ctx.verbose {
1042                println!("  Emulated actions/checkout: copied project files to workspace");
1043            }
1044
1045            StepResult {
1046                name: step_name,
1047                status: StepStatus::Success,
1048                output,
1049            }
1050        } else {
1051            // Get action info
1052            let image = prepare_action(&action_info, ctx.runtime).await?;
1053
1054            // Special handling for composite actions
1055            if image == "composite" && action_info.is_local {
1056                // Handle composite action
1057                let action_path = Path::new(&action_info.repository);
1058                execute_composite_action(
1059                    ctx.step,
1060                    action_path,
1061                    &step_env,
1062                    ctx.working_dir,
1063                    ctx.runtime,
1064                    ctx.runner_image,
1065                    ctx.verbose,
1066                )
1067                .await?
1068            } else {
1069                // Regular Docker or JavaScript action processing
1070                // ... (rest of the existing code for handling regular actions)
1071                // Build command for Docker action
1072                let mut cmd = Vec::new();
1073                let mut owned_strings: Vec<String> = Vec::new(); // Keep strings alive until after we use cmd
1074
1075                // Special handling for Rust actions
1076                if uses.starts_with("actions-rs/") {
1077                    wrkflw_logging::info(
1078                        "šŸ”„ Detected Rust action - using system Rust installation",
1079                    );
1080
1081                    // For toolchain action, verify Rust is installed
1082                    if uses.starts_with("actions-rs/toolchain@") {
1083                        let rustc_version = Command::new("rustc")
1084                            .arg("--version")
1085                            .output()
1086                            .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1087                            .unwrap_or_else(|_| "not found".to_string());
1088
1089                        wrkflw_logging::info(&format!(
1090                            "šŸ”„ Using system Rust: {}",
1091                            rustc_version.trim()
1092                        ));
1093
1094                        // Return success since we're using system Rust
1095                        return Ok(StepResult {
1096                            name: step_name,
1097                            status: StepStatus::Success,
1098                            output: format!("Using system Rust: {}", rustc_version.trim()),
1099                        });
1100                    }
1101
1102                    // For cargo action, execute cargo commands directly
1103                    if uses.starts_with("actions-rs/cargo@") {
1104                        let cargo_version = Command::new("cargo")
1105                            .arg("--version")
1106                            .output()
1107                            .map(|output| String::from_utf8_lossy(&output.stdout).to_string())
1108                            .unwrap_or_else(|_| "not found".to_string());
1109
1110                        wrkflw_logging::info(&format!(
1111                            "šŸ”„ Using system Rust/Cargo: {}",
1112                            cargo_version.trim()
1113                        ));
1114
1115                        // Get the command from the 'with' parameters
1116                        if let Some(with_params) = &ctx.step.with {
1117                            if let Some(command) = with_params.get("command") {
1118                                wrkflw_logging::info(&format!(
1119                                    "šŸ”„ Found command parameter: {}",
1120                                    command
1121                                ));
1122
1123                                // Build the actual command
1124                                let mut real_command = format!("cargo {}", command);
1125
1126                                // Add any arguments if specified
1127                                if let Some(args) = with_params.get("args") {
1128                                    if !args.is_empty() {
1129                                        // Resolve GitHub-style variables in args
1130                                        let resolved_args = if args.contains("${{") {
1131                                            wrkflw_logging::info(&format!(
1132                                                "šŸ”„ Resolving workflow variables in: {}",
1133                                                args
1134                                            ));
1135
1136                                            // Handle common matrix variables
1137                                            let mut resolved =
1138                                                args.replace("${{ matrix.target }}", "");
1139                                            resolved = resolved.replace("${{ matrix.os }}", "");
1140
1141                                            // Handle any remaining ${{ variables }} by removing them
1142                                            let re_pattern =
1143                                                regex::Regex::new(r"\$\{\{\s*([^}]+)\s*\}\}")
1144                                                    .unwrap_or_else(|_| {
1145                                                        wrkflw_logging::error(
1146                                                            "Failed to create regex pattern",
1147                                                        );
1148                                                        regex::Regex::new(r"\$\{\{.*?\}\}").unwrap()
1149                                                    });
1150
1151                                            let resolved =
1152                                                re_pattern.replace_all(&resolved, "").to_string();
1153                                            wrkflw_logging::info(&format!(
1154                                                "šŸ”„ Resolved to: {}",
1155                                                resolved
1156                                            ));
1157
1158                                            resolved.trim().to_string()
1159                                        } else {
1160                                            args.clone()
1161                                        };
1162
1163                                        // Only add if we have something left after resolving variables
1164                                        // and it's not just "--target" without a value
1165                                        if !resolved_args.is_empty() && resolved_args != "--target"
1166                                        {
1167                                            real_command.push_str(&format!(" {}", resolved_args));
1168                                        }
1169                                    }
1170                                }
1171
1172                                wrkflw_logging::info(&format!(
1173                                    "šŸ”„ Running actual command: {}",
1174                                    real_command
1175                                ));
1176
1177                                // Execute the command
1178                                let mut cmd = Command::new("sh");
1179                                cmd.arg("-c");
1180                                cmd.arg(&real_command);
1181                                cmd.current_dir(ctx.working_dir);
1182
1183                                // Add environment variables
1184                                for (key, value) in step_env {
1185                                    cmd.env(key, value);
1186                                }
1187
1188                                match cmd.output() {
1189                                    Ok(output) => {
1190                                        let exit_code = output.status.code().unwrap_or(-1);
1191                                        let stdout =
1192                                            String::from_utf8_lossy(&output.stdout).to_string();
1193                                        let stderr =
1194                                            String::from_utf8_lossy(&output.stderr).to_string();
1195
1196                                        return Ok(StepResult {
1197                                            name: step_name,
1198                                            status: if exit_code == 0 {
1199                                                StepStatus::Success
1200                                            } else {
1201                                                StepStatus::Failure
1202                                            },
1203                                            output: format!("{}\n{}", stdout, stderr),
1204                                        });
1205                                    }
1206                                    Err(e) => {
1207                                        return Ok(StepResult {
1208                                            name: step_name,
1209                                            status: StepStatus::Failure,
1210                                            output: format!("Failed to execute command: {}", e),
1211                                        });
1212                                    }
1213                                }
1214                            }
1215                        }
1216                    }
1217                }
1218
1219                if action_info.is_docker {
1220                    // Docker actions just run the container
1221                    cmd.push("sh");
1222                    cmd.push("-c");
1223                    cmd.push("echo 'Executing Docker action'");
1224                } else if action_info.is_local {
1225                    // For local actions, we need more complex logic based on action type
1226                    let action_dir = Path::new(&action_info.repository);
1227                    let action_yaml = action_dir.join("action.yml");
1228
1229                    if action_yaml.exists() {
1230                        // Parse the action.yml to determine action type
1231                        // This is simplified - real implementation would be more complex
1232                        cmd.push("sh");
1233                        cmd.push("-c");
1234                        cmd.push("echo 'Local action without action.yml'");
1235                    } else {
1236                        cmd.push("sh");
1237                        cmd.push("-c");
1238                        cmd.push("echo 'Local action without action.yml'");
1239                    }
1240                } else {
1241                    // For GitHub actions, check if we have special handling
1242                    if let Err(e) = emulation::handle_special_action(uses).await {
1243                        // Log error but continue
1244                        println!("   Warning: Special action handling failed: {}", e);
1245                    }
1246
1247                    // Check if we should hide GitHub action messages
1248                    let hide_action_value = ctx
1249                        .job_env
1250                        .get("WRKFLW_HIDE_ACTION_MESSAGES")
1251                        .cloned()
1252                        .unwrap_or_else(|| "not set".to_string());
1253
1254                    wrkflw_logging::debug(&format!(
1255                        "WRKFLW_HIDE_ACTION_MESSAGES value: {}",
1256                        hide_action_value
1257                    ));
1258
1259                    let hide_messages = hide_action_value == "true";
1260                    wrkflw_logging::debug(&format!("Should hide messages: {}", hide_messages));
1261
1262                    // Only log a message to the console if we're showing action messages
1263                    if !hide_messages {
1264                        // For Emulation mode, log a message about what action would be executed
1265                        println!("   āš™ļø Would execute GitHub action: {}", uses);
1266                    }
1267
1268                    // Extract the actual command from the GitHub action if applicable
1269                    let mut should_run_real_command = false;
1270                    let mut real_command_parts = Vec::new();
1271
1272                    // Check if this action has 'with' parameters that specify a command to run
1273                    if let Some(with_params) = &ctx.step.with {
1274                        // Common GitHub action pattern: has a 'command' parameter
1275                        if let Some(cmd) = with_params.get("command") {
1276                            if ctx.verbose {
1277                                wrkflw_logging::info(&format!(
1278                                    "šŸ”„ Found command parameter: {}",
1279                                    cmd
1280                                ));
1281                            }
1282
1283                            // Convert to real command based on action type patterns
1284                            if uses.contains("cargo") || uses.contains("rust") {
1285                                // Cargo command pattern
1286                                real_command_parts.push("cargo".to_string());
1287                                real_command_parts.push(cmd.clone());
1288                                should_run_real_command = true;
1289                            } else if uses.contains("node") || uses.contains("npm") {
1290                                // Node.js command pattern
1291                                if cmd == "npm" || cmd == "yarn" || cmd == "pnpm" {
1292                                    real_command_parts.push(cmd.clone());
1293                                } else {
1294                                    real_command_parts.push("npm".to_string());
1295                                    real_command_parts.push("run".to_string());
1296                                    real_command_parts.push(cmd.clone());
1297                                }
1298                                should_run_real_command = true;
1299                            } else if uses.contains("python") || uses.contains("pip") {
1300                                // Python command pattern
1301                                if cmd == "pip" {
1302                                    real_command_parts.push("pip".to_string());
1303                                } else {
1304                                    real_command_parts.push("python".to_string());
1305                                    real_command_parts.push("-m".to_string());
1306                                    real_command_parts.push(cmd.clone());
1307                                }
1308                                should_run_real_command = true;
1309                            } else {
1310                                // Generic command - try to execute directly if available
1311                                real_command_parts.push(cmd.clone());
1312                                should_run_real_command = true;
1313                            }
1314
1315                            // Add any arguments if specified
1316                            if let Some(args) = with_params.get("args") {
1317                                if !args.is_empty() {
1318                                    // Resolve GitHub-style variables in args
1319                                    let resolved_args = if args.contains("${{") {
1320                                        wrkflw_logging::info(&format!(
1321                                            "šŸ”„ Resolving workflow variables in: {}",
1322                                            args
1323                                        ));
1324
1325                                        // Handle common matrix variables
1326                                        let mut resolved = args.replace("${{ matrix.target }}", "");
1327                                        resolved = resolved.replace("${{ matrix.os }}", "");
1328
1329                                        // Handle any remaining ${{ variables }} by removing them
1330                                        let re_pattern =
1331                                            regex::Regex::new(r"\$\{\{\s*([^}]+)\s*\}\}")
1332                                                .unwrap_or_else(|_| {
1333                                                    wrkflw_logging::error(
1334                                                        "Failed to create regex pattern",
1335                                                    );
1336                                                    regex::Regex::new(r"\$\{\{.*?\}\}").unwrap()
1337                                                });
1338
1339                                        let resolved =
1340                                            re_pattern.replace_all(&resolved, "").to_string();
1341                                        wrkflw_logging::info(&format!(
1342                                            "šŸ”„ Resolved to: {}",
1343                                            resolved
1344                                        ));
1345
1346                                        resolved.trim().to_string()
1347                                    } else {
1348                                        args.clone()
1349                                    };
1350
1351                                    // Only add if we have something left after resolving variables
1352                                    if !resolved_args.is_empty() {
1353                                        real_command_parts.push(resolved_args);
1354                                    }
1355                                }
1356                            }
1357                        }
1358                    }
1359
1360                    if should_run_real_command && !real_command_parts.is_empty() {
1361                        // Build a final command string
1362                        let command_str = real_command_parts.join(" ");
1363                        wrkflw_logging::info(&format!(
1364                            "šŸ”„ Running actual command: {}",
1365                            command_str
1366                        ));
1367
1368                        // Replace the emulated command with a shell command to execute our command
1369                        cmd.clear();
1370                        cmd.push("sh");
1371                        cmd.push("-c");
1372                        owned_strings.push(command_str);
1373                        cmd.push(owned_strings.last().unwrap());
1374                    } else {
1375                        // Fall back to emulation for actions we don't know how to execute
1376                        cmd.clear();
1377                        cmd.push("sh");
1378                        cmd.push("-c");
1379
1380                        let echo_msg = format!("echo 'Would execute GitHub action: {}'", uses);
1381                        owned_strings.push(echo_msg);
1382                        cmd.push(owned_strings.last().unwrap());
1383                    }
1384                }
1385
1386                // Convert 'with' parameters to environment variables
1387                if let Some(with_params) = &ctx.step.with {
1388                    for (key, value) in with_params {
1389                        step_env.insert(format!("INPUT_{}", key.to_uppercase()), value.clone());
1390                    }
1391                }
1392
1393                // Convert environment HashMap to Vec<(&str, &str)> for container runtime
1394                let env_vars: Vec<(&str, &str)> = step_env
1395                    .iter()
1396                    .map(|(k, v)| (k.as_str(), v.as_str()))
1397                    .collect();
1398
1399                // Define the standard workspace path inside the container
1400                let container_workspace = Path::new("/github/workspace");
1401
1402                // Set up volume mapping from host working dir to container workspace
1403                let volumes: Vec<(&Path, &Path)> = vec![(ctx.working_dir, container_workspace)];
1404
1405                let output = ctx
1406                    .runtime
1407                    .run_container(
1408                        ctx.runner_image,
1409                        &cmd.to_vec(),
1410                        &env_vars,
1411                        container_workspace,
1412                        &volumes,
1413                    )
1414                    .await
1415                    .map_err(|e| ExecutionError::Runtime(format!("{}", e)))?;
1416
1417                // Check if this was called from 'run' branch - don't try to hide these outputs
1418                if output.exit_code == 0 {
1419                    // For GitHub actions in verbose mode, provide more detailed emulation information
1420                    let output_text = if ctx.verbose
1421                        && uses.contains('/')
1422                        && !uses.starts_with("./")
1423                    {
1424                        let mut detailed_output =
1425                            format!("Would execute GitHub action: {}\n", uses);
1426
1427                        // Add information about the action inputs if available
1428                        if let Some(with_params) = &ctx.step.with {
1429                            detailed_output.push_str("\nAction inputs:\n");
1430                            for (key, value) in with_params {
1431                                detailed_output.push_str(&format!("  {}: {}\n", key, value));
1432                            }
1433                        }
1434
1435                        // Add standard GitHub action environment variables
1436                        detailed_output.push_str("\nEnvironment variables:\n");
1437                        for (key, value) in step_env.iter() {
1438                            if key.starts_with("GITHUB_") || key.starts_with("INPUT_") {
1439                                detailed_output.push_str(&format!("  {}: {}\n", key, value));
1440                            }
1441                        }
1442
1443                        // Include the original output
1444                        detailed_output
1445                            .push_str(&format!("\nOutput:\n{}\n{}", output.stdout, output.stderr));
1446                        detailed_output
1447                    } else {
1448                        format!("{}\n{}", output.stdout, output.stderr)
1449                    };
1450
1451                    // Check if this is a cargo command that failed
1452                    if output.exit_code != 0 && (uses.contains("cargo") || uses.contains("rust")) {
1453                        // Add detailed error information for cargo commands
1454                        let mut error_details = format!(
1455                            "\n\nāŒ Command failed with exit code: {}\n",
1456                            output.exit_code
1457                        );
1458
1459                        // Add command details
1460                        error_details.push_str(&format!("Command: {}\n", cmd.join(" ")));
1461
1462                        // Add environment details
1463                        error_details.push_str("\nEnvironment:\n");
1464                        for (key, value) in step_env.iter() {
1465                            if key.starts_with("GITHUB_")
1466                                || key.starts_with("INPUT_")
1467                                || key.starts_with("RUST")
1468                            {
1469                                error_details.push_str(&format!("  {}: {}\n", key, value));
1470                            }
1471                        }
1472
1473                        // Add detailed output
1474                        error_details.push_str("\nDetailed output:\n");
1475                        error_details.push_str(&output.stdout);
1476                        error_details.push_str(&output.stderr);
1477
1478                        // Return failure with detailed error information
1479                        return Ok(StepResult {
1480                            name: step_name,
1481                            status: StepStatus::Failure,
1482                            output: format!("{}\n{}", output_text, error_details),
1483                        });
1484                    }
1485
1486                    StepResult {
1487                        name: step_name,
1488                        status: if output.exit_code == 0 {
1489                            StepStatus::Success
1490                        } else {
1491                            StepStatus::Failure
1492                        },
1493                        output: format!(
1494                            "Exit code: {}
1495{}
1496{}",
1497                            output.exit_code, output.stdout, output.stderr
1498                        ),
1499                    }
1500                } else {
1501                    StepResult {
1502                        name: step_name,
1503                        status: StepStatus::Failure,
1504                        output: format!(
1505                            "Exit code: {}\n{}\n{}",
1506                            output.exit_code, output.stdout, output.stderr
1507                        ),
1508                    }
1509                }
1510            }
1511        }
1512    } else if let Some(run) = &ctx.step.run {
1513        // Run step
1514        let mut output = String::new();
1515        let mut status = StepStatus::Success;
1516        let mut error_details = None;
1517
1518        // Check if this is a cargo command
1519        let is_cargo_cmd = run.trim().starts_with("cargo");
1520
1521        // Convert command string to array of string slices
1522        let cmd_parts: Vec<&str> = run.split_whitespace().collect();
1523
1524        // Convert environment variables to the required format
1525        let env_vars: Vec<(&str, &str)> = step_env
1526            .iter()
1527            .map(|(k, v)| (k.as_str(), v.as_str()))
1528            .collect();
1529
1530        // Define the standard workspace path inside the container
1531        let container_workspace = Path::new("/github/workspace");
1532
1533        // Set up volume mapping from host working dir to container workspace
1534        let volumes: Vec<(&Path, &Path)> = vec![(ctx.working_dir, container_workspace)];
1535
1536        // Execute the command
1537        match ctx
1538            .runtime
1539            .run_container(
1540                ctx.runner_image,
1541                &cmd_parts,
1542                &env_vars,
1543                container_workspace,
1544                &volumes,
1545            )
1546            .await
1547        {
1548            Ok(container_output) => {
1549                // Add command details to output
1550                output.push_str(&format!("Command: {}\n\n", run));
1551
1552                if !container_output.stdout.is_empty() {
1553                    output.push_str("Standard Output:\n");
1554                    output.push_str(&container_output.stdout);
1555                    output.push('\n');
1556                }
1557
1558                if !container_output.stderr.is_empty() {
1559                    output.push_str("Standard Error:\n");
1560                    output.push_str(&container_output.stderr);
1561                    output.push('\n');
1562                }
1563
1564                if container_output.exit_code != 0 {
1565                    status = StepStatus::Failure;
1566
1567                    // For cargo commands, add more detailed error information
1568                    if is_cargo_cmd {
1569                        let mut error_msg = String::new();
1570                        error_msg.push_str(&format!(
1571                            "\nCargo command failed with exit code {}\n",
1572                            container_output.exit_code
1573                        ));
1574                        error_msg.push_str("Common causes for cargo command failures:\n");
1575
1576                        if run.contains("fmt") {
1577                            error_msg.push_str(
1578                                "- Code formatting issues. Run 'cargo fmt' locally to fix.\n",
1579                            );
1580                        } else if run.contains("clippy") {
1581                            error_msg.push_str("- Linter warnings treated as errors. Run 'cargo clippy' locally to see details.\n");
1582                        } else if run.contains("test") {
1583                            error_msg.push_str("- Test failures. Run 'cargo test' locally to see which tests failed.\n");
1584                        } else if run.contains("build") {
1585                            error_msg.push_str(
1586                                "- Compilation errors. Check the error messages above.\n",
1587                            );
1588                        }
1589
1590                        error_details = Some(error_msg);
1591                    }
1592                }
1593            }
1594            Err(e) => {
1595                status = StepStatus::Failure;
1596                output.push_str(&format!("Error executing command: {}\n", e));
1597            }
1598        }
1599
1600        // If there are error details, append them to the output
1601        if let Some(details) = error_details {
1602            output.push_str(&details);
1603        }
1604
1605        StepResult {
1606            name: step_name,
1607            status,
1608            output,
1609        }
1610    } else {
1611        return Ok(StepResult {
1612            name: step_name,
1613            status: StepStatus::Skipped,
1614            output: "Step has neither 'uses' nor 'run'".to_string(),
1615        });
1616    };
1617
1618    Ok(step_result)
1619}
1620
1621fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError> {
1622    for entry in std::fs::read_dir(from)
1623        .map_err(|e| ExecutionError::Execution(format!("Failed to read directory: {}", e)))?
1624    {
1625        let entry =
1626            entry.map_err(|e| ExecutionError::Execution(format!("Failed to read entry: {}", e)))?;
1627        let path = entry.path();
1628
1629        // Skip hidden files/dirs and target directory for efficiency
1630        let file_name = match path.file_name() {
1631            Some(name) => name.to_string_lossy(),
1632            None => {
1633                return Err(ExecutionError::Execution(format!(
1634                    "Failed to get file name from path: {:?}",
1635                    path
1636                )));
1637            }
1638        };
1639        if file_name.starts_with(".") || file_name == "target" {
1640            continue;
1641        }
1642
1643        let dest_path = match path.file_name() {
1644            Some(name) => to.join(name),
1645            None => {
1646                return Err(ExecutionError::Execution(format!(
1647                    "Failed to get file name from path: {:?}",
1648                    path
1649                )));
1650            }
1651        };
1652
1653        if path.is_dir() {
1654            std::fs::create_dir_all(&dest_path)
1655                .map_err(|e| ExecutionError::Execution(format!("Failed to create dir: {}", e)))?;
1656
1657            // Recursively copy subdirectories
1658            copy_directory_contents(&path, &dest_path)?;
1659        } else {
1660            std::fs::copy(&path, &dest_path)
1661                .map_err(|e| ExecutionError::Execution(format!("Failed to copy file: {}", e)))?;
1662        }
1663    }
1664
1665    Ok(())
1666}
1667
1668fn get_runner_image(runs_on: &str) -> String {
1669    // Map GitHub runners to Docker images
1670    match runs_on.trim() {
1671        // ubuntu runners - micro images (minimal size)
1672        "ubuntu-latest" => "node:16-buster-slim",
1673        "ubuntu-22.04" => "node:16-bullseye-slim",
1674        "ubuntu-20.04" => "node:16-buster-slim",
1675        "ubuntu-18.04" => "node:16-buster-slim",
1676
1677        // ubuntu runners - medium images (with more tools)
1678        "ubuntu-latest-medium" => "catthehacker/ubuntu:act-latest",
1679        "ubuntu-22.04-medium" => "catthehacker/ubuntu:act-22.04",
1680        "ubuntu-20.04-medium" => "catthehacker/ubuntu:act-20.04",
1681        "ubuntu-18.04-medium" => "catthehacker/ubuntu:act-18.04",
1682
1683        // ubuntu runners - large images (with most tools)
1684        "ubuntu-latest-large" => "catthehacker/ubuntu:full-latest",
1685        "ubuntu-22.04-large" => "catthehacker/ubuntu:full-22.04",
1686        "ubuntu-20.04-large" => "catthehacker/ubuntu:full-20.04",
1687        "ubuntu-18.04-large" => "catthehacker/ubuntu:full-18.04",
1688
1689        // macOS runners - use a standard Rust image for compatibility
1690        "macos-latest" => "rust:latest",
1691        "macos-12" => "rust:latest",    // Monterey equivalent
1692        "macos-11" => "rust:latest",    // Big Sur equivalent
1693        "macos-10.15" => "rust:latest", // Catalina equivalent
1694
1695        // Windows runners - using servercore-based images
1696        "windows-latest" => "mcr.microsoft.com/windows/servercore:ltsc2022",
1697        "windows-2022" => "mcr.microsoft.com/windows/servercore:ltsc2022",
1698        "windows-2019" => "mcr.microsoft.com/windows/servercore:ltsc2019",
1699
1700        // Language-specific runners
1701        "python-latest" => "python:3.11-slim",
1702        "python-3.11" => "python:3.11-slim",
1703        "python-3.10" => "python:3.10-slim",
1704        "python-3.9" => "python:3.9-slim",
1705        "python-3.8" => "python:3.8-slim",
1706
1707        "node-latest" => "node:20-slim",
1708        "node-20" => "node:20-slim",
1709        "node-18" => "node:18-slim",
1710        "node-16" => "node:16-slim",
1711
1712        "java-latest" => "eclipse-temurin:17-jdk",
1713        "java-17" => "eclipse-temurin:17-jdk",
1714        "java-11" => "eclipse-temurin:11-jdk",
1715        "java-8" => "eclipse-temurin:8-jdk",
1716
1717        "go-latest" => "golang:1.21-slim",
1718        "go-1.21" => "golang:1.21-slim",
1719        "go-1.20" => "golang:1.20-slim",
1720        "go-1.19" => "golang:1.19-slim",
1721
1722        "dotnet-latest" => "mcr.microsoft.com/dotnet/sdk:7.0",
1723        "dotnet-7.0" => "mcr.microsoft.com/dotnet/sdk:7.0",
1724        "dotnet-6.0" => "mcr.microsoft.com/dotnet/sdk:6.0",
1725        "dotnet-5.0" => "mcr.microsoft.com/dotnet/sdk:5.0",
1726
1727        // Default case for other runners or custom strings
1728        _ => {
1729            // Check for platform prefixes and provide appropriate images
1730            let runs_on_lower = runs_on.trim().to_lowercase();
1731            if runs_on_lower.starts_with("macos") {
1732                "rust:latest" // Use Rust image for macOS runners
1733            } else if runs_on_lower.starts_with("windows") {
1734                "mcr.microsoft.com/windows/servercore:ltsc2022" // Default Windows image
1735            } else if runs_on_lower.starts_with("python") {
1736                "python:3.11-slim" // Default Python image
1737            } else if runs_on_lower.starts_with("node") {
1738                "node:20-slim" // Default Node.js image
1739            } else if runs_on_lower.starts_with("java") {
1740                "eclipse-temurin:17-jdk" // Default Java image
1741            } else if runs_on_lower.starts_with("go") {
1742                "golang:1.21-slim" // Default Go image
1743            } else if runs_on_lower.starts_with("dotnet") {
1744                "mcr.microsoft.com/dotnet/sdk:7.0" // Default .NET image
1745            } else {
1746                "ubuntu:latest" // Default to Ubuntu for everything else
1747            }
1748        }
1749    }
1750    .to_string()
1751}
1752
1753#[allow(dead_code)]
1754async fn prepare_runner_image(
1755    image: &str,
1756    runtime: &dyn ContainerRuntime,
1757    verbose: bool,
1758) -> Result<(), ExecutionError> {
1759    // Try to pull the image first
1760    if let Err(e) = runtime.pull_image(image).await {
1761        wrkflw_logging::warning(&format!("Failed to pull image {}: {}", image, e));
1762    }
1763
1764    // Check if this is a language-specific runner
1765    let language_info = extract_language_info(image);
1766    if let Some((language, version)) = language_info {
1767        // Try to prepare a language-specific environment
1768        if let Ok(custom_image) = runtime
1769            .prepare_language_environment(language, version, None)
1770            .await
1771            .map_err(|e| ExecutionError::Runtime(e.to_string()))
1772        {
1773            if verbose {
1774                wrkflw_logging::info(&format!("Using customized image: {}", custom_image));
1775            }
1776            return Ok(());
1777        }
1778    }
1779
1780    Ok(())
1781}
1782
1783#[allow(dead_code)]
1784fn extract_language_info(image: &str) -> Option<(&'static str, Option<&str>)> {
1785    let image_lower = image.to_lowercase();
1786
1787    // Check for language-specific images
1788    if image_lower.starts_with("python:") {
1789        Some(("python", Some(&image[7..])))
1790    } else if image_lower.starts_with("node:") {
1791        Some(("node", Some(&image[5..])))
1792    } else if image_lower.starts_with("eclipse-temurin:") {
1793        Some(("java", Some(&image[15..])))
1794    } else if image_lower.starts_with("golang:") {
1795        Some(("go", Some(&image[6..])))
1796    } else if image_lower.starts_with("mcr.microsoft.com/dotnet/sdk:") {
1797        Some(("dotnet", Some(&image[29..])))
1798    } else if image_lower.starts_with("rust:") {
1799        Some(("rust", Some(&image[5..])))
1800    } else {
1801        None
1802    }
1803}
1804
1805async fn execute_composite_action(
1806    step: &workflow::Step,
1807    action_path: &Path,
1808    job_env: &HashMap<String, String>,
1809    working_dir: &Path,
1810    runtime: &dyn ContainerRuntime,
1811    runner_image: &str,
1812    verbose: bool,
1813) -> Result<StepResult, ExecutionError> {
1814    // Find the action definition file
1815    let action_yaml = action_path.join("action.yml");
1816    let action_yaml_alt = action_path.join("action.yaml");
1817
1818    let action_file = if action_yaml.exists() {
1819        action_yaml
1820    } else if action_yaml_alt.exists() {
1821        action_yaml_alt
1822    } else {
1823        return Err(ExecutionError::Execution(format!(
1824            "No action.yml or action.yaml found in {}",
1825            action_path.display()
1826        )));
1827    };
1828
1829    // Parse the composite action definition
1830    let action_content = fs::read_to_string(&action_file)
1831        .map_err(|e| ExecutionError::Execution(format!("Failed to read action file: {}", e)))?;
1832
1833    let action_def: serde_yaml::Value = serde_yaml::from_str(&action_content)
1834        .map_err(|e| ExecutionError::Execution(format!("Invalid action YAML: {}", e)))?;
1835
1836    // Check if it's a composite action
1837    match action_def.get("runs").and_then(|v| v.get("using")) {
1838        Some(serde_yaml::Value::String(using)) if using == "composite" => {
1839            // Get the steps
1840            let steps = match action_def.get("runs").and_then(|v| v.get("steps")) {
1841                Some(serde_yaml::Value::Sequence(steps)) => steps,
1842                _ => {
1843                    return Err(ExecutionError::Execution(
1844                        "Composite action is missing steps".to_string(),
1845                    ))
1846                }
1847            };
1848
1849            // Process inputs from the calling step's 'with' parameters
1850            let mut action_env = job_env.clone();
1851            if let Some(inputs_def) = action_def.get("inputs") {
1852                if let Some(inputs_map) = inputs_def.as_mapping() {
1853                    for (input_name, input_def) in inputs_map {
1854                        if let Some(input_name_str) = input_name.as_str() {
1855                            // Get default value if available
1856                            let default_value = input_def
1857                                .get("default")
1858                                .and_then(|v| v.as_str())
1859                                .unwrap_or("");
1860
1861                            // Check if the input was provided in the 'with' section
1862                            let input_value = step
1863                                .with
1864                                .as_ref()
1865                                .and_then(|with| with.get(input_name_str))
1866                                .unwrap_or(&default_value.to_string())
1867                                .clone();
1868
1869                            // Add to environment as INPUT_X
1870                            action_env.insert(
1871                                format!("INPUT_{}", input_name_str.to_uppercase()),
1872                                input_value,
1873                            );
1874                        }
1875                    }
1876                }
1877            }
1878
1879            // Execute each step
1880            let mut step_outputs = Vec::new();
1881            for (idx, step_def) in steps.iter().enumerate() {
1882                // Convert the YAML step to our Step struct
1883                let composite_step = match convert_yaml_to_step(step_def) {
1884                    Ok(step) => step,
1885                    Err(e) => {
1886                        return Err(ExecutionError::Execution(format!(
1887                            "Failed to process composite action step {}: {}",
1888                            idx + 1,
1889                            e
1890                        )))
1891                    }
1892                };
1893
1894                // Execute the step - using Box::pin to handle async recursion
1895                let step_result = Box::pin(execute_step(StepExecutionContext {
1896                    step: &composite_step,
1897                    step_idx: idx,
1898                    job_env: &action_env,
1899                    working_dir,
1900                    runtime,
1901                    workflow: &workflow::WorkflowDefinition {
1902                        name: "Composite Action".to_string(),
1903                        on: vec![],
1904                        on_raw: serde_yaml::Value::Null,
1905                        jobs: HashMap::new(),
1906                    },
1907                    runner_image,
1908                    verbose,
1909                    matrix_combination: &None,
1910                }))
1911                .await?;
1912
1913                // Add output to results
1914                step_outputs.push(format!("Step {}: {}", idx + 1, step_result.output));
1915
1916                // Short-circuit on failure if needed
1917                if step_result.status == StepStatus::Failure {
1918                    return Ok(StepResult {
1919                        name: step
1920                            .name
1921                            .clone()
1922                            .unwrap_or_else(|| "Composite Action".to_string()),
1923                        status: StepStatus::Failure,
1924                        output: step_outputs.join("\n"),
1925                    });
1926                }
1927            }
1928
1929            // All steps completed successfully
1930            let output = if verbose {
1931                let mut detailed_output = format!(
1932                    "Executed composite action from: {}\n\n",
1933                    action_path.display()
1934                );
1935
1936                // Add information about the composite action if available
1937                if let Ok(action_content) =
1938                    serde_yaml::from_str::<serde_yaml::Value>(&action_content)
1939                {
1940                    if let Some(name) = action_content.get("name").and_then(|v| v.as_str()) {
1941                        detailed_output.push_str(&format!("Action name: {}\n", name));
1942                    }
1943
1944                    if let Some(description) =
1945                        action_content.get("description").and_then(|v| v.as_str())
1946                    {
1947                        detailed_output.push_str(&format!("Description: {}\n", description));
1948                    }
1949
1950                    detailed_output.push('\n');
1951                }
1952
1953                // Add individual step outputs
1954                detailed_output.push_str("Step outputs:\n");
1955                for output in &step_outputs {
1956                    detailed_output.push_str(&format!("{}\n", output));
1957                }
1958
1959                detailed_output
1960            } else {
1961                format!(
1962                    "Executed composite action with {} steps",
1963                    step_outputs.len()
1964                )
1965            };
1966
1967            Ok(StepResult {
1968                name: step
1969                    .name
1970                    .clone()
1971                    .unwrap_or_else(|| "Composite Action".to_string()),
1972                status: StepStatus::Success,
1973                output,
1974            })
1975        }
1976        _ => Err(ExecutionError::Execution(
1977            "Action is not a composite action or has invalid format".to_string(),
1978        )),
1979    }
1980}
1981
1982// Helper function to convert YAML step to our Step struct
1983fn convert_yaml_to_step(step_yaml: &serde_yaml::Value) -> Result<workflow::Step, String> {
1984    // Extract step properties
1985    let name = step_yaml
1986        .get("name")
1987        .and_then(|v| v.as_str())
1988        .map(|s| s.to_string());
1989
1990    let uses = step_yaml
1991        .get("uses")
1992        .and_then(|v| v.as_str())
1993        .map(|s| s.to_string());
1994
1995    let run = step_yaml
1996        .get("run")
1997        .and_then(|v| v.as_str())
1998        .map(|s| s.to_string());
1999
2000    let shell = step_yaml
2001        .get("shell")
2002        .and_then(|v| v.as_str())
2003        .map(|s| s.to_string());
2004
2005    let with = step_yaml.get("with").and_then(|v| v.as_mapping()).map(|m| {
2006        let mut with_map = HashMap::new();
2007        for (k, v) in m {
2008            if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
2009                with_map.insert(key.to_string(), value.to_string());
2010            }
2011        }
2012        with_map
2013    });
2014
2015    let env = step_yaml
2016        .get("env")
2017        .and_then(|v| v.as_mapping())
2018        .map(|m| {
2019            let mut env_map = HashMap::new();
2020            for (k, v) in m {
2021                if let (Some(key), Some(value)) = (k.as_str(), v.as_str()) {
2022                    env_map.insert(key.to_string(), value.to_string());
2023                }
2024            }
2025            env_map
2026        })
2027        .unwrap_or_default();
2028
2029    // For composite steps with shell, construct a run step
2030    let final_run = run;
2031
2032    // Extract continue_on_error
2033    let continue_on_error = step_yaml.get("continue-on-error").and_then(|v| v.as_bool());
2034
2035    Ok(workflow::Step {
2036        name,
2037        uses,
2038        run: final_run,
2039        with,
2040        env,
2041        continue_on_error,
2042    })
2043}
2044
2045/// Evaluate a job condition expression
2046/// This is a simplified implementation that handles basic GitHub Actions expressions
2047fn evaluate_job_condition(
2048    condition: &str,
2049    env_context: &HashMap<String, String>,
2050    workflow: &WorkflowDefinition,
2051) -> bool {
2052    wrkflw_logging::debug(&format!("Evaluating condition: {}", condition));
2053
2054    // For now, implement basic pattern matching for common conditions
2055    // TODO: Implement a full GitHub Actions expression evaluator
2056
2057    // Handle simple boolean conditions
2058    if condition == "true" {
2059        return true;
2060    }
2061    if condition == "false" {
2062        return false;
2063    }
2064
2065    // Handle github.event.pull_request.draft == false
2066    if condition.contains("github.event.pull_request.draft == false") {
2067        // For local execution, assume this is always true (not a draft)
2068        return true;
2069    }
2070
2071    // Handle needs.jobname.outputs.outputname == 'value' patterns
2072    if condition.contains("needs.") && condition.contains(".outputs.") {
2073        // For now, simulate that outputs are available but empty
2074        // This means conditions like needs.changes.outputs.source-code == 'true' will be false
2075        wrkflw_logging::debug(
2076            "Evaluating needs.outputs condition - defaulting to false for local execution",
2077        );
2078        return false;
2079    }
2080
2081    // Default to true for unknown conditions to avoid breaking workflows
2082    wrkflw_logging::warning(&format!(
2083        "Unknown condition pattern: '{}' - defaulting to true",
2084        condition
2085    ));
2086    true
2087}