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