Skip to main content

osp_cli/ui/
clipboard.rs

1use std::fmt::{Display, Formatter};
2use std::io::{IsTerminal, Write};
3use std::process::{Command, Stdio};
4
5use crate::ui::{Document, RenderSettings, render_document_for_copy};
6
7/// Clipboard service that tries OSC 52 and platform-specific clipboard helpers.
8#[derive(Debug, Clone)]
9#[must_use]
10pub struct ClipboardService {
11    prefer_osc52: bool,
12}
13
14impl Default for ClipboardService {
15    fn default() -> Self {
16        Self { prefer_osc52: true }
17    }
18}
19
20/// Errors returned while copying rendered output to the clipboard.
21#[derive(Debug)]
22pub enum ClipboardError {
23    /// No supported clipboard backend was available.
24    NoBackendAvailable {
25        /// Backend attempts that were tried or skipped.
26        attempts: Vec<String>,
27    },
28    /// A clipboard helper process could not be spawned.
29    SpawnFailed {
30        /// Command that failed to start.
31        command: String,
32        /// Human-readable spawn failure reason.
33        reason: String,
34    },
35    /// A clipboard helper process exited with failure status.
36    CommandFailed {
37        /// Command that was run.
38        command: String,
39        /// Exit status code, or `1` when unavailable.
40        status: i32,
41        /// Standard error output captured from the helper.
42        stderr: String,
43    },
44    /// Local I/O failure while preparing or sending clipboard data.
45    Io(String),
46}
47
48impl Display for ClipboardError {
49    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
50        match self {
51            ClipboardError::NoBackendAvailable { attempts } => {
52                write!(
53                    f,
54                    "no clipboard backend available (tried: {})",
55                    attempts.join(", ")
56                )
57            }
58            ClipboardError::SpawnFailed { command, reason } => {
59                write!(f, "failed to start clipboard command `{command}`: {reason}")
60            }
61            ClipboardError::CommandFailed {
62                command,
63                status,
64                stderr,
65            } => {
66                if stderr.trim().is_empty() {
67                    write!(
68                        f,
69                        "clipboard command `{command}` failed with status {status}"
70                    )
71                } else {
72                    write!(
73                        f,
74                        "clipboard command `{command}` failed with status {status}: {}",
75                        stderr.trim()
76                    )
77                }
78            }
79            ClipboardError::Io(reason) => write!(f, "clipboard I/O error: {reason}"),
80        }
81    }
82}
83
84impl std::error::Error for ClipboardError {}
85
86impl ClipboardService {
87    /// Creates a clipboard service with the default backend order.
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Enables or disables OSC 52 before falling back to external commands.
93    pub fn with_osc52(mut self, enabled: bool) -> Self {
94        self.prefer_osc52 = enabled;
95        self
96    }
97
98    /// Copies raw text to the clipboard.
99    ///
100    /// Returns an error if no backend succeeds or if a backend fails after starting.
101    pub fn copy_text(&self, text: &str) -> Result<(), ClipboardError> {
102        let mut attempts = Vec::new();
103
104        if self.prefer_osc52 && std::io::stdout().is_terminal() && osc52_enabled() {
105            let max_bytes = osc52_max_bytes();
106            let encoded_len = base64_encoded_len(text.len());
107            if encoded_len <= max_bytes {
108                attempts.push("osc52".to_string());
109                self.copy_via_osc52(text)?;
110                return Ok(());
111            }
112            attempts.push(format!("osc52 (payload {encoded_len} > {max_bytes})"));
113        }
114
115        for backend in platform_backends() {
116            attempts.push(backend.command.to_string());
117            match copy_via_command(backend.command, backend.args, text) {
118                Ok(()) => return Ok(()),
119                Err(ClipboardError::SpawnFailed { .. }) => continue,
120                Err(error) => return Err(error),
121            }
122        }
123
124        Err(ClipboardError::NoBackendAvailable { attempts })
125    }
126
127    /// Renders a document for copy/paste and writes the resulting text to the clipboard.
128    pub fn copy_document(
129        &self,
130        document: &Document,
131        settings: &RenderSettings,
132    ) -> Result<(), ClipboardError> {
133        let text = render_document_for_copy(document, settings);
134        self.copy_text(&text)
135    }
136
137    fn copy_via_osc52(&self, text: &str) -> Result<(), ClipboardError> {
138        let encoded = base64_encode(text.as_bytes());
139        let payload = format!("\x1b]52;c;{encoded}\x07");
140        std::io::stdout()
141            .write_all(payload.as_bytes())
142            .map_err(|err| ClipboardError::Io(err.to_string()))?;
143        std::io::stdout()
144            .flush()
145            .map_err(|err| ClipboardError::Io(err.to_string()))?;
146        Ok(())
147    }
148}
149
150struct ClipboardBackend {
151    command: &'static str,
152    args: &'static [&'static str],
153}
154
155fn platform_backends() -> Vec<ClipboardBackend> {
156    let mut backends = Vec::new();
157
158    if cfg!(target_os = "macos") {
159        backends.push(ClipboardBackend {
160            command: "pbcopy",
161            args: &[],
162        });
163        return backends;
164    }
165
166    if cfg!(target_os = "windows") {
167        backends.push(ClipboardBackend {
168            command: "clip",
169            args: &[],
170        });
171        return backends;
172    }
173
174    if std::env::var("WAYLAND_DISPLAY").is_ok() {
175        backends.push(ClipboardBackend {
176            command: "wl-copy",
177            args: &[],
178        });
179    }
180
181    backends.push(ClipboardBackend {
182        command: "xclip",
183        args: &["-selection", "clipboard"],
184    });
185    backends.push(ClipboardBackend {
186        command: "xsel",
187        args: &["--clipboard", "--input"],
188    });
189
190    backends
191}
192
193fn copy_via_command(command: &str, args: &[&str], text: &str) -> Result<(), ClipboardError> {
194    let mut child = Command::new(command)
195        .args(args)
196        .stdin(Stdio::piped())
197        .stdout(Stdio::null())
198        .stderr(Stdio::piped())
199        .spawn()
200        .map_err(|err| ClipboardError::SpawnFailed {
201            command: command.to_string(),
202            reason: err.to_string(),
203        })?;
204
205    if let Some(stdin) = child.stdin.as_mut() {
206        stdin
207            .write_all(text.as_bytes())
208            .map_err(|err| ClipboardError::Io(err.to_string()))?;
209    }
210
211    let output = child
212        .wait_with_output()
213        .map_err(|err| ClipboardError::Io(err.to_string()))?;
214
215    if output.status.success() {
216        Ok(())
217    } else {
218        Err(ClipboardError::CommandFailed {
219            command: command.to_string(),
220            status: output.status.code().unwrap_or(1),
221            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
222        })
223    }
224}
225
226const BASE64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
227const OSC52_MAX_BYTES_DEFAULT: usize = 100_000;
228
229fn osc52_enabled() -> bool {
230    match std::env::var("OSC52") {
231        Ok(value) => {
232            let value = value.trim().to_ascii_lowercase();
233            !(value == "0" || value == "false" || value == "off")
234        }
235        Err(_) => true,
236    }
237}
238
239fn osc52_max_bytes() -> usize {
240    std::env::var("OSC52_MAX_BYTES")
241        .ok()
242        .and_then(|value| value.parse::<usize>().ok())
243        .filter(|value| *value > 0)
244        .unwrap_or(OSC52_MAX_BYTES_DEFAULT)
245}
246
247fn base64_encoded_len(input_len: usize) -> usize {
248    if input_len == 0 {
249        return 0;
250    }
251
252    input_len.div_ceil(3).saturating_mul(4)
253}
254
255fn base64_encode(input: &[u8]) -> String {
256    if input.is_empty() {
257        return String::new();
258    }
259
260    let mut output = String::with_capacity(input.len().div_ceil(3) * 4);
261    let mut index = 0usize;
262
263    while index < input.len() {
264        let b0 = input[index];
265        let b1 = input.get(index + 1).copied().unwrap_or(0);
266        let b2 = input.get(index + 2).copied().unwrap_or(0);
267
268        let chunk = ((b0 as u32) << 16) | ((b1 as u32) << 8) | (b2 as u32);
269
270        let i0 = ((chunk >> 18) & 0x3f) as usize;
271        let i1 = ((chunk >> 12) & 0x3f) as usize;
272        let i2 = ((chunk >> 6) & 0x3f) as usize;
273        let i3 = (chunk & 0x3f) as usize;
274
275        output.push(BASE64_TABLE[i0] as char);
276        output.push(BASE64_TABLE[i1] as char);
277
278        if index + 1 < input.len() {
279            output.push(BASE64_TABLE[i2] as char);
280        } else {
281            output.push('=');
282        }
283
284        if index + 2 < input.len() {
285            output.push(BASE64_TABLE[i3] as char);
286        } else {
287            output.push('=');
288        }
289
290        index += 3;
291    }
292
293    output
294}
295
296#[cfg(test)]
297mod tests {
298    use std::sync::Mutex;
299
300    use crate::core::output::OutputFormat;
301    use crate::ui::{
302        Document, RenderSettings,
303        document::{Block, LineBlock, LinePart},
304    };
305
306    use super::{
307        ClipboardError, ClipboardService, OSC52_MAX_BYTES_DEFAULT, base64_encode,
308        base64_encoded_len, copy_via_command, osc52_enabled, osc52_max_bytes, platform_backends,
309    };
310
311    fn env_lock() -> &'static Mutex<()> {
312        crate::tests::env_lock()
313    }
314
315    fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
316        env_lock()
317            .lock()
318            .unwrap_or_else(|poisoned| poisoned.into_inner())
319    }
320
321    fn set_path_for_test(value: Option<&str>) {
322        let key = "PATH";
323        // Safety: these tests mutate process-global environment state in a
324        // scoped setup/teardown pattern and do not spawn concurrent threads.
325        match value {
326            Some(value) => unsafe { std::env::set_var(key, value) },
327            None => unsafe { std::env::remove_var(key) },
328        }
329    }
330
331    fn set_env_for_test(key: &str, value: Option<&str>) {
332        match value {
333            Some(value) => unsafe { std::env::set_var(key, value) },
334            None => unsafe { std::env::remove_var(key) },
335        }
336    }
337
338    #[test]
339    fn base64_encoder_matches_known_values() {
340        assert_eq!(base64_encode(b""), "");
341        assert_eq!(base64_encode(b"f"), "Zg==");
342        assert_eq!(base64_encode(b"fo"), "Zm8=");
343        assert_eq!(base64_encode(b"foo"), "Zm9v");
344        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
345    }
346
347    #[test]
348    fn base64_length_and_env_helpers_behave_predictably() {
349        let _guard = acquire_env_lock();
350        assert_eq!(base64_encoded_len(0), 0);
351        assert_eq!(base64_encoded_len(1), 4);
352        assert_eq!(base64_encoded_len(4), 8);
353
354        let osc52_original = std::env::var("OSC52").ok();
355        let max_original = std::env::var("OSC52_MAX_BYTES").ok();
356
357        set_env_for_test("OSC52", Some("off"));
358        assert!(!osc52_enabled());
359        set_env_for_test("OSC52", Some("yes"));
360        assert!(osc52_enabled());
361
362        set_env_for_test("OSC52_MAX_BYTES", Some("4096"));
363        assert_eq!(osc52_max_bytes(), 4096);
364        set_env_for_test("OSC52_MAX_BYTES", Some("0"));
365        assert_eq!(osc52_max_bytes(), 100_000);
366
367        set_env_for_test("OSC52", osc52_original.as_deref());
368        set_env_for_test("OSC52_MAX_BYTES", max_original.as_deref());
369    }
370
371    #[test]
372    fn clipboard_error_display_covers_backend_spawn_and_status_cases() {
373        assert_eq!(
374            ClipboardError::NoBackendAvailable {
375                attempts: vec!["osc52".to_string(), "xclip".to_string()],
376            }
377            .to_string(),
378            "no clipboard backend available (tried: osc52, xclip)"
379        );
380        assert_eq!(
381            ClipboardError::SpawnFailed {
382                command: "xclip".to_string(),
383                reason: "missing".to_string(),
384            }
385            .to_string(),
386            "failed to start clipboard command `xclip`: missing"
387        );
388        assert_eq!(
389            ClipboardError::CommandFailed {
390                command: "xclip".to_string(),
391                status: 7,
392                stderr: "no display".to_string(),
393            }
394            .to_string(),
395            "clipboard command `xclip` failed with status 7: no display"
396        );
397        assert_eq!(
398            ClipboardError::Io("broken pipe".to_string()).to_string(),
399            "clipboard I/O error: broken pipe"
400        );
401    }
402
403    #[test]
404    fn command_backend_reports_success_and_failure() {
405        let _guard = acquire_env_lock();
406        copy_via_command("/bin/sh", &["-c", "cat >/dev/null"], "hello")
407            .expect("shell sink should succeed");
408
409        let err = copy_via_command("/bin/sh", &["-c", "echo nope >&2; exit 7"], "hello")
410            .expect_err("non-zero clipboard command should fail");
411        assert!(matches!(
412            err,
413            ClipboardError::CommandFailed {
414                status: 7,
415                ref stderr,
416                ..
417            } if stderr.contains("nope")
418        ));
419    }
420
421    #[test]
422    fn platform_backends_prefers_wayland_when_present() {
423        let _guard = acquire_env_lock();
424        let original = std::env::var("WAYLAND_DISPLAY").ok();
425        set_env_for_test("WAYLAND_DISPLAY", Some("wayland-0"));
426        let backends = platform_backends();
427        set_env_for_test("WAYLAND_DISPLAY", original.as_deref());
428
429        if cfg!(target_os = "windows") || cfg!(target_os = "macos") {
430            assert!(!backends.is_empty());
431        } else {
432            assert_eq!(backends[0].command, "wl-copy");
433        }
434    }
435
436    #[test]
437    fn copy_without_osc52_reports_no_backend_when_path_is_empty() {
438        let _guard = acquire_env_lock();
439        let key = "PATH";
440        let original = std::env::var(key).ok();
441        set_path_for_test(Some(""));
442
443        let service = ClipboardService::new().with_osc52(false);
444        let result = service.copy_text("hello");
445
446        if let Some(value) = original {
447            set_path_for_test(Some(&value));
448        } else {
449            set_path_for_test(None);
450        }
451
452        match result {
453            Err(ClipboardError::NoBackendAvailable { attempts }) => {
454                assert!(!attempts.is_empty());
455            }
456            Err(ClipboardError::SpawnFailed { .. }) => {
457                // Acceptable when command lookup fails immediately.
458            }
459            other => panic!("unexpected result: {other:?}"),
460        }
461    }
462
463    #[test]
464    fn copy_document_uses_same_backend_path() {
465        let _guard = acquire_env_lock();
466        let key = "PATH";
467        let original = std::env::var(key).ok();
468        set_path_for_test(Some(""));
469
470        let service = ClipboardService::new().with_osc52(false);
471        let document = Document {
472            blocks: vec![Block::Line(LineBlock {
473                parts: vec![LinePart {
474                    text: "hello".to_string(),
475                    token: None,
476                }],
477            })],
478        };
479        let result =
480            service.copy_document(&document, &RenderSettings::test_plain(OutputFormat::Table));
481
482        if let Some(value) = original {
483            set_path_for_test(Some(&value));
484        } else {
485            set_path_for_test(None);
486        }
487
488        assert!(matches!(
489            result,
490            Err(ClipboardError::NoBackendAvailable { .. })
491                | Err(ClipboardError::SpawnFailed { .. })
492        ));
493    }
494
495    #[test]
496    fn command_backend_reports_spawn_failure_for_missing_binary() {
497        let err = copy_via_command("/definitely/missing/clipboard-bin", &[], "hello")
498            .expect_err("missing binary should fail to spawn");
499        assert!(matches!(err, ClipboardError::SpawnFailed { .. }));
500    }
501
502    #[test]
503    fn platform_backends_include_x11_fallbacks_without_wayland() {
504        let _guard = acquire_env_lock();
505        let original = std::env::var("WAYLAND_DISPLAY").ok();
506        set_env_for_test("WAYLAND_DISPLAY", None);
507        let backends = platform_backends();
508        set_env_for_test("WAYLAND_DISPLAY", original.as_deref());
509
510        if !(cfg!(target_os = "windows") || cfg!(target_os = "macos")) {
511            let names = backends
512                .iter()
513                .map(|backend| backend.command)
514                .collect::<Vec<_>>();
515            assert!(names.contains(&"xclip"));
516            assert!(names.contains(&"xsel"));
517        }
518    }
519
520    #[test]
521    fn command_failure_without_stderr_uses_short_display() {
522        let err = ClipboardError::CommandFailed {
523            command: "xclip".to_string(),
524            status: 9,
525            stderr: String::new(),
526        };
527        assert_eq!(
528            err.to_string(),
529            "clipboard command `xclip` failed with status 9"
530        );
531    }
532
533    #[test]
534    fn osc52_helpers_respect_env_toggles_and_defaults() {
535        let _guard = acquire_env_lock();
536        let original_enabled = std::env::var("OSC52").ok();
537        let original_max = std::env::var("OSC52_MAX_BYTES").ok();
538
539        set_env_for_test("OSC52", Some("off"));
540        assert!(!osc52_enabled());
541        set_env_for_test("OSC52", Some("FALSE"));
542        assert!(!osc52_enabled());
543        set_env_for_test("OSC52", None);
544        assert!(osc52_enabled());
545
546        set_env_for_test("OSC52_MAX_BYTES", Some("2048"));
547        assert_eq!(osc52_max_bytes(), 2048);
548        set_env_for_test("OSC52_MAX_BYTES", Some("0"));
549        assert_eq!(osc52_max_bytes(), OSC52_MAX_BYTES_DEFAULT);
550        set_env_for_test("OSC52_MAX_BYTES", Some("wat"));
551        assert_eq!(osc52_max_bytes(), OSC52_MAX_BYTES_DEFAULT);
552
553        set_env_for_test("OSC52", original_enabled.as_deref());
554        set_env_for_test("OSC52_MAX_BYTES", original_max.as_deref());
555    }
556
557    #[test]
558    fn base64_helpers_cover_empty_and_padded_inputs() {
559        assert_eq!(base64_encoded_len(0), 0);
560        assert_eq!(base64_encoded_len(1), 4);
561        assert_eq!(base64_encoded_len(2), 4);
562        assert_eq!(base64_encoded_len(3), 4);
563        assert_eq!(base64_encoded_len(4), 8);
564
565        assert_eq!(base64_encode(b""), "");
566        assert_eq!(base64_encode(b"f"), "Zg==");
567        assert_eq!(base64_encode(b"fo"), "Zm8=");
568        assert_eq!(base64_encode(b"foo"), "Zm9v");
569    }
570
571    #[test]
572    fn clipboard_service_builders_toggle_osc52_preference() {
573        let default = ClipboardService::new();
574        assert!(default.prefer_osc52);
575
576        let disabled = ClipboardService::new().with_osc52(false);
577        assert!(!disabled.prefer_osc52);
578    }
579
580    #[test]
581    fn copy_via_osc52_writer_is_callable_unit() {
582        let _guard = acquire_env_lock();
583        ClipboardService::new()
584            .copy_via_osc52("ping")
585            .expect("osc52 writer should succeed on stdout");
586    }
587}