1use once_cell::sync::Lazy;
20use std::io::{self, IsTerminal, Read};
21use std::sync::{Arc, Mutex};
22
23use crate::InputError;
24
25pub trait StdinReader: Send + Sync {
29 fn is_terminal(&self) -> bool;
33
34 fn read_to_string(&self) -> io::Result<String>;
38}
39
40pub trait EnvReader: Send + Sync {
42 fn var(&self, name: &str) -> Option<String>;
44}
45
46pub trait ClipboardReader: Send + Sync {
48 fn read(&self) -> Result<Option<String>, InputError>;
50}
51
52#[derive(Debug, Default, Clone, Copy)]
56pub struct RealStdin;
57
58impl StdinReader for RealStdin {
59 fn is_terminal(&self) -> bool {
60 std::io::stdin().is_terminal()
61 }
62
63 fn read_to_string(&self) -> io::Result<String> {
64 let mut buffer = String::new();
65 std::io::stdin().read_to_string(&mut buffer)?;
66 Ok(buffer)
67 }
68}
69
70#[derive(Debug, Default, Clone, Copy)]
72pub struct RealEnv;
73
74impl EnvReader for RealEnv {
75 fn var(&self, name: &str) -> Option<String> {
76 std::env::var(name).ok()
77 }
78}
79
80#[derive(Debug, Default, Clone, Copy)]
82pub struct RealClipboard;
83
84impl ClipboardReader for RealClipboard {
85 fn read(&self) -> Result<Option<String>, InputError> {
86 read_clipboard_impl()
87 }
88}
89
90#[cfg(target_os = "macos")]
91fn read_clipboard_impl() -> Result<Option<String>, InputError> {
92 let output = std::process::Command::new("pbpaste")
93 .output()
94 .map_err(|e| InputError::ClipboardFailed(e.to_string()))?;
95
96 if output.status.success() {
97 let content = String::from_utf8_lossy(&output.stdout).to_string();
98 if content.is_empty() {
99 Ok(None)
100 } else {
101 Ok(Some(content))
102 }
103 } else {
104 Ok(None)
105 }
106}
107
108#[cfg(target_os = "linux")]
109fn read_clipboard_impl() -> Result<Option<String>, InputError> {
110 let output = std::process::Command::new("xclip")
111 .args(["-selection", "clipboard", "-o"])
112 .output()
113 .map_err(|e| InputError::ClipboardFailed(e.to_string()))?;
114
115 if output.status.success() {
116 let content = String::from_utf8_lossy(&output.stdout).to_string();
117 if content.is_empty() {
118 Ok(None)
119 } else {
120 Ok(Some(content))
121 }
122 } else {
123 Ok(None)
124 }
125}
126
127#[cfg(not(any(target_os = "macos", target_os = "linux")))]
128fn read_clipboard_impl() -> Result<Option<String>, InputError> {
129 Err(InputError::ClipboardFailed(
130 "Clipboard not supported on this platform".to_string(),
131 ))
132}
133
134#[derive(Debug, Clone)]
140pub struct MockStdin {
141 is_terminal: bool,
142 content: Option<String>,
143}
144
145impl MockStdin {
146 pub fn terminal() -> Self {
148 Self {
149 is_terminal: true,
150 content: None,
151 }
152 }
153
154 pub fn piped(content: impl Into<String>) -> Self {
156 Self {
157 is_terminal: false,
158 content: Some(content.into()),
159 }
160 }
161
162 pub fn piped_empty() -> Self {
164 Self {
165 is_terminal: false,
166 content: Some(String::new()),
167 }
168 }
169}
170
171impl StdinReader for MockStdin {
172 fn is_terminal(&self) -> bool {
173 self.is_terminal
174 }
175
176 fn read_to_string(&self) -> io::Result<String> {
177 Ok(self.content.clone().unwrap_or_default())
178 }
179}
180
181#[derive(Debug, Clone, Default)]
183pub struct MockEnv {
184 vars: std::collections::HashMap<String, String>,
185}
186
187impl MockEnv {
188 pub fn new() -> Self {
190 Self::default()
191 }
192
193 pub fn with_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
195 self.vars.insert(name.into(), value.into());
196 self
197 }
198}
199
200impl EnvReader for MockEnv {
201 fn var(&self, name: &str) -> Option<String> {
202 self.vars.get(name).cloned()
203 }
204}
205
206#[derive(Debug, Clone, Default)]
208pub struct MockClipboard {
209 content: Option<String>,
210}
211
212impl MockClipboard {
213 pub fn empty() -> Self {
215 Self { content: None }
216 }
217
218 pub fn with_content(content: impl Into<String>) -> Self {
220 Self {
221 content: Some(content.into()),
222 }
223 }
224}
225
226impl ClipboardReader for MockClipboard {
227 fn read(&self) -> Result<Option<String>, InputError> {
228 Ok(self.content.clone())
229 }
230}
231
232type SharedStdin = Arc<dyn StdinReader + Send + Sync>;
241type SharedClipboard = Arc<dyn ClipboardReader + Send + Sync>;
242
243static STDIN_OVERRIDE: Lazy<Mutex<Option<SharedStdin>>> = Lazy::new(|| Mutex::new(None));
244static CLIPBOARD_OVERRIDE: Lazy<Mutex<Option<SharedClipboard>>> = Lazy::new(|| Mutex::new(None));
245
246pub fn set_default_stdin_reader(reader: SharedStdin) {
253 *STDIN_OVERRIDE.lock().unwrap() = Some(reader);
254}
255
256pub fn reset_default_stdin_reader() {
258 *STDIN_OVERRIDE.lock().unwrap() = None;
259}
260
261pub fn set_default_clipboard_reader(reader: SharedClipboard) {
265 *CLIPBOARD_OVERRIDE.lock().unwrap() = Some(reader);
266}
267
268pub fn reset_default_clipboard_reader() {
271 *CLIPBOARD_OVERRIDE.lock().unwrap() = None;
272}
273
274fn current_stdin_override() -> Option<SharedStdin> {
275 STDIN_OVERRIDE.lock().unwrap().clone()
276}
277
278fn current_clipboard_override() -> Option<SharedClipboard> {
279 CLIPBOARD_OVERRIDE.lock().unwrap().clone()
280}
281
282#[derive(Debug, Default, Clone, Copy)]
289pub struct DefaultStdin;
290
291impl StdinReader for DefaultStdin {
292 fn is_terminal(&self) -> bool {
293 if let Some(r) = current_stdin_override() {
294 return r.is_terminal();
295 }
296 RealStdin.is_terminal()
297 }
298
299 fn read_to_string(&self) -> io::Result<String> {
300 if let Some(r) = current_stdin_override() {
301 return r.read_to_string();
302 }
303 RealStdin.read_to_string()
304 }
305}
306
307#[derive(Debug, Default, Clone, Copy)]
313pub struct DefaultClipboard;
314
315impl ClipboardReader for DefaultClipboard {
316 fn read(&self) -> Result<Option<String>, InputError> {
317 if let Some(r) = current_clipboard_override() {
318 return r.read();
319 }
320 RealClipboard.read()
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use serial_test::serial;
328
329 #[test]
330 fn mock_stdin_terminal() {
331 let stdin = MockStdin::terminal();
332 assert!(stdin.is_terminal());
333 }
334
335 #[test]
336 fn mock_stdin_piped() {
337 let stdin = MockStdin::piped("hello world");
338 assert!(!stdin.is_terminal());
339 assert_eq!(stdin.read_to_string().unwrap(), "hello world");
340 }
341
342 #[test]
343 fn mock_stdin_piped_empty() {
344 let stdin = MockStdin::piped_empty();
345 assert!(!stdin.is_terminal());
346 assert_eq!(stdin.read_to_string().unwrap(), "");
347 }
348
349 #[test]
350 fn mock_env_empty() {
351 let env = MockEnv::new();
352 assert_eq!(env.var("MISSING"), None);
353 }
354
355 #[test]
356 fn mock_env_with_vars() {
357 let env = MockEnv::new()
358 .with_var("EDITOR", "vim")
359 .with_var("HOME", "/home/user");
360
361 assert_eq!(env.var("EDITOR"), Some("vim".to_string()));
362 assert_eq!(env.var("HOME"), Some("/home/user".to_string()));
363 assert_eq!(env.var("MISSING"), None);
364 }
365
366 #[test]
367 fn mock_clipboard_empty() {
368 let clipboard = MockClipboard::empty();
369 assert_eq!(clipboard.read().unwrap(), None);
370 }
371
372 #[test]
373 fn mock_clipboard_with_content() {
374 let clipboard = MockClipboard::with_content("clipboard text");
375 assert_eq!(
376 clipboard.read().unwrap(),
377 Some("clipboard text".to_string())
378 );
379 }
380
381 #[test]
382 #[serial]
383 fn default_stdin_uses_override() {
384 set_default_stdin_reader(Arc::new(MockStdin::piped("overridden")));
385 let reader = DefaultStdin;
386 assert!(!reader.is_terminal());
387 assert_eq!(reader.read_to_string().unwrap(), "overridden");
388 reset_default_stdin_reader();
389 }
390
391 #[test]
392 #[serial]
393 fn default_stdin_falls_back_without_override() {
394 reset_default_stdin_reader();
395 let reader = DefaultStdin;
398 let _ = reader.is_terminal();
399 }
400
401 #[test]
402 #[serial]
403 fn default_clipboard_uses_override() {
404 set_default_clipboard_reader(Arc::new(MockClipboard::with_content("paste")));
405 let reader = DefaultClipboard;
406 assert_eq!(reader.read().unwrap(), Some("paste".to_string()));
407 reset_default_clipboard_reader();
408 }
409}