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