Skip to main content

standout_input/sources/
clipboard.rs

1use std::sync::Arc;
2
3use clap::ArgMatches;
4
5use crate::collector::InputCollector;
6use crate::env::{ClipboardReader, RealClipboard};
7use crate::InputError;
8use crate::InputSources;
9
10#[derive(Clone)]
11pub struct ClipboardSource {
12    reader: Option<Arc<dyn ClipboardReader>>,
13    trim: bool,
14}
15
16impl ClipboardSource {
17    pub fn new() -> Self {
18        Self {
19            reader: None,
20            trim: true,
21        }
22    }
23
24    pub fn with_reader(reader: impl ClipboardReader + 'static) -> Self {
25        Self {
26            reader: Some(Arc::new(reader)),
27            trim: true,
28        }
29    }
30
31    pub fn with_shared_reader(reader: Arc<dyn ClipboardReader>) -> Self {
32        Self {
33            reader: Some(reader),
34            trim: true,
35        }
36    }
37
38    pub fn trim(mut self, trim: bool) -> Self {
39        self.trim = trim;
40        self
41    }
42
43    fn reader(&self) -> &dyn ClipboardReader {
44        self.reader
45            .as_deref()
46            .unwrap_or(&RealClipboard as &dyn ClipboardReader)
47    }
48}
49
50impl Default for ClipboardSource {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl InputCollector<String> for ClipboardSource {
57    fn name(&self) -> &'static str {
58        "clipboard"
59    }
60
61    fn is_available(&self, _matches: &ArgMatches) -> bool {
62        match self.reader().read() {
63            Ok(Some(content)) => !content.trim().is_empty(),
64            Ok(None) => false,
65            Err(e) => {
66                eprintln!("Warning: clipboard unavailable: {}", e);
67                false
68            }
69        }
70    }
71
72    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
73        match self.reader().read()? {
74            Some(content) => {
75                let result = if self.trim {
76                    content.trim().to_string()
77                } else {
78                    content
79                };
80
81                if result.is_empty() {
82                    Ok(None)
83                } else {
84                    Ok(Some(result))
85                }
86            }
87            None => Ok(None),
88        }
89    }
90
91    fn bind_sources(&self, sources: &InputSources) -> Option<Box<dyn InputCollector<String>>> {
92        if self.reader.is_some() {
93            return None;
94        }
95        Some(Box::new(Self {
96            reader: Some(sources.clipboard_arc()),
97            trim: self.trim,
98        }))
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::env::MockClipboard;
106    use clap::Command;
107
108    fn empty_matches() -> ArgMatches {
109        Command::new("test").try_get_matches_from(["test"]).unwrap()
110    }
111
112    #[test]
113    fn clipboard_available_when_has_content() {
114        let source = ClipboardSource::with_reader(MockClipboard::with_content("content"));
115        assert!(source.is_available(&empty_matches()));
116    }
117
118    #[test]
119    fn clipboard_unavailable_when_empty() {
120        let source = ClipboardSource::with_reader(MockClipboard::empty());
121        assert!(!source.is_available(&empty_matches()));
122    }
123
124    #[test]
125    fn clipboard_unavailable_when_whitespace_only() {
126        let source = ClipboardSource::with_reader(MockClipboard::with_content("   \n\t  "));
127        assert!(!source.is_available(&empty_matches()));
128    }
129
130    #[test]
131    fn clipboard_collects_content() {
132        let source = ClipboardSource::with_reader(MockClipboard::with_content("hello"));
133        let result = source.collect(&empty_matches()).unwrap();
134        assert_eq!(result, Some("hello".to_string()));
135    }
136
137    #[test]
138    fn clipboard_trims_whitespace() {
139        let source = ClipboardSource::with_reader(MockClipboard::with_content("  hello  \n"));
140        let result = source.collect(&empty_matches()).unwrap();
141        assert_eq!(result, Some("hello".to_string()));
142    }
143
144    #[test]
145    fn clipboard_no_trim() {
146        let source =
147            ClipboardSource::with_reader(MockClipboard::with_content("  hello  ")).trim(false);
148        let result = source.collect(&empty_matches()).unwrap();
149        assert_eq!(result, Some("  hello  ".to_string()));
150    }
151
152    #[test]
153    fn clipboard_returns_none_when_empty() {
154        let source = ClipboardSource::with_reader(MockClipboard::empty());
155        let result = source.collect(&empty_matches()).unwrap();
156        assert_eq!(result, None);
157    }
158
159    #[test]
160    fn bind_sources_uses_invocation_clipboard() {
161        let source = ClipboardSource::new();
162        let sources =
163            InputSources::from_process().with_clipboard(MockClipboard::with_content("bound"));
164        let bound = source.bind_sources(&sources).expect("unbound source binds");
165        let result = bound.collect(&empty_matches()).unwrap();
166        assert_eq!(result, Some("bound".to_string()));
167    }
168}