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 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 }
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}