Skip to main content

tess/
clipboard.rs

1//! System-clipboard access by shelling out to OS tools. No external crates.
2//!
3//! macOS: pbcopy / pbpaste. Linux: wl-copy/wl-paste (Wayland) -> xclip -> xsel.
4//! A missing tool yields an Err the caller surfaces on the status line.
5
6use std::io::Write;
7use std::process::{Command, Stdio};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Tool { PbCopyPaste, WlClipboard, Xclip, Xsel }
11
12/// Probe for the first available clipboard tool, in preference order.
13pub fn detect() -> Option<Tool> {
14    // Probe with `command -v` (POSIX, present on macOS + Linux) rather than
15    // running the tool itself — avoids side effects and works when no
16    // clipboard server is reachable. Re-probed per call; fine at interactive cadence.
17    const CANDIDATES: &[(&str, Tool)] = &[
18        ("pbpaste", Tool::PbCopyPaste),
19        ("wl-paste", Tool::WlClipboard),
20        ("xclip", Tool::Xclip),
21        ("xsel", Tool::Xsel),
22    ];
23    for (probe, tool) in CANDIDATES {
24        if which(probe) { return Some(*tool); }
25    }
26    None
27}
28
29/// True if `bin` is found on PATH (uses `command -v` via the shell, which is
30/// available on both macOS and Linux). Avoids running the clipboard tool itself.
31fn which(bin: &str) -> bool {
32    Command::new("sh").arg("-c").arg(format!("command -v {bin}"))
33        .stdout(Stdio::null()).stderr(Stdio::null()).stdin(Stdio::null())
34        .status().map(|s| s.success()).unwrap_or(false)
35}
36
37fn read_cmd(tool: Tool) -> Command {
38    let mut c;
39    match tool {
40        Tool::PbCopyPaste => { c = Command::new("pbpaste"); }
41        Tool::WlClipboard => { c = Command::new("wl-paste"); c.arg("--no-newline"); }
42        Tool::Xclip => { c = Command::new("xclip"); c.args(["-selection","clipboard","-o"]); }
43        Tool::Xsel => { c = Command::new("xsel"); c.args(["--clipboard","--output"]); }
44    }
45    c
46}
47
48fn write_cmd(tool: Tool) -> Command {
49    let mut c;
50    match tool {
51        Tool::PbCopyPaste => { c = Command::new("pbcopy"); }
52        Tool::WlClipboard => { c = Command::new("wl-copy"); }
53        Tool::Xclip => { c = Command::new("xclip"); c.args(["-selection","clipboard"]); }
54        Tool::Xsel => { c = Command::new("xsel"); c.args(["--clipboard","--input"]); }
55    }
56    c
57}
58
59/// Read clipboard contents. Err string is human-facing (for the status line).
60pub fn read() -> Result<Vec<u8>, String> {
61    let tool = detect().ok_or("no clipboard tool found (need pbpaste/wl-paste/xclip/xsel)")?;
62    let out = read_cmd(tool).stderr(Stdio::piped()).stdout(Stdio::piped()).stdin(Stdio::null())
63        .output()
64        .map_err(|e| format!("clipboard read failed: {e}"))?;
65    if !out.status.success() {
66        let err = String::from_utf8_lossy(&out.stderr);
67        let err = err.trim();
68        return Err(if err.is_empty() {
69            "clipboard read failed (tool exited with error)".to_string()
70        } else {
71            format!("clipboard read failed: {err}")
72        });
73    }
74    Ok(out.stdout)
75}
76
77/// Write `bytes` to the clipboard. Err string is human-facing.
78pub fn write(bytes: &[u8]) -> Result<(), String> {
79    let tool = detect().ok_or("no clipboard tool found (need pbcopy/wl-copy/xclip/xsel)")?;
80    let mut child = write_cmd(tool).stdin(Stdio::piped())
81        .stdout(Stdio::null()).stderr(Stdio::piped()).spawn()
82        .map_err(|e| format!("clipboard write failed: {e}"))?;
83    // Write then drop stdin so the tool sees EOF before we wait — otherwise
84    // wait_with_output could deadlock on a tool that drains stdin to completion.
85    let mut stdin = child.stdin.take().ok_or("clipboard stdin unavailable")?;
86    stdin.write_all(bytes).map_err(|e| format!("clipboard write failed: {e}"))?;
87    drop(stdin);
88    let out = child.wait_with_output().map_err(|e| format!("clipboard write failed: {e}"))?;
89    if !out.status.success() {
90        let err = String::from_utf8_lossy(&out.stderr);
91        let err = err.trim();
92        return Err(if err.is_empty() {
93            "clipboard write failed (tool exited with error)".to_string()
94        } else {
95            format!("clipboard write failed: {err}")
96        });
97    }
98    Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    #[test]
105    fn read_write_cmd_programs_distinct_per_tool() {
106        for t in [Tool::PbCopyPaste, Tool::WlClipboard, Tool::Xclip, Tool::Xsel] {
107            assert!(!read_cmd(t).get_program().is_empty());
108            assert!(!write_cmd(t).get_program().is_empty());
109        }
110    }
111    #[test]
112    fn xclip_read_uses_output_flag() {
113        let c = read_cmd(Tool::Xclip);
114        let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
115        assert!(args.contains(&"-o".to_string()));
116    }
117}