Skip to main content

sh_exec/
lib.rs

1use colored::*;
2use shells::sh;
3use std::env;
4use std::fmt;
5
6/// Custom error type for shell command execution with beautiful formatting
7#[derive(Debug)]
8pub enum ShellExecError {
9    ExecutionFailed {
10        command: String,
11        exit_code: i32,
12        stderr: Option<String>,
13        stdout: Option<String>,
14        error_id: String,
15    },
16    EnvVarNotFound {
17        var_name: String,
18        error_id: String,
19        source: env::VarError,
20    },
21    Timeout {
22        command: String,
23        duration_ms: u64,
24        error_id: String,
25    },
26    JoinFailed {
27        command: String,
28        error_id: String,
29    },
30}
31
32// Implement Display manually to override thiserror's default formatting
33impl fmt::Display for ShellExecError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        write!(f, "{}", self.format_detailed())
36    }
37}
38
39// Implement std::error::Error manually since we're not using thiserror's Error derive
40impl std::error::Error for ShellExecError {
41    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
42        match self {
43            ShellExecError::EnvVarNotFound { source, .. } => Some(source),
44            _ => None,
45        }
46    }
47}
48
49impl ShellExecError {
50    /// Format the error with colors and detailed information
51    pub fn format_detailed(&self) -> String {
52        match self {
53            ShellExecError::ExecutionFailed {
54                command,
55                exit_code,
56                stderr,
57                stdout,
58                error_id,
59            } => {
60                let mut output = String::new();
61                output.push_str(&format!("{}\n", "Command Execution Failed".red().bold()));
62                output.push_str(&format!("  {}: {}\n", "Command".cyan(), command));
63                output.push_str(&format!("  {}: {}\n", "Exit Code".cyan(), exit_code));
64                output.push_str(&format!("  {}: {}\n", "Error ID".cyan(), error_id.green()));
65
66                if let Some(stdout_val) = stdout {
67                    if !stdout_val.is_empty() {
68                        output.push_str(&format!("\n  {}:\n", "Standard Output".yellow()));
69                        for line in stdout_val.lines() {
70                            output.push_str(&format!("    {}\n", line));
71                        }
72                    }
73                }
74
75                if let Some(stderr_val) = stderr {
76                    if !stderr_val.is_empty() {
77                        output.push_str(&format!("\n  {}:\n", "Standard Error".red()));
78                        for line in stderr_val.lines() {
79                            output.push_str(&format!("    {}\n", line));
80                        }
81                    }
82                }
83
84                output
85            }
86            ShellExecError::EnvVarNotFound {
87                var_name,
88                error_id,
89                source,
90            } => {
91                format!(
92                    "{}\n  {}: {}\n  {}: {}\n  {}: {:?}\n",
93                    "Environment Variable Not Found".red().bold(),
94                    "Variable".cyan(),
95                    var_name,
96                    "Error ID".cyan(),
97                    error_id.green(),
98                    "Reason".cyan(),
99                    source
100                )
101            }
102            ShellExecError::Timeout {
103                command,
104                duration_ms,
105                error_id,
106            } => {
107                format!(
108                    "{}\n  {}: {}\n  {}: {}ms\n  {}: {}\n",
109                    "Command Timed Out".red().bold(),
110                    "Command".cyan(),
111                    command,
112                    "Timeout".cyan(),
113                    duration_ms,
114                    "Error ID".cyan(),
115                    error_id.green()
116                )
117            }
118            ShellExecError::JoinFailed { command, error_id } => {
119                format!(
120                    "{}\n  {}: {}\n  {}: {}\n",
121                    "Thread Join Failed".red().bold(),
122                    "Command".cyan(),
123                    command,
124                    "Error ID".cyan(),
125                    error_id.green()
126                )
127            }
128        }
129    }
130}
131
132/// Result type alias for shell operations
133pub type ShellExecResult<T> = anyhow::Result<T>;
134
135/// Output from a shell command execution
136#[derive(Debug, Clone)]
137pub struct CommandOutput {
138    pub stdout: String,
139    pub stderr: String,
140    pub exit_code: i32,
141}
142
143impl CommandOutput {
144    /// Returns stdout if it's not empty, None otherwise
145    pub fn stdout(&self) -> Option<String> {
146        if self.stdout.is_empty() {
147            None
148        } else {
149            Some(self.stdout.clone())
150        }
151    }
152
153    /// Returns stderr if it's not empty, None otherwise
154    pub fn stderr(&self) -> Option<String> {
155        if self.stderr.is_empty() {
156            None
157        } else {
158            Some(self.stderr.clone())
159        }
160    }
161
162    /// Returns true if the command succeeded (exit code 0)
163    pub fn success(&self) -> bool {
164        self.exit_code == 0
165    }
166}
167
168/// Executes a shell command and returns a Result containing the command's output
169///
170/// # Arguments
171/// * `cmd` - The shell command to execute
172/// * `error_id` - A unique identifier for debugging purposes
173///
174/// # Returns
175/// * `Ok(String)` - The stdout of the command if successful
176/// * `Err(ShellError)` - An error with diagnostic information if the command failed
177pub fn execute_command(cmd: &str, error_id: &str) -> ShellExecResult<String> {
178    let output = execute_command_raw(cmd, error_id)?;
179    Ok(output.stdout)
180}
181
182/// Executes a shell command and returns the full output structure
183///
184/// # Arguments
185/// * `cmd` - The shell command to execute
186/// * `error_id` - A unique identifier for debugging purposes
187///
188/// # Returns
189/// * `Ok(CommandOutput)` - The full output including stdout, stderr, and exit code
190/// * `Err(ShellError)` - An error with diagnostic information if the command failed
191pub fn execute_command_raw(cmd: &str, error_id: &str) -> Result<CommandOutput, ShellExecError> {
192    let (code, stdout, stderr) = sh!("{}", cmd);
193
194    let output = CommandOutput {
195        stdout,
196        stderr,
197        exit_code: code,
198    };
199
200    if code == 0 {
201        Ok(output)
202    } else {
203        Err(ShellExecError::ExecutionFailed {
204            command: cmd.to_string(),
205            exit_code: code,
206            stderr: output.stderr(),
207            stdout: output.stdout(),
208            error_id: error_id.to_string(),
209        }
210        .into())
211    }
212}
213
214/// Read the content of a given environment variable
215///
216/// # Arguments
217/// * `var_name` - The name of the environment variable
218/// * `error_id` - A unique identifier for debugging purposes
219///
220/// # Returns
221/// * `Ok(String)` - The value of the environment variable
222/// * `Err(ShellError)` - An error if the variable is not set
223pub fn get_env(var_name: &str, error_id: &str) -> ShellExecResult<String> {
224    env::var(var_name).map_err(|e| {
225        ShellExecError::EnvVarNotFound {
226            var_name: var_name.to_string(),
227            error_id: error_id.to_string(),
228            source: e,
229        }
230        .into()
231    })
232}
233
234/// Read an environment variable with a default value if not set
235///
236/// # Arguments
237/// * `var_name` - The name of the environment variable
238/// * `default` - The default value to return if the variable is not set
239pub fn get_env_or(var_name: &str, default: &str) -> String {
240    env::var(var_name).unwrap_or_else(|_| default.to_string())
241}
242
243/// Main runner that handles errors with nice formatting
244///
245/// This function runs your main logic and prints diagnostic information
246/// if an error occurs, including cargo package metadata.
247pub fn run_with_diagnostics<F>(f: F)
248where
249    F: FnOnce() -> anyhow::Result<()>,
250{
251    if let Err(report) = f() {
252        eprintln!("\n{}", "=".repeat(80).red());
253        eprintln!("{}", "Application Error".red().bold());
254        eprintln!("{}", "=".repeat(80).red());
255        eprintln!();
256
257        // Try to get ShellError for better formatting
258        if let Some(shell_err) = report.downcast_ref::<ShellExecError>() {
259            eprintln!("{}", shell_err.format_detailed());
260        } else {
261            // Fall back to anyhow's formatting
262            eprintln!("{:?}", report);
263        }
264
265        eprintln!();
266        eprintln!("{}", "Package Information:".cyan().bold());
267        eprintln!("  Name:        {}", env!("CARGO_PKG_NAME"));
268        eprintln!("  Version:     {}", env!("CARGO_PKG_VERSION"));
269        eprintln!("  Authors:     {}", env!("CARGO_PKG_AUTHORS"));
270        eprintln!("  Description: {}", env!("CARGO_PKG_DESCRIPTION"));
271        eprintln!("  Homepage:    {}", env!("CARGO_PKG_HOMEPAGE"));
272        eprintln!("  Repository:  {}", env!("CARGO_PKG_REPOSITORY"));
273        eprintln!();
274        std::process::exit(1);
275    }
276}
277
278/// Macro to trap panics and errors with nice error messages
279///
280/// # Example
281/// ```ignore
282/// trap_panics_and_errors!("main-entry-point", || {
283///     // Your main logic here
284///     Ok(())
285/// });
286/// ```
287#[macro_export]
288macro_rules! trap_panics_and_errors {
289    ($error_id:expr, $main:expr) => {{
290        use colored::Colorize;
291        use std::panic;
292
293        let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
294            $crate::run_with_diagnostics(|| {
295                $main().map_err(|e: Box<dyn std::error::Error>| {
296                    anyhow::anyhow!("[{}] {}", $error_id, e)
297                })
298            });
299        }));
300
301        if let Err(panic_info) = result {
302            eprintln!("\n{}", "=".repeat(80).red().bold());
303            eprintln!("{}", "PANIC OCCURRED".red().bold());
304            eprintln!("{}", "=".repeat(80).red().bold());
305            eprintln!("Error ID: {}", $error_id.to_string().green());
306            eprintln!("Panic Info: {:?}", panic_info);
307            eprintln!();
308            eprintln!("{}", "Package Information:".cyan().bold());
309            eprintln!("  Name:    {}", env!("CARGO_PKG_NAME"));
310            eprintln!("  Version: {}", env!("CARGO_PKG_VERSION"));
311            eprintln!();
312            std::process::exit(101);
313        }
314    }};
315}
316
317/// Execute a shell command with optional verbose output
318///
319/// # Example
320/// ```ignore
321/// let output = exec!("test-001", true, "echo {}", "Hello World")?;
322/// ```
323#[macro_export]
324macro_rules! exec {
325    ($error_id:expr, $verbose:expr, $($cmd:tt)*) => {{
326        use colored::Colorize;
327        let formatted_str = format!($($cmd)*);
328        if $verbose {
329            eprintln!(
330                "{}",
331                format!("[{}] {}", $error_id, formatted_str).magenta()
332            );
333        }
334        $crate::execute_command(&formatted_str, $error_id)
335    }};
336}
337
338/// Execute a shell command with logging at INFO level
339///
340/// # Example
341/// ```ignore
342/// let output = s!("test-002", "ls -la")?;
343/// ```
344#[macro_export]
345macro_rules! s {
346    ($error_id:expr, $($cmd:tt)*) => {{
347        use colored::Colorize;
348        use log::{debug, info, error};
349        let formatted_str = format!($($cmd)*);
350        info!("{}", format!("[{}] Executing: {}", $error_id, formatted_str).magenta());
351
352        let result = $crate::execute_command(&formatted_str, $error_id);
353
354        match &result {
355            Ok(output) => debug!("Output: {}", output),
356            Err(e) => {
357                if let Some(shell_err) = e.downcast_ref::<$crate::ShellExecError>() {
358                    error!("{}", shell_err.format_detailed());
359                } else {
360                    error!("Error: {:?}", e);
361                }
362            }
363        }
364
365        result
366    }};
367}
368
369/// Execute a shell command and panic on error
370///
371/// # Example
372/// ```ignore
373/// let output = e!("echo Hello");
374/// ```
375#[macro_export]
376macro_rules! e {
377    ($($cmd:tt)*) => {{
378        let formatted_str = format!($($cmd)*);
379        $crate::execute_command(&formatted_str, "no-error-id")
380            .expect(&format!("Command failed: {}", formatted_str))
381    }};
382}
383
384/// Execute a shell command with a timeout
385///
386/// # Example
387/// ```ignore
388/// use std::time::Duration;
389/// let output = a!("test-003", Duration::from_secs(5), "sleep 2 && echo done")?;
390/// ```
391#[macro_export]
392macro_rules! a {
393    ($error_id:expr, $duration:expr, $($cmd:tt)*) => {{
394        use std::{thread, time};
395        use colored::Colorize;
396        use log::{debug, info, error};
397
398        let formatted_str = format!($($cmd)*);
399        info!("{}", format!("[{}] Executing with timeout: {}", $error_id, formatted_str).magenta());
400
401        let error_id_clone = $error_id.to_string();
402        let cmd_clone = formatted_str.clone();
403
404        let handle = thread::spawn(move || {
405            $crate::execute_command(&cmd_clone, &error_id_clone)
406        });
407
408        let check_interval = time::Duration::from_millis(10);
409        let start = time::Instant::now();
410
411        let result = loop {
412            if handle.is_finished() {
413                break match handle.join() {
414                    Ok(result) => {
415                        match &result {
416                            Ok(output) => debug!("Output: {}", output),
417                            Err(e) => {
418                                if let Some(shell_err) = e.downcast_ref::<$crate::ShellExecError>() {
419                                    error!("{}", shell_err.format_detailed());
420                                } else {
421                                    error!("Error: {:?}", e);
422                                }
423                            }
424                        }
425                        result
426                    }
427                    Err(_) => {
428                        Err($crate::ShellExecError::JoinFailed {
429                            command: formatted_str.clone(),
430                            error_id: $error_id.to_string(),
431                        }.into())
432                    }
433                };
434            }
435
436            thread::sleep(check_interval);
437
438            if start.elapsed() >= $duration {
439                // Thread will be abandoned but command may continue running
440                let duration_ms = $duration.as_millis() as u64;
441                break Err($crate::ShellExecError::Timeout {
442                    command: formatted_str.clone(),
443                    duration_ms,
444                    error_id: $error_id.to_string(),
445                }.into());
446            }
447        };
448
449        result
450    }};
451}
452
453/// Read input from stdin with a prompt
454///
455/// # Arguments
456/// * `prompt` - The prompt to display to the user
457///
458/// # Returns
459/// The user's input as a String (trimmed of whitespace)
460pub fn read_prompt(prompt: &str) -> String {
461    use std::io::{self, Write};
462
463    print!("{}", prompt);
464    io::stdout().flush().expect("Failed to flush stdout");
465
466    let mut buffer = String::new();
467    io::stdin()
468        .read_line(&mut buffer)
469        .expect("Failed to read from stdin");
470
471    buffer.trim().to_string()
472}
473
474/// Read input from stdin with a prompt, returning a Result
475///
476/// # Arguments
477/// * `prompt` - The prompt to display to the user
478///
479/// # Returns
480/// * `Ok(String)` - The user's input (trimmed)
481/// * `Err` - If reading from stdin fails
482pub fn read_prompt_result(prompt: &str) -> anyhow::Result<String> {
483    use std::io::{self, Write};
484
485    print!("{}", prompt);
486    io::stdout()
487        .flush()
488        .map_err(|e| anyhow::anyhow!("Failed to flush stdout: {}", e))?;
489
490    let mut buffer = String::new();
491    io::stdin()
492        .read_line(&mut buffer)
493        .map_err(|e| anyhow::anyhow!("Failed to read from stdin: {}", e))?;
494
495    Ok(buffer.trim().to_string())
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_successful_command() {
504        let output = execute_command("echo Hello World", "test-001").unwrap();
505        assert_eq!(output.trim(), "Hello World");
506    }
507
508    #[test]
509    fn test_successful_command_raw() {
510        let output = execute_command_raw("echo Hello World", "test-002").unwrap();
511        assert_eq!(output.stdout.trim(), "Hello World");
512        assert!(output.success());
513        assert!(output.stdout().is_some());
514    }
515
516    #[test]
517    fn test_exec_macro() {
518        let output = exec!("test-003", false, "echo {}", "Hello World").unwrap();
519        assert_eq!(output.trim(), "Hello World");
520    }
521
522    #[test]
523    fn test_e_macro() {
524        let output = e!("echo test");
525        assert_eq!(output.trim(), "test");
526    }
527
528    #[test]
529    fn test_failing_command() {
530        let result = execute_command("nonexistent_command_xyz", "test-004");
531        assert!(result.is_err());
532
533        if let Err(e) = result {
534            let error_string = format!("{:?}", e);
535            assert!(error_string.contains("nonexistent_command_xyz"));
536        }
537    }
538
539    #[test]
540    fn test_command_output_options() {
541        let output = execute_command_raw("echo test", "test-005").unwrap();
542        assert!(output.stdout().is_some());
543        assert_eq!(output.stdout().unwrap().trim(), "test");
544
545        // stderr should be empty for echo
546        assert!(output.stderr().is_none() || output.stderr().unwrap().is_empty());
547    }
548
549    #[test]
550    fn test_get_env_or() {
551        let value = get_env_or("NONEXISTENT_VAR_XYZ", "default_value");
552        assert_eq!(value, "default_value");
553
554        // Set an env var and test
555        unsafe { std::env::set_var("TEST_VAR_XYZ", "test_value") };
556        let value = get_env_or("TEST_VAR_XYZ", "default");
557        assert_eq!(value, "test_value");
558    }
559
560    #[test]
561    fn test_timeout_macro() {
562        use std::time::Duration;
563
564        // This should succeed (quick command)
565        let result = a!("test-006", Duration::from_secs(5), "echo fast");
566        assert!(result.is_ok());
567    }
568
569    #[test]
570    fn test_formatted_error() {
571        // Test that errors format nicely
572        let result = execute_command("nonexistent_xyz_123", "format-test");
573        assert!(result.is_err());
574
575        if let Err(e) = result {
576            if let Some(shell_err) = e.downcast_ref::<ShellExecError>() {
577                let formatted = shell_err.format_detailed();
578                assert!(formatted.contains("Command Execution Failed"));
579                assert!(formatted.contains("nonexistent_xyz_123"));
580                assert!(formatted.contains("format-test"));
581            }
582        }
583    }
584}