use std::ffi::OsStr;
use std::io;
use std::path::Path;
use std::process::{Command, Stdio};
pub(crate) fn opener_command() -> &'static str {
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(target_os = "linux")]
let cmd = "xdg-open";
#[cfg(target_os = "windows")]
let cmd = "explorer";
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let cmd = "xdg-open";
cmd
}
pub(crate) fn spawn_and_reap<F>(program: &str, args: &[&OsStr], on_failure: F) -> io::Result<()>
where
F: FnOnce(String) + Send + 'static,
{
let mut cmd = Command::new(program);
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let program = program.to_string();
std::thread::spawn(move || {
use std::io::Read;
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
let status = child.wait();
#[cfg(windows)]
{
let _ = status;
let _ = on_failure;
let _ = program;
}
#[cfg(not(windows))]
match status {
Ok(status) if status.success() => {}
Ok(status) => {
let code = status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string());
on_failure(describe_failure(&program, &code, &stderr));
}
Err(err) => on_failure(format!("{program} failed: {err}")),
}
});
Ok(())
}
#[cfg_attr(windows, allow(dead_code))]
fn describe_failure(program: &str, code: &str, stderr: &str) -> String {
const MAX_DETAIL: usize = 200;
let said = stderr
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
if said.is_empty() {
return format!("{program} exited {code}");
}
let mut detail = said;
if detail.chars().count() > MAX_DETAIL {
detail = detail.chars().take(MAX_DETAIL).collect::<String>() + "…";
}
format!("{program} exited {code}: {detail}")
}
pub(crate) fn open_detached<F>(target: &Path, on_failure: F) -> io::Result<()>
where
F: FnOnce(String) + Send + 'static,
{
spawn_and_reap(opener_command(), &[target.as_os_str()], on_failure)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::Duration;
#[cfg(unix)]
#[test]
fn non_zero_exit_reports_failure() {
let (tx, rx) = mpsc::channel();
spawn_and_reap(
"sh",
&[OsStr::new("-c"), OsStr::new("exit 3")],
move |detail| {
let _ = tx.send(detail);
},
)
.expect("spawn sh");
let detail = rx
.recv_timeout(Duration::from_secs(10))
.expect("failure callback must fire for a non-zero exit");
assert!(
detail.contains("exited 3"),
"detail must name the exit code, got: {detail}"
);
}
#[cfg(unix)]
#[test]
fn success_never_calls_back() {
let (tx, rx) = mpsc::channel::<String>();
spawn_and_reap(
"sh",
&[OsStr::new("-c"), OsStr::new("true")],
move |detail| {
let _ = tx.send(detail);
},
)
.expect("spawn sh");
assert!(
rx.recv_timeout(Duration::from_millis(500)).is_err(),
"a successful launch must not report a failure"
);
}
#[cfg(unix)]
#[test]
fn non_zero_exit_surfaces_as_launcher_failed_event() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
spawn_and_reap(
"sh",
&[OsStr::new("-c"), OsStr::new("exit 3")],
move |detail| {
let _ = tx.send(crate::tui::events::TuiEvent::LauncherFailed {
target: "notes.md".to_string(),
detail,
});
},
)
.expect("spawn sh");
let event = rx.blocking_recv().expect("an event must arrive");
match event {
crate::tui::events::TuiEvent::LauncherFailed { target, detail } => {
assert_eq!(target, "notes.md");
assert!(
detail.contains("exited 3"),
"detail must name the exit code, got: {detail}"
);
}
_ => panic!("a failed launcher must surface as LauncherFailed"),
}
}
#[cfg(unix)]
#[test]
fn failure_detail_carries_the_launchers_own_stderr() {
let (tx, rx) = mpsc::channel();
spawn_and_reap(
"sh",
&[
OsStr::new("-c"),
OsStr::new("echo 'no view rule for text/markdown' >&2; exit 4"),
],
move |detail| {
let _ = tx.send(detail);
},
)
.expect("spawn sh");
let detail = rx
.recv_timeout(Duration::from_secs(10))
.expect("failure callback must fire");
assert!(
detail.contains("exited 4"),
"detail must name the exit code, got: {detail}"
);
assert!(
detail.contains("no view rule for text/markdown"),
"detail must carry the launcher's stderr, got: {detail}"
);
}
#[test]
fn failure_detail_is_single_line_and_capped() {
assert_eq!(describe_failure("xdg-open", "3", ""), "xdg-open exited 3");
let multi = describe_failure("xdg-open", "3", "first\n\n second \n");
assert_eq!(multi, "xdg-open exited 3: first second");
assert!(!multi.contains('\n'), "detail must not carry newlines");
let long = describe_failure("xdg-open", "3", &"x".repeat(500));
assert!(
long.chars().count() <= "xdg-open exited 3: ".chars().count() + 201,
"detail must be capped, got {} chars",
long.chars().count()
);
}
#[test]
fn missing_program_is_a_spawn_error() {
let err = spawn_and_reap("opencrabs-no-such-launcher-xyz", &[], |_| {})
.expect_err("a missing program must fail at spawn");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
}