Skip to main content

standout_input/
chain.rs

1use std::fmt;
2
3use clap::ArgMatches;
4
5use crate::collector::{InputCollector, InputSourceKind, ResolvedInput};
6use crate::InputError;
7use crate::InputSources;
8
9type ValidatorFn<T> = Box<dyn Fn(&T) -> Result<(), String> + Send + Sync>;
10
11pub struct InputChain<T> {
12    sources: Vec<(Box<dyn InputCollector<T>>, InputSourceKind)>,
13    validators: Vec<(ValidatorFn<T>, String)>,
14    default: Option<T>,
15}
16
17impl<T: Clone + Send + Sync + 'static> InputChain<T> {
18    pub fn new() -> Self {
19        Self {
20            sources: Vec::new(),
21            validators: Vec::new(),
22            default: None,
23        }
24    }
25
26    pub fn try_source<C: InputCollector<T> + 'static>(mut self, source: C) -> Self {
27        let kind = source_kind_from_name(source.name());
28        self.sources.push((Box::new(source), kind));
29        self
30    }
31
32    pub fn try_source_with_kind<C: InputCollector<T> + 'static>(
33        mut self,
34        source: C,
35        kind: InputSourceKind,
36    ) -> Self {
37        self.sources.push((Box::new(source), kind));
38        self
39    }
40
41    pub fn validate<F>(mut self, f: F, error_msg: impl Into<String>) -> Self
42    where
43        F: Fn(&T) -> bool + Send + Sync + 'static,
44    {
45        let msg = error_msg.into();
46        let msg_for_closure = msg.clone();
47        self.validators.push((
48            Box::new(move |value| {
49                if f(value) {
50                    Ok(())
51                } else {
52                    Err(msg_for_closure.clone())
53                }
54            }),
55            msg,
56        ));
57        self
58    }
59
60    pub fn validate_with<F>(mut self, f: F) -> Self
61    where
62        F: Fn(&T) -> Result<(), String> + Send + Sync + 'static,
63    {
64        self.validators
65            .push((Box::new(f), "validation failed".to_string()));
66        self
67    }
68
69    pub fn default(mut self, value: T) -> Self {
70        self.default = Some(value);
71        self
72    }
73
74    pub fn resolve(&self, matches: &ArgMatches) -> Result<T, InputError> {
75        self.resolve_from(matches, &InputSources::from_process())
76    }
77
78    pub fn resolve_from(
79        &self,
80        matches: &ArgMatches,
81        sources: &InputSources,
82    ) -> Result<T, InputError> {
83        self.resolve_from_with_source(matches, sources)
84            .map(|r| r.value)
85    }
86
87    pub fn resolve_with_source(
88        &self,
89        matches: &ArgMatches,
90    ) -> Result<ResolvedInput<T>, InputError> {
91        self.resolve_from_with_source(matches, &InputSources::from_process())
92    }
93
94    pub fn resolve_from_with_source(
95        &self,
96        matches: &ArgMatches,
97        sources: &InputSources,
98    ) -> Result<ResolvedInput<T>, InputError> {
99        for (source, kind) in &self.sources {
100            let bound = source.bind_sources(sources);
101            let source: &dyn InputCollector<T> = match bound.as_ref() {
102                Some(bound) => bound.as_ref(),
103                None => source.as_ref(),
104            };
105            if !source.is_available(matches) {
106                continue;
107            }
108
109            #[allow(clippy::while_let_loop)]
110            'collect: loop {
111                match source.collect(matches)? {
112                    Some(value) => {
113                        if let Err(msg) = source.validate(&value) {
114                            if source.can_retry() {
115                                eprintln!("Invalid: {}", msg);
116                                continue 'collect;
117                            }
118                            return Err(InputError::ValidationFailed(msg));
119                        }
120
121                        for (validator, _) in &self.validators {
122                            if let Err(msg) = validator(&value) {
123                                if source.can_retry() {
124                                    eprintln!("Invalid: {}", msg);
125                                    continue 'collect;
126                                }
127                                return Err(InputError::ValidationFailed(msg));
128                            }
129                        }
130
131                        return Ok(ResolvedInput {
132                            value,
133                            source: *kind,
134                        });
135                    }
136                    None => break,
137                }
138            }
139        }
140
141        if let Some(value) = &self.default {
142            return Ok(ResolvedInput {
143                value: value.clone(),
144                source: InputSourceKind::Default,
145            });
146        }
147
148        Err(InputError::NoInput)
149    }
150
151    pub fn has_available_source(&self, matches: &ArgMatches) -> bool {
152        self.has_available_source_from(matches, &InputSources::from_process())
153    }
154
155    pub fn has_available_source_from(&self, matches: &ArgMatches, sources: &InputSources) -> bool {
156        self.sources.iter().any(|(source, _)| {
157            let bound = source.bind_sources(sources);
158            let source: &dyn InputCollector<T> = match bound.as_ref() {
159                Some(bound) => bound.as_ref(),
160                None => source.as_ref(),
161            };
162            source.is_available(matches)
163        }) || self.default.is_some()
164    }
165
166    pub fn source_count(&self) -> usize {
167        self.sources.len()
168    }
169}
170
171impl<T: Clone + Send + Sync + 'static> Default for InputChain<T> {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177impl<T> fmt::Debug for InputChain<T> {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        f.debug_struct("InputChain")
180            .field(
181                "sources",
182                &self.sources.iter().map(|(_, k)| k).collect::<Vec<_>>(),
183            )
184            .field("validators", &self.validators.len())
185            .field("has_default", &self.default.is_some())
186            .finish()
187    }
188}
189
190fn source_kind_from_name(name: &str) -> InputSourceKind {
191    match name {
192        "argument" => InputSourceKind::Arg,
193        "flag" => InputSourceKind::Flag,
194        "file" => InputSourceKind::File,
195        "stdin" => InputSourceKind::Stdin,
196        "environment variable" => InputSourceKind::Env,
197        "clipboard" => InputSourceKind::Clipboard,
198        "editor" => InputSourceKind::Editor,
199        "prompt" => InputSourceKind::Prompt,
200        "default" => InputSourceKind::Default,
201        _ => InputSourceKind::Default,
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::env::{MockClipboard, MockEnv, MockStdin};
209    use crate::sources::{ArgSource, ClipboardSource, DefaultSource, EnvSource, StdinSource};
210    use clap::{Arg, Command};
211
212    fn make_matches(args: &[&str]) -> ArgMatches {
213        Command::new("test")
214            .arg(Arg::new("message").long("message").short('m'))
215            .try_get_matches_from(args)
216            .unwrap()
217    }
218
219    #[test]
220    fn chain_resolves_first_available() {
221        let matches = make_matches(&["test", "--message", "from arg"]);
222
223        let chain = InputChain::<String>::new()
224            .try_source(ArgSource::new("message"))
225            .try_source(DefaultSource::new("default".to_string()));
226
227        let result = chain.resolve_with_source(&matches).unwrap();
228        assert_eq!(result.value, "from arg");
229        assert_eq!(result.source, InputSourceKind::Arg);
230    }
231
232    #[test]
233    fn chain_falls_back_to_next_source() {
234        let matches = make_matches(&["test"]);
235
236        let chain = InputChain::<String>::new()
237            .try_source(ArgSource::new("message"))
238            .try_source(StdinSource::with_reader(MockStdin::piped("from stdin")));
239
240        let result = chain.resolve_with_source(&matches).unwrap();
241        assert_eq!(result.value, "from stdin");
242        assert_eq!(result.source, InputSourceKind::Stdin);
243    }
244
245    #[test]
246    fn chain_falls_back_to_default() {
247        let matches = make_matches(&["test"]);
248
249        let chain = InputChain::<String>::new()
250            .try_source(ArgSource::new("message"))
251            .try_source(StdinSource::with_reader(MockStdin::terminal()))
252            .default("default value".to_string());
253
254        let result = chain.resolve_with_source(&matches).unwrap();
255        assert_eq!(result.value, "default value");
256        assert_eq!(result.source, InputSourceKind::Default);
257    }
258
259    #[test]
260    fn chain_error_when_no_input() {
261        let matches = make_matches(&["test"]);
262
263        let chain = InputChain::<String>::new()
264            .try_source(ArgSource::new("message"))
265            .try_source(StdinSource::with_reader(MockStdin::terminal()));
266
267        let result = chain.resolve(&matches);
268        assert!(matches!(result, Err(InputError::NoInput)));
269    }
270
271    #[test]
272    fn chain_validation_passes() {
273        let matches = make_matches(&["test", "--message", "valid@email.com"]);
274
275        let chain = InputChain::<String>::new()
276            .try_source(ArgSource::new("message"))
277            .validate(|s| s.contains('@'), "Must contain @");
278
279        let result = chain.resolve(&matches).unwrap();
280        assert_eq!(result, "valid@email.com");
281    }
282
283    #[test]
284    fn chain_validation_fails() {
285        let matches = make_matches(&["test", "--message", "invalid"]);
286
287        let chain = InputChain::<String>::new()
288            .try_source(ArgSource::new("message"))
289            .validate(|s| s.contains('@'), "Must contain @");
290
291        let result = chain.resolve(&matches);
292        assert!(matches!(result, Err(InputError::ValidationFailed(_))));
293    }
294
295    #[test]
296    fn chain_multiple_validators() {
297        let matches = make_matches(&["test", "--message", "ab"]);
298
299        let chain = InputChain::<String>::new()
300            .try_source(ArgSource::new("message"))
301            .validate(|s| !s.is_empty(), "Cannot be empty")
302            .validate(|s| s.len() >= 3, "Must be at least 3 characters");
303
304        let result = chain.resolve(&matches);
305        assert!(matches!(result, Err(InputError::ValidationFailed(_))));
306    }
307
308    #[test]
309    fn chain_complex_fallback() {
310        let matches = make_matches(&["test"]);
311
312        let chain = InputChain::<String>::new()
313            .try_source(ArgSource::new("message"))
314            .try_source(StdinSource::with_reader(MockStdin::terminal()))
315            .try_source(EnvSource::with_reader("MY_MSG", MockEnv::new()))
316            .try_source(ClipboardSource::with_reader(MockClipboard::with_content(
317                "from clipboard",
318            )));
319
320        let result = chain.resolve_with_source(&matches).unwrap();
321        assert_eq!(result.value, "from clipboard");
322        assert_eq!(result.source, InputSourceKind::Clipboard);
323    }
324
325    #[test]
326    fn chain_has_available_source() {
327        let matches = make_matches(&["test"]);
328
329        let chain_with_default = InputChain::<String>::new()
330            .try_source(ArgSource::new("message"))
331            .default("default".to_string());
332
333        assert!(chain_with_default.has_available_source(&matches));
334
335        let chain_without = InputChain::<String>::new().try_source(ArgSource::new("message"));
336
337        assert!(!chain_without.has_available_source(&matches));
338    }
339
340    #[test]
341    fn chain_source_count() {
342        let chain = InputChain::<String>::new()
343            .try_source(ArgSource::new("a"))
344            .try_source(ArgSource::new("b"))
345            .try_source(ArgSource::new("c"));
346
347        assert_eq!(chain.source_count(), 3);
348    }
349
350    #[cfg(feature = "simple-prompts")]
351    #[test]
352    fn chain_resolve_from_uses_scripted_responder_without_tty() {
353        use crate::sources::{MockTerminal, TextPromptSource};
354        use crate::{PromptResponse, ScriptedResponder};
355        use std::sync::Arc;
356
357        let matches = make_matches(&["test"]);
358        let chain = InputChain::<String>::new().try_source(TextPromptSource::with_terminal(
359            "Name: ",
360            MockTerminal::non_terminal(),
361        ));
362        let sources =
363            InputSources::from_process().with_responder(Arc::new(ScriptedResponder::new([
364                PromptResponse::text("Ada"),
365            ])));
366
367        assert_eq!(chain.resolve_from(&matches, &sources).unwrap(), "Ada");
368    }
369
370    #[cfg(feature = "simple-prompts")]
371    #[test]
372    fn chain_validation_retries_interactive_source_via_scripted_responder() {
373        use crate::sources::{MockTerminal, TextPromptSource};
374        use crate::{PromptResponse, ScriptedResponder};
375        use std::sync::Arc;
376
377        let matches = make_matches(&["test"]);
378        let chain = InputChain::<String>::new()
379            .try_source(TextPromptSource::with_terminal(
380                "Name: ",
381                MockTerminal::non_terminal(),
382            ))
383            .validate(|s| s.len() >= 3, "too short");
384        let sources =
385            InputSources::from_process().with_responder(Arc::new(ScriptedResponder::new([
386                PromptResponse::text("ab"),
387                PromptResponse::text("Ada"),
388            ])));
389
390        assert_eq!(chain.resolve_from(&matches, &sources).unwrap(), "Ada");
391    }
392
393    #[cfg(feature = "simple-prompts")]
394    #[test]
395    fn chain_skips_interactive_source_without_responder_or_tty() {
396        use crate::sources::{MockTerminal, TextPromptSource};
397
398        let matches = make_matches(&["test"]);
399        let chain = InputChain::<String>::new()
400            .try_source(TextPromptSource::with_terminal(
401                "Name: ",
402                MockTerminal::non_terminal(),
403            ))
404            .default("fallback".to_string());
405        let sources = InputSources::from_process();
406
407        assert_eq!(chain.resolve_from(&matches, &sources).unwrap(), "fallback");
408        assert!(chain.has_available_source_from(&matches, &sources));
409    }
410
411    #[cfg(feature = "editor")]
412    #[test]
413    fn chain_resolve_from_uses_scripted_responder_for_editor() {
414        use crate::sources::{EditorSource, MockEditorRunner};
415        use crate::{PromptResponse, ScriptedResponder};
416        use std::sync::Arc;
417
418        let matches = make_matches(&["test"]);
419        let chain = InputChain::<String>::new()
420            .try_source(EditorSource::with_runner(MockEditorRunner::no_editor()));
421        let sources =
422            InputSources::from_process().with_responder(Arc::new(ScriptedResponder::new([
423                PromptResponse::text("edited body"),
424            ])));
425
426        assert_eq!(
427            chain.resolve_from(&matches, &sources).unwrap(),
428            "edited body"
429        );
430    }
431}