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, RealClipboard};
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 = RealClipboard> {
43    reader: Arc<R>,
44    trim: bool,
45}
46
47impl ClipboardSource<RealClipboard> {
48    /// Create a new clipboard source using real system clipboard.
49    pub fn new() -> Self {
50        Self {
51            reader: Arc::new(RealClipboard),
52            trim: true,
53        }
54    }
55}
56
57impl Default for ClipboardSource<RealClipboard> {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl<R: ClipboardReader> ClipboardSource<R> {
64    /// Create a clipboard source with a custom reader.
65    ///
66    /// This is primarily used for testing to inject mock clipboard.
67    pub fn with_reader(reader: R) -> Self {
68        Self {
69            reader: Arc::new(reader),
70            trim: true,
71        }
72    }
73
74    /// Control whether to trim whitespace from the clipboard content.
75    ///
76    /// Default is `true`.
77    pub fn trim(mut self, trim: bool) -> Self {
78        self.trim = trim;
79        self
80    }
81}
82
83impl<R: ClipboardReader + 'static> InputCollector<String> for ClipboardSource<R> {
84    fn name(&self) -> &'static str {
85        "clipboard"
86    }
87
88    fn is_available(&self, _matches: &ArgMatches) -> bool {
89        // Clipboard is available if it has content
90        match self.reader.read() {
91            Ok(Some(content)) => !content.trim().is_empty(),
92            Ok(None) => false,
93            Err(e) => {
94                // Log a warning for clipboard access errors (headless Linux, permission denied, etc.)
95                // This helps users diagnose why clipboard input isn't working
96                eprintln!("Warning: clipboard unavailable: {}", e);
97                false
98            }
99        }
100    }
101
102    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
103        match self.reader.read()? {
104            Some(content) => {
105                let result = if self.trim {
106                    content.trim().to_string()
107                } else {
108                    content
109                };
110
111                if result.is_empty() {
112                    Ok(None)
113                } else {
114                    Ok(Some(result))
115                }
116            }
117            None => Ok(None),
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::env::MockClipboard;
126    use clap::Command;
127
128    fn empty_matches() -> ArgMatches {
129        Command::new("test").try_get_matches_from(["test"]).unwrap()
130    }
131
132    #[test]
133    fn clipboard_available_when_has_content() {
134        let source = ClipboardSource::with_reader(MockClipboard::with_content("content"));
135        assert!(source.is_available(&empty_matches()));
136    }
137
138    #[test]
139    fn clipboard_unavailable_when_empty() {
140        let source = ClipboardSource::with_reader(MockClipboard::empty());
141        assert!(!source.is_available(&empty_matches()));
142    }
143
144    #[test]
145    fn clipboard_unavailable_when_whitespace_only() {
146        let source = ClipboardSource::with_reader(MockClipboard::with_content("   \n\t  "));
147        assert!(!source.is_available(&empty_matches()));
148    }
149
150    #[test]
151    fn clipboard_collects_content() {
152        let source = ClipboardSource::with_reader(MockClipboard::with_content("hello"));
153        let result = source.collect(&empty_matches()).unwrap();
154        assert_eq!(result, Some("hello".to_string()));
155    }
156
157    #[test]
158    fn clipboard_trims_whitespace() {
159        let source = ClipboardSource::with_reader(MockClipboard::with_content("  hello  \n"));
160        let result = source.collect(&empty_matches()).unwrap();
161        assert_eq!(result, Some("hello".to_string()));
162    }
163
164    #[test]
165    fn clipboard_no_trim() {
166        let source =
167            ClipboardSource::with_reader(MockClipboard::with_content("  hello  ")).trim(false);
168        let result = source.collect(&empty_matches()).unwrap();
169        assert_eq!(result, Some("  hello  ".to_string()));
170    }
171
172    #[test]
173    fn clipboard_returns_none_when_empty() {
174        let source = ClipboardSource::with_reader(MockClipboard::empty());
175        let result = source.collect(&empty_matches()).unwrap();
176        assert_eq!(result, None);
177    }
178}