Skip to main content

standout_input/
env.rs

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}