ferrous_actions/actions/
exec.rs

1use super::noop_stream;
2use super::push_line_splitter::PushLineSplitter;
3use crate::node;
4use crate::node::path::Path;
5use js_sys::{JsString, Object};
6use parking_lot::Mutex;
7use std::sync::Arc;
8use wasm_bindgen::closure::Closure;
9use wasm_bindgen::JsValue;
10
11#[derive(Debug, Clone, Copy)]
12enum StdioEnum {
13    Inherit,
14    Null,
15}
16
17/// Where output of a standard stream can be redirected
18#[derive(Debug, Clone, Copy)]
19pub struct Stdio {
20    inner: StdioEnum,
21}
22
23impl Stdio {
24    /// Constructs a `Stdio` which causes output to be discarded
25    pub fn null() -> Stdio {
26        Stdio { inner: StdioEnum::Null }
27    }
28
29    /// Constructs a `Stdio` which causes output to be send to the same location
30    /// as it would for the parent process
31    pub fn inherit() -> Stdio {
32        Stdio {
33            inner: StdioEnum::Inherit,
34        }
35    }
36}
37
38/// Work around for <https://github.com/FrancisRussell/ferrous-actions-dev/issues/81>
39struct StreamToLines {
40    splitter: Arc<Mutex<PushLineSplitter>>,
41    #[allow(clippy::type_complexity)]
42    callback: Arc<Box<dyn Fn(&str)>>,
43    closure: Closure<dyn Fn(JsValue)>,
44}
45
46impl StreamToLines {
47    #[allow(clippy::type_complexity)]
48    pub fn new(callback: Arc<Box<dyn Fn(&str)>>) -> StreamToLines {
49        let splitter: Arc<Mutex<PushLineSplitter>> = Arc::default();
50        let closure = {
51            let splitter = splitter.clone();
52            let callback = callback.clone();
53            Closure::new(move |data: JsValue| {
54                let data: js_sys::Uint8Array = data.into();
55                let mut splitter = splitter.lock();
56                let mut write_buffer = splitter.write_via_buffer(data.length() as usize);
57                data.copy_to(write_buffer.as_mut());
58                drop(write_buffer);
59                while let Some(line) = splitter.next_line() {
60                    callback(&line);
61                }
62            })
63        };
64        StreamToLines {
65            splitter,
66            callback,
67            closure,
68        }
69    }
70}
71
72impl Drop for StreamToLines {
73    fn drop(&mut self) {
74        let mut splitter = self.splitter.lock();
75        splitter.close();
76        while let Some(line) = splitter.next_line() {
77            (self.callback)(&line);
78        }
79    }
80}
81
82impl AsRef<JsValue> for StreamToLines {
83    fn as_ref(&self) -> &JsValue {
84        self.closure.as_ref()
85    }
86}
87
88/// Builder for executing a command
89pub struct Command {
90    path: Path,
91    args: Vec<JsString>,
92    #[allow(clippy::type_complexity)]
93    outline: Option<Arc<Box<dyn Fn(&str)>>>,
94    #[allow(clippy::type_complexity)]
95    errline: Option<Arc<Box<dyn Fn(&str)>>>,
96    stdout: Stdio,
97    stderr: Stdio,
98    cwd: Path,
99}
100
101impl Command {
102    /// Specified additional command arguments
103    pub fn args<I, S>(&mut self, args: I) -> &mut Command
104    where
105        I: IntoIterator<Item = S>,
106        S: Into<JsString>,
107    {
108        self.args.extend(args.into_iter().map(Into::into));
109        self
110    }
111
112    /// Specify a command argument
113    pub fn arg<S: Into<JsString>>(&mut self, arg: S) -> &mut Command {
114        self.args(std::iter::once(arg.into()));
115        self
116    }
117
118    /// Executes the command and returns the status code
119    pub async fn exec(&mut self) -> Result<i32, JsValue> {
120        let command = self.path.to_string();
121        let command = Self::escape_command(command.as_str());
122        let command: JsString = command.into();
123        let args: Vec<JsString> = self.args.iter().map(JsString::to_string).collect();
124        let options = js_sys::Map::new();
125        let listeners = js_sys::Map::new();
126
127        let outline_adapter = self.outline.clone().map(StreamToLines::new);
128        if let Some(callback) = &outline_adapter {
129            listeners.set(&"stdout".into(), callback.as_ref());
130        }
131        let errline_adapter = self.errline.clone().map(StreamToLines::new);
132        if let Some(callback) = &errline_adapter {
133            listeners.set(&"stderr".into(), callback.as_ref());
134        }
135
136        options.set(&"cwd".into(), &self.cwd.to_js_string());
137        let sink = noop_stream::Sink::default();
138        if let StdioEnum::Null = self.stdout.inner {
139            options.set(&"outStream".into(), sink.as_ref());
140        }
141        if let StdioEnum::Null = self.stderr.inner {
142            options.set(&"errStream".into(), sink.as_ref());
143        }
144
145        let listeners = Object::from_entries(&listeners).expect("Failed to convert listeners map to object");
146        options.set(&"listeners".into(), &listeners);
147        let options = Object::from_entries(&options).expect("Failed to convert options map to object");
148        let result = ffi::exec(&command, Some(args), &options).await.map(|r| {
149            #[allow(clippy::cast_possible_truncation)]
150            let code = r.as_f64().expect("exec didn't return a number") as i32;
151            code
152        });
153
154        // Be explicit about line-buffer flushing
155        drop(outline_adapter);
156        drop(errline_adapter);
157        result
158    }
159
160    /// Sets a callback to be called each time a new line is written to standard
161    /// output. Note that line splitting is done by an internal
162    /// re-implementation of line splitting and not the GitHub Actions
163    /// Toolkit one due to issues with the latter.
164    pub fn outline<F: Fn(&str) + 'static + Sync + Send>(&mut self, callback: F) -> &mut Command {
165        self.outline = Some(Arc::new(Box::new(callback)));
166        self
167    }
168
169    /// Sets a callback to be called each time a new line is written to standard
170    /// error. Note that line splitting is done by an internal re-implementation
171    /// of line splitting and not the GitHub Actions Toolkit one due to
172    /// issues with the latter.
173    pub fn errline<F: Fn(&str) + 'static + Sync + Send>(&mut self, callback: F) -> &mut Command {
174        self.errline = Some(Arc::new(Box::new(callback)));
175        self
176    }
177
178    /// Sets where standard output should be directed
179    pub fn stdout(&mut self, redirect: Stdio) -> &mut Command {
180        self.stdout = redirect;
181        self
182    }
183
184    /// Sets where standard error should be directed
185    pub fn stderr(&mut self, redirect: Stdio) -> &mut Command {
186        self.stderr = redirect;
187        self
188    }
189
190    /// Sets the current working directory of the command
191    pub fn current_dir(&mut self, path: &Path) -> &mut Command {
192        self.cwd = path.clone();
193        self
194    }
195
196    // Some bright spark had the idea of making an exec function that could both
197    // handle execvp and shell command style invocations rather than have two
198    // functions or some sort of flag to handle these different use cases.
199    // Consequently we now need to escape our command so the apparently bespoke
200    // unescaping strategy in `argStringToArray` will not mangle our command
201    // in the case it contains spaces or double quotes.
202    fn escape_command(command: &str) -> String {
203        let mut result = String::with_capacity(command.len());
204        // - Spaces must be located between quotes to not be considered a token
205        //   separator.
206        // - Outside of double quotes backslash is itself.
207        // - Within double quotes, backslash is itself unless followed by a double quote
208        //   in which case it is the double quote. This means double quotes cannot
209        //   surround a string-fragment containing a trailing backslash.
210        for c in command.chars() {
211            match c {
212                ' ' => result.push_str("\" \""),
213                '\"' => result.push_str("\"\\\""),
214                _ => result.push(c),
215            }
216        }
217        result
218    }
219}
220
221impl<'a> From<&'a Path> for Command {
222    /// Constructs a command that will execute the file at the specified path.
223    fn from(path: &'a Path) -> Command {
224        Command {
225            path: path.clone(),
226            args: Vec::new(),
227            outline: None,
228            errline: None,
229            stdout: Stdio::inherit(),
230            stderr: Stdio::inherit(),
231            cwd: node::process::cwd(),
232        }
233    }
234}
235
236/// Low level bindings to the GitHub Actions toolkit "exec" API
237pub mod ffi {
238    use js_sys::JsString;
239    use wasm_bindgen::prelude::*;
240
241    #[wasm_bindgen(module = "@actions/exec")]
242    extern "C" {
243        #[wasm_bindgen(catch)]
244        pub async fn exec(
245            comand_line: &JsString,
246            args: Option<Vec<JsString>>,
247            options: &JsValue,
248        ) -> Result<JsValue, JsValue>;
249    }
250}