Skip to main content

osp_cli/ui/clipboard/
mod.rs

1//! Clipboard transport for the canonical UI pipeline.
2//!
3//! This module owns the side effect of copying plain text. It does not know how
4//! output gets rendered for copy/paste; that stays in the UI facade.
5
6use std::fmt::{Display, Formatter};
7use std::io::{IsTerminal, Write};
8
9mod backend;
10
11#[cfg(test)]
12mod tests;
13
14/// Clipboard service that tries OSC 52 and platform-specific clipboard helpers.
15#[derive(Debug, Clone)]
16#[must_use]
17pub struct ClipboardService {
18    prefer_osc52: bool,
19}
20
21#[derive(Debug, Clone)]
22struct ClipboardPlan {
23    use_osc52: bool,
24    attempts: Vec<String>,
25    commands: Vec<backend::ClipboardCommand>,
26}
27
28impl Default for ClipboardService {
29    fn default() -> Self {
30        Self { prefer_osc52: true }
31    }
32}
33
34/// Errors returned while copying rendered output to the clipboard.
35#[derive(Debug)]
36pub enum ClipboardError {
37    /// No supported clipboard backend was available.
38    NoBackendAvailable {
39        /// Backend attempts that were tried or skipped.
40        attempts: Vec<String>,
41    },
42    /// A clipboard helper process could not be spawned.
43    SpawnFailed {
44        /// Command that failed to start.
45        command: String,
46        /// Human-readable spawn failure reason.
47        reason: String,
48    },
49    /// A clipboard helper process exited with failure status.
50    CommandFailed {
51        /// Command that was run.
52        command: String,
53        /// Exit status code, or `1` when unavailable.
54        status: i32,
55        /// Standard error output captured from the helper.
56        stderr: String,
57    },
58    /// Local I/O failure while preparing or sending clipboard data.
59    Io(String),
60}
61
62impl Display for ClipboardError {
63    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
64        match self {
65            ClipboardError::NoBackendAvailable { attempts } => {
66                write!(
67                    f,
68                    "no clipboard backend available (tried: {})",
69                    attempts.join(", ")
70                )
71            }
72            ClipboardError::SpawnFailed { command, reason } => {
73                write!(f, "failed to start clipboard command `{command}`: {reason}")
74            }
75            ClipboardError::CommandFailed {
76                command,
77                status,
78                stderr,
79            } => {
80                if stderr.trim().is_empty() {
81                    write!(
82                        f,
83                        "clipboard command `{command}` failed with status {status}"
84                    )
85                } else {
86                    write!(
87                        f,
88                        "clipboard command `{command}` failed with status {status}: {}",
89                        stderr.trim()
90                    )
91                }
92            }
93            ClipboardError::Io(reason) => write!(f, "clipboard I/O error: {reason}"),
94        }
95    }
96}
97
98impl std::error::Error for ClipboardError {}
99
100impl ClipboardService {
101    /// Creates a clipboard service with the default backend order.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Enables or disables OSC 52 before falling back to external commands.
107    pub fn with_osc52(mut self, enabled: bool) -> Self {
108        self.prefer_osc52 = enabled;
109        self
110    }
111
112    /// Copies raw text to the clipboard.
113    ///
114    /// Returns an error if no backend succeeds or if a backend fails after starting.
115    pub fn copy_text(&self, text: &str) -> Result<(), ClipboardError> {
116        let plan = self.plan_copy(text, std::io::stdout().is_terminal());
117        if plan.use_osc52 {
118            return self.copy_via_osc52(text);
119        }
120        self.copy_with_commands(text, plan.attempts, plan.commands)
121    }
122
123    fn copy_via_osc52(&self, text: &str) -> Result<(), ClipboardError> {
124        let payload = backend::osc52_payload(text);
125        let mut stdout = std::io::stdout();
126        if let Err(err) = stdout.write_all(payload.as_bytes()) {
127            return Err(ClipboardError::Io(err.to_string()));
128        }
129        if let Err(err) = stdout.flush() {
130            return Err(ClipboardError::Io(err.to_string()));
131        }
132        Ok(())
133    }
134
135    fn plan_copy(&self, text: &str, stdout_is_tty: bool) -> ClipboardPlan {
136        let mut attempts = Vec::new();
137        let commands = backend::platform_backends();
138
139        if self.prefer_osc52 && stdout_is_tty && backend::osc52_enabled() {
140            let max_bytes = backend::osc52_max_bytes();
141            let encoded_len = backend::base64_encoded_len(text.len());
142            if encoded_len <= max_bytes {
143                attempts.push("osc52".to_string());
144                return ClipboardPlan {
145                    use_osc52: true,
146                    attempts,
147                    commands,
148                };
149            }
150            attempts.push(format!("osc52 (payload {encoded_len} > {max_bytes})"));
151        }
152
153        ClipboardPlan {
154            use_osc52: false,
155            attempts,
156            commands,
157        }
158    }
159
160    fn copy_with_commands(
161        &self,
162        text: &str,
163        mut attempts: Vec<String>,
164        commands: Vec<backend::ClipboardCommand>,
165    ) -> Result<(), ClipboardError> {
166        for command in commands {
167            attempts.push(command.command.to_string());
168            match backend::copy_via_command(command, text) {
169                Ok(()) => return Ok(()),
170                Err(ClipboardError::SpawnFailed { .. }) => continue,
171                Err(error) => return Err(error),
172            }
173        }
174
175        Err(ClipboardError::NoBackendAvailable { attempts })
176    }
177}