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//!
7//! # Default readers and test overrides
8//!
9//! [`StdinSource::new`](crate::StdinSource::new) and
10//! [`ClipboardSource::new`](crate::ClipboardSource::new) both use "default"
11//! readers ([`DefaultStdin`], [`DefaultClipboard`]) that consult a
12//! process-global override before falling back to the real OS-backed
13//! implementation.
14//!
15//! Tests can swap in a mock without touching handler code by calling
16//! [`set_default_stdin_reader`] / [`set_default_clipboard_reader`]. The
17//! `TestHarness` in the `standout-test` crate wires these automatically.
18
19use once_cell::sync::Lazy;
20use std::io::{self, IsTerminal, Read};
21use std::sync::{Arc, Mutex};
22
23use crate::InputError;
24
25/// Abstraction over stdin reading.
26///
27/// This trait allows tests to mock stdin without actually piping data.
28pub trait StdinReader: Send + Sync {
29    /// Check if stdin is a terminal (TTY).
30    ///
31    /// Returns `true` if stdin is interactive, `false` if piped.
32    fn is_terminal(&self) -> bool;
33
34    /// Read all content from stdin.
35    ///
36    /// This should only be called if `is_terminal()` returns `false`.
37    fn read_to_string(&self) -> io::Result<String>;
38}
39
40/// Abstraction over environment variables.
41pub trait EnvReader: Send + Sync {
42    /// Get an environment variable value.
43    fn var(&self, name: &str) -> Option<String>;
44}
45
46/// Abstraction over clipboard access.
47pub trait ClipboardReader: Send + Sync {
48    /// Read text from the system clipboard.
49    fn read(&self) -> Result<Option<String>, InputError>;
50}
51
52// === Real implementations ===
53
54/// Real stdin reader using std::io.
55#[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/// Real environment variable reader.
71#[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/// Real clipboard reader using platform commands.
81#[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// === Mock implementations for testing ===
135
136/// Mock stdin reader for testing.
137///
138/// Allows tests to simulate both terminal and piped stdin.
139#[derive(Debug, Clone)]
140pub struct MockStdin {
141    is_terminal: bool,
142    content: Option<String>,
143}
144
145impl MockStdin {
146    /// Create a mock that simulates a terminal (no piped input).
147    pub fn terminal() -> Self {
148        Self {
149            is_terminal: true,
150            content: None,
151        }
152    }
153
154    /// Create a mock that simulates piped input.
155    pub fn piped(content: impl Into<String>) -> Self {
156        Self {
157            is_terminal: false,
158            content: Some(content.into()),
159        }
160    }
161
162    /// Create a mock that simulates empty piped input.
163    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/// Mock environment variable reader for testing.
182#[derive(Debug, Clone, Default)]
183pub struct MockEnv {
184    vars: std::collections::HashMap<String, String>,
185}
186
187impl MockEnv {
188    /// Create an empty mock environment.
189    pub fn new() -> Self {
190        Self::default()
191    }
192
193    /// Add an environment variable.
194    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/// Mock clipboard reader for testing.
207#[derive(Debug, Clone, Default)]
208pub struct MockClipboard {
209    content: Option<String>,
210}
211
212impl MockClipboard {
213    /// Create an empty clipboard mock.
214    pub fn empty() -> Self {
215        Self { content: None }
216    }
217
218    /// Create a clipboard mock with content.
219    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
232// === Process-global default reader overrides ===
233//
234// `StdinSource::new()` and `ClipboardSource::new()` resolve their reader
235// through the `DefaultStdin` / `DefaultClipboard` shims below, which consult
236// these slots before falling back to the real OS-backed readers. Tests use
237// `set_default_*_reader` to install a mock for a serial scope; the
238// `standout-test` `TestHarness` handles teardown via its `Drop` impl.
239
240type 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
246/// Installs a process-global stdin reader that [`DefaultStdin`] (and
247/// therefore [`StdinSource::new`](crate::StdinSource::new)) will delegate
248/// to until [`reset_default_stdin_reader`] is called.
249///
250/// Intended for test harnesses. Tests using this must run serially (e.g.
251/// via `#[serial]`) because the override is process-global.
252pub fn set_default_stdin_reader(reader: SharedStdin) {
253    *STDIN_OVERRIDE.lock().unwrap() = Some(reader);
254}
255
256/// Clears the stdin override installed by [`set_default_stdin_reader`].
257pub fn reset_default_stdin_reader() {
258    *STDIN_OVERRIDE.lock().unwrap() = None;
259}
260
261/// Installs a process-global clipboard reader that [`DefaultClipboard`]
262/// (and therefore [`ClipboardSource::new`](crate::ClipboardSource::new))
263/// will delegate to until [`reset_default_clipboard_reader`] is called.
264pub fn set_default_clipboard_reader(reader: SharedClipboard) {
265    *CLIPBOARD_OVERRIDE.lock().unwrap() = Some(reader);
266}
267
268/// Clears the clipboard override installed by
269/// [`set_default_clipboard_reader`].
270pub 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/// Stdin reader used by [`StdinSource::new`](crate::StdinSource::new).
283///
284/// Delegates to a reader installed via [`set_default_stdin_reader`] if one
285/// is present; otherwise falls back to [`RealStdin`]. The indirection lets
286/// a test harness inject a [`MockStdin`] without reconstructing sources
287/// inside handler code.
288#[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/// Clipboard reader used by
308/// [`ClipboardSource::new`](crate::ClipboardSource::new).
309///
310/// Delegates to a reader installed via [`set_default_clipboard_reader`] if
311/// one is present; otherwise falls back to [`RealClipboard`].
312#[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        // Behaviour matches RealStdin; we can only assert it doesn't panic
396        // and reports a terminal state consistent with the current process.
397        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}