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::{RealStdin, 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 = RealStdin> {
39    reader: Arc<R>,
40    trim: bool,
41}
42
43impl StdinSource<RealStdin> {
44    /// Create a new stdin source using real stdin.
45    pub fn new() -> Self {
46        Self {
47            reader: Arc::new(RealStdin),
48            trim: true,
49        }
50    }
51}
52
53impl Default for StdinSource<RealStdin> {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl<R: StdinReader> StdinSource<R> {
60    /// Create a stdin source with a custom reader.
61    ///
62    /// This is primarily used for testing to inject mock stdin.
63    pub fn with_reader(reader: R) -> Self {
64        Self {
65            reader: Arc::new(reader),
66            trim: true,
67        }
68    }
69
70    /// Control whether to trim whitespace from the input.
71    ///
72    /// Default is `true`.
73    pub fn trim(mut self, trim: bool) -> Self {
74        self.trim = trim;
75        self
76    }
77}
78
79impl<R: StdinReader + 'static> InputCollector<String> for StdinSource<R> {
80    fn name(&self) -> &'static str {
81        "stdin"
82    }
83
84    fn is_available(&self, _matches: &ArgMatches) -> bool {
85        // Stdin is available if it's piped (not a terminal)
86        !self.reader.is_terminal()
87    }
88
89    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
90        if self.reader.is_terminal() {
91            return Ok(None);
92        }
93
94        let content = self
95            .reader
96            .read_to_string()
97            .map_err(InputError::StdinFailed)?;
98
99        if content.is_empty() {
100            return Ok(None);
101        }
102
103        let result = if self.trim {
104            content.trim().to_string()
105        } else {
106            content
107        };
108
109        if result.is_empty() {
110            Ok(None)
111        } else {
112            Ok(Some(result))
113        }
114    }
115}
116
117/// Convenience function to read stdin if piped.
118///
119/// Returns `Ok(Some(content))` if stdin is piped and has content,
120/// `Ok(None)` if stdin is a terminal or empty.
121pub fn read_if_piped() -> Result<Option<String>, InputError> {
122    let reader = RealStdin;
123    if reader.is_terminal() {
124        return Ok(None);
125    }
126
127    let content = reader.read_to_string().map_err(InputError::StdinFailed)?;
128
129    if content.trim().is_empty() {
130        Ok(None)
131    } else {
132        Ok(Some(content.trim().to_string()))
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::env::MockStdin;
140    use clap::Command;
141
142    fn empty_matches() -> ArgMatches {
143        Command::new("test").try_get_matches_from(["test"]).unwrap()
144    }
145
146    #[test]
147    fn stdin_available_when_piped() {
148        let source = StdinSource::with_reader(MockStdin::piped("content"));
149        assert!(source.is_available(&empty_matches()));
150    }
151
152    #[test]
153    fn stdin_unavailable_when_terminal() {
154        let source = StdinSource::with_reader(MockStdin::terminal());
155        assert!(!source.is_available(&empty_matches()));
156    }
157
158    #[test]
159    fn stdin_reads_piped_content() {
160        let source = StdinSource::with_reader(MockStdin::piped("hello world"));
161        let result = source.collect(&empty_matches()).unwrap();
162        assert_eq!(result, Some("hello world".to_string()));
163    }
164
165    #[test]
166    fn stdin_trims_whitespace() {
167        let source = StdinSource::with_reader(MockStdin::piped("  hello  \n"));
168        let result = source.collect(&empty_matches()).unwrap();
169        assert_eq!(result, Some("hello".to_string()));
170    }
171
172    #[test]
173    fn stdin_no_trim() {
174        let source = StdinSource::with_reader(MockStdin::piped("  hello  \n")).trim(false);
175        let result = source.collect(&empty_matches()).unwrap();
176        assert_eq!(result, Some("  hello  \n".to_string()));
177    }
178
179    #[test]
180    fn stdin_returns_none_for_empty() {
181        let source = StdinSource::with_reader(MockStdin::piped_empty());
182        let result = source.collect(&empty_matches()).unwrap();
183        assert_eq!(result, None);
184    }
185
186    #[test]
187    fn stdin_returns_none_for_whitespace_only() {
188        let source = StdinSource::with_reader(MockStdin::piped("   \n\t  "));
189        let result = source.collect(&empty_matches()).unwrap();
190        assert_eq!(result, None);
191    }
192
193    #[test]
194    fn stdin_returns_none_when_terminal() {
195        let source = StdinSource::with_reader(MockStdin::terminal());
196        let result = source.collect(&empty_matches()).unwrap();
197        assert_eq!(result, None);
198    }
199}