From 90b3f6bac6e7d9a2194e7caadb06b84815cebb37 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Mon, 21 Apr 2025 18:04:52 +0530 Subject: [PATCH] make clippy happy --- src/evaluator.rs | 2 +- src/executor/docker.rs | 44 ++- src/executor/engine.rs | 687 ++++++++++++++++++------------------ src/executor/environment.rs | 8 +- src/github.rs | 6 +- src/runtime/container.rs | 24 +- src/runtime/emulation.rs | 103 +++--- src/ui.rs | 362 ++++++++++--------- src/validators/jobs.rs | 14 +- src/validators/steps.rs | 14 +- src/validators/triggers.rs | 4 +- 11 files changed, 638 insertions(+), 630 deletions(-) diff --git a/src/evaluator.rs b/src/evaluator.rs index bf8b210..156feb8 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -22,7 +22,7 @@ pub fn evaluate_workflow_file(path: &Path, verbose: bool) -> Result Result { let docker = Docker::connect_with_local_defaults().map_err(|e| { - ContainerError::ContainerStartFailed(format!("Failed to connect to Docker: {}", e)) + ContainerError::ContainerStart(format!("Failed to connect to Docker: {}", e)) })?; Ok(DockerRuntime { docker }) @@ -141,11 +141,11 @@ pub async fn create_job_network(docker: &Docker) -> Result, unwrap it safely let network_id = network.id.ok_or_else(|| { - ContainerError::NetworkOperationFailed("Network created but no ID returned".to_string()) + ContainerError::NetworkOperation("Network created but no ID returned".to_string()) })?; track_network(&network_id); @@ -268,13 +268,13 @@ impl ContainerRuntime for DockerRuntime { .docker .create_container(options, config) .await - .map_err(|e| ContainerError::ContainerStartFailed(e.to_string()))?; + .map_err(|e| ContainerError::ContainerStart(e.to_string()))?; // Start container self.docker .start_container::(&container.id, None) .await - .map_err(|e| ContainerError::ContainerExecutionFailed(e.to_string()))?; + .map_err(|e| ContainerError::ContainerExecution(e.to_string()))?; track_container(&container.id); @@ -300,17 +300,15 @@ impl ContainerRuntime for DockerRuntime { let mut stdout = String::new(); let mut stderr = String::new(); - for log_result in logs { - if let Ok(log) = log_result { - match log { - bollard::container::LogOutput::StdOut { message } => { - stdout.push_str(&String::from_utf8_lossy(&message)); - } - bollard::container::LogOutput::StdErr { message } => { - stderr.push_str(&String::from_utf8_lossy(&message)); - } - _ => {} + for log in logs.into_iter().flatten() { + match log { + bollard::container::LogOutput::StdOut { message } => { + stdout.push_str(&String::from_utf8_lossy(&message)); } + bollard::container::LogOutput::StdErr { message } => { + stderr.push_str(&String::from_utf8_lossy(&message)); + } + _ => {} } } @@ -335,7 +333,7 @@ impl ContainerRuntime for DockerRuntime { while let Some(result) = stream.next().await { if let Err(e) = result { - return Err(ContainerError::ImagePullFailed(e.to_string())); + return Err(ContainerError::ImagePull(e.to_string())); } } @@ -352,7 +350,7 @@ impl ContainerRuntime for DockerRuntime { if let Ok(file) = std::fs::File::open(dockerfile) { let mut header = tar::Header::new_gnu(); let metadata = file.metadata().map_err(|e| { - ContainerError::ContainerExecutionFailed(format!( + ContainerError::ContainerExecution(format!( "Failed to get file metadata: {}", e )) @@ -360,14 +358,14 @@ impl ContainerRuntime for DockerRuntime { let modified_time = metadata .modified() .map_err(|e| { - ContainerError::ContainerExecutionFailed(format!( + ContainerError::ContainerExecution(format!( "Failed to get file modification time: {}", e )) })? .elapsed() .map_err(|e| { - ContainerError::ContainerExecutionFailed(format!( + ContainerError::ContainerExecution(format!( "Failed to get elapsed time since modification: {}", e )) @@ -380,9 +378,9 @@ impl ContainerRuntime for DockerRuntime { tar_builder .append_data(&mut header, "Dockerfile", file) - .map_err(|e| ContainerError::ImageBuildFailed(e.to_string()))?; + .map_err(|e| ContainerError::ImageBuild(e.to_string()))?; } else { - return Err(ContainerError::ImageBuildFailed(format!( + return Err(ContainerError::ImageBuild(format!( "Cannot open Dockerfile at {}", dockerfile.display() ))); @@ -390,7 +388,7 @@ impl ContainerRuntime for DockerRuntime { tar_builder .into_inner() - .map_err(|e| ContainerError::ImageBuildFailed(e.to_string()))? + .map_err(|e| ContainerError::ImageBuild(e.to_string()))? }; let options = bollard::image::BuildImageOptions { @@ -412,7 +410,7 @@ impl ContainerRuntime for DockerRuntime { // For verbose output, we could log the build progress here } Err(e) => { - return Err(ContainerError::ImageBuildFailed(e.to_string())); + return Err(ContainerError::ImageBuild(e.to_string())); } } } diff --git a/src/executor/engine.rs b/src/executor/engine.rs index b7ddd62..b162dc1 100644 --- a/src/executor/engine.rs +++ b/src/executor/engine.rs @@ -17,7 +17,6 @@ use crate::runtime::container::ContainerRuntime; use crate::runtime::emulation::handle_special_action; #[allow(unused_variables, unused_assignments)] - /// Execute a GitHub Actions workflow file locally pub async fn execute_workflow( workflow_path: &Path, @@ -37,24 +36,29 @@ pub async fn execute_workflow( let runtime = initialize_runtime(runtime_type)?; // Create a temporary workspace directory - let workspace_dir = tempfile::tempdir().map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to create workspace: {}", e)) - })?; + let workspace_dir = tempfile::tempdir() + .map_err(|e| ExecutionError::Execution(format!("Failed to create workspace: {}", e)))?; // 4. Set up GitHub-like environment let env_context = environment::create_github_context(&workflow, workspace_dir.path()); // Setup GitHub environment files environment::setup_github_environment_files(workspace_dir.path()).map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to setup GitHub env files: {}", e)) + ExecutionError::Execution(format!("Failed to setup GitHub env files: {}", e)) })?; // 5. Execute jobs according to the plan let mut results = Vec::new(); for job_batch in execution_plan { // Execute jobs in parallel if they don't depend on each other - let job_results = - execute_job_batch(&job_batch, &workflow, &runtime, &env_context, verbose).await?; + let job_results = execute_job_batch( + &job_batch, + &workflow, + runtime.as_ref(), + &env_context, + verbose, + ) + .await?; results.extend(job_results); } @@ -80,9 +84,7 @@ fn initialize_runtime( } } } else { - logging::error(&format!( - "Docker not available, falling back to emulation mode" - )); + logging::error("Docker not available, falling back to emulation mode"); Ok(Box::new(crate::runtime::emulation::EmulationRuntime::new())) } } @@ -133,37 +135,38 @@ pub enum StepStatus { #[derive(Error, Debug)] pub enum ExecutionError { #[error("Parse error: {0}")] - ParseError(String), + Parse(String), #[error("Runtime error: {0}")] - RuntimeError(String), + Runtime(String), #[error("Execution error: {0}")] - ExecutionError(String), + Execution(String), #[error("IO error: {0}")] - IoError(#[from] std::io::Error), + Io(#[from] std::io::Error), } // Convert errors from other modules impl From for ExecutionError { fn from(err: String) -> Self { - ExecutionError::ParseError(err) + ExecutionError::Parse(err) } } // Add Action preparation functions async fn prepare_action( action: &ActionInfo, - runtime: &Box, + runtime: &dyn ContainerRuntime, ) -> Result { if action.is_docker { // Docker action: pull the image let image = action.repository.trim_start_matches("docker://"); - runtime.pull_image(image).await.map_err(|e| { - ExecutionError::RuntimeError(format!("Failed to pull Docker image: {}", e)) - })?; + runtime + .pull_image(image) + .await + .map_err(|e| ExecutionError::Runtime(format!("Failed to pull Docker image: {}", e)))?; return Ok(image.to_string()); } @@ -173,7 +176,7 @@ async fn prepare_action( let action_dir = Path::new(&action.repository); if !action_dir.exists() { - return Err(ExecutionError::ExecutionError(format!( + return Err(ExecutionError::Execution(format!( "Local action directory not found: {}", action_dir.display() ))); @@ -184,9 +187,10 @@ async fn prepare_action( // It's a Docker action, build it let tag = format!("wrkflw-local-action:{}", uuid::Uuid::new_v4()); - runtime.build_image(&dockerfile, &tag).await.map_err(|e| { - ExecutionError::RuntimeError(format!("Failed to build image: {}", e)) - })?; + runtime + .build_image(&dockerfile, &tag) + .await + .map_err(|e| ExecutionError::Runtime(format!("Failed to build image: {}", e)))?; return Ok(tag); } else { @@ -204,7 +208,7 @@ async fn prepare_action( async fn execute_job_batch( jobs: &[String], workflow: &WorkflowDefinition, - runtime: &Box, + runtime: &dyn ContainerRuntime, env_context: &HashMap, verbose: bool, ) -> Result, ExecutionError> { @@ -227,25 +231,33 @@ async fn execute_job_batch( Ok(results) } +// Before execute_job_with_matrix implementation, add this struct +struct JobExecutionContext<'a> { + job_name: &'a str, + workflow: &'a WorkflowDefinition, + runtime: &'a dyn ContainerRuntime, + env_context: &'a HashMap, + verbose: bool, +} + /// Execute a job, expanding matrix if present async fn execute_job_with_matrix( job_name: &str, workflow: &WorkflowDefinition, - runtime: &Box, + runtime: &dyn ContainerRuntime, env_context: &HashMap, verbose: bool, ) -> Result, ExecutionError> { // Get the job definition let job = workflow.jobs.get(job_name).ok_or_else(|| { - ExecutionError::ExecutionError(format!("Job '{}' not found in workflow", job_name)) + ExecutionError::Execution(format!("Job '{}' not found in workflow", job_name)) })?; // Check if this is a matrix job if let Some(matrix_config) = &job.matrix { // Expand the matrix into combinations - let combinations = matrix::expand_matrix(matrix_config).map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to expand matrix: {}", e)) - })?; + let combinations = matrix::expand_matrix(matrix_config) + .map_err(|e| ExecutionError::Execution(format!("Failed to expand matrix: {}", e)))?; if combinations.is_empty() { logging::info(&format!( @@ -269,222 +281,41 @@ async fn execute_job_with_matrix( }); // Execute matrix combinations - execute_matrix_combinations( + execute_matrix_combinations(MatrixExecutionContext { job_name, - job, - &combinations, + job_template: job, + combinations: &combinations, max_parallel, - matrix_config.fail_fast.unwrap_or(true), + fail_fast: matrix_config.fail_fast.unwrap_or(true), workflow, runtime, env_context, verbose, - ) + }) .await } else { // Regular job, no matrix - let result = execute_job(job_name, workflow, runtime, env_context, verbose).await?; + let ctx = JobExecutionContext { + job_name, + workflow, + runtime, + env_context, + verbose, + }; + let result = execute_job(ctx).await?; Ok(vec![result]) } } -/// Execute a set of matrix combinations -async fn execute_matrix_combinations( - job_name: &str, - job_template: &Job, - combinations: &[MatrixCombination], - max_parallel: usize, - fail_fast: bool, - workflow: &WorkflowDefinition, - runtime: &Box, - env_context: &HashMap, - verbose: bool, -) -> Result, ExecutionError> { - let mut results = Vec::new(); - let mut any_failed = false; - - // Process combinations in chunks limited by max_parallel - for chunk in combinations.chunks(max_parallel) { - // Skip processing if fail-fast is enabled and a previous job failed - if fail_fast && any_failed { - // Add skipped results for remaining combinations - for combination in chunk { - let combination_name = matrix::format_combination_name(job_name, combination); - results.push(JobResult { - name: combination_name, - status: JobStatus::Skipped, - steps: Vec::new(), - logs: "Job skipped due to previous matrix job failure".to_string(), - }); - } - continue; - } - - // Process this chunk of combinations in parallel - let chunk_futures = chunk.iter().map(|combination| { - execute_matrix_job( - job_name, - job_template, - combination, - workflow, - runtime, - env_context, - verbose, - ) - }); - - let chunk_results = future::join_all(chunk_futures).await; - - // Process results from this chunk - for result in chunk_results { - match result { - Ok(job_result) => { - if job_result.status == JobStatus::Failure { - any_failed = true; - } - results.push(job_result); - } - Err(e) => { - // On error, mark as failed and continue if not fail-fast - any_failed = true; - logging::error(&format!("Matrix job failed: {}", e)); - - if fail_fast { - return Err(e); - } - } - } - } - } - - Ok(results) -} - -/// Execute a single matrix job combination -async fn execute_matrix_job( - job_name: &str, - job_template: &Job, - combination: &MatrixCombination, - workflow: &WorkflowDefinition, - runtime: &Box, - base_env_context: &HashMap, - verbose: bool, -) -> Result { - // Create the matrix-specific job name - let matrix_job_name = matrix::format_combination_name(job_name, combination); - - logging::info(&format!("Executing matrix job: {}", matrix_job_name)); - - // Clone the environment and add matrix-specific values - let mut job_env = base_env_context.clone(); - environment::add_matrix_context(&mut job_env, combination); - - // Add job-level environment variables - for (key, value) in &job_template.env { - // TODO: Substitute matrix variable references in env values - job_env.insert(key.clone(), value.clone()); - } - - // Execute the job steps - let mut step_results = Vec::new(); - let mut job_logs = String::new(); - - // Create a temporary directory for this job execution - let job_dir = tempfile::tempdir().map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to create job directory: {}", e)) - })?; - - // Prepare the runner - let runner_image = get_runner_image(&job_template.runs_on); - prepare_runner_image(&runner_image, runtime, verbose).await?; - - // Copy project files to workspace - let current_dir = std::env::current_dir().map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to get current directory: {}", e)) - })?; - copy_directory_contents(¤t_dir, job_dir.path())?; - - let job_success = if job_template.steps.is_empty() { - logging::warning(&format!("Job '{}' has no steps", matrix_job_name)); - true - } else { - // Execute each step - for (idx, step) in job_template.steps.iter().enumerate() { - match execute_step( - step, - idx, - &job_env, - job_dir.path(), - runtime, - workflow, - &job_template.runs_on, // Pass the job's runner - verbose, - &Some(combination.values.clone()), - ) - .await - { - Ok(result) => { - job_logs.push_str(&format!("Step: {}\n", result.name)); - job_logs.push_str(&format!("Status: {:?}\n", result.status)); - job_logs.push_str(&result.output); - job_logs.push_str("\n\n"); - - step_results.push(result.clone()); - - if result.status != StepStatus::Success { - // Step failed, abort job - return Ok(JobResult { - name: matrix_job_name, - status: JobStatus::Failure, - steps: step_results, - logs: job_logs, - }); - } - } - Err(e) => { - // Log the error and abort the job - job_logs.push_str(&format!("Step execution error: {}\n\n", e)); - return Ok(JobResult { - name: matrix_job_name, - status: JobStatus::Failure, - steps: step_results, - logs: job_logs, - }); - } - } - } - - true - }; - - // Return job result - Ok(JobResult { - name: matrix_job_name, - status: if job_success { - JobStatus::Success - } else { - JobStatus::Failure - }, - steps: step_results, - logs: job_logs, - }) -} - #[allow(unused_variables, unused_assignments)] -async fn execute_job( - job_name: &str, - workflow: &WorkflowDefinition, - runtime: &Box, - env_context: &HashMap, - verbose: bool, -) -> Result { +async fn execute_job(ctx: JobExecutionContext<'_>) -> Result { // Get job definition - let job = workflow.jobs.get(job_name).ok_or_else(|| { - ExecutionError::ExecutionError(format!("Job '{}' not found in workflow", job_name)) + let job = ctx.workflow.jobs.get(ctx.job_name).ok_or_else(|| { + ExecutionError::Execution(format!("Job '{}' not found in workflow", ctx.job_name)) })?; // Clone context and add job-specific variables - let mut job_env = env_context.clone(); + let mut job_env = ctx.env_context.clone(); // Add job-level environment variables for (key, value) in &job.env { @@ -496,9 +327,8 @@ async fn execute_job( let mut job_logs = String::new(); // Create a temporary directory for this job execution - let job_dir = tempfile::tempdir().map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to create job directory: {}", e)) - })?; + let job_dir = tempfile::tempdir() + .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?; // Try to get a Docker client if using Docker and services exist let docker_client = if !job.services.is_empty() { @@ -518,22 +348,25 @@ async fn execute_job( let docker = match docker_client.as_ref() { Some(client) => client, None => { - return Err(ExecutionError::RuntimeError( + return Err(ExecutionError::Runtime( "Docker client is required but not available".to_string(), )); } }; match docker::create_job_network(docker).await { Ok(id) => { - logging::info(&format!("Created network {} for job '{}'", id, job_name)); + logging::info(&format!( + "Created network {} for job '{}'", + id, ctx.job_name + )); Some(id) } Err(e) => { logging::error(&format!( "Failed to create network for job '{}': {}", - job_name, e + ctx.job_name, e )); - return Err(ExecutionError::RuntimeError(format!( + return Err(ExecutionError::Runtime(format!( "Failed to create network: {}", e ))); @@ -549,7 +382,7 @@ async fn execute_job( if !job.services.is_empty() { if docker_client.is_none() { logging::error("Services are only supported with Docker runtime"); - return Err(ExecutionError::RuntimeError( + return Err(ExecutionError::Runtime( "Services require Docker runtime".to_string(), )); } @@ -557,13 +390,13 @@ async fn execute_job( logging::info(&format!( "Starting {} service containers for job '{}'", job.services.len(), - job_name + ctx.job_name )); let docker = match docker_client.as_ref() { Some(client) => client, None => { - return Err(ExecutionError::RuntimeError( + return Err(ExecutionError::Runtime( "Docker client is required but not available".to_string(), )); } @@ -577,7 +410,7 @@ async fn execute_job( )); // Prepare container configuration - let container_name = format!("wrkflw-service-{}-{}", job_name, service_name); + let container_name = format!("wrkflw-service-{}-{}", ctx.job_name, service_name); // Map ports if specified let mut port_bindings = HashMap::new(); @@ -676,7 +509,7 @@ async fn execute_job( docker::untrack_network(net_id); } - return Err(ExecutionError::RuntimeError(error_msg)); + return Err(ExecutionError::Runtime(error_msg)); } } } @@ -693,7 +526,7 @@ async fn execute_job( docker::untrack_network(net_id); } - return Err(ExecutionError::RuntimeError(error_msg)); + return Err(ExecutionError::Runtime(error_msg)); } } } @@ -704,31 +537,31 @@ async fn execute_job( // Prepare the runner environment let runner_image = get_runner_image(&job.runs_on); - prepare_runner_image(&runner_image, runtime, verbose).await?; + prepare_runner_image(&runner_image, ctx.runtime, ctx.verbose).await?; // Copy project files to workspace let current_dir = std::env::current_dir().map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to get current directory: {}", e)) + ExecutionError::Execution(format!("Failed to get current directory: {}", e)) })?; copy_directory_contents(¤t_dir, job_dir.path())?; - logging::info(&format!("Executing job: {}", job_name)); + logging::info(&format!("Executing job: {}", ctx.job_name)); let mut job_success = true; // Execute job steps for (idx, step) in job.steps.iter().enumerate() { - let step_result = execute_step( + let step_result = execute_step(StepExecutionContext { step, - idx, - &job_env, - job_dir.path(), - runtime, - workflow, - &job.runs_on, - verbose, - &None, - ) + step_idx: idx, + job_env: &job_env, + working_dir: job_dir.path(), + runtime: ctx.runtime, + workflow: ctx.workflow, + job_runs_on: &job.runs_on, + verbose: ctx.verbose, + matrix_combination: &None, + }) .await; match step_result { @@ -773,7 +606,7 @@ async fn execute_job( let docker = match docker_client.as_ref() { Some(client) => client, None => { - return Err(ExecutionError::RuntimeError( + return Err(ExecutionError::Runtime( "Docker client is required but not available".to_string(), )); } @@ -796,7 +629,7 @@ async fn execute_job( let docker = match docker_client.as_ref() { Some(client) => client, None => { - return Err(ExecutionError::RuntimeError( + return Err(ExecutionError::Runtime( "Docker client is required but not available".to_string(), )); } @@ -813,7 +646,7 @@ async fn execute_job( } Ok(JobResult { - name: job_name.to_string(), + name: ctx.job_name.to_string(), status: if job_success { JobStatus::Success } else { @@ -824,53 +657,243 @@ async fn execute_job( }) } -async fn execute_step( - step: &crate::parser::workflow::Step, - step_idx: usize, - job_env: &HashMap, - working_dir: &Path, - runtime: &Box, - workflow: &WorkflowDefinition, - job_runs_on: &str, +// Before the execute_matrix_combinations function, add this struct +struct MatrixExecutionContext<'a> { + job_name: &'a str, + job_template: &'a Job, + combinations: &'a [MatrixCombination], + max_parallel: usize, + fail_fast: bool, + workflow: &'a WorkflowDefinition, + runtime: &'a dyn ContainerRuntime, + env_context: &'a HashMap, verbose: bool, - matrix_combination: &Option>, -) -> Result { - let step_name = step +} + +/// Execute a set of matrix combinations +async fn execute_matrix_combinations( + ctx: MatrixExecutionContext<'_>, +) -> Result, ExecutionError> { + let mut results = Vec::new(); + let mut any_failed = false; + + // Process combinations in chunks limited by max_parallel + for chunk in ctx.combinations.chunks(ctx.max_parallel) { + // Skip processing if fail-fast is enabled and a previous job failed + if ctx.fail_fast && any_failed { + // Add skipped results for remaining combinations + for combination in chunk { + let combination_name = matrix::format_combination_name(ctx.job_name, combination); + results.push(JobResult { + name: combination_name, + status: JobStatus::Skipped, + steps: Vec::new(), + logs: "Job skipped due to previous matrix job failure".to_string(), + }); + } + continue; + } + + // Process this chunk of combinations in parallel + let chunk_futures = chunk.iter().map(|combination| { + execute_matrix_job( + ctx.job_name, + ctx.job_template, + combination, + ctx.workflow, + ctx.runtime, + ctx.env_context, + ctx.verbose, + ) + }); + + let chunk_results = future::join_all(chunk_futures).await; + + // Process results from this chunk + for result in chunk_results { + match result { + Ok(job_result) => { + if job_result.status == JobStatus::Failure { + any_failed = true; + } + results.push(job_result); + } + Err(e) => { + // On error, mark as failed and continue if not fail-fast + any_failed = true; + logging::error(&format!("Matrix job failed: {}", e)); + + if ctx.fail_fast { + return Err(e); + } + } + } + } + } + + Ok(results) +} + +/// Execute a single matrix job combination +async fn execute_matrix_job( + job_name: &str, + job_template: &Job, + combination: &MatrixCombination, + workflow: &WorkflowDefinition, + runtime: &dyn ContainerRuntime, + base_env_context: &HashMap, + verbose: bool, +) -> Result { + // Create the matrix-specific job name + let matrix_job_name = matrix::format_combination_name(job_name, combination); + + logging::info(&format!("Executing matrix job: {}", matrix_job_name)); + + // Clone the environment and add matrix-specific values + let mut job_env = base_env_context.clone(); + environment::add_matrix_context(&mut job_env, combination); + + // Add job-level environment variables + for (key, value) in &job_template.env { + // TODO: Substitute matrix variable references in env values + job_env.insert(key.clone(), value.clone()); + } + + // Execute the job steps + let mut step_results = Vec::new(); + let mut job_logs = String::new(); + + // Create a temporary directory for this job execution + let job_dir = tempfile::tempdir() + .map_err(|e| ExecutionError::Execution(format!("Failed to create job directory: {}", e)))?; + + // Prepare the runner + let runner_image = get_runner_image(&job_template.runs_on); + prepare_runner_image(&runner_image, runtime, verbose).await?; + + // Copy project files to workspace + let current_dir = std::env::current_dir().map_err(|e| { + ExecutionError::Execution(format!("Failed to get current directory: {}", e)) + })?; + copy_directory_contents(¤t_dir, job_dir.path())?; + + let job_success = if job_template.steps.is_empty() { + logging::warning(&format!("Job '{}' has no steps", matrix_job_name)); + true + } else { + // Execute each step + for (idx, step) in job_template.steps.iter().enumerate() { + match execute_step(StepExecutionContext { + step, + step_idx: idx, + job_env: &job_env, + working_dir: job_dir.path(), + runtime, + workflow, + job_runs_on: &job_template.runs_on, + verbose, + matrix_combination: &Some(combination.values.clone()), + }) + .await + { + Ok(result) => { + job_logs.push_str(&format!("Step: {}\n", result.name)); + job_logs.push_str(&format!("Status: {:?}\n", result.status)); + job_logs.push_str(&result.output); + job_logs.push_str("\n\n"); + + step_results.push(result.clone()); + + if result.status != StepStatus::Success { + // Step failed, abort job + return Ok(JobResult { + name: matrix_job_name, + status: JobStatus::Failure, + steps: step_results, + logs: job_logs, + }); + } + } + Err(e) => { + // Log the error and abort the job + job_logs.push_str(&format!("Step execution error: {}\n\n", e)); + return Ok(JobResult { + name: matrix_job_name, + status: JobStatus::Failure, + steps: step_results, + logs: job_logs, + }); + } + } + } + + true + }; + + // Return job result + Ok(JobResult { + name: matrix_job_name, + status: if job_success { + JobStatus::Success + } else { + JobStatus::Failure + }, + steps: step_results, + logs: job_logs, + }) +} + +// Before the execute_step function, add this struct +struct StepExecutionContext<'a> { + step: &'a crate::parser::workflow::Step, + step_idx: usize, + job_env: &'a HashMap, + working_dir: &'a Path, + runtime: &'a dyn ContainerRuntime, + workflow: &'a WorkflowDefinition, + job_runs_on: &'a str, + verbose: bool, + matrix_combination: &'a Option>, +} + +async fn execute_step(ctx: StepExecutionContext<'_>) -> Result { + let step_name = ctx + .step .name .clone() - .unwrap_or_else(|| format!("Step {}", step_idx + 1)); + .unwrap_or_else(|| format!("Step {}", ctx.step_idx + 1)); - if verbose { + if ctx.verbose { logging::info(&format!(" Executing step: {}", step_name)); } // Prepare step environment - let mut step_env = job_env.clone(); + let mut step_env = ctx.job_env.clone(); // Add step-level environment variables - for (key, value) in &step.env { + for (key, value) in &ctx.step.env { step_env.insert(key.clone(), value.clone()); } // Execute the step based on its type - if let Some(uses) = &step.uses { + if let Some(uses) = &ctx.step.uses { // Action step - let action_info = workflow.resolve_action(uses); + let action_info = ctx.workflow.resolve_action(uses); // Check if this is the checkout action if uses.starts_with("actions/checkout") { // Get the current directory (assumes this is where your project is) let current_dir = std::env::current_dir().map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to get current dir: {}", e)) + ExecutionError::Execution(format!("Failed to get current dir: {}", e)) })?; // Copy the project files to the workspace - copy_directory_contents(¤t_dir, working_dir)?; + copy_directory_contents(¤t_dir, ctx.working_dir)?; // Add info for logs - let output = format!("Emulated checkout: Copied current directory to workspace"); + let output = "Emulated checkout: Copied current directory to workspace".to_string(); - if verbose { + if ctx.verbose { println!(" Emulated actions/checkout: copied project files to workspace"); } @@ -881,20 +904,20 @@ async fn execute_step( }) } else { // Get action info - let image = prepare_action(&action_info, runtime).await?; + let image = prepare_action(&action_info, ctx.runtime).await?; // Special handling for composite actions if image == "composite" && action_info.is_local { // Handle composite action let action_path = Path::new(&action_info.repository); return execute_composite_action( - step, + ctx.step, action_path, &step_env, - working_dir, - runtime, - job_runs_on, - verbose, + ctx.working_dir, + ctx.runtime, + ctx.job_runs_on, + ctx.verbose, ) .await; } @@ -942,7 +965,7 @@ async fn execute_step( cmd.push(match owned_strings.last() { Some(s) => s, None => { - return Err(ExecutionError::ExecutionError( + return Err(ExecutionError::Execution( "Expected at least one string in action arguments".to_string(), )); } @@ -950,7 +973,7 @@ async fn execute_step( } // Convert 'with' parameters to environment variables - if let Some(with_params) = &step.with { + if let Some(with_params) = &ctx.step.with { for (key, value) in with_params { step_env.insert(format!("INPUT_{}", key.to_uppercase()), value.clone()); } @@ -963,18 +986,20 @@ async fn execute_step( .collect(); // Map volumes - let volumes: Vec<(&Path, &Path)> = vec![(working_dir, Path::new("/github/workspace"))]; + let volumes: Vec<(&Path, &Path)> = + vec![(ctx.working_dir, Path::new("/github/workspace"))]; - let output = runtime + let output = ctx + .runtime .run_container( &image, - &cmd.iter().map(|s| *s).collect::>(), + &cmd.to_vec(), &env_vars, Path::new("/github/workspace"), &volumes, ) .await - .map_err(|e| ExecutionError::RuntimeError(format!("{}", e)))?; + .map_err(|e| ExecutionError::Runtime(format!("{}", e)))?; if output.exit_code == 0 { Ok(StepResult { @@ -993,9 +1018,9 @@ async fn execute_step( }) } } - } else if let Some(run) = &step.run { + } else if let Some(run) = &ctx.step.run { // Apply GitHub-style matrix variable substitution to the command - let processed_run = substitution::process_step_run(run, matrix_combination); + let processed_run = substitution::process_step_run(run, ctx.matrix_combination); // Print the command we're trying to run let shell_default = "bash".to_string(); @@ -1035,22 +1060,23 @@ async fn execute_step( .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let image = if job_runs_on.contains("ubuntu") { + let image = if ctx.job_runs_on.contains("ubuntu") { // Try with a simple Ubuntu image "ubuntu:latest".to_string() } else { - get_runner_image(job_runs_on) + get_runner_image(ctx.job_runs_on) }; - runtime + ctx.runtime .pull_image(&image) .await - .map_err(|e| ExecutionError::RuntimeError(format!("Failed to pull image: {}", e)))?; + .map_err(|e| ExecutionError::Runtime(format!("Failed to pull image: {}", e)))?; // Map volumes - let volumes: Vec<(&Path, &Path)> = vec![(working_dir, Path::new("/github/workspace"))]; + let volumes: Vec<(&Path, &Path)> = vec![(ctx.working_dir, Path::new("/github/workspace"))]; - let output = runtime + let output = ctx + .runtime .run_container( &image, &cmd, @@ -1059,7 +1085,7 @@ async fn execute_step( &volumes, ) .await - .map_err(|e| ExecutionError::RuntimeError(format!("{}", e)))?; + .map_err(|e| ExecutionError::Runtime(format!("{}", e)))?; if output.exit_code == 0 { Ok(StepResult { @@ -1079,7 +1105,7 @@ async fn execute_step( } } else { // Neither 'uses' nor 'run' - this is an error - Err(ExecutionError::ExecutionError(format!( + Err(ExecutionError::Execution(format!( "Step '{}' has neither 'uses' nor 'run' directive", step_name ))) @@ -1088,17 +1114,17 @@ async fn execute_step( fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError> { for entry in std::fs::read_dir(from) - .map_err(|e| ExecutionError::ExecutionError(format!("Failed to read directory: {}", e)))? + .map_err(|e| ExecutionError::Execution(format!("Failed to read directory: {}", e)))? { - let entry = entry - .map_err(|e| ExecutionError::ExecutionError(format!("Failed to read entry: {}", e)))?; + let entry = + entry.map_err(|e| ExecutionError::Execution(format!("Failed to read entry: {}", e)))?; let path = entry.path(); // Skip hidden files/dirs and target directory for efficiency let file_name = match path.file_name() { Some(name) => name.to_string_lossy(), None => { - return Err(ExecutionError::ExecutionError(format!( + return Err(ExecutionError::Execution(format!( "Failed to get file name from path: {:?}", path ))); @@ -1111,7 +1137,7 @@ fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError> let dest_path = match path.file_name() { Some(name) => to.join(name), None => { - return Err(ExecutionError::ExecutionError(format!( + return Err(ExecutionError::Execution(format!( "Failed to get file name from path: {:?}", path ))); @@ -1119,16 +1145,14 @@ fn copy_directory_contents(from: &Path, to: &Path) -> Result<(), ExecutionError> }; if path.is_dir() { - std::fs::create_dir_all(&dest_path).map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to create dir: {}", e)) - })?; + std::fs::create_dir_all(&dest_path) + .map_err(|e| ExecutionError::Execution(format!("Failed to create dir: {}", e)))?; // Recursively copy subdirectories copy_directory_contents(&path, &dest_path)?; } else { - std::fs::copy(&path, &dest_path).map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to copy file: {}", e)) - })?; + std::fs::copy(&path, &dest_path) + .map_err(|e| ExecutionError::Execution(format!("Failed to copy file: {}", e)))?; } } @@ -1164,7 +1188,7 @@ fn get_runner_image(runs_on: &str) -> String { async fn prepare_runner_image( image: &str, - runtime: &Box, + runtime: &dyn ContainerRuntime, verbose: bool, ) -> Result<(), ExecutionError> { if verbose { @@ -1175,7 +1199,7 @@ async fn prepare_runner_image( runtime .pull_image(image) .await - .map_err(|e| ExecutionError::RuntimeError(format!("Failed to pull runner image: {}", e)))?; + .map_err(|e| ExecutionError::Runtime(format!("Failed to pull runner image: {}", e)))?; if verbose { println!(" Image {} ready", image); @@ -1189,7 +1213,7 @@ async fn execute_composite_action( action_path: &Path, job_env: &HashMap, working_dir: &Path, - runtime: &Box, + runtime: &dyn ContainerRuntime, job_runs_on: &str, verbose: bool, ) -> Result { @@ -1202,19 +1226,18 @@ async fn execute_composite_action( } else if action_yaml_alt.exists() { action_yaml_alt } else { - return Err(ExecutionError::ExecutionError(format!( + return Err(ExecutionError::Execution(format!( "No action.yml or action.yaml found in {}", action_path.display() ))); }; // Parse the composite action definition - let action_content = fs::read_to_string(&action_file).map_err(|e| { - ExecutionError::ExecutionError(format!("Failed to read action file: {}", e)) - })?; + let action_content = fs::read_to_string(&action_file) + .map_err(|e| ExecutionError::Execution(format!("Failed to read action file: {}", e)))?; let action_def: serde_yaml::Value = serde_yaml::from_str(&action_content) - .map_err(|e| ExecutionError::ExecutionError(format!("Invalid action YAML: {}", e)))?; + .map_err(|e| ExecutionError::Execution(format!("Invalid action YAML: {}", e)))?; // Check if it's a composite action match action_def.get("runs").and_then(|v| v.get("using")) { @@ -1223,7 +1246,7 @@ async fn execute_composite_action( let steps = match action_def.get("runs").and_then(|v| v.get("steps")) { Some(serde_yaml::Value::Sequence(steps)) => steps, _ => { - return Err(ExecutionError::ExecutionError( + return Err(ExecutionError::Execution( "Composite action is missing steps".to_string(), )) } @@ -1266,7 +1289,7 @@ async fn execute_composite_action( let composite_step = match convert_yaml_to_step(step_def) { Ok(step) => step, Err(e) => { - return Err(ExecutionError::ExecutionError(format!( + return Err(ExecutionError::Execution(format!( "Failed to process composite action step {}: {}", idx + 1, e @@ -1275,13 +1298,13 @@ async fn execute_composite_action( }; // Execute the step - using Box::pin to handle async recursion - let step_result = Box::pin(execute_step( - &composite_step, - idx, - &action_env, + let step_result = Box::pin(execute_step(StepExecutionContext { + step: &composite_step, + step_idx: idx, + job_env: &action_env, working_dir, runtime, - &crate::parser::workflow::WorkflowDefinition { + workflow: &crate::parser::workflow::WorkflowDefinition { name: "Composite Action".to_string(), on: vec![], on_raw: serde_yaml::Value::Null, @@ -1289,8 +1312,8 @@ async fn execute_composite_action( }, job_runs_on, verbose, - &None, - )) + matrix_combination: &None, + })) .await?; // Add output to results @@ -1319,7 +1342,7 @@ async fn execute_composite_action( output: format!("Composite action completed:\n{}", step_outputs.join("\n")), }) } - _ => Err(ExecutionError::ExecutionError( + _ => Err(ExecutionError::Execution( "Action is not a composite action or has invalid format".to_string(), )), } @@ -1375,17 +1398,13 @@ fn convert_yaml_to_step( .unwrap_or_default(); // For composite steps with shell, construct a run step - let final_run = if shell.is_some() && run.is_some() { - run - } else { - run - }; + let final_run = run; Ok(crate::parser::workflow::Step { name, uses, run: final_run, - with: with, + with, env, }) } diff --git a/src/executor/environment.rs b/src/executor/environment.rs index 6a942df..51731f7 100644 --- a/src/executor/environment.rs +++ b/src/executor/environment.rs @@ -113,7 +113,7 @@ fn value_to_string(value: &Value) -> String { Value::Sequence(seq) => { let items = seq .iter() - .map(|v| value_to_string(v)) + .map(value_to_string) .collect::>() .join(","); items @@ -134,7 +134,7 @@ fn value_to_string(value: &Value) -> String { fn get_repo_name() -> String { // Try to detect from git if available if let Ok(output) = std::process::Command::new("git") - .args(&["remote", "get-url", "origin"]) + .args(["remote", "get-url", "origin"]) .output() { if output.status.success() { @@ -200,7 +200,7 @@ fn get_workspace_path() -> String { fn get_current_sha() -> String { if let Ok(output) = std::process::Command::new("git") - .args(&["rev-parse", "HEAD"]) + .args(["rev-parse", "HEAD"]) .output() { if output.status.success() { @@ -213,7 +213,7 @@ fn get_current_sha() -> String { fn get_current_ref() -> String { if let Ok(output) = std::process::Command::new("git") - .args(&["symbolic-ref", "--short", "HEAD"]) + .args(["symbolic-ref", "--short", "HEAD"]) .output() { if output.status.success() { diff --git a/src/github.rs b/src/github.rs index da91e51..4a2df09 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,8 +1,6 @@ use lazy_static::lazy_static; use regex::Regex; -use reqwest; use reqwest::header; -use serde_json; use std::collections::HashMap; use std::fs; use std::path::Path; @@ -126,7 +124,7 @@ pub async fn list_workflows(_repo_info: &RepoInfo) -> Result, Github if path.is_file() && path .extension() - .map_or(false, |ext| ext == "yml" || ext == "yaml") + .is_some_and(|ext| ext == "yml" || ext == "yaml") { if let Some(file_name) = path.file_stem() { if let Some(name) = file_name.to_str() { @@ -195,7 +193,7 @@ pub async fn trigger_workflow( .json(&payload) .send() .await - .map_err(|e| GithubError::RequestError(e))?; + .map_err(GithubError::RequestError)?; if !response.status().is_success() { let status = response.status().as_u16(); diff --git a/src/runtime/container.rs b/src/runtime/container.rs index 666145d..6bb37a0 100644 --- a/src/runtime/container.rs +++ b/src/runtime/container.rs @@ -27,29 +27,29 @@ use std::fmt; #[derive(Debug)] pub enum ContainerError { - ImagePullFailed(String), - ImageBuildFailed(String), - ContainerStartFailed(String), - ContainerExecutionFailed(String), - NetworkCreationFailed(String), - NetworkOperationFailed(String), + ImagePull(String), + ImageBuild(String), + ContainerStart(String), + ContainerExecution(String), + NetworkCreation(String), + NetworkOperation(String), } impl fmt::Display for ContainerError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ContainerError::ImagePullFailed(msg) => write!(f, "Failed to pull image: {}", msg), - ContainerError::ImageBuildFailed(msg) => write!(f, "Failed to build image: {}", msg), - ContainerError::ContainerStartFailed(msg) => { + ContainerError::ImagePull(msg) => write!(f, "Failed to pull image: {}", msg), + ContainerError::ImageBuild(msg) => write!(f, "Failed to build image: {}", msg), + ContainerError::ContainerStart(msg) => { write!(f, "Failed to start container: {}", msg) } - ContainerError::ContainerExecutionFailed(msg) => { + ContainerError::ContainerExecution(msg) => { write!(f, "Container execution failed: {}", msg) } - ContainerError::NetworkCreationFailed(msg) => { + ContainerError::NetworkCreation(msg) => { write!(f, "Failed to create Docker network: {}", msg) } - ContainerError::NetworkOperationFailed(msg) => { + ContainerError::NetworkOperation(msg) => { write!(f, "Network operation failed: {}", msg) } } diff --git a/src/runtime/emulation.rs b/src/runtime/emulation.rs index d4bfd02..d811aa9 100644 --- a/src/runtime/emulation.rs +++ b/src/runtime/emulation.rs @@ -75,32 +75,33 @@ impl EmulationRuntime { fs::create_dir_all(&target_path).expect("Failed to create target directory"); // Copy files in this directory (not recursive for simplicity) - for entry in fs::read_dir(host_path).expect("Failed to read source directory") { - if let Ok(entry) = entry { - let source = entry.path(); - let file_name = match source.file_name() { - Some(name) => name, - None => { - eprintln!( - "Warning: Could not get file name from path: {:?}", - source - ); - continue; // Skip this file - } - }; - let dest = target_path.join(file_name); - - if source.is_file() { - if let Err(e) = fs::copy(&source, &dest) { - eprintln!( - "Warning: Failed to copy file from {:?} to {:?}: {}", - &source, &dest, e - ); - } - } else { - // We could make this recursive if needed - fs::create_dir_all(&dest).expect("Failed to create subdirectory"); + for entry in fs::read_dir(host_path) + .expect("Failed to read source directory") + .flatten() + { + let source = entry.path(); + let file_name = match source.file_name() { + Some(name) => name, + None => { + eprintln!( + "Warning: Could not get file name from path: {:?}", + source + ); + continue; // Skip this file } + }; + let dest = target_path.join(file_name); + + if source.is_file() { + if let Err(e) = fs::copy(&source, &dest) { + eprintln!( + "Warning: Failed to copy file from {:?} to {:?}: {}", + &source, &dest, e + ); + } + } else { + // We could make this recursive if needed + fs::create_dir_all(&dest).expect("Failed to create subdirectory"); } } } @@ -157,7 +158,7 @@ impl ContainerRuntime for EmulationRuntime { }); if is_long_running { - logging::info(&format!("Detected long-running command, will run detached")); + logging::info("Detected long-running command, will run detached"); let mut command = Command::new(cmd[0]); command.current_dir(&container_working_dir); @@ -186,7 +187,7 @@ impl ContainerRuntime for EmulationRuntime { }); } Err(e) => { - return Err(ContainerError::ContainerExecutionFailed(format!( + return Err(ContainerError::ContainerExecution(format!( "Failed to start detached process: {}", e ))); @@ -205,12 +206,10 @@ impl ContainerRuntime for EmulationRuntime { .unwrap_or(false); if !nix_installed { - logging::info(&format!( - "āš ļø Nix commands detected but Nix is not installed!" - )); - logging::info(&format!( - "šŸ”„ To use this workflow, please install Nix: https://nixos.org/download.html" - )); + logging::info("āš ļø Nix commands detected but Nix is not installed!"); + logging::info( + "šŸ”„ To use this workflow, please install Nix: https://nixos.org/download.html", + ); return Ok(ContainerOutput { stdout: String::new(), @@ -218,13 +217,13 @@ impl ContainerRuntime for EmulationRuntime { exit_code: 1, }); } else { - logging::info(&format!("āœ… Nix is installed, proceeding with command")); + logging::info("āœ… Nix is installed, proceeding with command"); } } // Ensure we have a command if cmd.is_empty() { - return Err(ContainerError::ContainerExecutionFailed( + return Err(ContainerError::ContainerExecution( "No command specified".to_string(), )); } @@ -266,8 +265,8 @@ impl ContainerRuntime for EmulationRuntime { command.current_dir(&container_working_dir); // Add flags - for i in 1..idx + 1 { - command.arg(cmd[i]); + for arg in cmd.iter().skip(1).take(idx) { + command.arg(arg); } // Add the command @@ -281,7 +280,7 @@ impl ContainerRuntime for EmulationRuntime { // Execute let output = command .output() - .map_err(|e| ContainerError::ContainerExecutionFailed(e.to_string()))?; + .map_err(|e| ContainerError::ContainerExecution(e.to_string()))?; return Ok(ContainerOutput { stdout: String::from_utf8_lossy(&output.stdout).to_string(), @@ -308,16 +307,14 @@ impl ContainerRuntime for EmulationRuntime { } // Log that we're running a background process - logging::info(&format!( - "Emulation: Running command with background processes" - )); + logging::info("Emulation: Running command with background processes"); // For commands with background processes, we could potentially track PIDs // However, since they're in a shell wrapper, we'd need to parse them from output let output = shell_command .output() - .map_err(|e| ContainerError::ContainerExecutionFailed(e.to_string()))?; + .map_err(|e| ContainerError::ContainerExecution(e.to_string()))?; return Ok(ContainerOutput { stdout: String::from_utf8_lossy(&output.stdout).to_string(), @@ -343,7 +340,7 @@ impl ContainerRuntime for EmulationRuntime { // Execute let output = command .output() - .map_err(|e| ContainerError::ContainerExecutionFailed(e.to_string()))?; + .map_err(|e| ContainerError::ContainerExecution(e.to_string()))?; Ok(ContainerOutput { stdout: String::from_utf8_lossy(&output.stdout).to_string(), @@ -413,7 +410,7 @@ fn copy_directory_contents(source: &Path, dest: &Path) -> std::io::Result<()> { pub async fn handle_special_action(action: &str) -> Result<(), ContainerError> { if action.starts_with("cachix/install-nix-action") { - logging::info(&format!("šŸ”„ Emulating cachix/install-nix-action")); + logging::info("šŸ”„ Emulating cachix/install-nix-action"); // In emulation mode, check if nix is installed let nix_installed = Command::new("which") @@ -423,15 +420,13 @@ pub async fn handle_special_action(action: &str) -> Result<(), ContainerError> { .unwrap_or(false); if !nix_installed { - logging::info(&format!("šŸ”„ Emulation: Nix is required but not installed.")); - logging::info(&format!( - "šŸ”„ To use this workflow, please install Nix: https://nixos.org/download.html" - )); - logging::info(&format!( - "šŸ”„ Continuing emulation, but nix commands will fail." - )); + logging::info("šŸ”„ Emulation: Nix is required but not installed."); + logging::info( + "šŸ”„ To use this workflow, please install Nix: https://nixos.org/download.html", + ); + logging::info("šŸ”„ Continuing emulation, but nix commands will fail."); } else { - logging::info(&format!("šŸ”„ Emulation: Using system-installed Nix")); + logging::info("šŸ”„ Emulation: Using system-installed Nix"); } Ok(()) } else { @@ -464,7 +459,7 @@ async fn cleanup_processes() { // On Unix-like systems, use kill command let _ = Command::new("kill") .arg("-TERM") - .arg(&pid.to_string()) + .arg(pid.to_string()) .output(); } @@ -504,7 +499,7 @@ async fn cleanup_workspaces() { // Only attempt to remove if it exists if workspace_path.exists() { match fs::remove_dir_all(&workspace_path) { - Ok(_) => logging::info(&format!("Successfully removed workspace directory")), + Ok(_) => logging::info("Successfully removed workspace directory"), Err(e) => logging::error(&format!("Error removing workspace: {}", e)), } } diff --git a/src/ui.rs b/src/ui.rs index 4118af8..12f4807 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -20,7 +20,6 @@ use ratatui::{ }, Frame, Terminal, }; -use regex; use std::io::{self, stdout}; use std::path::{Path, PathBuf}; use std::sync::mpsc; @@ -72,6 +71,9 @@ struct StepExecution { output: String, } +// Type alias for the complex execution result type +type ExecutionResultMsg = (usize, Result<(Vec, ()), String>); + // Application state struct App { workflows: Vec, @@ -83,16 +85,16 @@ struct App { validation_mode: bool, execution_queue: Vec, // Indices of workflows to execute current_execution: Option, - logs: Vec, // Overall execution logs - log_scroll: usize, // Scrolling position for logs - job_list_state: ListState, // For viewing job details - detailed_view: bool, // Whether we're in detailed view mode - step_list_state: ListState, // For selecting steps in detailed view - step_table_state: TableState, // For the steps table in detailed view - last_tick: Instant, // For UI animations and updates - tick_rate: Duration, // How often to update the UI - tx: mpsc::Sender<(usize, Result<(Vec, ()), String>)>, // Channel for async communication - status_message: Option, // Temporary status message to display + logs: Vec, // Overall execution logs + log_scroll: usize, // Scrolling position for logs + job_list_state: ListState, // For viewing job details + detailed_view: bool, // Whether we're in detailed view mode + step_list_state: ListState, // For selecting steps in detailed view + step_table_state: TableState, // For the steps table in detailed view + last_tick: Instant, // For UI animations and updates + tick_rate: Duration, // How often to update the UI + tx: mpsc::Sender, // Channel for async communication + status_message: Option, // Temporary status message to display status_message_time: Option, // When the message was set // Search and filter functionality @@ -153,10 +155,7 @@ impl LogFilterLevel { } impl App { - fn new( - runtime_type: RuntimeType, - tx: mpsc::Sender<(usize, Result<(Vec, ()), String>)>, - ) -> App { + fn new(runtime_type: RuntimeType, tx: mpsc::Sender) -> App { let mut workflow_list_state = ListState::default(); workflow_list_state.select(Some(0)); @@ -175,7 +174,7 @@ impl App { RuntimeType::Docker => { // Use the safe FD redirection utility from utils let is_docker_available = - match utils::fd::with_stderr_to_null(|| executor::docker::is_available()) { + match utils::fd::with_stderr_to_null(executor::docker::is_available) { Ok(result) => result, Err(_) => { logging::debug( @@ -451,15 +450,13 @@ impl App { // Queue selected workflows for execution fn queue_selected_for_execution(&mut self) { if let Some(idx) = self.workflow_list_state.selected() { - if idx < self.workflows.len() { - if !self.execution_queue.contains(&idx) { - self.execution_queue.push(idx); - let timestamp = Local::now().format("%H:%M:%S").to_string(); - self.logs.push(format!( - "[{}] Added '{}' to execution queue. Press 'Enter' to start.", - timestamp, self.workflows[idx].name - )); - } + if idx < self.workflows.len() && !self.execution_queue.contains(&idx) { + self.execution_queue.push(idx); + let timestamp = Local::now().format("%H:%M:%S").to_string(); + self.logs.push(format!( + "[{}] Added '{}' to execution queue. Press 'Enter' to start.", + timestamp, self.workflows[idx].name + )); } } } @@ -1001,26 +998,24 @@ fn load_workflows(dir_path: &Path) -> Vec { // Default path is .github/workflows let default_workflows_dir = Path::new(".github").join("workflows"); - let is_default_dir = dir_path == &default_workflows_dir || dir_path.ends_with("workflows"); + let is_default_dir = dir_path == default_workflows_dir || dir_path.ends_with("workflows"); if let Ok(entries) = std::fs::read_dir(dir_path) { - for entry in entries { - if let Ok(entry) = entry { - let path = entry.path(); - if path.is_file() && (is_workflow_file(&path) || !is_default_dir) { - let name = path.file_name().map_or_else( - || "[unknown]".to_string(), - |fname| fname.to_string_lossy().into_owned(), - ); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && (is_workflow_file(&path) || !is_default_dir) { + let name = path.file_name().map_or_else( + || "[unknown]".to_string(), + |fname| fname.to_string_lossy().into_owned(), + ); - workflows.push(Workflow { - name, - path, - selected: false, - status: WorkflowStatus::NotStarted, - execution_details: None, - }); - } + workflows.push(Workflow { + name, + path, + selected: false, + status: WorkflowStatus::NotStarted, + execution_details: None, + }); } } } @@ -1071,8 +1066,7 @@ fn render_ui(f: &mut Frame>, app: &mut App) { // Render the title bar with tabs fn render_title_bar(f: &mut Frame>, app: &App, area: Rect) { - // Create tabs - let titles = vec!["Workflows", "Execution", "Logs", "Help"]; + let titles = ["Workflows", "Execution", "Logs", "Help"]; let tabs = Tabs::new( titles .iter() @@ -1405,11 +1399,12 @@ fn render_execution_tab(f: &mut Frame>, app: &mut A let error_msg = execution .logs .iter() - .filter(|log| log.contains("Error:") || log.contains("Failed")) - .next() - .map_or("Failed to trigger workflow on GitHub.", |s| s.as_str()); + .find(|log| log.contains("Error:") || log.contains("Failed")); - (error_msg, None) + ( + error_msg.map_or("Failed to trigger workflow on GitHub.", |s| s.as_str()), + None, + ) } else { ("Triggering workflow on GitHub...", None) }; @@ -2646,7 +2641,7 @@ fn render_status_bar(f: &mut Frame>, app: &App, are if app.runtime_type == RuntimeType::Docker { // Check Docker silently using safe FD redirection let is_docker_available = - match utils::fd::with_stderr_to_null(|| executor::docker::is_available()) { + match utils::fd::with_stderr_to_null(executor::docker::is_available) { Ok(result) => result, Err(_) => { logging::debug("Failed to redirect stderr when checking Docker availability."); @@ -2763,6 +2758,7 @@ fn render_status_bar(f: &mut Frame>, app: &App, are } // Validate a workflow or directory containing workflows +#[allow(clippy::ptr_arg)] pub fn validate_workflow(path: &PathBuf, verbose: bool) -> io::Result<()> { let mut workflows = Vec::new(); @@ -2820,134 +2816,8 @@ pub fn validate_workflow(path: &PathBuf, verbose: bool) -> io::Result<()> { Ok(()) } -pub async fn execute_workflow_cli( - path: &PathBuf, - runtime_type: RuntimeType, - verbose: bool, -) -> io::Result<()> { - if !path.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("Workflow file does not exist: {}", path.display()), - )); - } - - println!("Validating workflow..."); - match evaluate_workflow_file(path, false) { - Ok(result) => { - if !result.is_valid { - println!("āŒ Cannot execute invalid workflow: {}", path.display()); - for (i, issue) in result.issues.iter().enumerate() { - println!(" {}. {}", i + 1, issue); - } - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Workflow validation failed", - )); - } - } - Err(e) => { - return Err(io::Error::new( - io::ErrorKind::Other, - format!("Error validating workflow: {}", e), - )); - } - } - - // Check Docker availability if Docker runtime is selected - let runtime_type = match runtime_type { - RuntimeType::Docker => { - if !executor::docker::is_available() { - println!("āš ļø Docker is not available. Using emulation mode instead."); - logging::warning("Docker is not available. Using emulation mode instead."); - RuntimeType::Emulation - } else { - RuntimeType::Docker - } - } - RuntimeType::Emulation => RuntimeType::Emulation, - }; - - println!("Executing workflow: {}", path.display()); - println!("Runtime mode: {:?}", runtime_type); - - match executor::execute_workflow(path, runtime_type, verbose).await { - Ok(result) => { - println!("\nWorkflow execution results:"); - - for job in &result.jobs { - match job.status { - JobStatus::Success => { - println!("\nāœ… Job succeeded: {}", job.name); - } - JobStatus::Failure => { - println!("\nāŒ Job failed: {}", job.name); - } - JobStatus::Skipped => { - println!("\nā­ļø Job skipped: {}", job.name); - } - } - - println!("-------------------------"); - - for step in job.steps.iter() { - match step.status { - StepStatus::Success => { - println!(" āœ… {}", step.name); - - if !step.output.trim().is_empty() && step.output.lines().count() <= 3 { - // For short outputs, show directly - println!(" {}", step.output.trim()); - } - } - StepStatus::Failure => { - println!(" āŒ {}", step.name); - - // For failures, always show output (truncated) - let output = if step.output.len() > 500 { - format!("{}... (truncated)", &step.output[..500]) - } else { - step.output.clone() - }; - - println!(" {}", output.trim().replace('\n', "\n ")); - } - StepStatus::Skipped => { - println!(" ā­ļø {} (skipped)", step.name); - } - } - } - } - - // Determine overall success - let failures = result - .jobs - .iter() - .filter(|job| job.status == JobStatus::Failure) - .count(); - - if failures > 0 { - println!("\nāŒ Workflow completed with failures"); - return Err(io::Error::new( - io::ErrorKind::Other, - "Workflow execution failed", - )); - } else { - println!("\nāœ… Workflow completed successfully!"); - Ok(()) - } - } - Err(e) => { - println!("āŒ Failed to execute workflow: {}", e); - Err(io::Error::new( - io::ErrorKind::Other, - format!("Workflow execution error: {}", e), - )) - } - } -} - // Main entry point for the TUI interface +#[allow(clippy::ptr_arg)] pub async fn run_wrkflw_tui( path: Option<&PathBuf>, runtime_type: RuntimeType, @@ -2964,8 +2834,8 @@ pub async fn run_wrkflw_tui( // Set up channel for async communication let (tx, rx): ( - mpsc::Sender<(usize, Result<(Vec, ()), String>)>, - mpsc::Receiver<(usize, Result<(Vec, ()), String>)>, + mpsc::Sender, + mpsc::Receiver, ) = mpsc::channel(); // Initialize app state @@ -3058,8 +2928,8 @@ pub async fn run_wrkflw_tui( fn run_tui_event_loop( terminal: &mut Terminal>, app: &mut App, - tx_clone: &mpsc::Sender<(usize, Result<(Vec, ()), String>)>, - rx: &mpsc::Receiver<(usize, Result<(Vec, ()), String>)>, + tx_clone: &mpsc::Sender, + rx: &mpsc::Receiver, verbose: bool, ) -> io::Result<()> { loop { @@ -3470,7 +3340,7 @@ async fn execute_curl_trigger( // Extract common workflow execution logic to avoid duplication fn start_next_workflow_execution( app: &mut App, - tx_clone: &mpsc::Sender<(usize, Result<(Vec, ()), String>)>, + tx_clone: &mpsc::Sender, verbose: bool, ) { if let Some(next_idx) = app.get_next_workflow_to_execute() { @@ -3483,7 +3353,7 @@ fn start_next_workflow_execution( RuntimeType::Docker => { // Use safe FD redirection to check Docker availability let is_docker_available = - match utils::fd::with_stderr_to_null(|| executor::docker::is_available()) { + match utils::fd::with_stderr_to_null(executor::docker::is_available) { Ok(result) => result, Err(_) => { logging::debug( @@ -3606,3 +3476,131 @@ fn start_next_workflow_execution( logging::info("All workflows completed execution"); } } + +#[allow(clippy::ptr_arg)] +pub async fn execute_workflow_cli( + path: &PathBuf, + runtime_type: RuntimeType, + verbose: bool, +) -> io::Result<()> { + if !path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("Workflow file does not exist: {}", path.display()), + )); + } + + println!("Validating workflow..."); + match evaluate_workflow_file(path, false) { + Ok(result) => { + if !result.is_valid { + println!("āŒ Cannot execute invalid workflow: {}", path.display()); + for (i, issue) in result.issues.iter().enumerate() { + println!(" {}. {}", i + 1, issue); + } + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Workflow validation failed", + )); + } + } + Err(e) => { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("Error validating workflow: {}", e), + )); + } + } + + // Check Docker availability if Docker runtime is selected + let runtime_type = match runtime_type { + RuntimeType::Docker => { + if !executor::docker::is_available() { + println!("āš ļø Docker is not available. Using emulation mode instead."); + logging::warning("Docker is not available. Using emulation mode instead."); + RuntimeType::Emulation + } else { + RuntimeType::Docker + } + } + RuntimeType::Emulation => RuntimeType::Emulation, + }; + + println!("Executing workflow: {}", path.display()); + println!("Runtime mode: {:?}", runtime_type); + + match executor::execute_workflow(path, runtime_type, verbose).await { + Ok(result) => { + println!("\nWorkflow execution results:"); + + for job in &result.jobs { + match job.status { + JobStatus::Success => { + println!("\nāœ… Job succeeded: {}", job.name); + } + JobStatus::Failure => { + println!("\nāŒ Job failed: {}", job.name); + } + JobStatus::Skipped => { + println!("\nā­ļø Job skipped: {}", job.name); + } + } + + println!("-------------------------"); + + for step in job.steps.iter() { + match step.status { + StepStatus::Success => { + println!(" āœ… {}", step.name); + + if !step.output.trim().is_empty() && step.output.lines().count() <= 3 { + // For short outputs, show directly + println!(" {}", step.output.trim()); + } + } + StepStatus::Failure => { + println!(" āŒ {}", step.name); + + // For failures, always show output (truncated) + let output = if step.output.len() > 500 { + format!("{}... (truncated)", &step.output[..500]) + } else { + step.output.clone() + }; + + println!(" {}", output.trim().replace('\n', "\n ")); + } + StepStatus::Skipped => { + println!(" ā­ļø {} (skipped)", step.name); + } + } + } + } + + // Determine overall success + let failures = result + .jobs + .iter() + .filter(|job| job.status == JobStatus::Failure) + .count(); + + if failures > 0 { + println!("\nāŒ Workflow completed with failures"); + Err(io::Error::new( + io::ErrorKind::Other, + "Workflow execution failed", + )) + } else { + println!("\nāœ… Workflow completed successfully!"); + Ok(()) + } + } + Err(e) => { + println!("āŒ Failed to execute workflow: {}", e); + Err(io::Error::new( + io::ErrorKind::Other, + format!("Workflow execution error: {}", e), + )) + } + } +} diff --git a/src/validators/jobs.rs b/src/validators/jobs.rs index b39c011..2d2203e 100644 --- a/src/validators/jobs.rs +++ b/src/validators/jobs.rs @@ -13,12 +13,12 @@ pub fn validate_jobs(jobs: &Value, result: &mut ValidationResult) { if let Some(job_name) = job_name.as_str() { if let Some(job_config) = job_config.as_mapping() { // Check for required 'runs-on' - if !job_config.contains_key(&Value::String("runs-on".to_string())) { + if !job_config.contains_key(Value::String("runs-on".to_string())) { result.add_issue(format!("Job '{}' is missing 'runs-on' field", job_name)); } // Check for steps - match job_config.get(&Value::String("steps".to_string())) { + match job_config.get(Value::String("steps".to_string())) { Some(Value::Sequence(steps)) => { if steps.is_empty() { result.add_issue(format!( @@ -45,11 +45,11 @@ pub fn validate_jobs(jobs: &Value, result: &mut ValidationResult) { // Check for job dependencies if let Some(Value::Sequence(needs)) = - job_config.get(&Value::String("needs".to_string())) + job_config.get(Value::String("needs".to_string())) { for need in needs { if let Some(need_str) = need.as_str() { - if !jobs_map.contains_key(&Value::String(need_str.to_string())) { + if !jobs_map.contains_key(Value::String(need_str.to_string())) { result.add_issue(format!( "Job '{}' depends on non-existent job '{}'", job_name, need_str @@ -58,9 +58,9 @@ pub fn validate_jobs(jobs: &Value, result: &mut ValidationResult) { } } } else if let Some(Value::String(need)) = - job_config.get(&Value::String("needs".to_string())) + job_config.get(Value::String("needs".to_string())) { - if !jobs_map.contains_key(&Value::String(need.clone())) { + if !jobs_map.contains_key(Value::String(need.clone())) { result.add_issue(format!( "Job '{}' depends on non-existent job '{}'", job_name, need @@ -69,7 +69,7 @@ pub fn validate_jobs(jobs: &Value, result: &mut ValidationResult) { } // Validate matrix configuration if present - if let Some(matrix) = job_config.get(&Value::String("matrix".to_string())) { + if let Some(matrix) = job_config.get(Value::String("matrix".to_string())) { validate_matrix(matrix, result); } } else { diff --git a/src/validators/steps.rs b/src/validators/steps.rs index 508e94a..5c969d8 100644 --- a/src/validators/steps.rs +++ b/src/validators/steps.rs @@ -2,12 +2,12 @@ use crate::models::ValidationResult; use crate::validators::validate_action_reference; use serde_yaml::Value; -pub fn validate_steps(steps: &Vec, job_name: &str, result: &mut ValidationResult) { +pub fn validate_steps(steps: &[Value], job_name: &str, result: &mut ValidationResult) { for (i, step) in steps.iter().enumerate() { if let Some(step_map) = step.as_mapping() { - if !step_map.contains_key(&Value::String("name".to_string())) - && !step_map.contains_key(&Value::String("uses".to_string())) - && !step_map.contains_key(&Value::String("run".to_string())) + if !step_map.contains_key(Value::String("name".to_string())) + && !step_map.contains_key(Value::String("uses".to_string())) + && !step_map.contains_key(Value::String("run".to_string())) { result.add_issue(format!( "Job '{}', step {}: Missing 'name', 'uses', or 'run' field", @@ -17,8 +17,8 @@ pub fn validate_steps(steps: &Vec, job_name: &str, result: &mut Validatio } // Check for both 'uses' and 'run' in the same step - if step_map.contains_key(&Value::String("uses".to_string())) - && step_map.contains_key(&Value::String("run".to_string())) + if step_map.contains_key(Value::String("uses".to_string())) + && step_map.contains_key(Value::String("run".to_string())) { result.add_issue(format!( "Job '{}', step {}: Contains both 'uses' and 'run' (should only use one)", @@ -28,7 +28,7 @@ pub fn validate_steps(steps: &Vec, job_name: &str, result: &mut Validatio } // Validate action reference if 'uses' is present - if let Some(Value::String(uses)) = step_map.get(&Value::String("uses".to_string())) { + if let Some(Value::String(uses)) = step_map.get(Value::String("uses".to_string())) { validate_action_reference(uses, job_name, i, result); } } else { diff --git a/src/validators/triggers.rs b/src/validators/triggers.rs index 4c0b859..58dcdbd 100644 --- a/src/validators/triggers.rs +++ b/src/validators/triggers.rs @@ -63,12 +63,12 @@ pub fn validate_triggers(on: &Value, result: &mut ValidationResult) { // Check schedule syntax if present if let Some(Value::Sequence(schedules)) = - event_map.get(&Value::String("schedule".to_string())) + event_map.get(Value::String("schedule".to_string())) { for schedule in schedules { if let Some(schedule_map) = schedule.as_mapping() { if let Some(Value::String(cron)) = - schedule_map.get(&Value::String("cron".to_string())) + schedule_map.get(Value::String("cron".to_string())) { validate_cron_syntax(cron, result); } else {