521 lines
18 KiB
Rust
521 lines
18 KiB
Rust
use std::io::{BufRead, BufReader, Write};
|
|
use std::fs::{self, File};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Output, Stdio};
|
|
use std::fmt;
|
|
use std::error::Error;
|
|
use std::io;
|
|
use std::thread;
|
|
|
|
use crate::text;
|
|
|
|
/// Error type for command and script execution operations
|
|
#[derive(Debug)]
|
|
pub enum RunError {
|
|
/// The command string was empty
|
|
EmptyCommand,
|
|
/// An error occurred while executing a command
|
|
CommandExecutionFailed(io::Error),
|
|
/// A command executed successfully but returned an error
|
|
CommandFailed(String),
|
|
/// An error occurred while preparing a script for execution
|
|
ScriptPreparationFailed(String),
|
|
/// An error occurred in a child process
|
|
ChildProcessError(String),
|
|
/// Failed to create a temporary directory
|
|
TempDirCreationFailed(io::Error),
|
|
/// Failed to create a script file
|
|
FileCreationFailed(io::Error),
|
|
/// Failed to write to a script file
|
|
FileWriteFailed(io::Error),
|
|
/// Failed to set file permissions
|
|
PermissionError(io::Error),
|
|
}
|
|
|
|
/// Implement Display for RunError to provide human-readable error messages
|
|
impl fmt::Display for RunError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
RunError::EmptyCommand => write!(f, "Empty command"),
|
|
RunError::CommandExecutionFailed(e) => write!(f, "Failed to execute command: {}", e),
|
|
RunError::CommandFailed(e) => write!(f, "{}", e),
|
|
RunError::ScriptPreparationFailed(e) => write!(f, "{}", e),
|
|
RunError::ChildProcessError(e) => write!(f, "{}", e),
|
|
RunError::TempDirCreationFailed(e) => write!(f, "Failed to create temporary directory: {}", e),
|
|
RunError::FileCreationFailed(e) => write!(f, "Failed to create script file: {}", e),
|
|
RunError::FileWriteFailed(e) => write!(f, "Failed to write to script file: {}", e),
|
|
RunError::PermissionError(e) => write!(f, "Failed to set file permissions: {}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Implement Error trait for RunError
|
|
impl Error for RunError {
|
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
match self {
|
|
RunError::CommandExecutionFailed(e) => Some(e),
|
|
RunError::TempDirCreationFailed(e) => Some(e),
|
|
RunError::FileCreationFailed(e) => Some(e),
|
|
RunError::FileWriteFailed(e) => Some(e),
|
|
RunError::PermissionError(e) => Some(e),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A structure to hold command execution results
|
|
#[derive(Debug, Clone)]
|
|
pub struct CommandResult {
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
pub success: bool,
|
|
pub code: i32,
|
|
}
|
|
|
|
impl CommandResult {
|
|
/// Create a default failed result with an error message
|
|
fn _error(message: &str) -> Self {
|
|
Self {
|
|
stdout: String::new(),
|
|
stderr: message.to_string(),
|
|
success: false,
|
|
code: -1,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Prepare a script file and return the path and interpreter
|
|
fn prepare_script_file(script_content: &str) -> Result<(PathBuf, String, tempfile::TempDir), RunError> {
|
|
// Dedent the script
|
|
let dedented = text::dedent(script_content);
|
|
|
|
// Create a temporary directory
|
|
let temp_dir = tempfile::tempdir()
|
|
.map_err(RunError::TempDirCreationFailed)?;
|
|
|
|
// Determine script extension and interpreter
|
|
#[cfg(target_os = "windows")]
|
|
let (ext, interpreter) = (".bat", "cmd.exe".to_string());
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
let (ext, interpreter) = (".sh", "/bin/bash".to_string());
|
|
|
|
// Create the script file
|
|
let script_path = temp_dir.path().join(format!("script{}", ext));
|
|
let mut file = File::create(&script_path)
|
|
.map_err(RunError::FileCreationFailed)?;
|
|
|
|
// For Unix systems, ensure the script has a shebang line with -e flag
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
{
|
|
let script_with_shebang = if dedented.trim_start().starts_with("#!") {
|
|
// Script already has a shebang, use it as is
|
|
dedented
|
|
} else {
|
|
// Add shebang with -e flag to ensure script fails on errors
|
|
format!("#!/bin/bash -e\n{}", dedented)
|
|
};
|
|
|
|
// Write the script content with shebang
|
|
file.write_all(script_with_shebang.as_bytes())
|
|
.map_err(RunError::FileWriteFailed)?;
|
|
}
|
|
|
|
// For Windows, just write the script as is
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
file.write_all(dedented.as_bytes())
|
|
.map_err(RunError::FileWriteFailed)?;
|
|
}
|
|
|
|
// Make the script executable (Unix only)
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(&script_path)
|
|
.map_err(|e| RunError::PermissionError(e))?
|
|
.permissions();
|
|
perms.set_mode(0o755); // rwxr-xr-x
|
|
fs::set_permissions(&script_path, perms)
|
|
.map_err(RunError::PermissionError)?;
|
|
}
|
|
|
|
Ok((script_path, interpreter, temp_dir))
|
|
}
|
|
|
|
/// Capture output from Child's stdio streams with optional printing
|
|
fn handle_child_output(mut child: Child, silent: bool) -> Result<CommandResult, RunError> {
|
|
// Prepare to read stdout & stderr line-by-line
|
|
let stdout = child.stdout.take();
|
|
let stderr = child.stderr.take();
|
|
|
|
// Process stdout
|
|
let stdout_handle = if let Some(out) = stdout {
|
|
let reader = BufReader::new(out);
|
|
let silent_clone = silent;
|
|
// Spawn a thread to capture and optionally print stdout
|
|
Some(std::thread::spawn(move || {
|
|
let mut local_buffer = String::new();
|
|
for line in reader.lines() {
|
|
if let Ok(l) = line {
|
|
// Print the line if not silent and flush immediately
|
|
if !silent_clone {
|
|
println!("{}", l);
|
|
std::io::stdout().flush().unwrap_or(());
|
|
}
|
|
// Store it in our captured buffer
|
|
local_buffer.push_str(&l);
|
|
local_buffer.push('\n');
|
|
}
|
|
}
|
|
local_buffer
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Process stderr
|
|
let stderr_handle = if let Some(err) = stderr {
|
|
let reader = BufReader::new(err);
|
|
let silent_clone = silent;
|
|
// Spawn a thread to capture and optionally print stderr
|
|
Some(std::thread::spawn(move || {
|
|
let mut local_buffer = String::new();
|
|
for line in reader.lines() {
|
|
if let Ok(l) = line {
|
|
// Print the line if not silent and flush immediately
|
|
if !silent_clone {
|
|
// Always print stderr, even if silent is true, for error visibility
|
|
eprintln!("\x1b[31mERROR: {}\x1b[0m", l); // Red color for errors
|
|
std::io::stderr().flush().unwrap_or(());
|
|
}
|
|
// Store it in our captured buffer
|
|
local_buffer.push_str(&l);
|
|
local_buffer.push('\n');
|
|
}
|
|
}
|
|
local_buffer
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Wait for the child process to exit
|
|
let status = child.wait()
|
|
.map_err(|e| RunError::ChildProcessError(format!("Failed to wait on child process: {}", e)))?;
|
|
|
|
// Join our stdout thread if it exists
|
|
let captured_stdout = if let Some(handle) = stdout_handle {
|
|
handle.join().unwrap_or_default()
|
|
} else {
|
|
"Failed to capture stdout".to_string()
|
|
};
|
|
|
|
// Join our stderr thread if it exists
|
|
let captured_stderr = if let Some(handle) = stderr_handle {
|
|
handle.join().unwrap_or_default()
|
|
} else {
|
|
"Failed to capture stderr".to_string()
|
|
};
|
|
|
|
// If the command failed, print the stderr if it wasn't already printed
|
|
if !status.success() && silent && !captured_stderr.is_empty() {
|
|
eprintln!("\x1b[31mCommand failed with error:\x1b[0m");
|
|
for line in captured_stderr.lines() {
|
|
eprintln!("\x1b[31m{}\x1b[0m", line);
|
|
}
|
|
}
|
|
|
|
// Return the command result
|
|
Ok(CommandResult {
|
|
stdout: captured_stdout,
|
|
stderr: captured_stderr,
|
|
success: status.success(),
|
|
code: status.code().unwrap_or(-1),
|
|
})
|
|
}
|
|
|
|
/// Processes Output structure from Command::output() into CommandResult
|
|
fn process_command_output(output: Result<Output, std::io::Error>) -> Result<CommandResult, RunError> {
|
|
match output {
|
|
Ok(out) => {
|
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
|
|
|
// Print stderr if there's any, even for silent execution
|
|
if !stderr.is_empty() {
|
|
eprintln!("\x1b[31mCommand stderr output:\x1b[0m");
|
|
for line in stderr.lines() {
|
|
eprintln!("\x1b[31m{}\x1b[0m", line);
|
|
}
|
|
}
|
|
|
|
// If the command failed, print a clear error message
|
|
if !out.status.success() {
|
|
eprintln!("\x1b[31mCommand failed with exit code: {}\x1b[0m",
|
|
out.status.code().unwrap_or(-1));
|
|
}
|
|
|
|
Ok(CommandResult {
|
|
stdout,
|
|
stderr,
|
|
success: out.status.success(),
|
|
code: out.status.code().unwrap_or(-1),
|
|
})
|
|
},
|
|
Err(e) => Err(RunError::CommandExecutionFailed(e)),
|
|
}
|
|
}
|
|
|
|
/// Common logic for running a command with optional silent mode
|
|
fn run_command_internal(command: &str, silent: bool) -> Result<CommandResult, RunError> {
|
|
let mut parts = command.split_whitespace();
|
|
let cmd = match parts.next() {
|
|
Some(c) => c,
|
|
None => return Err(RunError::EmptyCommand),
|
|
};
|
|
|
|
let args: Vec<&str> = parts.collect();
|
|
|
|
// Spawn the child process with piped stdout & stderr
|
|
let child = Command::new(cmd)
|
|
.args(&args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(RunError::CommandExecutionFailed)?;
|
|
|
|
handle_child_output(child, silent)
|
|
}
|
|
|
|
/// Execute a script with the given interpreter and path
|
|
fn execute_script_internal(interpreter: &str, script_path: &Path, silent: bool) -> Result<CommandResult, RunError> {
|
|
#[cfg(target_os = "windows")]
|
|
let command_args = vec!["/c", script_path.to_str().unwrap_or("")];
|
|
|
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
let command_args = vec![script_path.to_str().unwrap_or("")];
|
|
|
|
if silent {
|
|
// For silent execution, use output() which captures but doesn't display
|
|
let output = Command::new(interpreter)
|
|
.args(&command_args)
|
|
.output();
|
|
|
|
let result = process_command_output(output)?;
|
|
|
|
// If the script failed, return an error
|
|
if !result.success {
|
|
return Err(RunError::CommandFailed(format!(
|
|
"Script execution failed with exit code {}: {}",
|
|
result.code,
|
|
result.stderr.trim()
|
|
)));
|
|
}
|
|
|
|
Ok(result)
|
|
} else {
|
|
// For normal execution, spawn and handle the output streams
|
|
let child = Command::new(interpreter)
|
|
.args(&command_args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(RunError::CommandExecutionFailed)?;
|
|
|
|
let result = handle_child_output(child, false)?;
|
|
|
|
// If the script failed, return an error
|
|
if !result.success {
|
|
return Err(RunError::CommandFailed(format!(
|
|
"Script execution failed with exit code {}: {}",
|
|
result.code,
|
|
result.stderr.trim()
|
|
)));
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
/// Run a multiline script with optional silent mode
|
|
fn run_script_internal(script: &str, silent: bool) -> Result<CommandResult, RunError> {
|
|
// Print the script being executed if not silent
|
|
if !silent {
|
|
println!("\x1b[36mExecuting script:\x1b[0m");
|
|
for (i, line) in script.lines().enumerate() {
|
|
println!("\x1b[36m{:3}: {}\x1b[0m", i + 1, line);
|
|
}
|
|
println!("\x1b[36m---\x1b[0m");
|
|
}
|
|
|
|
let (script_path, interpreter, _temp_dir) = prepare_script_file(script)?;
|
|
// _temp_dir is kept in scope until the end of this function to ensure
|
|
// it's not dropped prematurely, which would clean up the directory
|
|
|
|
// Execute the script and handle the result
|
|
let result = execute_script_internal(&interpreter, &script_path, silent);
|
|
|
|
// If there was an error, print a clear error message
|
|
if let Err(ref e) = result {
|
|
eprintln!("\x1b[31mScript execution failed: {}\x1b[0m", e);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// A builder for configuring and executing commands or scripts
|
|
pub struct RunBuilder<'a> {
|
|
/// The command or script to run
|
|
cmd: &'a str,
|
|
/// Whether to return an error if the command fails (default: true)
|
|
die: bool,
|
|
/// Whether to suppress output to stdout/stderr (default: false)
|
|
silent: bool,
|
|
/// Whether to run the command asynchronously (default: false)
|
|
async_exec: bool,
|
|
/// Whether to log command execution (default: false)
|
|
log: bool,
|
|
}
|
|
|
|
impl<'a> RunBuilder<'a> {
|
|
/// Create a new RunBuilder with default settings
|
|
pub fn new(cmd: &'a str) -> Self {
|
|
Self {
|
|
cmd,
|
|
die: true,
|
|
silent: false,
|
|
async_exec: false,
|
|
log: false,
|
|
}
|
|
}
|
|
|
|
/// Set whether to return an error if the command fails
|
|
pub fn die(mut self, die: bool) -> Self {
|
|
self.die = die;
|
|
self
|
|
}
|
|
|
|
/// Set whether to suppress output to stdout/stderr
|
|
pub fn silent(mut self, silent: bool) -> Self {
|
|
self.silent = silent;
|
|
self
|
|
}
|
|
|
|
/// Set whether to run the command asynchronously
|
|
pub fn async_exec(mut self, async_exec: bool) -> Self {
|
|
self.async_exec = async_exec;
|
|
self
|
|
}
|
|
|
|
/// Set whether to log command execution
|
|
pub fn log(mut self, log: bool) -> Self {
|
|
self.log = log;
|
|
self
|
|
}
|
|
|
|
/// Execute the command or script with the configured options
|
|
pub fn execute(self) -> Result<CommandResult, RunError> {
|
|
let trimmed = self.cmd.trim();
|
|
|
|
// Log command execution if enabled
|
|
if self.log {
|
|
println!("\x1b[36m[LOG] Executing command: {}\x1b[0m", trimmed);
|
|
}
|
|
|
|
// Handle async execution
|
|
if self.async_exec {
|
|
let cmd_copy = trimmed.to_string();
|
|
let silent = self.silent;
|
|
let log = self.log;
|
|
|
|
// Spawn a thread to run the command asynchronously
|
|
thread::spawn(move || {
|
|
if log {
|
|
println!("\x1b[36m[ASYNC] Starting execution\x1b[0m");
|
|
}
|
|
|
|
let result = if cmd_copy.contains('\n') {
|
|
run_script_internal(&cmd_copy, silent)
|
|
} else {
|
|
run_command_internal(&cmd_copy, silent)
|
|
};
|
|
|
|
if log {
|
|
match &result {
|
|
Ok(res) => {
|
|
if res.success {
|
|
println!("\x1b[32m[ASYNC] Command completed successfully\x1b[0m");
|
|
} else {
|
|
eprintln!("\x1b[31m[ASYNC] Command failed with exit code: {}\x1b[0m", res.code);
|
|
}
|
|
},
|
|
Err(e) => {
|
|
eprintln!("\x1b[31m[ASYNC] Command failed with error: {}\x1b[0m", e);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Return a placeholder result for async execution
|
|
return Ok(CommandResult {
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
success: true,
|
|
code: 0,
|
|
});
|
|
}
|
|
|
|
// Execute the command or script
|
|
let result = if trimmed.contains('\n') {
|
|
// This is a multiline script
|
|
run_script_internal(trimmed, self.silent)
|
|
} else {
|
|
// This is a single command
|
|
run_command_internal(trimmed, self.silent)
|
|
};
|
|
|
|
// Handle die=false: convert errors to CommandResult with success=false
|
|
match result {
|
|
Ok(res) => {
|
|
// If the command failed but die is false, print a warning
|
|
if !res.success && !self.die && !self.silent {
|
|
eprintln!("\x1b[33mWarning: Command failed with exit code {} but 'die' is false\x1b[0m", res.code);
|
|
}
|
|
Ok(res)
|
|
},
|
|
Err(e) => {
|
|
// Always print the error, even if die is false
|
|
eprintln!("\x1b[31mCommand error: {}\x1b[0m", e);
|
|
|
|
if self.die {
|
|
Err(e)
|
|
} else {
|
|
// Convert error to CommandResult with success=false
|
|
Ok(CommandResult {
|
|
stdout: String::new(),
|
|
stderr: format!("Error: {}", e),
|
|
success: false,
|
|
code: -1,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a new RunBuilder for executing a command or script
|
|
pub fn run(cmd: &str) -> RunBuilder {
|
|
RunBuilder::new(cmd)
|
|
}
|
|
|
|
/// Run a command or multiline script with arguments
|
|
pub fn run_command(command: &str) -> Result<CommandResult, RunError> {
|
|
run(command).execute()
|
|
}
|
|
|
|
/// Run a command or multiline script with arguments silently
|
|
pub fn run_silent(command: &str) -> Result<CommandResult, RunError> {
|
|
run(command).silent(true).execute()
|
|
}
|