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)]
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#[derive(Debug)]
22pub enum ClipboardError {
23 NoBackendAvailable {
25 attempts: Vec<String>,
27 },
28 SpawnFailed {
30 command: String,
32 reason: String,
34 },
35 CommandFailed {
37 command: String,
39 status: i32,
41 stderr: String,
43 },
44 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 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn with_osc52(mut self, enabled: bool) -> Self {
94 self.prefer_osc52 = enabled;
95 self
96 }
97
98 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 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 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 }
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}