Skip to main content

standout_input/sources/
stdin.rs

1//! Stdin input source.
2
3use std::sync::Arc;
4
5use clap::ArgMatches;
6
7use crate::collector::InputCollector;
8use crate::env::{DefaultStdin, StdinReader};
9use crate::InputError;
10
11/// Collect input from piped stdin.
12///
13/// This source reads from stdin only when it is piped (not a terminal).
14/// If stdin is a TTY, the source returns `None` to allow the chain to
15/// continue to the next source.
16///
17/// # Example
18///
19/// ```ignore
20/// use standout_input::{InputChain, ArgSource, StdinSource};
21///
22/// // For: echo "hello" | myapp
23/// let chain = InputChain::<String>::new()
24///     .try_source(ArgSource::new("message"))
25///     .try_source(StdinSource::new());
26/// ```
27///
28/// # Testing
29///
30/// Use [`StdinSource::with_reader`] to inject a mock for testing:
31///
32/// ```ignore
33/// use standout_input::{StdinSource, MockStdin};
34///
35/// let source = StdinSource::with_reader(MockStdin::piped("test input"));
36/// ```
37#[derive(Clone)]
38pub struct StdinSource<R: StdinReader = DefaultStdin> {
39    reader: Arc<R>,
40    trim: bool,
41}
42
43impl StdinSource<DefaultStdin> {
44    /// Create a new stdin source.
45    ///
46    /// The source reads via
47    /// [`DefaultStdin`](crate::env::DefaultStdin), which honors a test
48    /// override installed through
49    /// [`set_default_stdin_reader`](crate::env::set_default_stdin_reader)
50    /// and otherwise falls back to real stdin.
51    pub fn new() -> Self {
52        Self {
53            reader: Arc::new(DefaultStdin),
54            trim: true,
55        }
56    }
57}
58
59impl Default for StdinSource<DefaultStdin> {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl<R: StdinReader> StdinSource<R> {
66    /// Create a stdin source with a custom reader.
67    ///
68    /// This is primarily used for testing to inject mock stdin.
69    pub fn with_reader(reader: R) -> Self {
70        Self {
71            reader: Arc::new(reader),
72            trim: true,
73        }
74    }
75
76    /// Control whether to trim whitespace from the input.
77    ///
78    /// Default is `true`.
79    pub fn trim(mut self, trim: bool) -> Self {
80        self.trim = trim;
81        self
82    }
83}
84
85impl<R: StdinReader + 'static> InputCollector<String> for StdinSource<R> {
86    fn name(&self) -> &'static str {
87        "stdin"
88    }
89
90    fn is_available(&self, _matches: &ArgMatches) -> bool {
91        // Stdin is available if it's piped (not a terminal)
92        !self.reader.is_terminal()
93    }
94
95    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
96        if self.reader.is_terminal() {
97            return Ok(None);
98        }
99
100        let content = self
101            .reader
102            .read_to_string()
103            .map_err(InputError::StdinFailed)?;
104
105        if content.is_empty() {
106            return Ok(None);
107        }
108
109        let result = if self.trim {
110            content.trim().to_string()
111        } else {
112            content
113        };
114
115        if result.is_empty() {
116            Ok(None)
117        } else {
118            Ok(Some(result))
119        }
120    }
121}
122
123/// Convenience function to read stdin if piped.
124///
125/// Returns `Ok(Some(content))` if stdin is piped and has content,
126/// `Ok(None)` if stdin is a terminal or empty. Honors any reader installed
127/// via [`set_default_stdin_reader`](crate::env::set_default_stdin_reader).
128pub fn read_if_piped() -> Result<Option<String>, InputError> {
129    let reader = DefaultStdin;
130    if reader.is_terminal() {
131        return Ok(None);
132    }
133
134    let content = reader.read_to_string().map_err(InputError::StdinFailed)?;
135
136    if content.trim().is_empty() {
137        Ok(None)
138    } else {
139        Ok(Some(content.trim().to_string()))
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::env::MockStdin;
147    use clap::Command;
148
149    fn empty_matches() -> ArgMatches {
150        Command::new("test").try_get_matches_from(["test"]).unwrap()
151    }
152
153    #[test]
154    fn stdin_available_when_piped() {
155        let source = StdinSource::with_reader(MockStdin::piped("content"));
156        assert!(source.is_available(&empty_matches()));
157    }
158
159    #[test]
160    fn stdin_unavailable_when_terminal() {
161        let source = StdinSource::with_reader(MockStdin::terminal());
162        assert!(!source.is_available(&empty_matches()));
163    }
164
165    #[test]
166    fn stdin_reads_piped_content() {
167        let source = StdinSource::with_reader(MockStdin::piped("hello world"));
168        let result = source.collect(&empty_matches()).unwrap();
169        assert_eq!(result, Some("hello world".to_string()));
170    }
171
172    #[test]
173    fn stdin_trims_whitespace() {
174        let source = StdinSource::with_reader(MockStdin::piped("  hello  \n"));
175        let result = source.collect(&empty_matches()).unwrap();
176        assert_eq!(result, Some("hello".to_string()));
177    }
178
179    #[test]
180    fn stdin_no_trim() {
181        let source = StdinSource::with_reader(MockStdin::piped("  hello  \n")).trim(false);
182        let result = source.collect(&empty_matches()).unwrap();
183        assert_eq!(result, Some("  hello  \n".to_string()));
184    }
185
186    #[test]
187    fn stdin_returns_none_for_empty() {
188        let source = StdinSource::with_reader(MockStdin::piped_empty());
189        let result = source.collect(&empty_matches()).unwrap();
190        assert_eq!(result, None);
191    }
192
193    #[test]
194    fn stdin_returns_none_for_whitespace_only() {
195        let source = StdinSource::with_reader(MockStdin::piped("   \n\t  "));
196        let result = source.collect(&empty_matches()).unwrap();
197        assert_eq!(result, None);
198    }
199
200    #[test]
201    fn stdin_returns_none_when_terminal() {
202        let source = StdinSource::with_reader(MockStdin::terminal());
203        let result = source.collect(&empty_matches()).unwrap();
204        assert_eq!(result, None);
205    }
206}