Skip to main content

standout_input/sources/
clipboard.rs

1//! Clipboard input source.
2
3use std::sync::Arc;
4
5use clap::ArgMatches;
6
7use crate::collector::InputCollector;
8use crate::env::{ClipboardReader, DefaultClipboard};
9use crate::InputError;
10
11/// Collect input from the system clipboard.
12///
13/// This source reads text from the system clipboard. It is available when
14/// the clipboard contains non-empty text content.
15///
16/// # Platform Support
17///
18/// - **macOS**: Uses `pbpaste`
19/// - **Linux**: Uses `xclip -selection clipboard -o`
20/// - **Other**: Returns an error
21///
22/// # Example
23///
24/// ```ignore
25/// use standout_input::{InputChain, ArgSource, ClipboardSource};
26///
27/// let chain = InputChain::<String>::new()
28///     .try_source(ArgSource::new("content"))
29///     .try_source(ClipboardSource::new());
30/// ```
31///
32/// # Testing
33///
34/// Use [`ClipboardSource::with_reader`] to inject a mock for testing:
35///
36/// ```ignore
37/// use standout_input::{ClipboardSource, MockClipboard};
38///
39/// let source = ClipboardSource::with_reader(MockClipboard::with_content("clipboard text"));
40/// ```
41#[derive(Clone)]
42pub struct ClipboardSource<R: ClipboardReader = DefaultClipboard> {
43    reader: Arc<R>,
44    trim: bool,
45}
46
47impl ClipboardSource<DefaultClipboard> {
48    /// Create a new clipboard source.
49    ///
50    /// Reads via
51    /// [`DefaultClipboard`](crate::env::DefaultClipboard), which honors a
52    /// test override installed through
53    /// [`set_default_clipboard_reader`](crate::env::set_default_clipboard_reader)
54    /// and otherwise falls back to the platform clipboard.
55    pub fn new() -> Self {
56        Self {
57            reader: Arc::new(DefaultClipboard),
58            trim: true,
59        }
60    }
61}
62
63impl Default for ClipboardSource<DefaultClipboard> {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl<R: ClipboardReader> ClipboardSource<R> {
70    /// Create a clipboard source with a custom reader.
71    ///
72    /// This is primarily used for testing to inject mock clipboard.
73    pub fn with_reader(reader: R) -> Self {
74        Self {
75            reader: Arc::new(reader),
76            trim: true,
77        }
78    }
79
80    /// Control whether to trim whitespace from the clipboard content.
81    ///
82    /// Default is `true`.
83    pub fn trim(mut self, trim: bool) -> Self {
84        self.trim = trim;
85        self
86    }
87}
88
89impl<R: ClipboardReader + 'static> InputCollector<String> for ClipboardSource<R> {
90    fn name(&self) -> &'static str {
91        "clipboard"
92    }
93
94    fn is_available(&self, _matches: &ArgMatches) -> bool {
95        // Clipboard is available if it has content
96        match self.reader.read() {
97            Ok(Some(content)) => !content.trim().is_empty(),
98            Ok(None) => false,
99            Err(e) => {
100                // Log a warning for clipboard access errors (headless Linux, permission denied, etc.)
101                // This helps users diagnose why clipboard input isn't working
102                eprintln!("Warning: clipboard unavailable: {}", e);
103                false
104            }
105        }
106    }
107
108    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
109        match self.reader.read()? {
110            Some(content) => {
111                let result = if self.trim {
112                    content.trim().to_string()
113                } else {
114                    content
115                };
116
117                if result.is_empty() {
118                    Ok(None)
119                } else {
120                    Ok(Some(result))
121                }
122            }
123            None => Ok(None),
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::env::MockClipboard;
132    use clap::Command;
133
134    fn empty_matches() -> ArgMatches {
135        Command::new("test").try_get_matches_from(["test"]).unwrap()
136    }
137
138    #[test]
139    fn clipboard_available_when_has_content() {
140        let source = ClipboardSource::with_reader(MockClipboard::with_content("content"));
141        assert!(source.is_available(&empty_matches()));
142    }
143
144    #[test]
145    fn clipboard_unavailable_when_empty() {
146        let source = ClipboardSource::with_reader(MockClipboard::empty());
147        assert!(!source.is_available(&empty_matches()));
148    }
149
150    #[test]
151    fn clipboard_unavailable_when_whitespace_only() {
152        let source = ClipboardSource::with_reader(MockClipboard::with_content("   \n\t  "));
153        assert!(!source.is_available(&empty_matches()));
154    }
155
156    #[test]
157    fn clipboard_collects_content() {
158        let source = ClipboardSource::with_reader(MockClipboard::with_content("hello"));
159        let result = source.collect(&empty_matches()).unwrap();
160        assert_eq!(result, Some("hello".to_string()));
161    }
162
163    #[test]
164    fn clipboard_trims_whitespace() {
165        let source = ClipboardSource::with_reader(MockClipboard::with_content("  hello  \n"));
166        let result = source.collect(&empty_matches()).unwrap();
167        assert_eq!(result, Some("hello".to_string()));
168    }
169
170    #[test]
171    fn clipboard_no_trim() {
172        let source =
173            ClipboardSource::with_reader(MockClipboard::with_content("  hello  ")).trim(false);
174        let result = source.collect(&empty_matches()).unwrap();
175        assert_eq!(result, Some("  hello  ".to_string()));
176    }
177
178    #[test]
179    fn clipboard_returns_none_when_empty() {
180        let source = ClipboardSource::with_reader(MockClipboard::empty());
181        let result = source.collect(&empty_matches()).unwrap();
182        assert_eq!(result, None);
183    }
184}