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