Skip to main content

standout_input/sources/
stdin.rs

1use std::sync::Arc;
2
3use clap::ArgMatches;
4
5use crate::collector::InputCollector;
6use crate::env::{RealStdin, StdinReader};
7use crate::InputError;
8use crate::InputSources;
9
10#[derive(Clone)]
11pub struct StdinSource {
12    reader: Option<Arc<dyn StdinReader>>,
13    trim: bool,
14}
15
16impl StdinSource {
17    pub fn new() -> Self {
18        Self {
19            reader: None,
20            trim: true,
21        }
22    }
23
24    pub fn with_reader(reader: impl StdinReader + 'static) -> Self {
25        Self {
26            reader: Some(Arc::new(reader)),
27            trim: true,
28        }
29    }
30
31    pub fn with_shared_reader(reader: Arc<dyn StdinReader>) -> Self {
32        Self {
33            reader: Some(reader),
34            trim: true,
35        }
36    }
37
38    pub fn trim(mut self, trim: bool) -> Self {
39        self.trim = trim;
40        self
41    }
42
43    fn reader(&self) -> &dyn StdinReader {
44        self.reader
45            .as_deref()
46            .unwrap_or(&RealStdin as &dyn StdinReader)
47    }
48}
49
50impl Default for StdinSource {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl InputCollector<String> for StdinSource {
57    fn name(&self) -> &'static str {
58        "stdin"
59    }
60
61    fn is_available(&self, _matches: &ArgMatches) -> bool {
62        !self.reader().is_terminal()
63    }
64
65    fn collect(&self, _matches: &ArgMatches) -> Result<Option<String>, InputError> {
66        if self.reader().is_terminal() {
67            return Ok(None);
68        }
69
70        let content = self
71            .reader()
72            .read_to_string()
73            .map_err(InputError::StdinFailed)?;
74
75        if content.is_empty() {
76            return Ok(None);
77        }
78
79        let result = if self.trim {
80            content.trim().to_string()
81        } else {
82            content
83        };
84
85        if result.is_empty() {
86            Ok(None)
87        } else {
88            Ok(Some(result))
89        }
90    }
91
92    fn bind_sources(&self, sources: &InputSources) -> Option<Box<dyn InputCollector<String>>> {
93        if self.reader.is_some() {
94            return None;
95        }
96        Some(Box::new(Self {
97            reader: Some(sources.stdin_arc()),
98            trim: self.trim,
99        }))
100    }
101}
102
103pub fn read_if_piped() -> Result<Option<String>, InputError> {
104    read_if_piped_from(&InputSources::from_process())
105}
106
107pub fn read_if_piped_from(sources: &InputSources) -> Result<Option<String>, InputError> {
108    let reader = sources.stdin();
109    if reader.is_terminal() {
110        return Ok(None);
111    }
112
113    let content = reader.read_to_string().map_err(InputError::StdinFailed)?;
114
115    if content.trim().is_empty() {
116        Ok(None)
117    } else {
118        Ok(Some(content.trim().to_string()))
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::env::MockStdin;
126    use clap::Command;
127
128    fn empty_matches() -> ArgMatches {
129        Command::new("test").try_get_matches_from(["test"]).unwrap()
130    }
131
132    #[test]
133    fn stdin_available_when_piped() {
134        let source = StdinSource::with_reader(MockStdin::piped("content"));
135        assert!(source.is_available(&empty_matches()));
136    }
137
138    #[test]
139    fn stdin_unavailable_when_terminal() {
140        let source = StdinSource::with_reader(MockStdin::terminal());
141        assert!(!source.is_available(&empty_matches()));
142    }
143
144    #[test]
145    fn stdin_reads_piped_content() {
146        let source = StdinSource::with_reader(MockStdin::piped("hello world"));
147        let result = source.collect(&empty_matches()).unwrap();
148        assert_eq!(result, Some("hello world".to_string()));
149    }
150
151    #[test]
152    fn stdin_trims_whitespace() {
153        let source = StdinSource::with_reader(MockStdin::piped("  hello  \n"));
154        let result = source.collect(&empty_matches()).unwrap();
155        assert_eq!(result, Some("hello".to_string()));
156    }
157
158    #[test]
159    fn stdin_no_trim() {
160        let source = StdinSource::with_reader(MockStdin::piped("  hello  \n")).trim(false);
161        let result = source.collect(&empty_matches()).unwrap();
162        assert_eq!(result, Some("  hello  \n".to_string()));
163    }
164
165    #[test]
166    fn stdin_returns_none_for_empty() {
167        let source = StdinSource::with_reader(MockStdin::piped_empty());
168        let result = source.collect(&empty_matches()).unwrap();
169        assert_eq!(result, None);
170    }
171
172    #[test]
173    fn stdin_returns_none_for_whitespace_only() {
174        let source = StdinSource::with_reader(MockStdin::piped("   \n\t  "));
175        let result = source.collect(&empty_matches()).unwrap();
176        assert_eq!(result, None);
177    }
178
179    #[test]
180    fn stdin_returns_none_when_terminal() {
181        let source = StdinSource::with_reader(MockStdin::terminal());
182        let result = source.collect(&empty_matches()).unwrap();
183        assert_eq!(result, None);
184    }
185
186    #[test]
187    fn bind_sources_uses_invocation_stdin() {
188        let source = StdinSource::new();
189        let sources = InputSources::from_process().with_stdin(MockStdin::piped("bound"));
190        let bound = source.bind_sources(&sources).expect("unbound source binds");
191        let result = bound.collect(&empty_matches()).unwrap();
192        assert_eq!(result, Some("bound".to_string()));
193    }
194
195    #[test]
196    fn bind_sources_keeps_explicit_reader() {
197        let source = StdinSource::with_reader(MockStdin::piped("explicit"));
198        let sources = InputSources::from_process().with_stdin(MockStdin::piped("ignored"));
199        assert!(source.bind_sources(&sources).is_none());
200        assert_eq!(
201            source.collect(&empty_matches()).unwrap(),
202            Some("explicit".to_string())
203        );
204    }
205}