use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::OnceLock;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CopyCommand {
pub program: String,
pub args: Vec<String>,
pub source: CopySource,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CopySource {
Detected(&'static str),
Override,
}
impl CopyCommand {
fn new_detected(program: &str, args: &[&str], label: &'static str) -> Self {
Self {
program: program.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
source: CopySource::Detected(label),
}
}
fn new_override(program: String, args: Vec<String>) -> Self {
Self {
program,
args,
source: CopySource::Override,
}
}
pub fn label(&self) -> String {
match &self.source {
CopySource::Detected(name) => (*name).to_string(),
CopySource::Override => format!("override({})", self.program),
}
}
}
pub fn resolve(override_argv: Option<&[String]>) -> Option<CopyCommand> {
if let Some(argv) = override_argv {
if !argv.is_empty() {
let mut iter = argv.iter().cloned();
let program = iter.next()?;
if program.is_empty() {
return None;
}
let args: Vec<String> = iter.collect();
return Some(CopyCommand::new_override(program, args));
}
}
static AUTO: OnceLock<Option<CopyCommand>> = OnceLock::new();
AUTO.get_or_init(detect_chain).clone()
}
fn detect_chain() -> Option<CopyCommand> {
if env_set("WAYLAND_DISPLAY") && which("wl-copy").is_some() {
return Some(CopyCommand::new_detected("wl-copy", &[], "wl-copy"));
}
if env_set("DISPLAY") {
if which("xclip").is_some() {
return Some(CopyCommand::new_detected(
"xclip",
&["-selection", "clipboard"],
"xclip",
));
}
if which("xsel").is_some() {
return Some(CopyCommand::new_detected(
"xsel",
&["--clipboard", "--input"],
"xsel",
));
}
}
if cfg!(target_os = "macos") && which("pbcopy").is_some() {
return Some(CopyCommand::new_detected("pbcopy", &[], "pbcopy"));
}
None
}
fn env_set(name: &str) -> bool {
std::env::var_os(name)
.map(|v| !v.is_empty())
.unwrap_or(false)
}
fn which(program: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
let candidate = dir.join(program);
if is_executable(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(unix)]
fn is_executable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(path: &std::path::Path) -> bool {
path.is_file()
}
pub fn copy(text: &str, override_argv: Option<&[String]>) -> Result<String, ClipboardError> {
let cmd = resolve(override_argv).ok_or(ClipboardError::NoCommand)?;
let label = cmd.label();
let mut child = Command::new(&cmd.program)
.args(&cmd.args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| ClipboardError::Spawn {
program: cmd.program.clone(),
source: e,
})?;
{
let stdin = child
.stdin
.as_mut()
.ok_or(ClipboardError::StdinUnavailable)?;
stdin
.write_all(text.as_bytes())
.map_err(ClipboardError::Write)?;
}
let status = child.wait().map_err(ClipboardError::Wait)?;
if !status.success() {
return Err(ClipboardError::ExitStatus {
program: cmd.program.clone(),
code: status.code(),
});
}
Ok(label)
}
#[derive(Debug)]
pub enum ClipboardError {
NoCommand,
Spawn {
program: String,
source: std::io::Error,
},
StdinUnavailable,
Write(std::io::Error),
Wait(std::io::Error),
ExitStatus { program: String, code: Option<i32> },
}
impl std::fmt::Display for ClipboardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCommand => write!(f, "no clipboard command available"),
Self::Spawn { program, source } => {
write!(f, "failed to spawn {program}: {source}")
}
Self::StdinUnavailable => write!(f, "child stdin unavailable"),
Self::Write(e) => write!(f, "failed to write to clipboard: {e}"),
Self::Wait(e) => write!(f, "wait on clipboard child failed: {e}"),
Self::ExitStatus { program, code } => match code {
Some(c) => write!(f, "{program} exited with {c}"),
None => write!(f, "{program} terminated by signal"),
},
}
}
}
impl std::error::Error for ClipboardError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn override_with_program_and_args_is_used_verbatim() {
let argv = vec!["my-tool".to_string(), "--clip".to_string()];
let cmd = resolve(Some(&argv)).expect("override resolves");
assert_eq!(cmd.program, "my-tool");
assert_eq!(cmd.args, vec!["--clip".to_string()]);
assert_eq!(cmd.source, CopySource::Override);
}
#[test]
fn override_empty_array_falls_through_to_auto_detect() {
let empty: Vec<String> = Vec::new();
let a = resolve(Some(&empty));
let b = resolve(None);
assert_eq!(a, b);
}
#[test]
fn override_empty_program_is_rejected() {
let argv = vec!["".to_string()];
assert!(resolve(Some(&argv)).is_none());
}
#[test]
fn override_label_marks_user_supplied_source() {
let argv = vec!["pbcopy".to_string()];
let cmd = resolve(Some(&argv)).unwrap();
assert_eq!(cmd.label(), "override(pbcopy)");
}
#[test]
fn copy_with_missing_program_returns_spawn_error() {
let argv = vec!["this-binary-definitely-does-not-exist-zzz".to_string()];
let err = copy("hello", Some(&argv)).unwrap_err();
assert!(matches!(err, ClipboardError::Spawn { .. }));
}
}