Skip to main content

cba/
broc.rs

1//! Utilities for (spawning) processes
2
3use crate::{StringError, bait::ResultExt, bog::BogOkExt, ebog};
4use cfg_if::cfg_if;
5use log::{debug, trace};
6use std::{
7    env,
8    ffi::{OsStr, OsString},
9    path::Path,
10    process::{Child, ChildStdout, Command, Stdio, exit},
11    sync::LazyLock,
12};
13
14#[easy_ext::ext(ChildExt)]
15impl Child {
16    pub fn wait_for_code(&mut self) -> i32 {
17        if let Some(status) = self.wait()._elog() {
18            status.code().unwrap_or(1)
19        } else {
20            1
21        }
22    }
23}
24
25#[easy_ext::ext(CommandExt)]
26impl Command {
27    /// Use [`SHELL`] to create a command from a shell script
28    /// On unix, the empty string is given to $0 so that subsequent args are fed to the script directly.
29    /// On windows (todo)
30    pub fn from_script(script: &str) -> Self {
31        let (shell, arg) = &*SHELL;
32
33        let mut ret = Command::new(shell);
34
35        ret.arg(arg).arg(script).arg(""); // the first argument is the program name which we leave empty
36
37        ret
38    }
39
40    pub fn with_arg<S: AsRef<OsStr>>(mut self, arg: S) -> Self {
41        self.arg(arg);
42        self
43    }
44
45    pub fn with_args<I, S>(mut self, args: I) -> Self
46    where
47        I: IntoIterator<Item = S>,
48        S: AsRef<OsStr>,
49    {
50        self.args(args);
51        self
52    }
53
54    /// Display the command.
55    /// Does not escape arguments.
56    pub fn display(&self) -> String {
57        std::iter::once(self.get_program())
58            .chain(self.get_args())
59            .map(|s| s.to_string_lossy())
60            .collect::<Vec<_>>()
61            .join(" ")
62    }
63
64    /// Detach the command.
65    /// Does nothing on unsupported platforms (not windows or unix).
66    pub fn detach(&mut self) -> &mut Self {
67        cfg_if! {
68            if #[cfg(unix)] {
69                use std::os::unix::process::CommandExt;
70
71                unsafe {
72                    self.pre_exec(|| {
73                        // POSIX.1-2017 describes `fork` as async-signal-safe with the following note:
74                        //
75                        // > While the fork() function is async-signal-safe, there is no way for
76                        // > an implementation to determine whether the fork handlers established by
77                        // > pthread_atfork() are async-signal-safe. [...] It is therefore undefined for the
78                        // > fork handlers to execute functions that are not async-signal-safe when fork()
79                        // > is called from a signal handler.
80                        //
81                        // POSIX.1-2024 removes this guarantee and introduces an async-signal-safe
82                        // replacement `_Fork`, which we'd like to use, but macOS doesn't support it yet.
83                        //
84                        // Since we aren't registering any fork handlers, and hopefully the OS doesn't
85                        // either, we're fine on systems compatible with POSIX.1-2017, which should be
86                        // enough for a long while. If this ever becomes a problem in the future, we should
87                        // be able to switch to `_Fork`.
88                        match libc::fork() {
89                            -1 => (),
90                            0 => (),
91                            _ => libc::_exit(0),
92                        }
93
94                        if libc::setsid() == -1 {
95                            // continue even if setsid fails
96                        }
97                        Ok(())
98                    });
99                }
100            } else if #[cfg(windows)] {
101                use std::os::windows::process::CommandExt;
102
103                const DETACHED_PROCESS: u32 = 0x00000008;
104                const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
105
106                self.creation_flags(
107                    DETACHED_PROCESS // detaches console like CREATE_NO_WINDOW, but also eliminates io
108                    | CREATE_NEW_PROCESS_GROUP
109                );
110            } else {
111                log::info!("Failed to detach: unsupported platform")
112            }
113        }
114
115        self
116    }
117
118    /// One-off spawn executable.
119    /// Logs the command in debug builds.
120    /// Prints error.
121    pub fn spawn_detached(&mut self) -> Option<Child> {
122        let ep = format!("Failed to spawn: {}", self.display());
123        debug!("Spawning detached: {self:?}");
124
125        self.stdin(Stdio::null())
126            .stdout(Stdio::null())
127            .stderr(Stdio::null())
128            .detach();
129
130        self.spawn().prefix(&ep)._ebog()
131    }
132
133    /// Spawn command with piped stdout.
134    /// Debug logs the command.
135    pub fn spawn_piped(&mut self) -> Result<ChildStdout, StringError> {
136        trace!("Spawning piped: {self:?}");
137
138        match self
139            .stdin(Stdio::null())
140            .stdout(Stdio::piped())
141            .stderr(Stdio::null())
142            .spawn()
143            .prefix(&format!("Failed to spawn: {}", self.display()))?
144            .stdout
145            .take()
146        {
147            Some(s) => Ok(s),
148            None => Err(format!("No stdout for {}.", self.display()).into()), // stdout failure has no reason suffix
149        }
150    }
151
152    /// Run command and return full stdout as a String.
153    /// If the command exits non-zero, returns an error including exit code and stderr.
154    pub fn read_to_string(&mut self) -> Result<String, StringError> {
155        trace!("Collecting output: {self:?}");
156
157        let output = self
158            .stdin(Stdio::null())
159            .stdout(Stdio::piped())
160            .stderr(Stdio::piped())
161            .output()
162            .prefix(&format!("Failed to spawn: {}", self.display()))?;
163
164        if !output.status.success() {
165            let stderr = String::from_utf8_lossy(&output.stderr);
166
167            let code = output
168                .status
169                .code()
170                .map_or("None".to_string(), |c| c.to_string());
171
172            return Err(format!(
173                "{} exited with code {}: {}",
174                self.display(),
175                code,
176                stderr.trim()
177            )
178            .into());
179        }
180
181        let stdout = String::from_utf8(output.stdout)
182            .map_err(|_| format!("{} produced non-UTF8 stdout", self.display()))?;
183
184        Ok(stdout)
185    }
186
187    /// Naive check of whether a command succeeds. (i.e. health check).
188    pub fn success(&mut self) -> bool {
189        self.stdout(Stdio::null())
190            .stderr(Stdio::null())
191            .status()
192            .map(|status| status.success())
193            .unwrap_or(false)
194    }
195
196    /// Platform-agnostic exec the command.
197    ///
198    /// Logs and displays errors.
199    pub fn _exec(&mut self) -> ! {
200        debug!("Becoming: {self:?}");
201
202        #[cfg(not(windows))]
203        {
204            // replace current process
205            use std::os::unix::process::CommandExt;
206            let err = self.exec();
207
208            ebog!("Could not exec {}: {err}", self.display());
209            exit(1)
210        }
211
212        #[cfg(windows)]
213        {
214            match self.status() {
215                Ok(status) => exit(
216                    status
217                        .code()
218                        .unwrap_or(if status.success() { 0 } else { 1 }),
219                ),
220                Err(err) => {
221                    ebog!("Could not exec {}: {err}", self.display());
222                    exit(1)
223                }
224            }
225        }
226    }
227
228    /// Spawn the command, with trace and error logging.
229    pub fn _spawn(&mut self) -> Option<Child> {
230        trace!("Spawning: {self:?}");
231        self.spawn()
232            .prefix(&format!("Could not spawn: {}", self.display()))
233            ._elog()
234    }
235}
236
237/// Join arguments into a single string
238/// Non-UTF-8 arguments are not escaped
239/// Todo: support windows
240pub fn format_sh_command(inputs: &[impl AsRef<OsStr>], double: bool) -> OsString {
241    let mut cmd = OsString::new();
242    let mut first = true;
243
244    for arg in inputs {
245        if !first {
246            cmd.push(" ");
247        }
248        first = false;
249
250        let os = arg.as_ref();
251
252        match os.to_str() {
253            Some(s) => {
254                if double {
255                    let escaped = s
256                        .replace("\\", "\\\\")
257                        .replace('\"', "\\\"")
258                        .replace("$", "\\$");
259                    cmd.push("\"");
260                    cmd.push(escaped);
261                    cmd.push("\"");
262                } else {
263                    let escaped = s.replace('\'', "'\\''");
264                    cmd.push("'");
265                    cmd.push(escaped);
266                    cmd.push("'");
267                }
268            }
269            None => {
270                // no shell-escape if not valid UTF-8 since there is no safe way to do it.
271                cmd.push(os);
272            }
273        }
274    }
275
276    cmd
277}
278
279pub fn display_sh_prog_and_args(prog: impl AsRef<OsStr>, args: &[impl AsRef<OsStr>]) -> String {
280    format_sh_command(
281        &{
282            let mut i = vec![prog.as_ref()];
283            i.extend(args.iter().map(|x| x.as_ref()));
284            i
285        },
286        false,
287    )
288    .to_string_lossy()
289    .to_string()
290}
291
292/// (shell path, shell arg)
293pub static SHELL: LazyLock<(String, String)> = LazyLock::new(|| {
294    #[cfg(windows)]
295    {
296        let path = env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
297
298        let lower = path.to_lowercase();
299        let flag = if lower.contains("powershell") || lower.contains("pwsh") {
300            "-Command".to_string()
301        } else {
302            "/C".to_string()
303        };
304        (path, flag)
305    }
306    #[cfg(unix)]
307    {
308        let path = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
309        let flag = "-c".to_string();
310        log::debug!("SHELL: {}, {}", path, flag);
311        (path, flag)
312    }
313});
314
315/// # Note
316/// AI generated cuz i dunno windows, seems fine?
317pub fn current_shell() -> String {
318    #[cfg(unix)]
319    {
320        if let Ok(shell) = env::var("SHELL") {
321            if let Some(name) = Path::new(&shell).file_name().and_then(|n| n.to_str()) {
322                return name.to_ascii_lowercase();
323            }
324        }
325    }
326
327    #[cfg(windows)]
328    {
329        // Prefer modern PowerShell if present
330        if let Ok(ps) = env::var("PSModulePath") {
331            if !ps.is_empty() {
332                return "pwsh".into();
333            }
334        }
335
336        if let Ok(comspec) = env::var("COMSPEC") {
337            if let Some(name) = Path::new(&comspec).file_name().and_then(|n| n.to_str()) {
338                return name.to_ascii_lowercase();
339            }
340        }
341    }
342
343    String::new()
344}
345
346pub fn tty_or_inherit() -> Stdio {
347    if let Ok(mut tty) = std::fs::File::open("/dev/tty") {
348        let _ = std::io::Write::flush(&mut tty); // does nothing but seems logical
349        Stdio::from(tty)
350    } else {
351        log::error!("Failed to open /dev/tty");
352        Stdio::inherit()
353    }
354}
355
356use std::{cell::RefCell, collections::HashMap};
357thread_local! {
358    static HAS_CACHE: RefCell<HashMap<String, bool>> = RefCell::new(HashMap::new());
359}
360
361pub fn has(name: &str) -> bool {
362    HAS_CACHE.with(|cache| {
363        let mut cache = cache.borrow_mut();
364        if let Some(&found) = cache.get(name) {
365            found
366        } else {
367            let found = which::which(name).is_ok();
368            cache.insert(name.to_owned(), found);
369            found
370        }
371    })
372}
373
374// ENV VARS
375pub type EnvVars = Vec<(String, String)>;
376
377#[macro_export]
378macro_rules! env_vars {
379    ($( $name:expr => $value:expr ),* $(,)?) => {
380        Vec::<(String, String)>::from([
381            $( ($name.into(), $value.into()) ),*
382            ]
383        )
384    };
385}