Skip to main content

standout_input/sources/
arg.rs

1//! CLI argument input sources.
2
3use clap::ArgMatches;
4
5use crate::collector::{InputCollector, InputSourceKind, ResolvedInput};
6use crate::InputError;
7
8/// Collect input from a CLI argument.
9///
10/// This source reads a string value from a clap argument. It is available
11/// when the argument was provided by the user.
12///
13/// # Example
14///
15/// ```ignore
16/// use standout_input::{InputChain, ArgSource};
17///
18/// // For: myapp --message "hello"
19/// let chain = InputChain::<String>::new()
20///     .try_source(ArgSource::new("message"));
21/// ```
22#[derive(Debug, Clone)]
23pub struct ArgSource {
24    name: String,
25}
26
27impl ArgSource {
28    /// Create a new argument source.
29    ///
30    /// The `name` should match the argument name defined in clap.
31    pub fn new(name: impl Into<String>) -> Self {
32        Self { name: name.into() }
33    }
34
35    /// Get the argument name.
36    pub fn arg_name(&self) -> &str {
37        &self.name
38    }
39}
40
41impl InputCollector<String> for ArgSource {
42    fn name(&self) -> &'static str {
43        "argument"
44    }
45
46    fn is_available(&self, matches: &ArgMatches) -> bool {
47        matches.contains_id(&self.name) && matches.get_one::<String>(&self.name).is_some()
48    }
49
50    fn collect(&self, matches: &ArgMatches) -> Result<Option<String>, InputError> {
51        Ok(matches.get_one::<String>(&self.name).cloned())
52    }
53}
54
55/// Collect input from a CLI flag.
56///
57/// This source reads a boolean flag value. It is always available since
58/// flags have a default value of `false`.
59///
60/// # Example
61///
62/// ```ignore
63/// use standout_input::{InputChain, FlagSource};
64///
65/// // For: myapp --verbose
66/// let chain = InputChain::<bool>::new()
67///     .try_source(FlagSource::new("verbose"));
68/// ```
69#[derive(Debug, Clone)]
70pub struct FlagSource {
71    name: String,
72    invert: bool,
73}
74
75impl FlagSource {
76    /// Create a new flag source.
77    ///
78    /// The `name` should match the flag name defined in clap.
79    pub fn new(name: impl Into<String>) -> Self {
80        Self {
81            name: name.into(),
82            invert: false,
83        }
84    }
85
86    /// Invert the flag value.
87    ///
88    /// Useful for patterns like `--no-editor` where the flag being set
89    /// means "don't use editor" (i.e., `false` for "use editor").
90    pub fn inverted(mut self) -> Self {
91        self.invert = true;
92        self
93    }
94
95    /// Get the flag name.
96    pub fn flag_name(&self) -> &str {
97        &self.name
98    }
99}
100
101impl InputCollector<bool> for FlagSource {
102    fn name(&self) -> &'static str {
103        "flag"
104    }
105
106    fn is_available(&self, matches: &ArgMatches) -> bool {
107        // Flags are always "available" - they default to false
108        matches.contains_id(&self.name)
109    }
110
111    fn collect(&self, matches: &ArgMatches) -> Result<Option<bool>, InputError> {
112        let value = matches.get_flag(&self.name);
113        let result = if self.invert { !value } else { value };
114
115        // Only return Some if the flag was explicitly set (true)
116        // This allows the chain to continue if the flag wasn't provided
117        if matches.get_flag(&self.name) {
118            Ok(Some(result))
119        } else {
120            Ok(None)
121        }
122    }
123}
124
125/// Resolve a flag source to a [`ResolvedInput`].
126impl FlagSource {
127    /// Resolve the flag, returning metadata about the source.
128    pub fn resolve(&self, matches: &ArgMatches) -> Result<ResolvedInput<bool>, InputError> {
129        let value = matches.get_flag(&self.name);
130        let result = if self.invert { !value } else { value };
131
132        Ok(ResolvedInput {
133            value: result,
134            source: InputSourceKind::Flag,
135        })
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use clap::{Arg, Command};
143
144    fn make_matches(args: &[&str]) -> ArgMatches {
145        Command::new("test")
146            .arg(Arg::new("message").long("message").short('m'))
147            .arg(
148                Arg::new("verbose")
149                    .long("verbose")
150                    .short('v')
151                    .action(clap::ArgAction::SetTrue),
152            )
153            .arg(
154                Arg::new("no-editor")
155                    .long("no-editor")
156                    .action(clap::ArgAction::SetTrue),
157            )
158            .try_get_matches_from(args)
159            .unwrap()
160    }
161
162    #[test]
163    fn arg_source_available_when_provided() {
164        let matches = make_matches(&["test", "--message", "hello"]);
165        let source = ArgSource::new("message");
166
167        assert!(source.is_available(&matches));
168        assert_eq!(source.collect(&matches).unwrap(), Some("hello".to_string()));
169    }
170
171    #[test]
172    fn arg_source_unavailable_when_missing() {
173        let matches = make_matches(&["test"]);
174        let source = ArgSource::new("message");
175
176        assert!(!source.is_available(&matches));
177        assert_eq!(source.collect(&matches).unwrap(), None);
178    }
179
180    #[test]
181    fn flag_source_returns_some_when_set() {
182        let matches = make_matches(&["test", "--verbose"]);
183        let source = FlagSource::new("verbose");
184
185        assert!(source.is_available(&matches));
186        assert_eq!(source.collect(&matches).unwrap(), Some(true));
187    }
188
189    #[test]
190    fn flag_source_returns_none_when_not_set() {
191        let matches = make_matches(&["test"]);
192        let source = FlagSource::new("verbose");
193
194        // Flag is "available" (defined) but returns None if not explicitly set
195        assert!(source.is_available(&matches));
196        assert_eq!(source.collect(&matches).unwrap(), None);
197    }
198
199    #[test]
200    fn flag_source_inverted() {
201        let matches = make_matches(&["test", "--no-editor"]);
202        let source = FlagSource::new("no-editor").inverted();
203
204        // --no-editor is set (true), but inverted means "use editor = false"
205        assert_eq!(source.collect(&matches).unwrap(), Some(false));
206    }
207
208    #[test]
209    fn flag_source_inverted_not_set() {
210        let matches = make_matches(&["test"]);
211        let source = FlagSource::new("no-editor").inverted();
212
213        // Flag not set, so returns None (not inverted false)
214        assert_eq!(source.collect(&matches).unwrap(), None);
215    }
216}