Skip to main content

argot_cmd/model/
flag.rs

1use serde::{Deserialize, Serialize};
2
3use super::BuildError;
4
5/// A named flag accepted by a command, e.g. `--verbose` or `-v`.
6///
7/// Flags can be boolean (no value) or value-taking (`--output json`). Boolean
8/// flags are stored as `"true"` in [`crate::ParsedCommand::flags`] when
9/// present. Use [`Flag::builder`] to construct instances.
10///
11/// # Examples
12///
13/// ```
14/// # use argot_cmd::Flag;
15/// let flag = Flag::builder("verbose")
16///     .short('v')
17///     .description("Enable verbose output")
18///     .build()
19///     .unwrap();
20///
21/// assert_eq!(flag.name, "verbose");
22/// assert_eq!(flag.short, Some('v'));
23/// assert!(!flag.takes_value);
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
26pub struct Flag {
27    /// The long flag name, used as `--name` on the command line and as the key
28    /// in [`crate::ParsedCommand::flags`].
29    pub name: String,
30    /// Optional single-character short form, used as `-c` on the command line.
31    pub short: Option<char>,
32    /// Human-readable description shown in help output.
33    pub description: String,
34    /// Whether the parser returns an error when this flag is absent.
35    pub required: bool,
36    /// Whether the flag consumes the following token (or `=value`) as its value.
37    ///
38    /// When `false` the flag is boolean: its presence sets the value to
39    /// `"true"`.
40    pub takes_value: bool,
41    /// Value substituted when the flag is not provided (optional flags only).
42    pub default: Option<String>,
43    /// If set, the flag value must be one of these strings (case-sensitive).
44    /// Only meaningful when `takes_value` is true.
45    pub choices: Option<Vec<String>>,
46    /// If true, this flag may appear multiple times in an invocation.
47    ///
48    /// For boolean flags: occurrences are counted; stored as a numeric string
49    /// (e.g., `-v -v -v` → `"3"`).
50    /// For value-taking flags: values are collected into a JSON array string
51    /// (e.g., `--tag a --tag b` → `["a","b"]`).
52    pub repeatable: bool,
53    /// Environment variable to check when the flag is absent from the command line.
54    ///
55    /// Lookup order: CLI argv → env var → `default` → required error.
56    pub env: Option<String>,
57}
58
59/// Consuming builder for [`Flag`].
60///
61/// Obtain via [`Flag::builder`]. Call [`FlagBuilder::build`] when done.
62///
63/// # Examples
64///
65/// ```
66/// # use argot_cmd::Flag;
67/// let flag = Flag::builder("output")
68///     .short('o')
69///     .description("Output format")
70///     .takes_value()
71///     .default_value("text")
72///     .build()
73///     .unwrap();
74///
75/// assert!(flag.takes_value);
76/// assert_eq!(flag.default.as_deref(), Some("text"));
77/// ```
78pub struct FlagBuilder {
79    name: String,
80    short: Option<char>,
81    description: String,
82    required: bool,
83    takes_value: bool,
84    default: Option<String>,
85    choices: Option<Vec<String>>,
86    repeatable: bool,
87    env: Option<String>,
88}
89
90impl Flag {
91    /// Create a new [`FlagBuilder`] with the given long flag name.
92    ///
93    /// # Arguments
94    ///
95    /// - `name` — The long flag name (without the `--` prefix). Must be
96    ///   non-empty after trimming (enforced by [`FlagBuilder::build`]).
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// # use argot_cmd::Flag;
102    /// let flag = Flag::builder("dry-run").build().unwrap();
103    /// assert_eq!(flag.name, "dry-run");
104    /// ```
105    pub fn builder(name: impl Into<String>) -> FlagBuilder {
106        FlagBuilder {
107            name: name.into(),
108            short: None,
109            description: String::new(),
110            required: false,
111            takes_value: false,
112            default: None,
113            choices: None,
114            repeatable: false,
115            env: None,
116        }
117    }
118}
119
120impl FlagBuilder {
121    /// Set the short single-character form of this flag (e.g. `'v'` for `-v`).
122    pub fn short(mut self, c: char) -> Self {
123        self.short = Some(c);
124        self
125    }
126
127    /// Set the human-readable description shown in help output.
128    pub fn description(mut self, d: impl Into<String>) -> Self {
129        self.description = d.into();
130        self
131    }
132
133    /// Mark this flag as required.
134    ///
135    /// The parser will return [`crate::ParseError::MissingFlag`] if the flag
136    /// is absent from the invocation.
137    pub fn required(mut self) -> Self {
138        self.required = true;
139        self
140    }
141
142    /// Mark this flag as value-taking.
143    ///
144    /// When set, the parser expects either `--name=value` or `--name value`
145    /// syntax. Without this, the flag is boolean and the mere presence of the
146    /// flag sets the value to `"true"`.
147    pub fn takes_value(mut self) -> Self {
148        self.takes_value = true;
149        self
150    }
151
152    /// Set the default value used when this flag is not provided.
153    ///
154    /// Only meaningful for optional (`!required`) value-taking flags. If the
155    /// flag is absent from the invocation, the default is inserted into
156    /// [`crate::ParsedCommand::flags`] automatically by the parser.
157    pub fn default_value(mut self, d: impl Into<String>) -> Self {
158        self.default = Some(d.into());
159        self
160    }
161
162    /// Restrict this flag's value to one of the given choices.
163    ///
164    /// Only meaningful for value-taking flags (`takes_value()`).
165    /// The parser returns [`crate::ParseError::InvalidChoice`] if the provided
166    /// value is not in the list.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// # use argot_cmd::Flag;
172    /// let flag = Flag::builder("format")
173    ///     .takes_value()
174    ///     .choices(["json", "yaml", "text"])
175    ///     .build()
176    ///     .unwrap();
177    /// let expected: Vec<String> = vec!["json".into(), "yaml".into(), "text".into()];
178    /// assert_eq!(flag.choices.as_deref(), Some(expected.as_slice()));
179    /// ```
180    pub fn choices(mut self, choices: impl IntoIterator<Item = impl Into<String>>) -> Self {
181        self.choices = Some(choices.into_iter().map(Into::into).collect());
182        self
183    }
184
185    /// Allow this flag to be specified more than once.
186    ///
187    /// For boolean flags: occurrences are counted and stored as a numeric string.
188    /// For value-taking flags: values are collected into a JSON array string.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// # use argot_cmd::Flag;
194    /// let flag = Flag::builder("verbose").repeatable().build().unwrap();
195    /// assert!(flag.repeatable);
196    /// ```
197    pub fn repeatable(mut self) -> Self {
198        self.repeatable = true;
199        self
200    }
201
202    /// Register an environment variable as a fallback source for this flag.
203    ///
204    /// When the flag is not provided on the command line, the parser calls
205    /// `std::env::var(var_name)`. If the variable is set and non-empty, its
206    /// value is used (validated against `choices` if set). The full lookup
207    /// order is: CLI → env var → default value → required error.
208    ///
209    /// # Examples
210    ///
211    /// ```
212    /// # use argot_cmd::Flag;
213    /// let flag = Flag::builder("token").takes_value().env("DEPLOY_TOKEN").build().unwrap();
214    /// assert_eq!(flag.env.as_deref(), Some("DEPLOY_TOKEN"));
215    /// ```
216    pub fn env(mut self, var_name: impl Into<String>) -> Self {
217        self.env = Some(var_name.into());
218        self
219    }
220
221    /// Consume the builder and return a [`Flag`].
222    ///
223    /// # Errors
224    ///
225    /// Returns [`BuildError::EmptyCanonical`] if the flag name is empty or
226    /// consists entirely of whitespace.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// # use argot_cmd::{Flag, BuildError};
232    /// assert!(Flag::builder("verbose").build().is_ok());
233    /// assert_eq!(Flag::builder("").build().unwrap_err(), BuildError::EmptyCanonical);
234    /// ```
235    pub fn build(self) -> Result<Flag, BuildError> {
236        if self.name.trim().is_empty() {
237            return Err(BuildError::EmptyCanonical);
238        }
239        Ok(Flag {
240            name: self.name,
241            short: self.short,
242            description: self.description,
243            required: self.required,
244            takes_value: self.takes_value,
245            default: self.default,
246            choices: self.choices,
247            repeatable: self.repeatable,
248            env: self.env,
249        })
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_builder_happy_path() {
259        let flag = Flag::builder("verbose")
260            .short('v')
261            .description("verbose output")
262            .build()
263            .unwrap();
264        assert_eq!(flag.name, "verbose");
265        assert_eq!(flag.short, Some('v'));
266        assert!(!flag.required);
267        assert!(!flag.takes_value);
268    }
269
270    #[test]
271    fn test_builder_empty_name() {
272        assert!(Flag::builder("").build().is_err());
273        assert!(Flag::builder("  ").build().is_err());
274    }
275
276    #[test]
277    fn test_takes_value_with_default() {
278        let flag = Flag::builder("output")
279            .takes_value()
280            .default_value("stdout")
281            .build()
282            .unwrap();
283        assert!(flag.takes_value);
284        assert_eq!(flag.default.as_deref(), Some("stdout"));
285    }
286
287    #[test]
288    fn test_serde_round_trip() {
289        let flag = Flag::builder("format")
290            .short('f')
291            .takes_value()
292            .required()
293            .build()
294            .unwrap();
295        let json = serde_json::to_string(&flag).unwrap();
296        let de: Flag = serde_json::from_str(&json).unwrap();
297        assert_eq!(flag, de);
298    }
299}