Skip to main content

ite_cli/
runner.rs

1//! Subprocess boundary for configured shell bindings. The executable calls
2//! this module when `app` returns a shell effect; interaction policy remains in
3//! `app`, and terminal suspension/resumption remains in `main`.
4//!
5//! Commands run through `sh -c` with `$path` and `$relpath` exported. Background
6//! bindings detach from standard streams, while foreground bindings read from
7//! `/dev/tty` when ite itself consumed piped stdin.
8
9use std::ffi::OsStr;
10use std::io::IsTerminal;
11use std::process::{Command, ExitStatus, Stdio};
12
13/// Run `cmd` through `sh -c`. The focused node's action values are exported as
14/// `$path` and `$relpath`.
15///
16/// Foreground commands inherit the terminal and their exit status is returned;
17/// background commands are detached from stdio and `None` is returned.
18pub fn run_shell(
19    cmd: &str,
20    path: &OsStr,
21    relpath: &OsStr,
22    bg: bool,
23) -> std::io::Result<Option<ExitStatus>> {
24    let mut command = Command::new("sh");
25    command
26        .arg("-c")
27        .arg(cmd)
28        .env("path", path)
29        .env("relpath", relpath);
30    if bg {
31        command
32            .stdin(Stdio::null())
33            .stdout(Stdio::null())
34            .stderr(Stdio::null())
35            .spawn()?;
36        Ok(None)
37    } else {
38        // When JSON was piped in, fd 0 is an exhausted pipe; hand interactive
39        // commands the terminal instead, as fzf's execute action does.
40        if !std::io::stdin().is_terminal()
41            && let Ok(tty) = std::fs::File::open("/dev/tty")
42        {
43            command.stdin(Stdio::from(tty));
44        }
45        command.status().map(Some)
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn foreground_command_sees_path_env_vars() {
55        let dir = tempfile::tempdir().unwrap();
56        let out = dir.path().join("out");
57        let status = run_shell(
58            &format!(
59                "printf '%s\\n%s' \"$path\" \"$relpath\" > {}",
60                out.display()
61            ),
62            OsStr::new("/abs/some/file.txt"),
63            OsStr::new("some/file.txt"),
64            false,
65        )
66        .unwrap()
67        .expect("foreground returns a status");
68        assert!(status.success());
69        assert_eq!(
70            std::fs::read_to_string(out).unwrap(),
71            "/abs/some/file.txt\nsome/file.txt"
72        );
73    }
74
75    #[test]
76    fn foreground_reports_failure_status() {
77        let status = run_shell("exit 3", OsStr::new("/x"), OsStr::new("x"), false)
78            .unwrap()
79            .unwrap();
80        assert_eq!(status.code(), Some(3));
81    }
82
83    #[test]
84    fn background_command_detaches() {
85        let dir = tempfile::tempdir().unwrap();
86        let out = dir.path().join("out");
87        let result = run_shell(
88            &format!("echo \"$relpath\" > {}", out.display()),
89            OsStr::new("/abs/f"),
90            OsStr::new("f"),
91            true,
92        )
93        .unwrap();
94        assert!(result.is_none());
95        // The detached process still runs to completion.
96        for _ in 0..50 {
97            if out.exists() {
98                break;
99            }
100            std::thread::sleep(std::time::Duration::from_millis(10));
101        }
102        assert_eq!(std::fs::read_to_string(out).unwrap().trim(), "f");
103    }
104}