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}