1use std::io::{self, IsTerminal, Read};
8
9use crate::InputError;
10
11pub trait StdinReader: Send + Sync {
15 fn is_terminal(&self) -> bool;
19
20 fn read_to_string(&self) -> io::Result<String>;
24}
25
26pub trait EnvReader: Send + Sync {
28 fn var(&self, name: &str) -> Option<String>;
30}
31
32pub trait ClipboardReader: Send + Sync {
34 fn read(&self) -> Result<Option<String>, InputError>;
36}
37
38#[derive(Debug, Default, Clone, Copy)]
42pub struct RealStdin;
43
44impl StdinReader for RealStdin {
45 fn is_terminal(&self) -> bool {
46 std::io::stdin().is_terminal()
47 }
48
49 fn read_to_string(&self) -> io::Result<String> {
50 let mut buffer = String::new();
51 std::io::stdin().read_to_string(&mut buffer)?;
52 Ok(buffer)
53 }
54}
55
56#[derive(Debug, Default, Clone, Copy)]
58pub struct RealEnv;
59
60impl EnvReader for RealEnv {
61 fn var(&self, name: &str) -> Option<String> {
62 std::env::var(name).ok()
63 }
64}
65
66#[derive(Debug, Default, Clone, Copy)]
68pub struct RealClipboard;
69
70impl ClipboardReader for RealClipboard {
71 fn read(&self) -> Result<Option<String>, InputError> {
72 read_clipboard_impl()
73 }
74}
75
76#[cfg(target_os = "macos")]
77fn read_clipboard_impl() -> Result<Option<String>, InputError> {
78 let output = std::process::Command::new("pbpaste")
79 .output()
80 .map_err(|e| InputError::ClipboardFailed(e.to_string()))?;
81
82 if output.status.success() {
83 let content = String::from_utf8_lossy(&output.stdout).to_string();
84 if content.is_empty() {
85 Ok(None)
86 } else {
87 Ok(Some(content))
88 }
89 } else {
90 Ok(None)
91 }
92}
93
94#[cfg(target_os = "linux")]
95fn read_clipboard_impl() -> Result<Option<String>, InputError> {
96 let output = std::process::Command::new("xclip")
97 .args(["-selection", "clipboard", "-o"])
98 .output()
99 .map_err(|e| InputError::ClipboardFailed(e.to_string()))?;
100
101 if output.status.success() {
102 let content = String::from_utf8_lossy(&output.stdout).to_string();
103 if content.is_empty() {
104 Ok(None)
105 } else {
106 Ok(Some(content))
107 }
108 } else {
109 Ok(None)
110 }
111}
112
113#[cfg(not(any(target_os = "macos", target_os = "linux")))]
114fn read_clipboard_impl() -> Result<Option<String>, InputError> {
115 Err(InputError::ClipboardFailed(
116 "Clipboard not supported on this platform".to_string(),
117 ))
118}
119
120#[derive(Debug, Clone)]
126pub struct MockStdin {
127 is_terminal: bool,
128 content: Option<String>,
129}
130
131impl MockStdin {
132 pub fn terminal() -> Self {
134 Self {
135 is_terminal: true,
136 content: None,
137 }
138 }
139
140 pub fn piped(content: impl Into<String>) -> Self {
142 Self {
143 is_terminal: false,
144 content: Some(content.into()),
145 }
146 }
147
148 pub fn piped_empty() -> Self {
150 Self {
151 is_terminal: false,
152 content: Some(String::new()),
153 }
154 }
155}
156
157impl StdinReader for MockStdin {
158 fn is_terminal(&self) -> bool {
159 self.is_terminal
160 }
161
162 fn read_to_string(&self) -> io::Result<String> {
163 Ok(self.content.clone().unwrap_or_default())
164 }
165}
166
167#[derive(Debug, Clone, Default)]
169pub struct MockEnv {
170 vars: std::collections::HashMap<String, String>,
171}
172
173impl MockEnv {
174 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn with_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
181 self.vars.insert(name.into(), value.into());
182 self
183 }
184}
185
186impl EnvReader for MockEnv {
187 fn var(&self, name: &str) -> Option<String> {
188 self.vars.get(name).cloned()
189 }
190}
191
192#[derive(Debug, Clone, Default)]
194pub struct MockClipboard {
195 content: Option<String>,
196}
197
198impl MockClipboard {
199 pub fn empty() -> Self {
201 Self { content: None }
202 }
203
204 pub fn with_content(content: impl Into<String>) -> Self {
206 Self {
207 content: Some(content.into()),
208 }
209 }
210}
211
212impl ClipboardReader for MockClipboard {
213 fn read(&self) -> Result<Option<String>, InputError> {
214 Ok(self.content.clone())
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn mock_stdin_terminal() {
224 let stdin = MockStdin::terminal();
225 assert!(stdin.is_terminal());
226 }
227
228 #[test]
229 fn mock_stdin_piped() {
230 let stdin = MockStdin::piped("hello world");
231 assert!(!stdin.is_terminal());
232 assert_eq!(stdin.read_to_string().unwrap(), "hello world");
233 }
234
235 #[test]
236 fn mock_stdin_piped_empty() {
237 let stdin = MockStdin::piped_empty();
238 assert!(!stdin.is_terminal());
239 assert_eq!(stdin.read_to_string().unwrap(), "");
240 }
241
242 #[test]
243 fn mock_env_empty() {
244 let env = MockEnv::new();
245 assert_eq!(env.var("MISSING"), None);
246 }
247
248 #[test]
249 fn mock_env_with_vars() {
250 let env = MockEnv::new()
251 .with_var("EDITOR", "vim")
252 .with_var("HOME", "/home/user");
253
254 assert_eq!(env.var("EDITOR"), Some("vim".to_string()));
255 assert_eq!(env.var("HOME"), Some("/home/user".to_string()));
256 assert_eq!(env.var("MISSING"), None);
257 }
258
259 #[test]
260 fn mock_clipboard_empty() {
261 let clipboard = MockClipboard::empty();
262 assert_eq!(clipboard.read().unwrap(), None);
263 }
264
265 #[test]
266 fn mock_clipboard_with_content() {
267 let clipboard = MockClipboard::with_content("clipboard text");
268 assert_eq!(
269 clipboard.read().unwrap(),
270 Some("clipboard text".to_string())
271 );
272 }
273}