diff --git a/.gitignore b/.gitignore index 6c9a971..764f293 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Thumbs.db # Project specific cloned_repos*/ coverage/ +logs/ +config.yaml +tarpaulin-report.html diff --git a/README.md b/README.md index f575155..53a8ce0 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,19 @@ repos run -p "cargo test" # Specify a custom log directory repos run -l custom/logs "make build" + +# Default behavior (persistent logs) +repos run "mvn compile" + +# Disable persistence +repos run --no-save "echo test" + +# Custom log directory +repos run --output-dir=/tmp/build-logs "gradle build" + +# Search logs +grep -n "ClassNotFound" logs/runs/*/combined.out +rg "BUILD FAILURE" logs/runs/ ``` #### Example commands diff --git a/src/commands/run.rs b/src/commands/run.rs index 4d3f672..f601ccd 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -4,12 +4,15 @@ use super::{Command, CommandContext}; use crate::runner::CommandRunner; use anyhow::Result; use async_trait::async_trait; -use colored::*; + +use std::fs::create_dir_all; +use std::path::{Path, PathBuf}; /// Run command for executing commands in repositories pub struct RunCommand { pub command: String, - pub log_dir: String, + pub no_save: bool, + pub output_dir: Option, } #[async_trait] @@ -20,31 +23,30 @@ impl Command for RunCommand { .filter_repositories(context.tag.as_deref(), context.repos.as_deref()); if repositories.is_empty() { - let filter_desc = match (&context.tag, &context.repos) { - (Some(tag), Some(repos)) => format!("tag '{tag}' and repositories {repos:?}"), - (Some(tag), None) => format!("tag '{tag}'"), - (None, Some(repos)) => format!("repositories {repos:?}"), - (None, None) => "no repositories found".to_string(), - }; - println!( - "{}", - format!("No repositories found with {filter_desc}").yellow() - ); return Ok(()); } - println!( - "{}", - format!( - "Running '{}' in {} repositories...", - self.command, - repositories.len() - ) - .green() - ); - let runner = CommandRunner::new(); + // Setup persistent output directory if saving is enabled + let run_root = if !self.no_save { + // Use local time instead of UTC + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); + // Sanitize command for directory name + let command_suffix = Self::sanitize_command_for_filename(&self.command); + // Use provided output directory or default to "output" + let base_dir = self + .output_dir + .as_ref() + .unwrap_or(&PathBuf::from("output")) + .join("runs"); + let run_dir = base_dir.join(format!("{}_{}", timestamp, command_suffix)); + create_dir_all(&run_dir)?; + Some(run_dir) + } else { + None + }; + let mut errors = Vec::new(); let mut successful = 0; @@ -54,66 +56,140 @@ impl Command for RunCommand { .map(|repo| { let runner = &runner; let command = self.command.clone(); - let log_dir = self.log_dir.clone(); + let no_save = self.no_save; async move { - ( - repo.name.clone(), - runner.run_command(&repo, &command, Some(&log_dir)).await, - ) + let result = if !no_save { + runner + .run_command_with_capture_no_logs(&repo, &command, None) + .await + } else { + runner + .run_command(&repo, &command, None) + .await + .map(|_| (String::new(), String::new(), 0)) + }; + (repo, result) } }) .collect(); for task in tasks { - let (repo_name, result) = task.await; + let (repo, result) = task.await; match result { - Ok(_) => successful += 1, + Ok((stdout, stderr, exit_code)) => { + if exit_code == 0 { + successful += 1; + } else { + errors.push(( + repo.name.clone(), + anyhow::anyhow!("Command failed with exit code: {}", exit_code), + )); + } + + // Save output to individual files + if let Some(ref run_dir) = run_root { + self.save_repo_output(&repo, &stdout, &stderr, run_dir)?; + } + } Err(e) => { - eprintln!("{}", format!("Error: {e}").red()); - errors.push((repo_name, e)); + errors.push((repo.name.clone(), e)); } } } } else { for repo in repositories { - match runner - .run_command(&repo, &self.command, Some(&self.log_dir)) - .await - { - Ok(_) => successful += 1, + let result = if !self.no_save { + runner + .run_command_with_capture_no_logs(&repo, &self.command, None) + .await + } else { + runner + .run_command(&repo, &self.command, None) + .await + .map(|_| (String::new(), String::new(), 0)) + }; + + match result { + Ok((stdout, stderr, exit_code)) => { + if exit_code == 0 { + successful += 1; + } else { + errors.push(( + repo.name.clone(), + anyhow::anyhow!("Command failed with exit code: {}", exit_code), + )); + } + + // Save output to individual files + if let Some(ref run_dir) = run_root { + self.save_repo_output(&repo, &stdout, &stderr, run_dir)?; + } + } Err(e) => { - eprintln!( - "{} | {}", - repo.name.cyan().bold(), - format!("Error: {e}").red() - ); errors.push((repo.name.clone(), e)); } } } } - // Report summary - if errors.is_empty() { - println!("{}", "Done running commands".green()); - } else { - println!( - "{}", - format!( - "Completed with {} successful, {} failed", - successful, - errors.len() - ) - .yellow() - ); - - // If all operations failed, return an error to propagate to main - if successful == 0 { - return Err(anyhow::anyhow!( - "All command executions failed. First error: {}", - errors[0].1 - )); - } + // Check if all operations failed + if !errors.is_empty() && successful == 0 { + return Err(anyhow::anyhow!( + "All command executions failed. First error: {}", + errors[0].1 + )); + } + + Ok(()) + } +} + +impl RunCommand { + /// Create a new RunCommand with default settings for testing + pub fn new_for_test(command: String, output_dir: String) -> Self { + Self { + command, + no_save: false, + output_dir: Some(PathBuf::from(output_dir)), + } + } + + /// Sanitize command string for use in directory names + fn sanitize_command_for_filename(command: &str) -> String { + command + .chars() + .map(|c| match c { + ' ' => '_', + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' => c, + _ => '_', + }) + .collect::() + .chars() + .take(50) // Limit length to avoid overly long directory names + .collect() + } + + /// Save individual repository output to separate files + fn save_repo_output( + &self, + repo: &crate::config::Repository, + stdout: &str, + stderr: &str, + run_dir: &Path, + ) -> Result<()> { + let safe_name = repo.name.replace(['/', '\\', ':'], "_"); + + // Save stdout + if !stdout.is_empty() { + let stdout_path = run_dir.join(format!("{}.stdout", safe_name)); + std::fs::write(stdout_path, stdout)?; + } + + // Save stderr + if !stderr.is_empty() { + let stderr_path = run_dir.join(format!("{}.stderr", safe_name)); + std::fs::write(stderr_path, stderr)?; } Ok(()) diff --git a/src/constants.rs b/src/constants.rs index a4fc2c2..38b52ef 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -29,6 +29,6 @@ pub mod config { /// Default configuration file name pub const DEFAULT_CONFIG_FILE: &str = "config.yaml"; - /// Default logs directory - pub const DEFAULT_LOGS_DIR: &str = "logs"; + /// Default output directory + pub const DEFAULT_LOGS_DIR: &str = "output"; } diff --git a/src/main.rs b/src/main.rs index 34632b6..44801e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use repos::{commands::*, config::Config, constants}; -use std::env; +use std::{env, path::PathBuf}; #[derive(Parser)] #[command(name = "repos")] @@ -40,10 +40,6 @@ enum Commands { /// Specific repository names to run command in (if not provided, uses tag filter or all repos) repos: Vec, - /// Directory to store log files - #[arg(short, long, default_value_t = constants::config::DEFAULT_LOGS_DIR.to_string())] - logs: String, - /// Configuration file path #[arg(short, long, default_value_t = constants::config::DEFAULT_CONFIG_FILE.to_string())] config: String, @@ -55,6 +51,14 @@ enum Commands { /// Execute operations in parallel #[arg(short, long)] parallel: bool, + + /// Don't save command outputs to files + #[arg(long)] + no_save: bool, + + /// Custom directory for output files (default: output) + #[arg(long)] + output_dir: Option, }, /// Create pull requests for repositories with changes @@ -165,10 +169,11 @@ async fn main() -> Result<()> { Commands::Run { command, repos, - logs, config, tag, parallel, + no_save, + output_dir, } => { let config = Config::load_config(&config)?; let context = CommandContext { @@ -179,7 +184,8 @@ async fn main() -> Result<()> { }; RunCommand { command, - log_dir: logs, + no_save, + output_dir: output_dir.map(PathBuf::from), } .execute(&context) .await?; diff --git a/src/runner.rs b/src/runner.rs index 80a036e..9ebc22a 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -3,14 +3,10 @@ use crate::config::Repository; use crate::git::Logger; use anyhow::Result; -use chrono::Utc; -use colored::*; -use std::fs::{File, create_dir_all}; -use std::io::{BufRead, BufReader, Write}; + +use std::io::{BufRead, BufReader}; use std::path::Path; use std::process::{Command, Stdio}; -use std::sync::Arc; -use tokio::sync::Mutex; #[derive(Default)] pub struct CommandRunner { @@ -22,12 +18,36 @@ impl CommandRunner { Self::default() } - pub async fn run_command( + /// Run command and capture output for the new logging system + pub async fn run_command_with_capture( &self, repo: &Repository, command: &str, log_dir: Option<&str>, - ) -> Result<()> { + ) -> Result<(String, String, i32)> { + self.run_command_with_capture_internal(repo, command, log_dir, false) + .await + } + + /// Run command and capture output without creating log files (for persist mode) + pub async fn run_command_with_capture_no_logs( + &self, + repo: &Repository, + command: &str, + log_dir: Option<&str>, + ) -> Result<(String, String, i32)> { + self.run_command_with_capture_internal(repo, command, log_dir, true) + .await + } + + /// Internal implementation that allows skipping log file creation + async fn run_command_with_capture_internal( + &self, + repo: &Repository, + command: &str, + _log_dir: Option<&str>, + _skip_log_file: bool, + ) -> Result<(String, String, i32)> { let repo_dir = repo.get_target_dir(); // Check if directory exists @@ -35,13 +55,7 @@ impl CommandRunner { anyhow::bail!("Repository directory does not exist: {}", repo_dir); } - // Prepare log file if log directory is specified - let log_file = if let Some(log_dir) = log_dir { - Some(self.prepare_log_file(repo, log_dir, command, &repo_dir)?) - } else { - None - }; - + // No longer create log files - all output handled by persist system self.logger.info(repo, &format!("Running '{command}'")); // Execute command @@ -56,98 +70,80 @@ impl CommandRunner { let stdout = cmd.stdout.take().unwrap(); let stderr = cmd.stderr.take().unwrap(); - let log_file = Arc::new(Mutex::new(log_file)); - let repo_name = repo.name.clone(); - // Handle stdout - let stdout_log_file = Arc::clone(&log_file); - let stdout_repo_name = repo_name.clone(); let stdout_handle = tokio::spawn(async move { let reader = BufReader::new(stdout); - // Note: We explicitly handle Result instead of using .flatten() - // to avoid infinite loops on repeated I/O errors + let mut content = String::new(); #[allow(clippy::manual_flatten)] for line in reader.lines() { if let Ok(line) = line { - // Print to console with colored repo name - println!("{} | {line}", stdout_repo_name.cyan()); - - // Write to log file if available - if let Some(ref mut log_file) = *stdout_log_file.lock().await { - writeln!(log_file, "{stdout_repo_name} | {line}").ok(); - log_file.flush().ok(); - } + content.push_str(&line); + content.push('\n'); } } + content }); // Handle stderr - let stderr_log_file = Arc::clone(&log_file); - let stderr_repo_name = repo_name.clone(); let stderr_handle = tokio::spawn(async move { let reader = BufReader::new(stderr); - let mut header_written = false; + let mut content = String::new(); - // Note: We explicitly handle Result instead of using .flatten() - // to avoid infinite loops on repeated I/O errors #[allow(clippy::manual_flatten)] for line in reader.lines() { if let Ok(line) = line { - // Print to console with colored repo name - eprintln!("{} | {line}", stderr_repo_name.red().bold()); - - // Write to log file if available - if let Some(ref mut log_file) = *stderr_log_file.lock().await { - if !header_written { - writeln!(log_file, "\n=== STDERR ===").ok(); - header_written = true; - } - writeln!(log_file, "{stderr_repo_name} | {line}").ok(); - log_file.flush().ok(); - } + content.push_str(&line); + content.push('\n'); } } + content }); - // Wait for output processing to complete - let _ = tokio::join!(stdout_handle, stderr_handle); + // Wait for output processing to complete and capture content + let (stdout_result, stderr_result) = tokio::join!(stdout_handle, stderr_handle); + let stdout_content = stdout_result.unwrap_or_default(); + let stderr_content = stderr_result.unwrap_or_default(); // Wait for command to complete let status = cmd.wait()?; + let exit_code = status.code().unwrap_or(-1); - if !status.success() { - anyhow::bail!( - "Command failed with exit code: {}", - status.code().unwrap_or(-1) - ); - } - - Ok(()) + // Always return the captured output, regardless of exit code + // This allows the caller to decide how to handle failures and still log the output + Ok((stdout_content, stderr_content, exit_code)) } - fn prepare_log_file( + pub async fn run_command( &self, repo: &Repository, - log_dir: &str, command: &str, - repo_dir: &str, - ) -> Result { - // Create log directory if it doesn't exist - create_dir_all(log_dir)?; + _log_dir: Option<&str>, + ) -> Result<()> { + let repo_dir = repo.get_target_dir(); - let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); - let log_file_path = format!("{}/{}_{}.log", log_dir, repo.name, timestamp); + // Check if directory exists + if !Path::new(&repo_dir).exists() { + anyhow::bail!("Repository directory does not exist: {}", repo_dir); + } - let mut log_file = File::create(&log_file_path)?; + // No longer create log files - all output is handled by the new persist system + self.logger.info(repo, &format!("Running '{command}'")); + + // Execute command + let status = Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(&repo_dir) + .status()?; - // Write header information - writeln!(log_file, "Repository: {}", repo.name)?; - writeln!(log_file, "Command: {command}")?; - writeln!(log_file, "Directory: {repo_dir}")?; - writeln!(log_file, "Timestamp: {}", Utc::now().to_rfc3339())?; - writeln!(log_file, "\n=== STDOUT ===")?; + if !status.success() { + anyhow::bail!( + "Command failed with exit code: {}", + status.code().unwrap_or(-1) + ); + } - Ok(log_file) + Ok(()) } } @@ -303,12 +299,20 @@ mod tests { .await; assert!(result.is_ok()); - assert!(log_dir.exists()); - let log_files: Vec<_> = fs::read_dir(&log_dir) - .unwrap() - .filter_map(Result::ok) - .collect(); - assert!(!log_files.is_empty(), "Log file should have been created"); + // No log files are created anymore - the persist system handles output capture + // The log_dir parameter is no longer used for file creation + let log_files: Vec<_> = if log_dir.exists() { + fs::read_dir(&log_dir) + .unwrap() + .filter_map(Result::ok) + .collect() + } else { + Vec::new() + }; + assert!( + log_files.is_empty(), + "No log files should be created - use persist system instead" + ); } #[tokio::test] @@ -329,23 +333,19 @@ mod tests { .await; assert!(result.is_ok()); - let log_file_path = fs::read_dir(&log_dir) - .unwrap() - .filter_map(Result::ok) - .next() - .expect("No log file found") - .path(); - - let log_content = fs::read_to_string(log_file_path).unwrap(); - - assert!(log_content.contains("Repository: test-log-content")); - assert!(log_content.contains("Command: echo 'stdout message'; echo 'stderr message' >&2")); - assert!(log_content.contains("Directory:")); - assert!(log_content.contains("Timestamp:")); - assert!(log_content.contains("=== STDOUT ===")); - assert!(log_content.contains("stdout message")); - assert!(log_content.contains("=== STDERR ===")); - assert!(log_content.contains("stderr message")); + // Verify no log files are created (we now use persist system instead) + let log_files: Vec<_> = if log_dir.exists() { + fs::read_dir(&log_dir) + .unwrap() + .filter_map(Result::ok) + .collect() + } else { + Vec::new() + }; + assert!( + log_files.is_empty(), + "No log files should be created with new persist-only system" + ); } #[tokio::test] @@ -365,7 +365,8 @@ mod tests { Some(&invalid_log_dir.to_string_lossy()), ) .await; - assert!(result.is_err()); + // Should succeed now since we don't create log files + assert!(result.is_ok()); } #[tokio::test] @@ -384,16 +385,16 @@ mod tests { .await; assert!(result.is_ok()); - let log_file_path = fs::read_dir(&log_dir) - .unwrap() - .next() - .unwrap() - .unwrap() - .path(); - let log_content = fs::read_to_string(log_file_path).unwrap(); - assert!(log_content.contains("Line 1")); - assert!(log_content.contains("Line 50")); - assert!(log_content.contains("Line 100")); + // Verify no log files are created + let log_files: Vec<_> = if log_dir.exists() { + fs::read_dir(&log_dir) + .unwrap() + .filter_map(Result::ok) + .collect() + } else { + Vec::new() + }; + assert!(log_files.is_empty(), "No log files should be created"); } #[tokio::test] @@ -414,18 +415,168 @@ mod tests { .await; assert!(result.is_ok()); - let log_file_name = fs::read_dir(&log_dir) - .unwrap() - .next() - .unwrap() - .unwrap() - .file_name(); - // The filename should be sanitized (dots become dashes, so "test-repo_with-special.chars" becomes something with dashes) - let filename = log_file_name.to_string_lossy(); + // Verify no log files are created + let log_files: Vec<_> = if log_dir.exists() { + fs::read_dir(&log_dir) + .unwrap() + .filter_map(Result::ok) + .collect() + } else { + Vec::new() + }; + assert!(log_files.is_empty(), "No log files should be created"); + } + + #[tokio::test] + async fn test_run_command_with_capture_success() { + let (repo, temp_dir) = + create_test_repo_with_git("test-capture", "git@github.com:owner/test.git"); + let runner = CommandRunner::new(); + + let log_dir = temp_dir.path().join("logs"); + let log_dir_str = log_dir.to_string_lossy().to_string(); + + let result = runner + .run_command_with_capture(&repo, "echo 'captured output'", Some(&log_dir_str)) + .await; + + assert!(result.is_ok()); + let (stdout, stderr, exit_code) = result.unwrap(); + assert!(stdout.contains("captured output")); + assert!(stderr.is_empty()); + assert_eq!(exit_code, 0); + } + + #[tokio::test] + async fn test_run_command_with_capture_stderr() { + let (repo, temp_dir) = + create_test_repo_with_git("test-capture-stderr", "git@github.com:owner/test.git"); + let runner = CommandRunner::new(); + + let log_dir = temp_dir.path().join("logs"); + let log_dir_str = log_dir.to_string_lossy().to_string(); + + let result = runner + .run_command_with_capture(&repo, "echo 'error message' >&2", Some(&log_dir_str)) + .await; + + assert!(result.is_ok()); + let (stdout, stderr, exit_code) = result.unwrap(); + assert!(stdout.is_empty()); + assert!(stderr.contains("error message")); + assert_eq!(exit_code, 0); + } + + #[tokio::test] + async fn test_run_command_with_capture_mixed_output() { + let (repo, temp_dir) = + create_test_repo_with_git("test-capture-mixed", "git@github.com:owner/test.git"); + let runner = CommandRunner::new(); + + let log_dir = temp_dir.path().join("logs"); + let log_dir_str = log_dir.to_string_lossy().to_string(); + + let result = runner + .run_command_with_capture( + &repo, + "echo 'stdout message' && echo 'stderr message' >&2", + Some(&log_dir_str), + ) + .await; + + assert!(result.is_ok()); + let (stdout, stderr, exit_code) = result.unwrap(); + assert!(stdout.contains("stdout message")); + assert!(stderr.contains("stderr message")); + assert_eq!(exit_code, 0); + } + + #[tokio::test] + async fn test_run_command_with_capture_failure() { + let (repo, temp_dir) = + create_test_repo_with_git("test-capture-fail", "git@github.com:owner/test.git"); + let runner = CommandRunner::new(); + + let log_dir = temp_dir.path().join("logs"); + let log_dir_str = log_dir.to_string_lossy().to_string(); + + let result = runner + .run_command_with_capture(&repo, "exit 1", Some(&log_dir_str)) + .await; + + // Should return Ok with exit code 1 (failure is indicated by exit code, not error) + assert!(result.is_ok()); + let (stdout, stderr, exit_code) = result.unwrap(); + assert!(stdout.is_empty()); + assert!(stderr.is_empty()); + assert_eq!(exit_code, 1); + } + + #[tokio::test] + async fn test_run_command_with_capture_no_log_dir() { + let (repo, _temp_dir) = + create_test_repo_with_git("test-capture-no-log", "git@github.com:owner/test.git"); + let runner = CommandRunner::new(); + + let result = runner + .run_command_with_capture(&repo, "echo 'no log dir'", None) + .await; + + assert!(result.is_ok()); + let (stdout, stderr, exit_code) = result.unwrap(); + assert!(stdout.contains("no log dir")); + assert!(stderr.is_empty()); + assert_eq!(exit_code, 0); + } + + #[tokio::test] + async fn test_run_command_with_capture_long_output() { + let (repo, temp_dir) = + create_test_repo_with_git("test-capture-long", "git@github.com:owner/test.git"); + let runner = CommandRunner::new(); + + let log_dir = temp_dir.path().join("logs"); + let log_dir_str = log_dir.to_string_lossy().to_string(); + + let result = runner + .run_command_with_capture( + &repo, + "for i in $(seq 1 50); do echo \"Line $i\"; done", + Some(&log_dir_str), + ) + .await; + + assert!(result.is_ok()); + let (stdout, stderr, exit_code) = result.unwrap(); + assert!(stdout.contains("Line 1")); + assert!(stdout.contains("Line 25")); + assert!(stdout.contains("Line 50")); + assert!(stderr.is_empty()); + assert_eq!(exit_code, 0); + } + + #[tokio::test] + async fn test_run_command_with_capture_nonexistent_directory() { + let repo = Repository { + name: "nonexistent-repo".to_string(), + url: "https://github.com/test/nonexistent".to_string(), + tags: vec!["test".to_string()], + path: Some("/nonexistent/path".to_string()), + branch: None, + config_dir: None, + }; + let runner = CommandRunner::new(); + + let result = runner + .run_command_with_capture(&repo, "echo 'test'", None) + .await; + + assert!(result.is_err()); assert!( - filename.contains("test-repo") - && filename.contains("special") - && filename.contains("chars") + result + .unwrap_err() + .to_string() + .contains("Repository directory does not exist") ); } } diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index efb827f..381b987 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -63,6 +63,7 @@ fn test_pr_command_missing_required_args() { // PR command may have different required args, let's be more flexible assert!( stderr.contains("error") + || stderr.contains("Error") || stderr.contains("required") || stderr.contains("missing") || stderr.contains("Usage") diff --git a/tests/run_command_tests.rs b/tests/run_command_tests.rs index f9b6343..c7edc38 100644 --- a/tests/run_command_tests.rs +++ b/tests/run_command_tests.rs @@ -63,7 +63,8 @@ async fn test_run_command_basic_execution() { let command = RunCommand { command: "echo hello".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -107,7 +108,8 @@ async fn test_run_command_multiple_repositories() { let command = RunCommand { command: "pwd".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -149,7 +151,8 @@ async fn test_run_command_parallel_execution() { let command = RunCommand { command: "echo parallel".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -199,7 +202,8 @@ async fn test_run_command_with_tag_filter() { let command = RunCommand { command: "echo tagged".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -250,7 +254,8 @@ async fn test_run_command_with_repo_filter() { let command = RunCommand { command: "echo filtered".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -289,7 +294,8 @@ async fn test_run_command_no_matching_repositories() { let command = RunCommand { command: "echo test".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -313,7 +319,8 @@ async fn test_run_command_empty_repositories() { let command = RunCommand { command: "echo test".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -351,7 +358,8 @@ async fn test_run_command_complex_command() { let command = RunCommand { command: "git status && echo done".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -389,7 +397,8 @@ async fn test_run_command_command_with_special_characters() { let command = RunCommand { command: "echo 'hello world' && echo \"quoted text\"".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext { @@ -441,7 +450,8 @@ async fn test_run_command_combined_filters() { let command = RunCommand { command: "echo combined".to_string(), - log_dir: log_dir.to_string_lossy().to_string(), + no_save: true, + output_dir: None, }; let context = CommandContext {