luff 0.2.1

Print files with formatting
Documentation
//! Clipboard operations for copying output
//!
//! This module provides cross-platform clipboard functionality using the
//! arboard crate. It handles copying formatted output to the system clipboard
//! with proper error handling for size limits and platform-specific issues.

use crate::error::ClipboardError;
use std::sync::mpsc;
use std::time::Duration;

/// Maximum clipboard content size (1GB) to prevent OOM.
///
/// This matches the documented hard limit in CLI help text.
/// The configurable `max_clipboard_mb` (default 100MB) is enforced
/// at the buffer layer; this constant is a last-resort safety net.
const CLIPBOARD_SIZE_LIMIT: usize = 1024 * 1024 * 1024;

/// Clipboard operation timeout (10 seconds) to prevent hangs
const CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(10);

/// Check whether content exceeds the clipboard size limit.
///
/// Extracted so the rejection path can be tested without allocating 1GB+.
const fn check_size_limit(content_len: usize, limit: usize) -> Result<(), ClipboardError> {
    if content_len > limit {
        return Err(ClipboardError::SizeExceeded { content_len, limit });
    }
    Ok(())
}

/// Copy content to the system clipboard with safety guards
///
/// This function attempts to copy the provided content to the system clipboard
/// with size limits and timeout protection to prevent resource exhaustion.
///
/// # Safety Features
///
/// - **Size limit**: Rejects content > 1GB before attempting copy
/// - **Timeout**: Aborts operation after 10 seconds to prevent hangs
/// - **Graceful degradation**: Returns clear error messages, doesn't panic
/// - **Cross-platform**: Handles platform-specific quirks
///
/// # Arguments
///
/// * `content` - The text content to copy to clipboard
///
/// # Errors
///
/// Returns a [`ClipboardError`] if:
/// - Content exceeds size limit ([`ClipboardError::SizeExceeded`])
/// - Clipboard is not available ([`ClipboardError::Access`])
/// - Operation times out ([`ClipboardError::Timeout`])
/// - Platform-specific clipboard API fails ([`ClipboardError::Write`])
/// - Thread infrastructure fails ([`ClipboardError::ThreadSpawn`], [`ClipboardError::ThreadLost`])
///
/// # Platform Notes
///
/// - **Linux**: Requires X11 or Wayland display server
/// - **macOS**: Uses `NSPasteboard` APIs
/// - **Windows**: Uses Win32 clipboard APIs
/// - **Headless**: Will fail gracefully with [`ClipboardError::Access`]
///
/// # Examples
///
/// ```no_run
/// use luff::cli::copy_to_clipboard;
///
/// let content = "Hello, clipboard!";
/// match copy_to_clipboard(content) {
///     Ok(()) => eprintln!("✓ Copied to clipboard"),
///     Err(e) => eprintln!("✗ Clipboard error: {}", e),
/// }
/// ```
pub fn copy_to_clipboard(content: &str) -> Result<(), ClipboardError> {
    // Pre-emptive size check to avoid OOM or hanging
    check_size_limit(content.len(), CLIPBOARD_SIZE_LIMIT)?;

    // Use a channel to communicate result from the thread
    // This allows us to implement a timeout on the main thread
    let (tx, rx) = mpsc::channel();
    let content = content.to_string();

    // Spawn clipboard operation in separate thread.
    // Clipboard APIs can block indefinitely on some platforms
    // (e.g. Linux X11 if clipboard is locked).
    //
    // We hold the JoinHandle so the thread is not fully detached.
    // On timeout we intentionally leak it — the thread may still be
    // blocked in the platform clipboard API, and there is no safe
    // way to cancel it. For a CLI process this is acceptable: the
    // OS reclaims everything on exit. On Linux/X11, arboard keeps
    // the clipboard alive via an event loop in this thread, so
    // letting it run until process exit is actually *required* for
    // the paste to survive.
    let join_handle = std::thread::Builder::new()
        .name("clipboard-writer".into())
        .spawn(move || {
            let result = (|| -> Result<(), ClipboardError> {
                let mut clipboard =
                    arboard::Clipboard::new().map_err(|e| ClipboardError::Access(e.to_string()))?;

                clipboard
                    .set_text(content)
                    .map_err(|e| ClipboardError::Write(e.to_string()))?;

                Ok(())
            })();

            // Send result back to main thread.
            // If receiver is dropped (timeout occurred), this fails
            // silently, which is expected.
            let _ = tx.send(result);
        })
        .map_err(ClipboardError::ThreadSpawn)?;

    // Wait for completion with timeout
    match rx.recv_timeout(CLIPBOARD_TIMEOUT) {
        Ok(result) => {
            // Thread completed within timeout — join it to propagate
            // any panic that happened after sending the result.
            if let Err(panic_payload) = join_handle.join() {
                std::panic::resume_unwind(panic_payload);
            }
            result
        }
        Err(mpsc::RecvTimeoutError::Timeout) => {
            // Intentionally leak the join handle. The thread may be
            // blocked in a platform API call with no cancellation
            // mechanism. It will be cleaned up on process exit.
            log::debug!(
                "Clipboard thread still running after {CLIPBOARD_TIMEOUT:?}; \
                 it will be cleaned up on process exit"
            );
            Err(ClipboardError::Timeout)
        }
        Err(mpsc::RecvTimeoutError::Disconnected) => {
            // Thread panicked before sending — propagate.
            match join_handle.join() {
                Err(panic_payload) => std::panic::resume_unwind(panic_payload),
                Ok(()) => Err(ClipboardError::ThreadLost),
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_clipboard_size_limit_matches_documented_hard_limit() {
        // The CLI help text documents "hard limit of 1GB". This constant
        // must agree with that documentation.
        assert_eq!(
            CLIPBOARD_SIZE_LIMIT,
            1024 * 1024 * 1024,
            "CLIPBOARD_SIZE_LIMIT must be 1GB to match documented hard limit"
        );
    }

    #[test]
    fn test_size_limit_rejects_oversized_content() {
        let result = check_size_limit(1001, 1000);
        assert!(
            matches!(
                result,
                Err(ClipboardError::SizeExceeded {
                    content_len: 1001,
                    limit: 1000
                })
            ),
            "Expected SizeExceeded {{ 1001, 1000 }}, got {result:?}"
        );
    }

    #[test]
    fn test_size_limit_accepts_content_at_boundary() {
        assert!(check_size_limit(1000, 1000).is_ok());
    }

    #[test]
    fn test_size_limit_accepts_content_under_limit() {
        assert!(check_size_limit(999, 1000).is_ok());
    }

    #[test]
    fn test_size_limit_accepts_empty() {
        assert!(check_size_limit(0, 1000).is_ok());
    }

    #[test]
    fn test_clipboard_with_small_content() {
        // This test may fail in CI environments without a display
        // That's expected and acceptable
        let content = "test content";
        let _ = copy_to_clipboard(content);
    }

    #[test]
    fn test_empty_content() {
        let _ = copy_to_clipboard("");
    }

    #[test]
    fn test_unicode_content() {
        let content = "Hello 世界 🌍 مرحبا";
        let _ = copy_to_clipboard(content);
    }

    #[test]
    fn test_multiline_content() {
        let content = "line 1\nline 2\nline 3";
        let _ = copy_to_clipboard(content);
    }
}