diff --git a/src/session.rs b/src/session.rs index 7f50954..10ce9f7 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,7 +1,5 @@ use std::fs; -use std::io::Write as _; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; use crate::adapters; use crate::config::AppConfig; @@ -558,7 +556,7 @@ fn cmd_session_share( }; if copy_url { - copy_to_clipboard(&url)?; + crate::utils::copy_to_clipboard(&url)?; } if open { crate::utils::open_url_in_default_browser(&url)?; @@ -923,25 +921,6 @@ fn ensure_parent_dir(path: &Path) -> Result<()> { Ok(()) } -fn copy_to_clipboard(text: &str) -> Result<()> { - let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") { - ("pbcopy", &[]) - } else if cfg!(target_os = "windows") { - ("clip.exe", &[]) - } else { - ("xclip", &["-selection", "clipboard"]) - }; - let mut child = Command::new(program).args(args).stdin(Stdio::piped()).spawn()?; - if let Some(stdin) = child.stdin.as_mut() { - stdin.write_all(text.as_bytes())?; - } - let status = child.wait()?; - if !status.success() { - anyhow::bail!("{program} exited with status {status}"); - } - Ok(()) -} - fn read_tldr_file(path: &Path) -> Option { match fs::read_to_string(path) { Ok(content) => { diff --git a/src/tui/app.rs b/src/tui/app.rs index 68356cc..8987f0d 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,6 +1,6 @@ -use std::io::Write; use std::path::Path; -use std::process::{Command, Stdio}; +#[cfg(test)] +use std::process::Command; use std::sync::mpsc; use std::time::{Duration, Instant}; @@ -2507,26 +2507,10 @@ impl App { } fn copy_to_clipboard(&mut self, text: &str) { - let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") { - ("pbcopy", &[]) - } else if cfg!(target_os = "windows") { - ("clip.exe", &[]) - } else { - ("xclip", &["-selection", "clipboard"]) - }; - - match Command::new(cmd).args(args).stdin(Stdio::piped()).spawn() { - Ok(mut child) => { - if let Some(ref mut stdin) = child.stdin { - let _ = stdin.write_all(text.as_bytes()); - } - let _ = child.wait(); - self.status_message = Some("Copied to clipboard".to_string()); - } - Err(_) => { - self.status_message = Some(format!("Failed to copy ({cmd} not found)")); - } - } + self.status_message = Some(match crate::utils::copy_to_clipboard(text) { + Ok(()) => "Copied to clipboard".to_string(), + Err(error) => error.to_string(), + }); } fn start_export(&mut self) { diff --git a/src/utils.rs b/src/utils.rs index ea2fcf1..e705fbd 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ -use std::process::Command; +use std::io::Write as _; +use std::process::{Command, Stdio}; pub(crate) fn open_url_in_default_browser(url: &str) -> anyhow::Result<()> { let (program, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") { @@ -15,6 +16,60 @@ pub(crate) fn open_url_in_default_browser(url: &str) -> anyhow::Result<()> { Ok(()) } +pub(crate) fn clipboard_candidates() -> &'static [(&'static str, &'static [&'static str])] { + if cfg!(target_os = "macos") { + &[("pbcopy", &[])] + } else if cfg!(target_os = "windows") { + &[("clip.exe", &[])] + } else { + &[ + ("wl-copy", &[]), + ("xclip", &["-selection", "clipboard"]), + ("xsel", &["--clipboard", "--input"]), + ] + } +} + +fn try_clipboard_candidates(candidates: &[(&str, &[&str])], text: &str) -> anyhow::Result<()> { + let mut last_error = None; + for (program, args) in candidates { + let mut child = match Command::new(program).args(*args).stdin(Stdio::piped()).spawn() { + Ok(child) => child, + Err(error) => { + last_error = Some(error.into()); + continue; + } + }; + let write_error = match child.stdin.take() { + Some(mut stdin) => stdin.write_all(text.as_bytes()).err().map(anyhow::Error::from), + None => Some(anyhow::anyhow!("{program} stdin unavailable")), + }; + let status = child.wait(); + if let Some(error) = write_error { + last_error = Some(anyhow::anyhow!("{program} stdin write failed: {error}")); + continue; + } + match status { + Ok(status) if status.success() => return Ok(()), + Ok(status) => { + last_error = Some(anyhow::anyhow!("{program} exited with status {status}")); + } + Err(error) => { + last_error = Some(anyhow::anyhow!("{program} wait failed: {error}")); + } + } + } + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no clipboard utility found"))) +} + +pub(crate) fn copy_to_clipboard(text: &str) -> anyhow::Result<()> { + try_clipboard_candidates(clipboard_candidates(), text).map_err(|error| { + anyhow::anyhow!( + "Failed to copy to clipboard (install wl-clipboard, xclip, or xsel): {error}" + ) + }) +} + pub(crate) fn format_age(started_at: i64) -> String { let now = chrono::Utc::now().timestamp_millis(); let diff_hours = (now - started_at) / (1000 * 3600); @@ -113,6 +168,61 @@ fn is_noise_first_message(content: &str) -> bool { mod tests { use super::*; + #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + fn clipboard_candidates_fall_back_wl_copy_then_xclip_then_xsel_on_linux() { + let candidates = clipboard_candidates(); + + assert_eq!( + candidates, + &[ + ("wl-copy", &[][..]), + ("xclip", &["-selection", "clipboard"][..]), + ("xsel", &["--clipboard", "--input"][..]), + ] + ); + } + + #[test] + #[cfg(unix)] + fn clipboard_candidates_fall_through_after_spawn_failure() { + let candidates: &[(&str, &[&str])] = &[ + ("recall-test-nonexistent-clipboard-tool-xyz", &[]), + ("sh", &["-c", "cat >/dev/null; exit 0"]), + ]; + + assert!(try_clipboard_candidates(candidates, "hello").is_ok()); + } + + #[test] + #[cfg(unix)] + fn clipboard_candidates_fall_through_after_nonzero_exit() { + let candidates: &[(&str, &[&str])] = + &[("sh", &["-c", "cat >/dev/null; exit 1"]), ("sh", &["-c", "cat >/dev/null; exit 0"])]; + + assert!(try_clipboard_candidates(candidates, "hello").is_ok()); + } + + #[test] + #[cfg(unix)] + fn clipboard_candidates_reject_partial_stdin_write() { + let candidates: &[(&str, &[&str])] = &[ + ("sh", &["-c", "dd bs=1 count=1 of=/dev/null 2>/dev/null"]), + ("sh", &["-c", "cat >/dev/null; exit 1"]), + ]; + let text = "x".repeat(1024 * 1024); + + assert!(try_clipboard_candidates(candidates, &text).is_err()); + } + + #[test] + #[cfg(unix)] + fn clipboard_candidates_error_when_all_fail() { + let candidates: &[(&str, &[&str])] = &[("sh", &["-c", "cat >/dev/null; exit 1"])]; + + assert!(try_clipboard_candidates(candidates, "hello").is_err()); + } + #[test] fn title_empty_input_returns_untitled() { assert_eq!(title_from_user_messages(&[]), "Untitled");