1mod backend;
4mod error;
5mod format;
6mod image_data;
7mod uri;
8
9use std::path::PathBuf;
10
11pub use error::ClipboardError;
12pub use image_data::RgbaImageData;
13
14const DISABLE_CLIPBOARD_ENV: &str = "AGENTTY_DISABLE_CLIPBOARD";
15
16pub struct Clipboard {
18 backend: Box<dyn backend::ClipboardBackend>,
19}
20
21impl Clipboard {
22 pub fn new() -> Result<Self, ClipboardError> {
29 Self::new_with_backend(Self::disabled_by_env(), backend::new_backend)
30 }
31
32 pub fn read_text(&mut self) -> Result<String, ClipboardError> {
38 self.backend.read_text()
39 }
40
41 pub fn read_file_list(&mut self) -> Result<Vec<PathBuf>, ClipboardError> {
47 self.backend.read_file_list()
48 }
49
50 pub fn read_image_rgba(&mut self) -> Result<RgbaImageData, ClipboardError> {
56 self.backend.read_image_rgba()
57 }
58
59 fn new_with_backend<B>(
60 disabled_error: Option<ClipboardError>,
61 backend_factory: impl FnOnce() -> B,
62 ) -> Result<Self, ClipboardError>
63 where
64 B: IntoBackendResult,
65 {
66 if let Some(error) = disabled_error {
67 return Err(error);
68 }
69
70 let backend = backend_factory().into_backend_result()?;
71
72 Ok(Self { backend })
73 }
74
75 fn disabled_by_env() -> Option<ClipboardError> {
76 std::env::var_os(DISABLE_CLIPBOARD_ENV).map(|_| ClipboardError::Unavailable {
77 reason: format!("clipboard access is disabled by `{DISABLE_CLIPBOARD_ENV}`"),
78 })
79 }
80}
81
82trait IntoBackendResult {
83 fn into_backend_result(self) -> Result<Box<dyn backend::ClipboardBackend>, ClipboardError>;
84}
85
86impl IntoBackendResult for Box<dyn backend::ClipboardBackend> {
87 fn into_backend_result(self) -> Result<Box<dyn backend::ClipboardBackend>, ClipboardError> {
88 Ok(self)
89 }
90}
91
92impl IntoBackendResult for Result<Box<dyn backend::ClipboardBackend>, ClipboardError> {
93 fn into_backend_result(self) -> Result<Box<dyn backend::ClipboardBackend>, ClipboardError> {
94 self
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use std::process::Command;
101
102 use super::*;
103
104 const DISABLED_CONSTRUCTOR_CHILD_ENV: &str = "AGENTTY_TEST_DISABLED_CONSTRUCTOR_CHILD";
105
106 struct TestClipboardBackend;
107
108 impl backend::ClipboardBackend for TestClipboardBackend {
109 fn read_text(&mut self) -> Result<String, ClipboardError> {
110 Ok("test clipboard text".to_string())
111 }
112
113 fn read_file_list(&mut self) -> Result<Vec<PathBuf>, ClipboardError> {
114 Ok(vec![PathBuf::from("/tmp/test.txt")])
115 }
116
117 fn read_image_rgba(&mut self) -> Result<RgbaImageData, ClipboardError> {
118 Ok(RgbaImageData {
119 height: 1,
120 rgba_bytes: vec![0, 0, 0, 255],
121 width: 1,
122 })
123 }
124 }
125
126 #[test]
127 fn test_new_respects_disable_environment_variable_in_child_process() {
128 if std::env::var_os(DISABLED_CONSTRUCTOR_CHILD_ENV).is_some() {
129 let expected_environment_variable = DISABLE_CLIPBOARD_ENV;
131
132 let result = Clipboard::new();
134
135 assert!(matches!(
137 result,
138 Err(ClipboardError::Unavailable { reason })
139 if reason.contains(expected_environment_variable)
140 ));
141
142 return;
143 }
144
145 let current_test_binary =
147 std::env::current_exe().expect("current test binary path should be available");
148
149 let output = Command::new(current_test_binary)
151 .arg("--exact")
152 .arg("tests::test_new_respects_disable_environment_variable_in_child_process")
153 .arg("--nocapture")
154 .env(DISABLED_CONSTRUCTOR_CHILD_ENV, "1")
155 .env(DISABLE_CLIPBOARD_ENV, "1")
156 .output()
157 .expect("disabled constructor child test should run");
158 let stderr = String::from_utf8_lossy(&output.stderr);
159
160 assert!(
162 output.status.success(),
163 "disabled constructor child test failed: {stderr}"
164 );
165 }
166
167 #[test]
168 fn test_new_with_backend_respects_disabled_error() {
169 let disabled_error = ClipboardError::Unavailable {
171 reason: "test clipboard is disabled".to_string(),
172 };
173 let backend_factory = unavailable_backend_factory;
174
175 let result = Clipboard::new_with_backend(Some(disabled_error), backend_factory);
177
178 assert!(matches!(
180 result,
181 Err(ClipboardError::Unavailable { reason })
182 if reason == "test clipboard is disabled"
183 ));
184 }
185
186 #[test]
187 fn test_new_with_backend_accepts_infallible_backend() {
188 let backend_factory =
190 || Box::new(TestClipboardBackend) as Box<dyn backend::ClipboardBackend>;
191
192 let mut clipboard = Clipboard::new_with_backend(None, backend_factory)
194 .expect("infallible test backend should initialize");
195 let text = clipboard
196 .read_text()
197 .expect("test backend should return text");
198 let paths = clipboard
199 .read_file_list()
200 .expect("test backend should return a file list");
201 let image = clipboard
202 .read_image_rgba()
203 .expect("test backend should return image data");
204
205 assert_eq!(text, "test clipboard text");
207 assert_eq!(paths, vec![PathBuf::from("/tmp/test.txt")]);
208 assert_eq!(image.width, 1);
209 assert_eq!(image.height, 1);
210 assert_eq!(image.rgba_bytes, vec![0, 0, 0, 255]);
211 }
212
213 #[test]
214 fn test_new_with_backend_propagates_backend_error() {
215 let backend_factory = unavailable_backend_factory;
217
218 let result = Clipboard::new_with_backend(None, backend_factory);
220
221 assert!(matches!(
223 result,
224 Err(ClipboardError::Unavailable { reason })
225 if reason == "test backend is unavailable"
226 ));
227 }
228
229 fn unavailable_backend_factory() -> Result<Box<dyn backend::ClipboardBackend>, ClipboardError> {
230 Err(ClipboardError::Unavailable {
231 reason: "test backend is unavailable".to_string(),
232 })
233 }
234}