1use std::ffi::OsStr;
10use std::process::{Command, Stdio};
11
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
15pub struct Opener {
16 pub program: &'static str,
17 pub args: &'static [&'static str],
18}
19
20pub 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 "windows" => Opener {
40 program: "cmd",
41 args: &["/C", "start", ""],
42 },
43 _ => return None,
44 };
45 Some(opener)
46}
47
48pub fn opener() -> Option<Opener> {
50 opener_for(std::env::consts::OS)
51}
52
53pub 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
71fn 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 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}