Skip to main content

ag_clipboard/
lib.rs

1//! Narrow read-only clipboard access used by Agentty prompt image capture.
2
3mod 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
16/// System clipboard reader for text, copied files, and RGBA image data.
17pub struct Clipboard {
18    backend: Box<dyn backend::ClipboardBackend>,
19}
20
21impl Clipboard {
22    /// Opens the best clipboard backend available on the current platform.
23    ///
24    /// # Errors
25    /// Returns [`ClipboardError::Unavailable`] when no supported clipboard
26    /// backend is available for the current display server or operating
27    /// system.
28    pub fn new() -> Result<Self, ClipboardError> {
29        Self::new_with_backend(Self::disabled_by_env(), backend::new_backend)
30    }
31
32    /// Reads clipboard text.
33    ///
34    /// # Errors
35    /// Returns a [`ClipboardError`] when the clipboard has no text or the
36    /// backend cannot complete the read.
37    pub fn read_text(&mut self) -> Result<String, ClipboardError> {
38        self.backend.read_text()
39    }
40
41    /// Reads copied filesystem paths from the clipboard.
42    ///
43    /// # Errors
44    /// Returns a [`ClipboardError`] when the clipboard has no file-list payload
45    /// or the backend cannot complete the read.
46    pub fn read_file_list(&mut self) -> Result<Vec<PathBuf>, ClipboardError> {
47        self.backend.read_file_list()
48    }
49
50    /// Reads clipboard image data as RGBA pixels.
51    ///
52    /// # Errors
53    /// Returns a [`ClipboardError`] when the clipboard has no image payload,
54    /// image decoding fails, or the backend cannot complete the read.
55    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            // Arrange
130            let expected_environment_variable = DISABLE_CLIPBOARD_ENV;
131
132            // Act
133            let result = Clipboard::new();
134
135            // Assert
136            assert!(matches!(
137                result,
138                Err(ClipboardError::Unavailable { reason })
139                    if reason.contains(expected_environment_variable)
140            ));
141
142            return;
143        }
144
145        // Arrange
146        let current_test_binary =
147            std::env::current_exe().expect("current test binary path should be available");
148
149        // Act
150        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
161        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        // Arrange
170        let disabled_error = ClipboardError::Unavailable {
171            reason: "test clipboard is disabled".to_string(),
172        };
173        let backend_factory = unavailable_backend_factory;
174
175        // Act
176        let result = Clipboard::new_with_backend(Some(disabled_error), backend_factory);
177
178        // Assert
179        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        // Arrange
189        let backend_factory =
190            || Box::new(TestClipboardBackend) as Box<dyn backend::ClipboardBackend>;
191
192        // Act
193        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
206        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        // Arrange
216        let backend_factory = unavailable_backend_factory;
217
218        // Act
219        let result = Clipboard::new_with_backend(None, backend_factory);
220
221        // Assert
222        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}