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