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)]
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#[derive(Debug)]
21pub enum ClipboardError {
22 NoBackendAvailable {
24 attempts: Vec<String>,
26 },
27 SpawnFailed {
29 command: String,
31 reason: String,
33 },
34 CommandFailed {
36 command: String,
38 status: i32,
40 stderr: String,
42 },
43 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 pub fn new() -> Self {
88 Self::default()
89 }
90
91 pub fn with_osc52(mut self, enabled: bool) -> Self {
93 self.prefer_osc52 = enabled;
94 self
95 }
96
97 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 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 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 }
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}