use std::fmt::{Display, Formatter};
use std::io::{IsTerminal, Write};
mod backend;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone)]
#[must_use]
pub struct ClipboardService {
prefer_osc52: bool,
}
#[derive(Debug, Clone)]
struct ClipboardPlan {
use_osc52: bool,
attempts: Vec<String>,
commands: Vec<backend::ClipboardCommand>,
}
impl Default for ClipboardService {
fn default() -> Self {
Self { prefer_osc52: true }
}
}
#[derive(Debug)]
pub enum ClipboardError {
NoBackendAvailable {
attempts: Vec<String>,
},
SpawnFailed {
command: String,
reason: String,
},
CommandFailed {
command: String,
status: i32,
stderr: String,
},
Io(String),
}
impl Display for ClipboardError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ClipboardError::NoBackendAvailable { attempts } => {
write!(
f,
"no clipboard backend available (tried: {})",
attempts.join(", ")
)
}
ClipboardError::SpawnFailed { command, reason } => {
write!(f, "failed to start clipboard command `{command}`: {reason}")
}
ClipboardError::CommandFailed {
command,
status,
stderr,
} => {
if stderr.trim().is_empty() {
write!(
f,
"clipboard command `{command}` failed with status {status}"
)
} else {
write!(
f,
"clipboard command `{command}` failed with status {status}: {}",
stderr.trim()
)
}
}
ClipboardError::Io(reason) => write!(f, "clipboard I/O error: {reason}"),
}
}
}
impl std::error::Error for ClipboardError {}
impl ClipboardService {
pub fn new() -> Self {
Self::default()
}
pub fn with_osc52(mut self, enabled: bool) -> Self {
self.prefer_osc52 = enabled;
self
}
pub fn copy_text(&self, text: &str) -> Result<(), ClipboardError> {
let plan = self.plan_copy(text, std::io::stdout().is_terminal());
if plan.use_osc52 {
return self.copy_via_osc52(text);
}
self.copy_with_commands(text, plan.attempts, plan.commands)
}
fn copy_via_osc52(&self, text: &str) -> Result<(), ClipboardError> {
let payload = backend::osc52_payload(text);
let mut stdout = std::io::stdout();
if let Err(err) = stdout.write_all(payload.as_bytes()) {
return Err(ClipboardError::Io(err.to_string()));
}
if let Err(err) = stdout.flush() {
return Err(ClipboardError::Io(err.to_string()));
}
Ok(())
}
fn plan_copy(&self, text: &str, stdout_is_tty: bool) -> ClipboardPlan {
let mut attempts = Vec::new();
let commands = backend::platform_backends();
if self.prefer_osc52 && stdout_is_tty && backend::osc52_enabled() {
let max_bytes = backend::osc52_max_bytes();
let encoded_len = backend::base64_encoded_len(text.len());
if encoded_len <= max_bytes {
attempts.push("osc52".to_string());
return ClipboardPlan {
use_osc52: true,
attempts,
commands,
};
}
attempts.push(format!("osc52 (payload {encoded_len} > {max_bytes})"));
}
ClipboardPlan {
use_osc52: false,
attempts,
commands,
}
}
fn copy_with_commands(
&self,
text: &str,
mut attempts: Vec<String>,
commands: Vec<backend::ClipboardCommand>,
) -> Result<(), ClipboardError> {
for command in commands {
attempts.push(command.command.to_string());
match backend::copy_via_command(command, text) {
Ok(()) => return Ok(()),
Err(ClipboardError::SpawnFailed { .. }) => continue,
Err(error) => return Err(error),
}
}
Err(ClipboardError::NoBackendAvailable { attempts })
}
}