Skip to main content

standout_input/
env.rs

1//! Environment abstractions for testability.
2//!
3//! This module provides traits that abstract over OS interactions,
4//! allowing tests to run without depending on actual terminal state,
5//! stdin piping, or clipboard contents.
6
7use std::io::{self, IsTerminal, Read};
8
9use crate::InputError;
10
11/// Abstraction over stdin reading.
12///
13/// This trait allows tests to mock stdin without actually piping data.
14pub trait StdinReader: Send + Sync {
15    /// Check if stdin is a terminal (TTY).
16    ///
17    /// Returns `true` if stdin is interactive, `false` if piped.
18    fn is_terminal(&self) -> bool;
19
20    /// Read all content from stdin.
21    ///
22    /// This should only be called if `is_terminal()` returns `false`.
23    fn read_to_string(&self) -> io::Result<String>;
24}
25
26/// Abstraction over environment variables.
27pub trait EnvReader: Send + Sync {
28    /// Get an environment variable value.
29    fn var(&self, name: &str) -> Option<String>;
30}
31
32/// Abstraction over clipboard access.
33pub trait ClipboardReader: Send + Sync {
34    /// Read text from the system clipboard.
35    fn read(&self) -> Result<Option<String>, InputError>;
36}
37
38// === Real implementations ===
39
40/// Real stdin reader using std::io.
41#[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/// Real environment variable reader.
57#[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/// Real clipboard reader using platform commands.
67#[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// === Mock implementations for testing ===
121
122/// Mock stdin reader for testing.
123///
124/// Allows tests to simulate both terminal and piped stdin.
125#[derive(Debug, Clone)]
126pub struct MockStdin {
127    is_terminal: bool,
128    content: Option<String>,
129}
130
131impl MockStdin {
132    /// Create a mock that simulates a terminal (no piped input).
133    pub fn terminal() -> Self {
134        Self {
135            is_terminal: true,
136            content: None,
137        }
138    }
139
140    /// Create a mock that simulates piped input.
141    pub fn piped(content: impl Into<String>) -> Self {
142        Self {
143            is_terminal: false,
144            content: Some(content.into()),
145        }
146    }
147
148    /// Create a mock that simulates empty piped input.
149    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/// Mock environment variable reader for testing.
168#[derive(Debug, Clone, Default)]
169pub struct MockEnv {
170    vars: std::collections::HashMap<String, String>,
171}
172
173impl MockEnv {
174    /// Create an empty mock environment.
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// Add an environment variable.
180    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/// Mock clipboard reader for testing.
193#[derive(Debug, Clone, Default)]
194pub struct MockClipboard {
195    content: Option<String>,
196}
197
198impl MockClipboard {
199    /// Create an empty clipboard mock.
200    pub fn empty() -> Self {
201        Self { content: None }
202    }
203
204    /// Create a clipboard mock with content.
205    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}