1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
use super::{Action, ActionError};
use duct_sh::sh_dangerous;
use log::{debug, error};
use thiserror::Error;

/// An action to run a custom shell script.
///
/// The passed script is running in a subshell (`/bin/sh` on *nix, `cmd.exe` on Windows).
/// so it can use any feature in these shells: variable expansion, pipes, redirection.
/// Both the stdout and stderr will be captured and logged. If the script fails,
/// the failure will also be logged.
pub struct ScriptAction {
    directory: String,
    command: String,
}

/// Custom error describing the error cases for the ScriptAction.
#[derive(Debug, Error)]
pub enum ScriptError {
    /// The underlying Rust command creation failed. The parameter contains the error.
    #[error("the script cannot run: {0}")]
    ScriptFailure(#[from] std::io::Error),
    /// The script returned a non-zero exit code, usually meaning it failed to start
    /// or encountered an error. The parameters are the exit code and the failed output.
    #[error("the script returned non-zero exit code {0} with message: {1}")]
    NonZeroExitcode(i32, String),
    /// The script output contains non-UTF8 characters.
    #[error("the script returned invalid characters")]
    NonUtf8Return,
}

impl From<ScriptError> for ActionError {
    fn from(value: ScriptError) -> Self {
        match value {
            ScriptError::ScriptFailure(_)
            | ScriptError::NonZeroExitcode(_, _)
            | ScriptError::NonUtf8Return => ActionError::FailedAction(value.to_string()),
        }
    }
}

impl ScriptAction {
    /// Creates a new script to be started in the given directory.
    pub fn new(directory: String, command: String) -> Self {
        ScriptAction { directory, command }
    }

    fn run_inner(&self) -> Result<String, ScriptError> {
        // We can run `sh_dangerous`, because it is on the user's computer.
        let output = sh_dangerous(&self.command)
            .stderr_to_stdout()
            .stdout_capture()
            .dir(&self.directory)
            .unchecked()
            .run()?;

        let output_str =
            std::str::from_utf8(&output.stdout).map_err(|_| ScriptError::NonUtf8Return)?;
        let output_str = output_str.trim_end().to_string();

        if output.status.success() {
            Ok(output_str)
        } else {
            Err(ScriptError::NonZeroExitcode(
                output.status.code().unwrap_or(-1),
                output_str,
            ))
        }
    }
}

impl Action for ScriptAction {
    /// Run the script in a subshell (`/bin/sh` on *nix, `cmd.exe` on Windows).
    /// If the script fails to start, return a non-zero error code or prints non-utf8
    /// characters, this function will result in an error.
    fn run(&self) -> Result<(), ActionError> {
        debug!(
            "Running script: {} in directory {}.",
            self.command, self.directory
        );

        match self.run_inner() {
            Ok(result) => {
                debug!("Command success, output:");
                result.lines().for_each(|line| {
                    debug!("{line}");
                });
                Ok(())
            }
            Err(err) => {
                error!("Failed: {err}.");
                Err(err.into())
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_should_create_new_script() {
        let action = ScriptAction::new(String::from("."), String::from("echo test"));

        assert_eq!("echo test", action.command);
        assert_eq!(".", action.directory);
    }

    #[test]
    fn it_should_run_the_script() -> Result<(), ScriptError> {
        let action = ScriptAction::new(String::from("."), String::from("echo test"));

        let output = action.run_inner()?;
        assert_eq!("test", output);

        Ok(())
    }

    #[test]
    fn it_should_catch_error_output() -> Result<(), ScriptError> {
        let action = ScriptAction::new(String::from("."), String::from("echo err >&2"));

        let output = action.run_inner()?;
        assert_eq!("err", output);

        Ok(())
    }

    #[test]
    fn it_should_fail_if_the_script_fails() -> Result<(), ScriptError> {
        let action = ScriptAction::new(String::from("."), String::from("false"));

        let result = action.run_inner();
        assert!(
            matches!(result, Err(ScriptError::NonZeroExitcode(1, _))),
            "{result:?} should match non zero exit code"
        );

        Ok(())
    }

    #[test]
    fn it_should_fail_if_the_script_returns_non_utf8() -> Result<(), ScriptError> {
        let action =
            ScriptAction::new(String::from("."), String::from("/bin/echo -e '\\xc3\\x28'"));

        let result = action.run_inner();
        assert!(
            matches!(result, Err(ScriptError::NonUtf8Return)),
            "{result:?} should match non utf8 return"
        );

        Ok(())
    }
}