Skip to main content

ite_cli/
opener.rs

1//! Subprocess boundary for the platform's default "open this path" program.
2//! `app` decides *what* to open; this module knows *how*, as one table arm per
3//! operating system.
4//!
5//! The opener is spawned detached from ite's standard streams: some handlers
6//! return at once and others linger for the lifetime of the application they
7//! launch, and neither should write over the TUI or hold up the event loop.
8
9use std::ffi::OsStr;
10use std::process::{Command, Stdio};
11
12/// The program that hands a path to a platform's default handler, plus the
13/// fixed arguments that precede the path.
14#[derive(Clone, Copy, PartialEq, Eq, Debug)]
15pub struct Opener {
16    pub program: &'static str,
17    pub args: &'static [&'static str],
18}
19
20/// The opener for an [`std::env::consts::OS`] value; `None` where ite does not
21/// know of one. Supporting another platform is one more arm.
22pub fn opener_for(os: &str) -> Option<Opener> {
23    let opener = match os {
24        "macos" => Opener {
25            program: "open",
26            args: &[],
27        },
28        "linux" | "freebsd" | "netbsd" | "openbsd" | "dragonfly" | "solaris" | "illumos" => {
29            Opener {
30                program: "xdg-open",
31                args: &[],
32            }
33        }
34        // `start` is a shell builtin rather than a program, and it reads a
35        // leading quoted argument as the new window's title; the empty string
36        // keeps the path a path. Untested: no Windows target ships today (see
37        // dist-workspace.toml), and whoever adds one should check how `cmd`
38        // re-splits the path it is handed.
39        "windows" => Opener {
40            program: "cmd",
41            args: &["/C", "start", ""],
42        },
43        _ => return None,
44    };
45    Some(opener)
46}
47
48/// The opener for the platform this binary runs on.
49pub fn opener() -> Option<Opener> {
50    opener_for(std::env::consts::OS)
51}
52
53/// Hand `path` to the platform's default handler. The error is a message fit
54/// for the user, not a cause to stop exploring.
55pub fn open(path: &OsStr) -> Result<(), String> {
56    open_with(opener(), path)
57}
58
59fn open_with(opener: Option<Opener>, path: &OsStr) -> Result<(), String> {
60    let opener =
61        opener.ok_or_else(|| format!("no default opener known for {}", std::env::consts::OS))?;
62    spawn(opener, path).map_err(|error| {
63        format!(
64            "cannot open {} with {}: {error}",
65            path.to_string_lossy(),
66            opener.program
67        )
68    })
69}
70
71/// Spawn `opener` on `path`, detached from ite's standard streams and left to
72/// outlive the event loop.
73fn spawn(opener: Opener, path: &OsStr) -> std::io::Result<()> {
74    Command::new(opener.program)
75        .args(opener.args)
76        .arg(path)
77        .stdin(Stdio::null())
78        .stdout(Stdio::null())
79        .stderr(Stdio::null())
80        .spawn()?;
81    Ok(())
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn each_known_platform_names_its_opener() {
90        assert_eq!(
91            opener_for("macos"),
92            Some(Opener {
93                program: "open",
94                args: &[]
95            })
96        );
97        assert_eq!(
98            opener_for("linux"),
99            Some(Opener {
100                program: "xdg-open",
101                args: &[]
102            })
103        );
104        assert_eq!(
105            opener_for("windows"),
106            Some(Opener {
107                program: "cmd",
108                args: &["/C", "start", ""]
109            })
110        );
111    }
112
113    #[test]
114    fn the_bsds_share_the_freedesktop_opener() {
115        for os in ["freebsd", "netbsd", "openbsd", "dragonfly", "illumos"] {
116            assert_eq!(opener_for(os).unwrap().program, "xdg-open", "{os}");
117        }
118    }
119
120    #[test]
121    fn an_unknown_platform_has_no_opener() {
122        assert_eq!(opener_for("haiku"), None);
123    }
124
125    #[test]
126    fn the_platform_ite_is_built_for_has_an_opener() {
127        assert!(opener().is_some(), "{}", std::env::consts::OS);
128    }
129
130    #[test]
131    fn the_path_is_the_final_argument_and_the_child_is_detached() {
132        let dir = tempfile::tempdir().unwrap();
133        let target = dir.path().join("marker");
134        // A stand-in opener: writes the path it was handed next to itself.
135        let opener = Opener {
136            program: "sh",
137            args: &["-c", "printf %s \"$0\" > \"$(dirname \"$0\")/out\""],
138        };
139
140        spawn(opener, target.as_os_str()).unwrap();
141
142        let out = dir.path().join("out");
143        for _ in 0..50 {
144            if out.exists() {
145                break;
146            }
147            std::thread::sleep(std::time::Duration::from_millis(10));
148        }
149        assert_eq!(
150            std::fs::read_to_string(out).unwrap(),
151            target.display().to_string()
152        );
153    }
154
155    #[test]
156    fn an_unstartable_opener_names_the_path_and_the_program() {
157        let error = open_with(
158            Some(Opener {
159                program: "ite-nonexistent-opener",
160                args: &[],
161            }),
162            OsStr::new("/some/file"),
163        )
164        .unwrap_err();
165        assert!(error.contains("/some/file"), "{error}");
166        assert!(error.contains("ite-nonexistent-opener"), "{error}");
167    }
168
169    #[test]
170    fn a_platform_without_an_opener_says_so_rather_than_guessing() {
171        let error = open_with(None, OsStr::new("/some/file")).unwrap_err();
172        assert!(error.contains("no default opener"), "{error}");
173    }
174}