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}