1use super::{utils::command::create_command, Action, ActionError};
2use crate::context::Context;
3use duct::Expression;
4use log::{debug, error, info};
5use std::io::{BufRead, BufReader};
6use thiserror::Error;
7
8const ACTION_NAME: &str = "SCRIPT";
9
10#[derive(Debug)]
17pub struct ScriptAction {
18 directory: String,
19 command: String,
20 script: Expression,
21 runs_in_shell: bool,
22}
23
24#[derive(Debug, Error)]
26pub enum ScriptError {
27 #[error("the command {0:?} cannot be parsed")]
29 CommandParseFailure(String),
30 #[error("the script cannot run: {0}")]
32 ScriptFailure(#[from] std::io::Error),
33 #[error("the script returned non-zero exit code {0}")]
36 NonZeroExitcode(i32),
37 #[error("the script returned invalid output")]
39 OutputFailure,
40}
41
42impl From<ScriptError> for ActionError {
43 fn from(value: ScriptError) -> Self {
44 match value {
45 ScriptError::CommandParseFailure(_)
46 | ScriptError::ScriptFailure(_)
47 | ScriptError::NonZeroExitcode(_)
48 | ScriptError::OutputFailure => ActionError::FailedAction(value.to_string()),
49 }
50 }
51}
52
53impl ScriptAction {
54 pub fn new(
56 directory: String,
57 original_command: String,
58 runs_in_shell: bool,
59 ) -> Result<Self, ScriptError> {
60 let (command, script) = create_command(&original_command, runs_in_shell)
61 .ok_or(ScriptError::CommandParseFailure(original_command))?;
62
63 let script = script
64 .env("CI", "true")
65 .env("GW_ACTION_NAME", ACTION_NAME)
66 .env("GW_DIRECTORY", &directory)
67 .stderr_to_stdout()
68 .stdout_capture()
69 .dir(&directory)
70 .unchecked();
71
72 Ok(ScriptAction {
73 directory,
74 command,
75 script,
76 runs_in_shell,
77 })
78 }
79
80 fn run_inner(&self, context: &Context) -> Result<(), ScriptError> {
81 let mut script = self.script.clone();
83
84 for (key, value) in context {
86 script = script.env(format!("GW_{key}"), value);
87 }
88
89 info!(
91 "Running script {:?} {}in {}.",
92 self.command,
93 if self.runs_in_shell {
94 "in a shell "
95 } else {
96 ""
97 },
98 self.directory,
99 );
100 let child = script.reader()?;
101
102 let reader = BufReader::new(&child).lines();
103 let command_id = self.command.as_str();
104 for line in reader {
105 match line {
106 Ok(line) => debug!("[{command_id}] {line}"),
107 Err(_) => debug!("[{command_id}] <output cannot be parsed>"),
108 }
109 }
110
111 if let Ok(Some(output)) = child.try_wait() {
112 if output.status.success() {
113 info!("Script {:?} finished successfully.", self.command);
114 Ok(())
115 } else {
116 Err(ScriptError::NonZeroExitcode(
117 output.status.code().unwrap_or(-1),
118 ))
119 }
120 } else {
121 Err(ScriptError::OutputFailure)
122 }
123 }
124}
125
126impl Action for ScriptAction {
127 fn run(&mut self, context: &Context) -> Result<(), ActionError> {
131 Ok(self.run_inner(context)?)
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use std::collections::HashMap;
139
140 fn validate_output<F>(command: &str, asserter: F)
141 where
142 F: Fn(Vec<&str>),
143 {
144 let command = format!("[{command}] ");
145 testing_logger::validate(|captured_logs| {
146 let output: Vec<&str> = captured_logs
147 .iter()
148 .filter_map(|line| {
149 if line.body.starts_with(&command) {
150 Some(line.body.as_str().trim_start_matches(&command))
151 } else {
152 None
153 }
154 })
155 .collect();
156
157 asserter(output);
158 });
159 }
160
161 const ECHO_TEST: &str = "echo test";
162 const EXIT_NONZERO: &str = "exit 1";
163
164 #[cfg(unix)]
165 const ECHO_INVALID_UNICODE: &str =
166 "python -c \"import sys; sys.stdout.buffer.write(b'\\xc3\\x28')\"; sys.stdout.flush()";
167 #[cfg(unix)]
168 const ECHO_STDERR: &str = "echo err >&2";
169 #[cfg(unix)]
170 const PRINTENV: &str = "printenv";
171
172 #[cfg(not(unix))]
173 const PRINTENV: &str = "set";
174
175 #[test]
176 fn it_should_create_new_script() {
177 let command = String::from(ECHO_TEST);
178 let action = ScriptAction::new(String::from("."), command, true).unwrap();
179
180 assert_eq!("echo", action.command);
181 assert_eq!(".", action.directory);
182 }
183
184 #[test]
185 fn it_should_fail_if_command_is_invalid() {
186 let result = ScriptAction::new(String::from("."), String::from("echo 'test"), false);
187
188 assert!(
189 matches!(result, Err(ScriptError::CommandParseFailure(_))),
190 "{result:?} should match CommandParseFailure"
191 );
192 }
193
194 #[test]
195 fn it_should_run_the_script() -> Result<(), ScriptError> {
196 testing_logger::setup();
197
198 let command = String::from(ECHO_TEST);
199 let action = ScriptAction::new(String::from("."), command, true)?;
200
201 let context: Context = HashMap::new();
202 action.run_inner(&context)?;
203
204 validate_output("echo", |lines| {
205 assert_eq!(vec!["test"], lines);
206 });
207
208 Ok(())
209 }
210
211 #[test]
212 fn it_should_set_the_env_vars() -> Result<(), ScriptError> {
213 testing_logger::setup();
214
215 let command = String::from(PRINTENV);
216 let action = ScriptAction::new(String::from("."), command, true)?;
217
218 let context: Context = HashMap::from([
219 ("TRIGGER_NAME", "TEST-TRIGGER".to_string()),
220 ("CHECK_NAME", "TEST-CHECK".to_string()),
221 ]);
222 action.run_inner(&context)?;
223
224 validate_output(PRINTENV, |lines| {
225 assert!(lines.contains(&"CI=true"));
226 assert!(lines.contains(&"GW_TRIGGER_NAME=TEST-TRIGGER"));
227 assert!(lines.contains(&"GW_CHECK_NAME=TEST-CHECK"));
228 assert!(lines.contains(&"GW_ACTION_NAME=SCRIPT"));
229 assert!(lines.contains(&"GW_DIRECTORY=."));
230 });
231
232 Ok(())
233 }
234
235 #[test]
236 fn it_should_keep_the_already_set_env_vars() -> Result<(), ScriptError> {
237 testing_logger::setup();
238
239 std::env::set_var("GW_TEST", "GW_TEST");
240
241 let command = String::from(PRINTENV);
242 let action = ScriptAction::new(String::from("."), command, true)?;
243
244 let context: Context = HashMap::new();
245 action.run_inner(&context)?;
246
247 validate_output(PRINTENV, |lines| {
248 assert!(lines.contains(&"GW_TEST=GW_TEST"));
249 });
250
251 Ok(())
252 }
253
254 #[test]
255 #[cfg(unix)]
256 fn it_should_catch_error_output() -> Result<(), ScriptError> {
257 testing_logger::setup();
258
259 let command = String::from(ECHO_STDERR);
260 let action = ScriptAction::new(String::from("."), command, true)?;
261
262 let context: Context = HashMap::new();
263 action.run_inner(&context)?;
264
265 validate_output("echo", |lines| {
266 assert_eq!(vec!["err"], lines);
267 });
268
269 Ok(())
270 }
271
272 #[test]
273 #[cfg(unix)]
274 fn it_should_record_if_the_script_returns_non_utf8() -> Result<(), ScriptError> {
275 testing_logger::setup();
276
277 let command = String::from(ECHO_INVALID_UNICODE);
278 let action = ScriptAction::new(String::from("."), command, false)?;
279
280 let context: Context = HashMap::new();
281 action.run_inner(&context)?;
282
283 validate_output("python", |lines| {
284 assert_eq!(vec!["<output cannot be parsed>"], lines);
285 });
286
287 Ok(())
288 }
289
290 #[test]
291 fn it_should_fail_if_the_script_fails() -> Result<(), ScriptError> {
292 let command = String::from(EXIT_NONZERO);
293 let action = ScriptAction::new(String::from("."), command, true)?;
294
295 let context: Context = HashMap::new();
296 let result = action.run_inner(&context);
297 assert!(
298 matches!(result, Err(ScriptError::NonZeroExitcode(1))),
299 "{result:?} should match non zero exit code"
300 );
301
302 Ok(())
303 }
304}