osp_cli/ui/clipboard/
mod.rs1use std::fmt::{Display, Formatter};
7use std::io::{IsTerminal, Write};
8
9mod backend;
10
11#[cfg(test)]
12mod tests;
13
14#[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#[derive(Debug)]
36pub enum ClipboardError {
37 NoBackendAvailable {
39 attempts: Vec<String>,
41 },
42 SpawnFailed {
44 command: String,
46 reason: String,
48 },
49 CommandFailed {
51 command: String,
53 status: i32,
55 stderr: String,
57 },
58 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 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn with_osc52(mut self, enabled: bool) -> Self {
108 self.prefer_osc52 = enabled;
109 self
110 }
111
112 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}