Skip to main content

command_stream/zx/
shell.rs

1//! The [`Shell`] builder (zx `$`), its options and the scoped defaults used by
2//! [`within`], [`configure`] and [`cd`].
3
4use std::cell::RefCell;
5use std::collections::HashMap;
6use std::future::Future;
7use std::path::{Path, PathBuf};
8use std::sync::RwLock;
9use std::time::Duration;
10
11use once_cell::sync::Lazy;
12
13use super::error::ZxError;
14use super::kill::SIGTERM;
15use super::process::ProcessPromise;
16use super::util::{
17    build_cmd, parse_bool, parse_duration, quote, quote_powershell, to_camel_case, QuoteFn, ZxArg,
18};
19pub use crate::local_bin::PreferLocal;
20
21/// Prefix used by bash so that failures inside pipelines are not hidden.
22pub const BASH_PREFIX: &str = "set -euo pipefail;";
23/// Postfix used by PowerShell so that the exit code is propagated.
24pub const POWERSHELL_POSTFIX: &str = "; exit $LastExitCode";
25
26/// Options controlling how commands are run (the fields of zx `$`).
27#[derive(Debug, Clone)]
28pub struct Options {
29    /// Working directory; `None` means the process working directory.
30    pub cwd: Option<PathBuf>,
31    /// Full environment; `None` inherits the process environment.
32    pub env: Option<HashMap<String, String>>,
33    /// Shell executable; `None` means no shell is available.
34    pub shell: Option<String>,
35    /// Text prepended to every command.
36    pub prefix: String,
37    /// Text appended to every command.
38    pub postfix: String,
39    /// Log commands and their stdout to stderr.
40    pub verbose: bool,
41    /// Suppress all logging, including the echo of the command's stderr.
42    pub quiet: bool,
43    /// Resolve (return `Ok`) even when the command fails.
44    pub nothrow: bool,
45    /// Kill the command after this long.
46    pub timeout: Option<Duration>,
47    /// Signal used when the timeout fires.
48    pub timeout_signal: String,
49    /// Default signal for [`RunningProcess::kill`](super::RunningProcess::kill).
50    pub kill_signal: String,
51    /// Prepend local `node_modules/.bin` directories to `PATH`.
52    pub prefer_local: PreferLocal,
53    /// Data written to the command's stdin.
54    pub input: Option<Vec<u8>>,
55    /// Function used to quote interpolated arguments.
56    pub quote: QuoteFn,
57}
58
59impl Options {
60    /// Built-in defaults: bash (found via `PATH`, falling back to `sh`) with
61    /// the `set -euo pipefail;` prefix, everything else off.
62    pub fn builtin() -> Self {
63        let mut opts = Options {
64            cwd: None,
65            env: None,
66            shell: None,
67            prefix: String::new(),
68            postfix: String::new(),
69            verbose: false,
70            quiet: false,
71            nothrow: false,
72            timeout: None,
73            timeout_signal: SIGTERM.to_string(),
74            kill_signal: SIGTERM.to_string(),
75            prefer_local: PreferLocal::Off,
76            input: None,
77            quote,
78        };
79        opts.use_bash();
80        opts
81    }
82
83    /// Switch to bash: `which bash` (or `sh`), `set -euo pipefail;` prefix,
84    /// no postfix, [`quote`].
85    pub fn use_bash(&mut self) {
86        let bash = find_executable("bash");
87        self.prefix = if bash.is_some() {
88            BASH_PREFIX.to_string()
89        } else {
90            String::new()
91        };
92        self.shell = bash.or_else(|| find_executable("sh"));
93        self.postfix = String::new();
94        self.quote = quote;
95    }
96
97    /// Switch to `pwsh` with PowerShell quoting.
98    pub fn use_pwsh(&mut self) {
99        self.use_powershell_named("pwsh");
100    }
101
102    /// Switch to `powershell.exe` with PowerShell quoting.
103    pub fn use_powershell(&mut self) {
104        self.use_powershell_named("powershell.exe");
105    }
106
107    fn use_powershell_named(&mut self, name: &str) {
108        self.shell = Some(find_executable(name).unwrap_or_else(|| name.to_string()));
109        self.prefix = String::new();
110        self.postfix = POWERSHELL_POSTFIX.to_string();
111        self.quote = quote_powershell;
112    }
113
114    /// Apply `<prefix>*` environment variables (camel-cased) onto the options.
115    ///
116    /// Recognised names: `cwd`, `preferLocal`, `verbose`, `quiet`, `timeout`,
117    /// `timeoutSignal`, `killSignal`, `prefix`, `postfix`, `shell`. Empty
118    /// values and unknown names are ignored.
119    pub fn resolve_env<I, K, V>(&mut self, prefix: &str, env: I)
120    where
121        I: IntoIterator<Item = (K, V)>,
122        K: AsRef<str>,
123        V: AsRef<str>,
124    {
125        for (key, value) in env {
126            let (key, value) = (key.as_ref(), value.as_ref());
127            let Some(name) = key.strip_prefix(prefix) else {
128                continue;
129            };
130            if value.is_empty() {
131                continue;
132            }
133            let flag = || parse_bool(value).unwrap_or(false);
134            match to_camel_case(name).as_str() {
135                "cwd" => self.cwd = Some(PathBuf::from(value)),
136                "preferLocal" => {
137                    self.prefer_local = match parse_bool(value) {
138                        Some(true) => PreferLocal::Cwd,
139                        Some(false) => PreferLocal::Off,
140                        None => PreferLocal::Dirs(vec![PathBuf::from(value)]),
141                    }
142                }
143                "verbose" => self.verbose = flag(),
144                "quiet" => self.quiet = flag(),
145                "timeout" => self.timeout = parse_duration(value).ok(),
146                "timeoutSignal" => self.timeout_signal = value.to_string(),
147                "killSignal" => self.kill_signal = value.to_string(),
148                "prefix" => self.prefix = value.to_string(),
149                "postfix" => self.postfix = value.to_string(),
150                "shell" => self.shell = Some(value.to_string()),
151                _ => {}
152            }
153        }
154    }
155
156    /// The effective working directory for spawned commands.
157    pub fn effective_cwd(&self) -> PathBuf {
158        match &self.cwd {
159            Some(dir) if dir.is_absolute() => dir.clone(),
160            Some(dir) => std::env::current_dir().unwrap_or_default().join(dir),
161            None => std::env::current_dir().unwrap_or_default(),
162        }
163    }
164}
165
166impl Default for Options {
167    fn default() -> Self {
168        Self::builtin()
169    }
170}
171
172/// Locate an executable on `PATH`.
173pub fn find_executable(name: &str) -> Option<String> {
174    which::which(name)
175        .ok()
176        .map(|p| p.to_string_lossy().into_owned())
177}
178
179static GLOBAL: Lazy<RwLock<Options>> = Lazy::new(|| {
180    let mut opts = Options::builtin();
181    opts.resolve_env("ZX_", std::env::vars());
182    RwLock::new(opts)
183});
184
185tokio::task_local! {
186    static SCOPE: RefCell<Options>;
187}
188
189fn in_scope() -> bool {
190    SCOPE.try_with(|_| ()).is_ok()
191}
192
193/// Snapshot of the options active in the current [`within`] scope (or the
194/// global defaults, which honour `ZX_*` environment variables).
195pub fn current_options() -> Options {
196    SCOPE
197        .try_with(|scope| scope.borrow().clone())
198        .unwrap_or_else(|_| GLOBAL.read().unwrap_or_else(|e| e.into_inner()).clone())
199}
200
201/// Mutate the options of the current scope (zx `$.verbose = true`, ...).
202///
203/// Outside of [`within`] this changes the process-wide defaults.
204pub fn configure<R>(update: impl FnOnce(&mut Options) -> R) -> R {
205    if in_scope() {
206        SCOPE.with(|scope| update(&mut scope.borrow_mut()))
207    } else {
208        let mut global = GLOBAL.write().unwrap_or_else(|e| e.into_inner());
209        update(&mut global)
210    }
211}
212
213/// Run `fut` with a private copy of the current options, so that changes made
214/// inside (via [`configure`] or [`cd`]) do not leak out.
215///
216/// The scope is task-local: it follows `.await` points but is not inherited
217/// by `tokio::spawn`ed tasks.
218pub async fn within<F: Future>(fut: F) -> F::Output {
219    SCOPE.scope(RefCell::new(current_options()), fut).await
220}
221
222/// Synchronous flavour of [`within`].
223pub fn within_sync<R>(f: impl FnOnce() -> R) -> R {
224    SCOPE.sync_scope(RefCell::new(current_options()), f)
225}
226
227/// Switch the current scope to bash.
228pub fn use_bash() {
229    configure(Options::use_bash);
230}
231
232/// Switch the current scope to `pwsh`.
233pub fn use_pwsh() {
234    configure(Options::use_pwsh);
235}
236
237/// Switch the current scope to `powershell.exe`.
238pub fn use_powershell() {
239    configure(Options::use_powershell);
240}
241
242/// Change the working directory of the current scope (zx `cd()`).
243///
244/// Relative paths resolve against the scope's current directory. The process
245/// working directory is left alone so that parallel scopes do not interfere.
246/// A [`ProcessOutput`](super::ProcessOutput) can be passed by reference: its
247/// trimmed output is used as the path.
248pub fn cd<P: AsRef<Path>>(dir: P) -> Result<PathBuf, ZxError> {
249    let dir = dir.as_ref();
250    let base = current_options().effective_cwd();
251    let target = base.join(dir);
252    let resolved = std::fs::canonicalize(&target).map_err(|e| {
253        ZxError::new(format!(
254            "ENOENT: {}, chdir '{}' -> '{}'",
255            e,
256            base.display(),
257            dir.display()
258        ))
259    })?;
260    if !resolved.is_dir() {
261        return Err(ZxError::new(format!(
262            "ENOTDIR: not a directory, chdir '{}'",
263            dir.display()
264        )));
265    }
266    let opts = configure(|opts| {
267        opts.cwd = Some(resolved.clone());
268        opts.clone()
269    });
270    let dir = resolved.display().to_string();
271    super::log::log(
272        &super::log::LogEntry::Cd { dir },
273        opts.verbose && !opts.quiet,
274    );
275    Ok(resolved)
276}
277
278/// A command factory carrying [`Options`] (the zx `$`).
279///
280/// `Shell::new()` snapshots the current scope; the builder methods return a
281/// modified copy (zx presets such as `$({verbose: true})`).
282#[derive(Debug, Clone, Default)]
283pub struct Shell {
284    opts: Options,
285}
286
287macro_rules! setter {
288    ($(#[$doc:meta])* $name:ident: $ty:ty => |$o:ident, $v:ident| $body:expr) => {
289        $(#[$doc])*
290        pub fn $name(mut self, $v: $ty) -> Self {
291            let $o = &mut self.opts;
292            $body;
293            self
294        }
295    };
296}
297
298impl Shell {
299    /// A shell using the options of the current scope.
300    pub fn new() -> Self {
301        Self {
302            opts: current_options(),
303        }
304    }
305
306    /// A shell with explicit options.
307    pub fn with_options(opts: Options) -> Self {
308        Self { opts }
309    }
310
311    /// The options used by this shell.
312    pub fn options(&self) -> &Options {
313        &self.opts
314    }
315
316    /// Mutable access to the options.
317    pub fn options_mut(&mut self) -> &mut Options {
318        &mut self.opts
319    }
320
321    setter!(/// Set the working directory.
322        cwd: impl AsRef<Path> => |o, v| o.cwd = Some(v.as_ref().to_path_buf()));
323    setter!(/// Replace the whole environment.
324        env: HashMap<String, String> => |o, v| o.env = Some(v));
325    setter!(/// Set the shell executable.
326        shell: impl Into<String> => |o, v| o.shell = Some(v.into()));
327    setter!(/// Set the command prefix.
328        prefix: impl Into<String> => |o, v| o.prefix = v.into());
329    setter!(/// Set the command postfix.
330        postfix: impl Into<String> => |o, v| o.postfix = v.into());
331    setter!(/// Enable or disable verbose logging.
332        verbose: bool => |o, v| o.verbose = v);
333    setter!(/// Enable or disable quiet mode.
334        quiet: bool => |o, v| o.quiet = v);
335    setter!(/// Do not fail on non-zero exit codes.
336        nothrow: bool => |o, v| o.nothrow = v);
337    setter!(/// Kill commands after `timeout`.
338        timeout: Duration => |o, v| o.timeout = Some(v));
339    setter!(/// Signal used on timeout.
340        timeout_signal: impl Into<String> => |o, v| o.timeout_signal = v.into());
341    setter!(/// Default signal for `kill()`.
342        kill_signal: impl Into<String> => |o, v| o.kill_signal = v.into());
343    setter!(/// Prepend `<cwd>/node_modules/.bin` to `PATH` when `true`.
344        prefer_local: bool => |o, v| o.prefer_local = if v { PreferLocal::Cwd } else { PreferLocal::Off });
345    setter!(/// Prepend `<dir>/node_modules/.bin` for each of `dirs` to `PATH`.
346        prefer_local_dirs: Vec<PathBuf> => |o, v| o.prefer_local = PreferLocal::Dirs(v));
347    setter!(/// Data written to stdin of every command.
348        input: impl Into<Vec<u8>> => |o, v| o.input = Some(v.into()));
349    setter!(/// Use a custom quoting function.
350        quote_with: QuoteFn => |o, v| o.quote = v);
351
352    /// Set a single environment variable (seeding from the process environment
353    /// when no explicit environment was configured yet).
354    pub fn env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
355        self.opts
356            .env
357            .get_or_insert_with(|| std::env::vars().collect())
358            .insert(key.into(), value.into());
359        self
360    }
361
362    /// Switch to bash.
363    pub fn use_bash(mut self) -> Self {
364        self.opts.use_bash();
365        self
366    }
367
368    /// Switch to `pwsh`.
369    pub fn use_pwsh(mut self) -> Self {
370        self.opts.use_pwsh();
371        self
372    }
373
374    /// Switch to `powershell.exe`.
375    pub fn use_powershell(mut self) -> Self {
376        self.opts.use_powershell();
377        self
378    }
379
380    /// Build a command from template pieces and arguments (the zx tagged
381    /// template). Arguments are quoted with the shell's quote function.
382    pub fn cmd<S: AsRef<str>>(&self, pieces: &[S], args: &[ZxArg]) -> ProcessPromise {
383        let pieces: Vec<&str> = pieces.iter().map(|p| p.as_ref()).collect();
384        match build_cmd(self.opts.quote, &pieces, args) {
385            Ok(cmd) => ProcessPromise::new(self.opts.clone(), cmd),
386            Err(err) => ProcessPromise::failed(self.opts.clone(), err),
387        }
388    }
389
390    /// Build a command from a raw string (no interpolation, no quoting).
391    pub fn command(&self, cmd: impl Into<String>) -> ProcessPromise {
392        ProcessPromise::new(self.opts.clone(), cmd.into())
393    }
394}