Skip to main content

sal_process/
run.rs

1use std::error::Error;
2use std::fmt;
3use std::fs::{self, File};
4use std::io;
5use std::io::{BufRead, BufReader, Write};
6use std::path::{Path, PathBuf};
7use std::process::{Child, Command, Output, Stdio};
8use std::thread;
9
10use sal_text;
11
12/// Error type for command and script execution operations
13#[derive(Debug)]
14pub enum RunError {
15    /// The command string was empty
16    EmptyCommand,
17    /// An error occurred while executing a command
18    CommandExecutionFailed(io::Error),
19    /// A command executed successfully but returned an error
20    CommandFailed(String),
21    /// An error occurred while preparing a script for execution
22    ScriptPreparationFailed(String),
23    /// An error occurred in a child process
24    ChildProcessError(String),
25    /// Failed to create a temporary directory
26    TempDirCreationFailed(io::Error),
27    /// Failed to create a script file
28    FileCreationFailed(io::Error),
29    /// Failed to write to a script file
30    FileWriteFailed(io::Error),
31    /// Failed to set file permissions
32    PermissionError(io::Error),
33}
34
35/// Implement Display for RunError to provide human-readable error messages
36impl fmt::Display for RunError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            RunError::EmptyCommand => write!(f, "Empty command"),
40            RunError::CommandExecutionFailed(e) => write!(f, "Failed to execute command: {}", e),
41            RunError::CommandFailed(e) => write!(f, "{}", e),
42            RunError::ScriptPreparationFailed(e) => write!(f, "{}", e),
43            RunError::ChildProcessError(e) => write!(f, "{}", e),
44            RunError::TempDirCreationFailed(e) => {
45                write!(f, "Failed to create temporary directory: {}", e)
46            }
47            RunError::FileCreationFailed(e) => write!(f, "Failed to create script file: {}", e),
48            RunError::FileWriteFailed(e) => write!(f, "Failed to write to script file: {}", e),
49            RunError::PermissionError(e) => write!(f, "Failed to set file permissions: {}", e),
50        }
51    }
52}
53
54// Implement Error trait for RunError
55impl Error for RunError {
56    fn source(&self) -> Option<&(dyn Error + 'static)> {
57        match self {
58            RunError::CommandExecutionFailed(e) => Some(e),
59            RunError::TempDirCreationFailed(e) => Some(e),
60            RunError::FileCreationFailed(e) => Some(e),
61            RunError::FileWriteFailed(e) => Some(e),
62            RunError::PermissionError(e) => Some(e),
63            _ => None,
64        }
65    }
66}
67
68/// A structure to hold command execution results
69#[derive(Debug, Clone)]
70pub struct CommandResult {
71    pub stdout: String,
72    pub stderr: String,
73    pub success: bool,
74    pub code: i32,
75}
76
77impl CommandResult {
78    // Implementation methods can be added here as needed
79}
80
81/// Prepare a script file and return the path and interpreter
82fn prepare_script_file(
83    script_content: &str,
84) -> Result<(PathBuf, String, tempfile::TempDir), RunError> {
85    // Dedent the script
86    let dedented = sal_text::dedent(script_content);
87
88    // Create a temporary directory
89    let temp_dir = tempfile::tempdir().map_err(RunError::TempDirCreationFailed)?;
90
91    // Determine script extension and interpreter
92    #[cfg(target_os = "windows")]
93    let (ext, interpreter) = (".bat", "cmd.exe".to_string());
94
95    #[cfg(any(target_os = "macos", target_os = "linux"))]
96    let (ext, interpreter) = (".sh", "/bin/bash".to_string());
97
98    // Create the script file
99    let script_path = temp_dir.path().join(format!("script{}", ext));
100    let mut file = File::create(&script_path).map_err(RunError::FileCreationFailed)?;
101
102    // For Unix systems, ensure the script has a shebang line with -e flag
103    #[cfg(any(target_os = "macos", target_os = "linux"))]
104    {
105        let script_with_shebang = if dedented.trim_start().starts_with("#!") {
106            // Script already has a shebang, use it as is
107            dedented
108        } else {
109            // Add shebang with -e flag to ensure script fails on errors
110            format!("#!/bin/bash -e\n{}", dedented)
111        };
112
113        // Write the script content with shebang
114        file.write_all(script_with_shebang.as_bytes())
115            .map_err(RunError::FileWriteFailed)?;
116    }
117
118    // For Windows, just write the script as is
119    #[cfg(target_os = "windows")]
120    {
121        file.write_all(dedented.as_bytes())
122            .map_err(RunError::FileWriteFailed)?;
123    }
124
125    // Make the script executable (Unix only)
126    #[cfg(any(target_os = "macos", target_os = "linux"))]
127    {
128        use std::os::unix::fs::PermissionsExt;
129        let mut perms = fs::metadata(&script_path)
130            .map_err(|e| RunError::PermissionError(e))?
131            .permissions();
132        perms.set_mode(0o755); // rwxr-xr-x
133        fs::set_permissions(&script_path, perms).map_err(RunError::PermissionError)?;
134    }
135
136    Ok((script_path, interpreter, temp_dir))
137}
138
139/// Capture output from Child's stdio streams with optional printing
140fn handle_child_output(mut child: Child, silent: bool) -> Result<CommandResult, RunError> {
141    // Prepare to read stdout & stderr line-by-line
142    let stdout = child.stdout.take();
143    let stderr = child.stderr.take();
144
145    // Process stdout
146    let stdout_handle = if let Some(out) = stdout {
147        let reader = BufReader::new(out);
148        let silent_clone = silent;
149        // Spawn a thread to capture and optionally print stdout
150        Some(std::thread::spawn(move || {
151            let mut local_buffer = String::new();
152            for line in reader.lines() {
153                if let Ok(l) = line {
154                    // Print the line if not silent and flush immediately
155                    if !silent_clone {
156                        println!("{}", l);
157                        std::io::stdout().flush().unwrap_or(());
158                    }
159                    // Store it in our captured buffer
160                    local_buffer.push_str(&l);
161                    local_buffer.push('\n');
162                }
163            }
164            local_buffer
165        }))
166    } else {
167        None
168    };
169
170    // Process stderr
171    let stderr_handle = if let Some(err) = stderr {
172        let reader = BufReader::new(err);
173        let silent_clone = silent;
174        // Spawn a thread to capture and optionally print stderr
175        Some(std::thread::spawn(move || {
176            let mut local_buffer = String::new();
177            for line in reader.lines() {
178                if let Ok(l) = line {
179                    // Print the line if not silent and flush immediately
180                    if !silent_clone {
181                        // Print all stderr messages
182                        eprintln!("\x1b[31mERROR: {}\x1b[0m", l); // Red color for errors
183                        std::io::stderr().flush().unwrap_or(());
184                    }
185                    // Store it in our captured buffer
186                    local_buffer.push_str(&l);
187                    local_buffer.push('\n');
188                }
189            }
190            local_buffer
191        }))
192    } else {
193        None
194    };
195
196    // Wait for the child process to exit
197    let status = child.wait().map_err(|e| {
198        RunError::ChildProcessError(format!("Failed to wait on child process: {}", e))
199    })?;
200
201    // Join our stdout thread if it exists
202    let captured_stdout = if let Some(handle) = stdout_handle {
203        handle.join().unwrap_or_default()
204    } else {
205        "Failed to capture stdout".to_string()
206    };
207
208    // Join our stderr thread if it exists
209    let captured_stderr = if let Some(handle) = stderr_handle {
210        handle.join().unwrap_or_default()
211    } else {
212        "Failed to capture stderr".to_string()
213    };
214
215    // If the command failed, print the stderr if it wasn't already printed
216    if !status.success() && silent && !captured_stderr.is_empty() {
217        eprintln!("\x1b[31mCommand failed with error:\x1b[0m");
218        for line in captured_stderr.lines() {
219            eprintln!("\x1b[31m{}\x1b[0m", line);
220        }
221    }
222
223    // Return the command result
224    Ok(CommandResult {
225        stdout: captured_stdout,
226        stderr: captured_stderr,
227        success: status.success(),
228        code: status.code().unwrap_or(-1),
229    })
230}
231
232/// Processes Output structure from Command::output() into CommandResult
233fn process_command_output(
234    output: Result<Output, std::io::Error>,
235) -> Result<CommandResult, RunError> {
236    match output {
237        Ok(out) => {
238            let stdout = String::from_utf8_lossy(&out.stdout).to_string();
239            let stderr = String::from_utf8_lossy(&out.stderr).to_string();
240            // We'll collect stderr but not print it here
241            // It will be included in the error message if the command fails
242
243            // If the command failed, print a clear error message
244            if !out.status.success() {
245                eprintln!(
246                    "\x1b[31mCommand failed with exit code: {}\x1b[0m",
247                    out.status.code().unwrap_or(-1)
248                );
249            }
250
251            Ok(CommandResult {
252                stdout,
253                stderr,
254                success: out.status.success(),
255                code: out.status.code().unwrap_or(-1),
256            })
257        }
258        Err(e) => Err(RunError::CommandExecutionFailed(e)),
259    }
260}
261
262/// Common logic for running a command with optional silent mode
263fn run_command_internal(command: &str, silent: bool) -> Result<CommandResult, RunError> {
264    let mut parts = command.split_whitespace();
265    let cmd = match parts.next() {
266        Some(c) => c,
267        None => return Err(RunError::EmptyCommand),
268    };
269
270    let args: Vec<&str> = parts.collect();
271
272    // Spawn the child process with piped stdout & stderr
273    let child = Command::new(cmd)
274        .args(&args)
275        .stdout(Stdio::piped())
276        .stderr(Stdio::piped())
277        .spawn()
278        .map_err(RunError::CommandExecutionFailed)?;
279
280    handle_child_output(child, silent)
281}
282
283/// Execute a script with the given interpreter and path
284fn execute_script_internal(
285    interpreter: &str,
286    script_path: &Path,
287    silent: bool,
288) -> Result<CommandResult, RunError> {
289    #[cfg(target_os = "windows")]
290    let command_args = vec!["/c", script_path.to_str().unwrap_or("")];
291
292    #[cfg(any(target_os = "macos", target_os = "linux"))]
293    let command_args = vec!["-e", script_path.to_str().unwrap_or("")];
294
295    if silent {
296        // For silent execution, use output() which captures but doesn't display
297        let output = Command::new(interpreter).args(&command_args).output();
298
299        let result = process_command_output(output)?;
300
301        // If the script failed, return an error
302        if !result.success {
303            return Err(RunError::CommandFailed(format!(
304                "Script execution failed with exit code {}: {}",
305                result.code,
306                result.stderr.trim()
307            )));
308        }
309
310        Ok(result)
311    } else {
312        // For normal execution, spawn and handle the output streams
313        let child = Command::new(interpreter)
314            .args(&command_args)
315            .stdout(Stdio::piped())
316            .stderr(Stdio::piped())
317            .spawn()
318            .map_err(RunError::CommandExecutionFailed)?;
319
320        let result = handle_child_output(child, false)?;
321
322        // If the script failed, return an error
323        if !result.success {
324            return Err(RunError::CommandFailed(format!(
325                "Script execution failed with exit code {}: {}",
326                result.code,
327                result.stderr.trim()
328            )));
329        }
330
331        Ok(result)
332    }
333}
334
335/// Run a multiline script with optional silent mode
336fn run_script_internal(script: &str, silent: bool) -> Result<CommandResult, RunError> {
337    // Prepare the script file first to get the content with shebang
338    let (script_path, interpreter, _temp_dir) = prepare_script_file(script)?;
339
340    // Print the script being executed if not silent
341    if !silent {
342        println!("\x1b[36mExecuting script:\x1b[0m");
343
344        // Read the script file to get the content with shebang
345        if let Ok(script_content) = fs::read_to_string(&script_path) {
346            for (i, line) in script_content.lines().enumerate() {
347                println!("\x1b[36m{:3}: {}\x1b[0m", i + 1, line);
348            }
349        } else {
350            // Fallback to original script if reading fails
351            for (i, line) in script.lines().enumerate() {
352                println!("\x1b[36m{:3}: {}\x1b[0m", i + 1, line);
353            }
354        }
355
356        println!("\x1b[36m---\x1b[0m");
357    }
358
359    // _temp_dir is kept in scope until the end of this function to ensure
360    // it's not dropped prematurely, which would clean up the directory
361
362    // Execute the script and handle the result
363    let result = execute_script_internal(&interpreter, &script_path, silent);
364
365    // If there was an error, print a clear error message only if it's not a CommandFailed error
366    // (which would already have printed the stderr)
367    if let Err(ref e) = result {
368        if !matches!(e, RunError::CommandFailed(_)) {
369            eprintln!("\x1b[31mScript execution failed: {}\x1b[0m", e);
370        }
371    }
372
373    result
374}
375
376/// A builder for configuring and executing commands or scripts
377pub struct RunBuilder<'a> {
378    /// The command or script to run
379    cmd: &'a str,
380    /// Whether to return an error if the command fails (default: true)
381    die: bool,
382    /// Whether to suppress output to stdout/stderr (default: false)
383    silent: bool,
384    /// Whether to run the command asynchronously (default: false)
385    async_exec: bool,
386    /// Whether to log command execution (default: false)
387    log: bool,
388}
389
390impl<'a> RunBuilder<'a> {
391    /// Create a new RunBuilder with default settings
392    pub fn new(cmd: &'a str) -> Self {
393        Self {
394            cmd,
395            die: true,
396            silent: false,
397            async_exec: false,
398            log: false,
399        }
400    }
401
402    /// Set whether to return an error if the command fails
403    pub fn die(mut self, die: bool) -> Self {
404        self.die = die;
405        self
406    }
407
408    /// Set whether to suppress output to stdout/stderr
409    pub fn silent(mut self, silent: bool) -> Self {
410        self.silent = silent;
411        self
412    }
413
414    /// Set whether to run the command asynchronously
415    pub fn async_exec(mut self, async_exec: bool) -> Self {
416        self.async_exec = async_exec;
417        self
418    }
419
420    /// Set whether to log command execution
421    pub fn log(mut self, log: bool) -> Self {
422        self.log = log;
423        self
424    }
425
426    /// Execute the command or script with the configured options
427    pub fn execute(self) -> Result<CommandResult, RunError> {
428        let trimmed = self.cmd.trim();
429
430        // Log command execution if enabled
431        if self.log {
432            println!("\x1b[36m[LOG] Executing command: {}\x1b[0m", trimmed);
433        }
434
435        // Handle async execution
436        if self.async_exec {
437            let cmd_copy = trimmed.to_string();
438            let silent = self.silent;
439            let log = self.log;
440
441            // Spawn a thread to run the command asynchronously
442            thread::spawn(move || {
443                if log {
444                    println!("\x1b[36m[ASYNC] Starting execution\x1b[0m");
445                }
446
447                let result = if cmd_copy.contains('\n') {
448                    run_script_internal(&cmd_copy, silent)
449                } else {
450                    run_command_internal(&cmd_copy, silent)
451                };
452
453                if log {
454                    match &result {
455                        Ok(res) => {
456                            if res.success {
457                                println!("\x1b[32m[ASYNC] Command completed successfully\x1b[0m");
458                            } else {
459                                eprintln!(
460                                    "\x1b[31m[ASYNC] Command failed with exit code: {}\x1b[0m",
461                                    res.code
462                                );
463                            }
464                        }
465                        Err(e) => {
466                            eprintln!("\x1b[31m[ASYNC] Command failed with error: {}\x1b[0m", e);
467                        }
468                    }
469                }
470            });
471
472            // Return a placeholder result for async execution
473            return Ok(CommandResult {
474                stdout: String::new(),
475                stderr: String::new(),
476                success: true,
477                code: 0,
478            });
479        }
480
481        // Execute the command or script
482        let result = if trimmed.contains('\n') {
483            // This is a multiline script
484            run_script_internal(trimmed, self.silent)
485        } else {
486            // This is a single command
487            run_command_internal(trimmed, self.silent)
488        };
489
490        // Handle die=false: convert errors to CommandResult with success=false
491        match result {
492            Ok(res) => {
493                // If the command failed but die is false, print a warning
494                if !res.success && !self.die && !self.silent {
495                    eprintln!("\x1b[33mWarning: Command failed with exit code {} but 'die' is false\x1b[0m", res.code);
496                }
497                Ok(res)
498            }
499            Err(e) => {
500                // Print the error only if it's not a CommandFailed error
501                // (which would already have printed the stderr)
502                if !matches!(e, RunError::CommandFailed(_)) {
503                    eprintln!("\x1b[31mCommand error: {}\x1b[0m", e);
504                }
505
506                if self.die {
507                    Err(e)
508                } else {
509                    // Convert error to CommandResult with success=false
510                    Ok(CommandResult {
511                        stdout: String::new(),
512                        stderr: format!("Error: {}", e),
513                        success: false,
514                        code: -1,
515                    })
516                }
517            }
518        }
519    }
520}
521
522/// Create a new RunBuilder for executing a command or script
523pub fn run(cmd: &str) -> RunBuilder {
524    RunBuilder::new(cmd)
525}
526
527/// Run a command or multiline script with arguments
528pub fn run_command(command: &str) -> Result<CommandResult, RunError> {
529    run(command).execute()
530}
531
532/// Run a command or multiline script with arguments silently
533pub fn run_silent(command: &str) -> Result<CommandResult, RunError> {
534    run(command).silent(true).execute()
535}