argot_cmd/model/command.rs
1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use super::{Argument, BuildError, Example, Flag};
7
8/// A handler function that can be registered on a [`Command`].
9///
10/// The function is stored in an [`Arc`] so that [`Command::clone`] only
11/// increments a reference count rather than copying the closure. The
12/// higher-ranked trait bound (`for<'a>`) allows the handler to be called with
13/// a [`ParsedCommand`] of any lifetime, which is required because the parsed
14/// command borrows from the command tree at call time.
15///
16/// # Examples
17///
18/// ```
19/// # use std::sync::Arc;
20/// # use argot_cmd::HandlerFn;
21/// let handler: HandlerFn = Arc::new(|parsed| {
22/// println!("running command: {}", parsed.command.canonical);
23/// Ok(())
24/// });
25/// ```
26pub type HandlerFn =
27 Arc<dyn for<'a> Fn(&ParsedCommand<'a>) -> Result<(), Box<dyn std::error::Error>> + Send + Sync>;
28
29/// An async handler function that can be registered on a [`Command`].
30///
31/// The function receives a [`ParsedCommand`] reference and returns a
32/// pinned, boxed future. Use [`Command::builder`] → [`CommandBuilder::async_handler`]
33/// to register one.
34///
35/// # Feature
36///
37/// Requires the `async` feature flag.
38///
39/// # Examples
40///
41/// ```
42/// # #[cfg(feature = "async")] {
43/// use std::sync::Arc;
44/// use argot_cmd::AsyncHandlerFn;
45///
46/// let handler: AsyncHandlerFn = Arc::new(|parsed| Box::pin(async move {
47/// println!("async command: {}", parsed.command.canonical);
48/// Ok(())
49/// }));
50/// # }
51/// ```
52#[cfg(feature = "async")]
53pub type AsyncHandlerFn = std::sync::Arc<
54 dyn for<'a> Fn(
55 &'a ParsedCommand<'a>,
56 ) -> std::pin::Pin<
57 Box<
58 dyn std::future::Future<
59 Output = Result<(), Box<dyn std::error::Error + Send + Sync>>,
60 > + Send
61 + 'a,
62 >,
63 > + Send
64 + Sync,
65>;
66
67/// The result of successfully parsing an invocation against a [`Command`].
68///
69/// `ParsedCommand` borrows the matched [`Command`] from the registry (lifetime
70/// `'a`) and owns the resolved argument and flag value maps. Keys in both maps
71/// are the canonical names of the argument/flag definitions.
72///
73/// # Examples
74///
75/// ```
76/// # use argot_cmd::{Command, Argument, Parser};
77/// let cmd = Command::builder("get")
78/// .argument(
79/// Argument::builder("id")
80/// .required()
81/// .build()
82/// .unwrap(),
83/// )
84/// .build()
85/// .unwrap();
86///
87/// let cmds = vec![cmd];
88/// let parser = Parser::new(&cmds);
89/// let parsed = parser.parse(&["get", "42"]).unwrap();
90///
91/// assert_eq!(parsed.command.canonical, "get");
92/// assert_eq!(parsed.args["id"], "42");
93/// ```
94#[derive(Debug)]
95pub struct ParsedCommand<'a> {
96 /// The matched [`Command`] definition, borrowed from the registry.
97 pub command: &'a Command,
98 /// Resolved positional argument values, keyed by argument name.
99 pub args: HashMap<String, String>,
100 /// Resolved flag values, keyed by flag name.
101 ///
102 /// Boolean flags (those without `takes_value`) are stored as `"true"`.
103 /// Flags that were not provided but have a `default` value are included
104 /// with that default.
105 pub flags: HashMap<String, String>,
106}
107
108impl<'a> ParsedCommand<'a> {
109 /// Return a positional argument value by name, or `None` if absent.
110 ///
111 /// # Examples
112 ///
113 /// ```
114 /// # use argot_cmd::{Argument, Command, Parser};
115 /// let cmd = Command::builder("get")
116 /// .argument(Argument::builder("id").required().build().unwrap())
117 /// .build().unwrap();
118 /// let cmds = vec![cmd];
119 /// let parsed = Parser::new(&cmds).parse(&["get", "42"]).unwrap();
120 /// assert_eq!(parsed.arg("id"), Some("42"));
121 /// assert_eq!(parsed.arg("missing"), None);
122 /// ```
123 pub fn arg(&self, name: &str) -> Option<&str> {
124 self.args.get(name).map(String::as_str)
125 }
126
127 /// Return a flag value by name, or `None` if absent.
128 ///
129 /// # Examples
130 ///
131 /// ```
132 /// # use argot_cmd::{Command, Flag, Parser};
133 /// let cmd = Command::builder("run")
134 /// .flag(Flag::builder("output").takes_value().default_value("text").build().unwrap())
135 /// .build().unwrap();
136 /// let cmds = vec![cmd];
137 /// let parsed = Parser::new(&cmds).parse(&["run", "--output=json"]).unwrap();
138 /// assert_eq!(parsed.flag("output"), Some("json"));
139 /// ```
140 pub fn flag(&self, name: &str) -> Option<&str> {
141 self.flags.get(name).map(String::as_str)
142 }
143
144 /// Return `true` if a boolean flag is present and set to `"true"`, `false` otherwise.
145 ///
146 /// # Examples
147 ///
148 /// ```
149 /// # use argot_cmd::{Command, Flag, Parser};
150 /// let cmd = Command::builder("build")
151 /// .flag(Flag::builder("release").build().unwrap())
152 /// .build().unwrap();
153 /// let cmds = vec![cmd];
154 /// let parsed = Parser::new(&cmds).parse(&["build", "--release"]).unwrap();
155 /// assert!(parsed.flag_bool("release"));
156 /// assert!(!parsed.flag_bool("missing"));
157 /// ```
158 pub fn flag_bool(&self, name: &str) -> bool {
159 self.flags.get(name).map(|v| v == "true").unwrap_or(false)
160 }
161
162 /// Return the occurrence count for a repeatable boolean flag (stored as a numeric string).
163 /// Returns `0` if the flag was not provided.
164 ///
165 /// For flags stored as `"true"` (non-repeatable), returns `1`.
166 ///
167 /// # Examples
168 ///
169 /// ```
170 /// # use argot_cmd::{Command, Flag, Parser};
171 /// // With a repeatable flag (see Flag::repeatable), -v -v -v → flag_count("verbose") == 3
172 /// // With a normal flag, --verbose → flag_count("verbose") == 1 (stored as "true")
173 /// let cmd = Command::builder("run")
174 /// .flag(Flag::builder("verbose").short('v').build().unwrap())
175 /// .build().unwrap();
176 /// let cmds = vec![cmd];
177 /// let parsed = Parser::new(&cmds).parse(&["run", "-v"]).unwrap();
178 /// // Non-repeatable flag stores "true"; flag_count returns 1
179 /// assert_eq!(parsed.flag_count("verbose"), 1);
180 /// assert_eq!(parsed.flag_count("missing"), 0);
181 /// ```
182 pub fn flag_count(&self, name: &str) -> u64 {
183 match self.flags.get(name) {
184 None => 0,
185 Some(v) if v == "true" => 1,
186 Some(v) if v == "false" => 0,
187 Some(v) => v.parse().unwrap_or(0),
188 }
189 }
190
191 /// Return all values for a repeatable value-taking flag as a `Vec<String>`.
192 ///
193 /// - If the flag was provided multiple times (repeatable), the stored JSON array is
194 /// deserialized into a `Vec`.
195 /// - If the flag was provided once (non-repeatable), returns a single-element `Vec`.
196 /// - If the flag was not provided, returns an empty `Vec`.
197 ///
198 /// # Examples
199 ///
200 /// ```
201 /// # use argot_cmd::{Command, Flag, Parser};
202 /// let cmd = Command::builder("run")
203 /// .flag(Flag::builder("output").takes_value().default_value("text").build().unwrap())
204 /// .build().unwrap();
205 /// let cmds = vec![cmd];
206 /// let parsed = Parser::new(&cmds).parse(&["run", "--output=json"]).unwrap();
207 /// assert_eq!(parsed.flag_values("output"), vec!["json"]);
208 /// assert!(parsed.flag_values("missing").is_empty());
209 /// ```
210 pub fn flag_values(&self, name: &str) -> Vec<String> {
211 match self.flags.get(name) {
212 None => vec![],
213 Some(v) => serde_json::from_str::<Vec<String>>(v).unwrap_or_else(|_| vec![v.clone()]),
214 }
215 }
216
217 /// Return `true` if `name` is present in the parsed flag map.
218 ///
219 /// A flag is present when it was explicitly provided on the command line
220 /// **or** when the parser inserted a default or env-var value.
221 /// To distinguish explicit from default, use [`ParsedCommand::flag`] and
222 /// compare with the flag definition's default.
223 ///
224 /// # Examples
225 ///
226 /// ```
227 /// # use argot_cmd::{Command, Flag, Parser};
228 /// let cmd = Command::builder("run")
229 /// .flag(Flag::builder("verbose").build().unwrap())
230 /// .flag(Flag::builder("output").takes_value().default_value("text").build().unwrap())
231 /// .build().unwrap();
232 /// let cmds = vec![cmd];
233 /// let parsed = Parser::new(&cmds).parse(&["run", "--verbose"]).unwrap();
234 /// assert!(parsed.has_flag("verbose"));
235 /// assert!(parsed.has_flag("output")); // present via default
236 /// assert!(!parsed.has_flag("missing"));
237 /// ```
238 pub fn has_flag(&self, name: &str) -> bool {
239 self.flags.contains_key(name)
240 }
241
242 /// Parse a positional argument as type `T` using [`std::str::FromStr`].
243 ///
244 /// Returns:
245 /// - `None` if the argument was not provided (absent from the map)
246 /// - `Some(Ok(value))` if parsing succeeded
247 /// - `Some(Err(e))` if the string was present but could not be parsed
248 ///
249 /// # Examples
250 ///
251 /// ```
252 /// # use argot_cmd::{Argument, Command, Parser};
253 /// let cmd = Command::builder("resize")
254 /// .argument(Argument::builder("width").required().build().unwrap())
255 /// .build().unwrap();
256 /// let cmds = vec![cmd];
257 /// let parsed = Parser::new(&cmds).parse(&["resize", "1920"]).unwrap();
258 ///
259 /// let width: u32 = parsed.arg_as("width").unwrap().unwrap();
260 /// assert_eq!(width, 1920u32);
261 ///
262 /// assert!(parsed.arg_as::<u32>("missing").is_none());
263 /// ```
264 pub fn arg_as<T: std::str::FromStr>(&self, name: &str) -> Option<Result<T, T::Err>> {
265 self.args.get(name).map(|v| v.parse())
266 }
267
268 /// Parse a flag value as type `T` using [`std::str::FromStr`].
269 ///
270 /// Returns:
271 /// - `None` if the flag was not set (and has no default)
272 /// - `Some(Ok(value))` if parsing succeeded
273 /// - `Some(Err(e))` if the string was present but could not be parsed
274 ///
275 /// # Examples
276 ///
277 /// ```
278 /// # use argot_cmd::{Command, Flag, Parser};
279 /// let cmd = Command::builder("serve")
280 /// .flag(Flag::builder("port").takes_value().default_value("8080").build().unwrap())
281 /// .build().unwrap();
282 /// let cmds = vec![cmd];
283 /// let parsed = Parser::new(&cmds).parse(&["serve"]).unwrap();
284 ///
285 /// let port: u16 = parsed.flag_as("port").unwrap().unwrap();
286 /// assert_eq!(port, 8080u16);
287 /// ```
288 pub fn flag_as<T: std::str::FromStr>(&self, name: &str) -> Option<Result<T, T::Err>> {
289 self.flags.get(name).map(|v| v.parse())
290 }
291
292 /// Parse a positional argument as type `T`, returning a default if absent or unparseable.
293 ///
294 /// # Examples
295 ///
296 /// ```
297 /// # use argot_cmd::{Argument, Command, Parser};
298 /// let cmd = Command::builder("run")
299 /// .argument(Argument::builder("count").build().unwrap())
300 /// .build().unwrap();
301 /// let cmds = vec![cmd];
302 /// let parsed = Parser::new(&cmds).parse(&["run"]).unwrap();
303 ///
304 /// assert_eq!(parsed.arg_as_or("count", 1u32), 1u32);
305 /// ```
306 pub fn arg_as_or<T: std::str::FromStr>(&self, name: &str, default: T) -> T {
307 self.arg_as(name).and_then(|r| r.ok()).unwrap_or(default)
308 }
309
310 /// Parse a flag value as type `T`, returning a default if absent or unparseable.
311 ///
312 /// # Examples
313 ///
314 /// ```
315 /// # use argot_cmd::{Command, Flag, Parser};
316 /// let cmd = Command::builder("serve")
317 /// .flag(Flag::builder("workers").takes_value().build().unwrap())
318 /// .build().unwrap();
319 /// let cmds = vec![cmd];
320 /// let parsed = Parser::new(&cmds).parse(&["serve"]).unwrap();
321 ///
322 /// assert_eq!(parsed.flag_as_or("workers", 4u32), 4u32);
323 /// ```
324 pub fn flag_as_or<T: std::str::FromStr>(&self, name: &str, default: T) -> T {
325 self.flag_as(name).and_then(|r| r.ok()).unwrap_or(default)
326 }
327}
328
329/// A command in the registry, potentially with subcommands.
330///
331/// Commands are the central unit of argot. Each command has a canonical name,
332/// optional aliases and alternate spellings, human-readable documentation,
333/// typed positional arguments, named flags, usage examples, and an optional
334/// handler closure. Commands can be nested arbitrarily deep via
335/// [`Command::subcommands`].
336///
337/// Use [`Command::builder`] to construct instances — direct struct
338/// construction is intentionally not exposed.
339///
340/// # Serialization
341///
342/// `Command` implements `serde::Serialize` / `Deserialize`. The [`handler`]
343/// field is skipped during serialization (it cannot be represented in JSON)
344/// and will be `None` after deserialization.
345///
346/// # Examples
347///
348/// ```
349/// # use argot_cmd::{Command, Argument, Flag, Example};
350/// let cmd = Command::builder("deploy")
351/// .alias("d")
352/// .summary("Deploy the app")
353/// .description("Deploys to the specified environment.")
354/// .argument(
355/// Argument::builder("env")
356/// .description("Target environment")
357/// .required()
358/// .build()
359/// .unwrap(),
360/// )
361/// .flag(
362/// Flag::builder("dry-run")
363/// .short('n')
364/// .description("Simulate only")
365/// .build()
366/// .unwrap(),
367/// )
368/// .example(Example::new("deploy to prod", "myapp deploy production"))
369/// .build()
370/// .unwrap();
371///
372/// assert_eq!(cmd.canonical, "deploy");
373/// assert_eq!(cmd.aliases, vec!["d"]);
374/// ```
375///
376/// [`handler`]: Command::handler
377#[derive(Clone, Serialize, Deserialize)]
378pub struct Command {
379 /// The primary, canonical name used to invoke this command.
380 pub canonical: String,
381 /// Alternative names that resolve to this command (e.g. `"ls"` for `"list"`).
382 pub aliases: Vec<String>,
383 /// Alternate capitalizations or spellings (e.g. `"LIST"` for `"list"`).
384 ///
385 /// Spellings differ from aliases semantically: they represent the same
386 /// word written differently rather than a short-form abbreviation.
387 pub spellings: Vec<String>,
388 /// One-line description shown in command listings.
389 pub summary: String,
390 /// Full prose description shown in detailed help output.
391 pub description: String,
392 /// Ordered list of positional arguments accepted by this command.
393 pub arguments: Vec<Argument>,
394 /// Named flags (long and/or short) accepted by this command.
395 pub flags: Vec<Flag>,
396 /// Usage examples shown in help and Markdown documentation.
397 pub examples: Vec<Example>,
398 /// Nested sub-commands (e.g. `remote add`, `remote remove`).
399 pub subcommands: Vec<Command>,
400 /// Prose tips about correct usage, surfaced to AI agents.
401 pub best_practices: Vec<String>,
402 /// Prose warnings about incorrect usage, surfaced to AI agents.
403 pub anti_patterns: Vec<String>,
404 /// Natural-language phrases describing what this command does.
405 ///
406 /// Used for intent-based discovery (e.g. [`crate::query::Registry::match_intent`])
407 /// but intentionally excluded from normal help output.
408 pub semantic_aliases: Vec<String>,
409 /// Optional runtime handler invoked by [`crate::cli::Cli::run`].
410 ///
411 /// Skipped during JSON serialization/deserialization.
412 #[serde(skip)]
413 pub handler: Option<HandlerFn>,
414 /// Optional async runtime handler (feature: `async`).
415 ///
416 /// Skipped during JSON serialization.
417 #[cfg(feature = "async")]
418 #[serde(skip)]
419 pub async_handler: Option<AsyncHandlerFn>,
420 /// Arbitrary application-defined metadata.
421 ///
422 /// Use this to attach structured data that is not covered by the built-in
423 /// fields (e.g., permission requirements, category tags, telemetry labels).
424 ///
425 /// Serialized to JSON as an object; absent from output when empty.
426 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
427 pub extra: HashMap<String, serde_json::Value>,
428 /// Groups of mutually exclusive flag names.
429 ///
430 /// At most one flag in each group may be provided in a single invocation.
431 /// Validated at build time (flags must exist) and enforced at parse time.
432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
433 pub exclusive_groups: Vec<Vec<String>>,
434 /// Whether this command mutates state (creates, updates, or deletes resources).
435 ///
436 /// Set to `true` for commands that are not safe to run freely without
437 /// a `--dry-run` preview first. AI agents use this field to decide
438 /// whether they need explicit user confirmation before dispatching.
439 ///
440 /// Use [`CommandBuilder::mutating`] to set this field.
441 ///
442 /// # Examples
443 ///
444 /// ```
445 /// # use argot_cmd::Command;
446 /// let cmd = Command::builder("delete")
447 /// .summary("Delete a resource")
448 /// .mutating()
449 /// .build()
450 /// .unwrap();
451 ///
452 /// assert!(cmd.mutating);
453 /// ```
454 #[serde(default)]
455 pub mutating: bool,
456}
457
458impl std::fmt::Debug for Command {
459 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460 let mut ds = f.debug_struct("Command");
461 ds.field("canonical", &self.canonical)
462 .field("aliases", &self.aliases)
463 .field("spellings", &self.spellings)
464 .field("summary", &self.summary)
465 .field("description", &self.description)
466 .field("arguments", &self.arguments)
467 .field("flags", &self.flags)
468 .field("examples", &self.examples)
469 .field("subcommands", &self.subcommands)
470 .field("best_practices", &self.best_practices)
471 .field("anti_patterns", &self.anti_patterns)
472 .field("semantic_aliases", &self.semantic_aliases)
473 .field("handler", &self.handler.as_ref().map(|_| "<handler>"));
474 #[cfg(feature = "async")]
475 ds.field(
476 "async_handler",
477 &self.async_handler.as_ref().map(|_| "<async_handler>"),
478 );
479 ds.field("extra", &self.extra)
480 .field("exclusive_groups", &self.exclusive_groups)
481 .field("mutating", &self.mutating)
482 .finish()
483 }
484}
485
486impl PartialEq for Command {
487 fn eq(&self, other: &Self) -> bool {
488 self.canonical == other.canonical
489 && self.aliases == other.aliases
490 && self.spellings == other.spellings
491 && self.summary == other.summary
492 && self.description == other.description
493 && self.arguments == other.arguments
494 && self.flags == other.flags
495 && self.examples == other.examples
496 && self.subcommands == other.subcommands
497 && self.best_practices == other.best_practices
498 && self.anti_patterns == other.anti_patterns
499 && self.semantic_aliases == other.semantic_aliases
500 && self.extra == other.extra
501 && self.exclusive_groups == other.exclusive_groups
502 && self.mutating == other.mutating
503 }
504}
505
506impl Eq for Command {}
507
508impl std::hash::Hash for Command {
509 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
510 self.canonical.hash(state);
511 self.aliases.hash(state);
512 self.spellings.hash(state);
513 self.summary.hash(state);
514 self.description.hash(state);
515 self.arguments.hash(state);
516 self.flags.hash(state);
517 self.examples.hash(state);
518 self.subcommands.hash(state);
519 self.best_practices.hash(state);
520 self.anti_patterns.hash(state);
521 self.semantic_aliases.hash(state);
522 // handler is intentionally excluded (not hashable)
523 // extra: hash keys in sorted order, with their JSON string representation
524 {
525 let mut keys: Vec<&String> = self.extra.keys().collect();
526 keys.sort();
527 for k in keys {
528 k.hash(state);
529 self.extra[k].to_string().hash(state);
530 }
531 }
532 self.exclusive_groups.hash(state);
533 self.mutating.hash(state);
534 }
535}
536
537impl PartialOrd for Command {
538 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
539 Some(self.cmp(other))
540 }
541}
542
543/// Commands are ordered by canonical name, then by their full field contents.
544impl Ord for Command {
545 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
546 self.canonical
547 .cmp(&other.canonical)
548 .then_with(|| self.summary.cmp(&other.summary))
549 .then_with(|| self.aliases.cmp(&other.aliases))
550 }
551}
552
553impl Command {
554 /// Create a new [`CommandBuilder`] with the given canonical name.
555 ///
556 /// # Arguments
557 ///
558 /// - `canonical` — The primary command name. Must be non-empty after
559 /// trimming (enforced by [`CommandBuilder::build`]).
560 ///
561 /// # Examples
562 ///
563 /// ```
564 /// # use argot_cmd::Command;
565 /// let cmd = Command::builder("list").build().unwrap();
566 /// assert_eq!(cmd.canonical, "list");
567 /// ```
568 pub fn builder(canonical: impl Into<String>) -> CommandBuilder {
569 CommandBuilder {
570 canonical: canonical.into(),
571 aliases: Vec::new(),
572 spellings: Vec::new(),
573 summary: String::new(),
574 description: String::new(),
575 arguments: Vec::new(),
576 flags: Vec::new(),
577 examples: Vec::new(),
578 subcommands: Vec::new(),
579 best_practices: Vec::new(),
580 anti_patterns: Vec::new(),
581 semantic_aliases: Vec::new(),
582 handler: None,
583 #[cfg(feature = "async")]
584 async_handler: None,
585 extra: HashMap::new(),
586 exclusive_groups: Vec::new(),
587 mutating: false,
588 }
589 }
590
591 /// All strings this command can be matched by for exact lookup
592 /// (canonical + aliases + spellings), lowercased.
593 pub(crate) fn matchable_strings(&self) -> Vec<String> {
594 let mut v = vec![self.canonical.to_lowercase()];
595 v.extend(self.aliases.iter().map(|s| s.to_lowercase()));
596 v.extend(self.spellings.iter().map(|s| s.to_lowercase()));
597 v
598 }
599
600 /// Strings eligible for prefix matching (canonical + aliases only), lowercased.
601 ///
602 /// Spellings are intentionally excluded: they are exact-match-only so they
603 /// do not contribute to prefix ambiguity resolution and never appear in
604 /// "did you mean?" / ambiguous-candidate output.
605 pub(crate) fn prefix_matchable_strings(&self) -> Vec<String> {
606 let mut v = vec![self.canonical.to_lowercase()];
607 v.extend(self.aliases.iter().map(|s| s.to_lowercase()));
608 v
609 }
610}
611
612/// Consuming builder for [`Command`].
613///
614/// Obtain an instance via [`Command::builder`]. All setter methods consume and
615/// return `self` to allow method chaining. Call [`CommandBuilder::build`] to
616/// produce a [`Command`].
617///
618/// # Examples
619///
620/// ```
621/// # use argot_cmd::{Command, Flag};
622/// let cmd = Command::builder("run")
623/// .alias("r")
624/// .summary("Run the pipeline")
625/// .flag(Flag::builder("verbose").short('v').build().unwrap())
626/// .build()
627/// .unwrap();
628///
629/// assert_eq!(cmd.canonical, "run");
630/// assert_eq!(cmd.aliases, vec!["r"]);
631/// ```
632pub struct CommandBuilder {
633 canonical: String,
634 aliases: Vec<String>,
635 spellings: Vec<String>,
636 summary: String,
637 description: String,
638 arguments: Vec<Argument>,
639 flags: Vec<Flag>,
640 examples: Vec<Example>,
641 subcommands: Vec<Command>,
642 best_practices: Vec<String>,
643 anti_patterns: Vec<String>,
644 semantic_aliases: Vec<String>,
645 handler: Option<HandlerFn>,
646 #[cfg(feature = "async")]
647 async_handler: Option<AsyncHandlerFn>,
648 extra: HashMap<String, serde_json::Value>,
649 exclusive_groups: Vec<Vec<String>>,
650 mutating: bool,
651}
652
653impl CommandBuilder {
654 /// Replace the entire alias list with the given collection.
655 ///
656 /// To add a single alias use [`CommandBuilder::alias`].
657 pub fn aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
658 self.aliases = aliases.into_iter().map(Into::into).collect();
659 self
660 }
661
662 /// Append a single alias.
663 pub fn alias(mut self, alias: impl Into<String>) -> Self {
664 self.aliases.push(alias.into());
665 self
666 }
667
668 /// Replace the entire spelling list with the given collection.
669 ///
670 /// Spellings are silent typo-corrections or abbreviated forms that the
671 /// resolver accepts but does **not** advertise in help text. They differ
672 /// from aliases in that they will never appear in the `--help` aliases
673 /// line, and they are excluded from prefix-match ambiguity candidates.
674 ///
675 /// To add a single spelling use [`CommandBuilder::spelling`].
676 pub fn spellings(mut self, spellings: impl IntoIterator<Item = impl Into<String>>) -> Self {
677 self.spellings = spellings.into_iter().map(Into::into).collect();
678 self
679 }
680
681 /// Register a common misspelling or abbreviated form that resolves to this command.
682 ///
683 /// Unlike [`CommandBuilder::alias`], spellings are **not shown in help text**.
684 /// They serve as silent typo-correction or shorthand that resolves
685 /// to the canonical command name without advertising the alternative.
686 ///
687 /// # Difference from `alias`
688 ///
689 /// | Feature | `alias` | `spelling` |
690 /// |---------|---------|------------|
691 /// | Shown in `--help` | Yes | No |
692 /// | Resolver exact match | Yes | Yes |
693 /// | Resolver prefix match | Yes | No |
694 /// | Appears in `aliases` field | Yes | No (goes in `spellings`) |
695 ///
696 /// # Examples
697 ///
698 /// ```
699 /// # use argot_cmd::Command;
700 /// let cmd = Command::builder("deploy")
701 /// .alias("release") // shown in help: "deploy (release)"
702 /// .alias("ship") // shown in help
703 /// .spelling("deply") // silent typo correction
704 /// .spelling("delpoy") // silent typo correction
705 /// .build().unwrap();
706 ///
707 /// assert!(cmd.aliases.contains(&"release".to_string()));
708 /// assert!(cmd.spellings.contains(&"deply".to_string()));
709 /// ```
710 pub fn spelling(mut self, s: impl Into<String>) -> Self {
711 self.spellings.push(s.into());
712 self
713 }
714
715 /// Set the one-line summary shown in command listings.
716 pub fn summary(mut self, s: impl Into<String>) -> Self {
717 self.summary = s.into();
718 self
719 }
720
721 /// Set the full prose description shown in detailed help output.
722 pub fn description(mut self, d: impl Into<String>) -> Self {
723 self.description = d.into();
724 self
725 }
726
727 /// Append a positional [`Argument`] definition.
728 ///
729 /// Arguments are bound in declaration order when the command is parsed.
730 pub fn argument(mut self, arg: Argument) -> Self {
731 self.arguments.push(arg);
732 self
733 }
734
735 /// Append a [`Flag`] definition.
736 pub fn flag(mut self, flag: Flag) -> Self {
737 self.flags.push(flag);
738 self
739 }
740
741 /// Append a usage [`Example`].
742 pub fn example(mut self, example: Example) -> Self {
743 self.examples.push(example);
744 self
745 }
746
747 /// Append a subcommand.
748 ///
749 /// Subcommands are resolved before positional arguments during parsing, so
750 /// a token that matches a subcommand name is consumed as the subcommand
751 /// selector, not as a positional value.
752 pub fn subcommand(mut self, cmd: Command) -> Self {
753 self.subcommands.push(cmd);
754 self
755 }
756
757 /// Append a best-practice tip surfaced to AI agents.
758 pub fn best_practice(mut self, bp: impl Into<String>) -> Self {
759 self.best_practices.push(bp.into());
760 self
761 }
762
763 /// Append an anti-pattern warning surfaced to AI agents.
764 pub fn anti_pattern(mut self, ap: impl Into<String>) -> Self {
765 self.anti_patterns.push(ap.into());
766 self
767 }
768
769 /// Replace the entire semantic alias list with the given collection.
770 ///
771 /// Semantic aliases are natural-language phrases used for intent-based
772 /// discovery but are **not shown in help text**.
773 ///
774 /// To add a single semantic alias use [`CommandBuilder::semantic_alias`].
775 pub fn semantic_aliases(
776 mut self,
777 aliases: impl IntoIterator<Item = impl Into<String>>,
778 ) -> Self {
779 self.semantic_aliases = aliases.into_iter().map(Into::into).collect();
780 self
781 }
782
783 /// Append a single natural-language phrase for intent-based discovery.
784 ///
785 /// Unlike [`CommandBuilder::alias`], semantic aliases are natural-language
786 /// phrases describing what the command does (e.g. `"release to production"`,
787 /// `"push to environment"`). They are used by
788 /// [`crate::query::Registry::match_intent`] but are **not shown in help text**.
789 ///
790 /// # Examples
791 ///
792 /// ```
793 /// # use argot_cmd::Command;
794 /// let cmd = Command::builder("deploy")
795 /// .semantic_alias("release to production")
796 /// .semantic_alias("push to environment")
797 /// .build().unwrap();
798 ///
799 /// assert_eq!(cmd.semantic_aliases, vec!["release to production", "push to environment"]);
800 /// ```
801 pub fn semantic_alias(mut self, s: impl Into<String>) -> Self {
802 self.semantic_aliases.push(s.into());
803 self
804 }
805
806 /// Set the runtime handler invoked when this command is dispatched.
807 ///
808 /// The handler receives a [`ParsedCommand`] and should return `Ok(())`
809 /// on success or a boxed error on failure.
810 pub fn handler(mut self, h: HandlerFn) -> Self {
811 self.handler = Some(h);
812 self
813 }
814
815 /// Register an async handler for this command (feature: `async`).
816 ///
817 /// The handler receives a [`ParsedCommand`] and returns a boxed future.
818 /// Use [`crate::Cli::run_async`] to dispatch async handlers.
819 ///
820 /// # Examples
821 ///
822 /// ```
823 /// # #[cfg(feature = "async")] {
824 /// use std::sync::Arc;
825 /// use argot_cmd::Command;
826 ///
827 /// let cmd = Command::builder("deploy")
828 /// .async_handler(Arc::new(|parsed| Box::pin(async move {
829 /// println!("deployed!");
830 /// Ok(())
831 /// })))
832 /// .build()
833 /// .unwrap();
834 /// # }
835 /// ```
836 #[cfg(feature = "async")]
837 pub fn async_handler(mut self, h: AsyncHandlerFn) -> Self {
838 self.async_handler = Some(h);
839 self
840 }
841
842 /// Set an arbitrary metadata entry on this command.
843 ///
844 /// # Examples
845 ///
846 /// ```
847 /// # use argot_cmd::Command;
848 /// # use serde_json::json;
849 /// let cmd = Command::builder("deploy")
850 /// .meta("category", json!("infrastructure"))
851 /// .meta("min_role", json!("ops"))
852 /// .build()
853 /// .unwrap();
854 ///
855 /// assert_eq!(cmd.extra["category"], json!("infrastructure"));
856 /// ```
857 pub fn meta(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
858 self.extra.insert(key.into(), value);
859 self
860 }
861
862 /// Declare a group of mutually exclusive flags.
863 ///
864 /// At most one flag from `flags` may be provided in a single invocation.
865 /// The parser returns [`crate::ParseError::MutuallyExclusive`] if two or
866 /// more flags from the same group are present.
867 ///
868 /// # Panics (at build time via [`BuildError`])
869 ///
870 /// [`CommandBuilder::build`] returns an error if:
871 /// - The group has fewer than 2 flags.
872 /// - Any flag name in the group is not defined on this command.
873 ///
874 /// # Examples
875 ///
876 /// ```
877 /// # use argot_cmd::{Command, Flag};
878 /// let cmd = Command::builder("export")
879 /// .flag(Flag::builder("json").build().unwrap())
880 /// .flag(Flag::builder("yaml").build().unwrap())
881 /// .flag(Flag::builder("csv").build().unwrap())
882 /// .exclusive(["json", "yaml", "csv"])
883 /// .build()
884 /// .unwrap();
885 /// assert_eq!(cmd.exclusive_groups.len(), 1);
886 /// ```
887 pub fn exclusive(mut self, flags: impl IntoIterator<Item = impl Into<String>>) -> Self {
888 self.exclusive_groups
889 .push(flags.into_iter().map(Into::into).collect());
890 self
891 }
892
893 /// Mark this command as mutating (i.e., it modifies state).
894 ///
895 /// Mutating commands are flagged in help output and JSON schema so that
896 /// AI agents know they should exercise caution — typically by running a
897 /// `--dry-run` first — before dispatching the command.
898 ///
899 /// # Examples
900 ///
901 /// ```
902 /// # use argot_cmd::Command;
903 /// let cmd = Command::builder("delete")
904 /// .summary("Delete a resource")
905 /// .mutating()
906 /// .build()
907 /// .unwrap();
908 ///
909 /// assert!(cmd.mutating);
910 /// ```
911 pub fn mutating(mut self) -> Self {
912 self.mutating = true;
913 self
914 }
915
916 /// Consume the builder and return a [`Command`].
917 ///
918 /// # Errors
919 ///
920 /// - [`BuildError::EmptyCanonical`] — canonical name is empty or whitespace.
921 /// - [`BuildError::AliasEqualsCanonical`] — an alias is identical to the
922 /// canonical name (case-insensitive).
923 /// - [`BuildError::DuplicateAlias`] — two aliases share the same string
924 /// (case-insensitive comparison).
925 /// - [`BuildError::DuplicateFlagName`] — two flags share the same long name.
926 /// - [`BuildError::DuplicateShortFlag`] — two flags share the same short
927 /// character.
928 /// - [`BuildError::DuplicateArgumentName`] — two positional arguments share
929 /// the same name.
930 /// - [`BuildError::DuplicateSubcommandName`] — two subcommands share the
931 /// same canonical name.
932 /// - [`BuildError::VariadicNotLast`] — a variadic argument is not the last
933 /// argument in the list.
934 ///
935 /// # Examples
936 ///
937 /// ```
938 /// # use argot_cmd::{Command, BuildError};
939 /// assert!(Command::builder("ok").build().is_ok());
940 /// assert_eq!(Command::builder("").build().unwrap_err(), BuildError::EmptyCanonical);
941 /// ```
942 pub fn build(self) -> Result<Command, BuildError> {
943 if self.canonical.trim().is_empty() {
944 return Err(BuildError::EmptyCanonical);
945 }
946
947 // 1. Alias equals canonical
948 let canonical_lower = self.canonical.to_lowercase();
949 for alias in &self.aliases {
950 if alias.to_lowercase() == canonical_lower {
951 return Err(BuildError::AliasEqualsCanonical(alias.clone()));
952 }
953 }
954
955 // 2. Duplicate aliases
956 let mut seen_aliases = std::collections::HashSet::new();
957 for alias in &self.aliases {
958 let key = alias.to_lowercase();
959 if !seen_aliases.insert(key) {
960 return Err(BuildError::DuplicateAlias(alias.clone()));
961 }
962 }
963
964 // 3. Duplicate flag names (long)
965 let mut seen_flag_names = std::collections::HashSet::new();
966 for flag in &self.flags {
967 if !seen_flag_names.insert(flag.name.clone()) {
968 return Err(BuildError::DuplicateFlagName(flag.name.clone()));
969 }
970 }
971
972 // 4. Duplicate short flags
973 let mut seen_short_flags = std::collections::HashSet::new();
974 for flag in &self.flags {
975 if let Some(c) = flag.short {
976 if !seen_short_flags.insert(c) {
977 return Err(BuildError::DuplicateShortFlag(c));
978 }
979 }
980 }
981
982 // 5. Duplicate argument names
983 let mut seen_arg_names = std::collections::HashSet::new();
984 for arg in &self.arguments {
985 if !seen_arg_names.insert(arg.name.clone()) {
986 return Err(BuildError::DuplicateArgumentName(arg.name.clone()));
987 }
988 }
989
990 // 6. Duplicate subcommand canonical names
991 let mut seen_sub_names = std::collections::HashSet::new();
992 for sub in &self.subcommands {
993 if !seen_sub_names.insert(sub.canonical.clone()) {
994 return Err(BuildError::DuplicateSubcommandName(sub.canonical.clone()));
995 }
996 }
997
998 // 7. Variadic argument must be last
999 for (i, arg) in self.arguments.iter().enumerate() {
1000 if arg.variadic && i != self.arguments.len() - 1 {
1001 return Err(BuildError::VariadicNotLast(arg.name.clone()));
1002 }
1003 }
1004
1005 // 8. Flags with choices must have a non-empty choices list
1006 for flag in &self.flags {
1007 if let Some(choices) = &flag.choices {
1008 if choices.is_empty() {
1009 return Err(BuildError::EmptyChoices(flag.name.clone()));
1010 }
1011 }
1012 }
1013
1014 // 9. Mutual exclusivity group validation
1015 for group in &self.exclusive_groups {
1016 if group.len() < 2 {
1017 return Err(BuildError::ExclusiveGroupTooSmall);
1018 }
1019 for flag_name in group {
1020 if !self.flags.iter().any(|f| &f.name == flag_name) {
1021 return Err(BuildError::ExclusiveGroupUnknownFlag(flag_name.clone()));
1022 }
1023 }
1024 }
1025
1026 Ok(Command {
1027 canonical: self.canonical,
1028 aliases: self.aliases,
1029 spellings: self.spellings,
1030 summary: self.summary,
1031 description: self.description,
1032 arguments: self.arguments,
1033 flags: self.flags,
1034 examples: self.examples,
1035 subcommands: self.subcommands,
1036 best_practices: self.best_practices,
1037 anti_patterns: self.anti_patterns,
1038 semantic_aliases: self.semantic_aliases,
1039 handler: self.handler,
1040 #[cfg(feature = "async")]
1041 async_handler: self.async_handler,
1042 extra: self.extra,
1043 exclusive_groups: self.exclusive_groups,
1044 mutating: self.mutating,
1045 })
1046 }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use crate::model::{Argument, Flag};
1053
1054 fn make_simple_cmd() -> Command {
1055 Command::builder("run")
1056 .alias("r")
1057 .spelling("RUN")
1058 .summary("Run something")
1059 .description("Runs the thing.")
1060 .build()
1061 .unwrap()
1062 }
1063
1064 #[test]
1065 fn test_builder_happy_path() {
1066 let cmd = make_simple_cmd();
1067 assert_eq!(cmd.canonical, "run");
1068 assert_eq!(cmd.aliases, vec!["r"]);
1069 assert_eq!(cmd.spellings, vec!["RUN"]);
1070 }
1071
1072 #[test]
1073 fn test_builder_empty_canonical() {
1074 assert_eq!(
1075 Command::builder("").build().unwrap_err(),
1076 BuildError::EmptyCanonical
1077 );
1078 assert_eq!(
1079 Command::builder(" ").build().unwrap_err(),
1080 BuildError::EmptyCanonical
1081 );
1082 }
1083
1084 #[test]
1085 fn test_partial_eq_ignores_handler() {
1086 let cmd1 = Command::builder("run").build().unwrap();
1087 let mut cmd2 = cmd1.clone();
1088 cmd2.handler = Some(Arc::new(|_| Ok(())));
1089 assert_eq!(cmd1, cmd2);
1090 }
1091
1092 #[test]
1093 fn test_serde_round_trip_skips_handler() {
1094 let cmd = Command::builder("deploy")
1095 .summary("Deploy the app")
1096 .argument(
1097 Argument::builder("env")
1098 .description("target env")
1099 .required()
1100 .build()
1101 .unwrap(),
1102 )
1103 .flag(
1104 Flag::builder("dry-run")
1105 .description("dry run mode")
1106 .build()
1107 .unwrap(),
1108 )
1109 .handler(Arc::new(|_| Ok(())))
1110 .build()
1111 .unwrap();
1112
1113 let json = serde_json::to_string(&cmd).unwrap();
1114 let de: Command = serde_json::from_str(&json).unwrap();
1115 assert_eq!(cmd, de);
1116 assert!(de.handler.is_none());
1117 }
1118
1119 #[test]
1120 fn test_matchable_strings() {
1121 let cmd = Command::builder("Git")
1122 .alias("g")
1123 .spelling("GIT")
1124 .build()
1125 .unwrap();
1126 let matchables = cmd.matchable_strings();
1127 assert!(matchables.contains(&"git".to_string()));
1128 assert!(matchables.contains(&"g".to_string()));
1129 assert!(matchables.contains(&"git".to_string())); // spelling lowercased
1130 }
1131
1132 #[test]
1133 fn test_clone_shares_handler() {
1134 let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1135 let called_clone = called.clone();
1136 let cmd = Command::builder("x")
1137 .handler(Arc::new(move |_| {
1138 called_clone.store(true, std::sync::atomic::Ordering::SeqCst);
1139 Ok(())
1140 }))
1141 .build()
1142 .unwrap();
1143 let cmd2 = cmd.clone();
1144 assert!(std::sync::Arc::ptr_eq(
1145 cmd.handler.as_ref().unwrap(),
1146 cmd2.handler.as_ref().unwrap()
1147 ));
1148 }
1149
1150 #[test]
1151 fn test_duplicate_alias_rejected() {
1152 let err = Command::builder("cmd")
1153 .alias("a")
1154 .alias("a")
1155 .build()
1156 .unwrap_err();
1157 assert!(matches!(err, BuildError::DuplicateAlias(_)));
1158 }
1159
1160 #[test]
1161 fn test_alias_equals_canonical_rejected() {
1162 let err = Command::builder("deploy")
1163 .alias("deploy")
1164 .build()
1165 .unwrap_err();
1166 assert!(matches!(err, BuildError::AliasEqualsCanonical(_)));
1167 }
1168
1169 #[test]
1170 fn test_duplicate_flag_name_rejected() {
1171 let flag = Flag::builder("verbose").build().unwrap();
1172 let err = Command::builder("cmd")
1173 .flag(flag.clone())
1174 .flag(flag)
1175 .build()
1176 .unwrap_err();
1177 assert!(matches!(err, BuildError::DuplicateFlagName(_)));
1178 }
1179
1180 #[test]
1181 fn test_duplicate_short_flag_rejected() {
1182 let f1 = Flag::builder("verbose").short('v').build().unwrap();
1183 let f2 = Flag::builder("version").short('v').build().unwrap();
1184 let err = Command::builder("cmd")
1185 .flag(f1)
1186 .flag(f2)
1187 .build()
1188 .unwrap_err();
1189 assert!(matches!(err, BuildError::DuplicateShortFlag('v')));
1190 }
1191
1192 #[test]
1193 fn test_duplicate_argument_name_rejected() {
1194 let arg = Argument::builder("env").build().unwrap();
1195 let err = Command::builder("cmd")
1196 .argument(arg.clone())
1197 .argument(arg)
1198 .build()
1199 .unwrap_err();
1200 assert!(matches!(err, BuildError::DuplicateArgumentName(_)));
1201 }
1202
1203 #[test]
1204 fn test_duplicate_subcommand_name_rejected() {
1205 let sub = Command::builder("add").build().unwrap();
1206 let err = Command::builder("remote")
1207 .subcommand(sub.clone())
1208 .subcommand(sub)
1209 .build()
1210 .unwrap_err();
1211 assert!(matches!(err, BuildError::DuplicateSubcommandName(_)));
1212 }
1213
1214 #[test]
1215 fn test_variadic_must_be_last() {
1216 let variadic = Argument::builder("files").variadic().build().unwrap();
1217 let after = Argument::builder("extra").build().unwrap();
1218 let err = Command::builder("cmd")
1219 .argument(variadic)
1220 .argument(after)
1221 .build()
1222 .unwrap_err();
1223 assert!(matches!(err, BuildError::VariadicNotLast(_)));
1224 }
1225
1226 #[test]
1227 fn test_meta_field_serde() {
1228 let cmd = Command::builder("x")
1229 .meta("role", serde_json::json!("admin"))
1230 .build()
1231 .unwrap();
1232 let json = serde_json::to_string(&cmd).unwrap();
1233 assert!(json.contains("admin"));
1234 let de: Command = serde_json::from_str(&json).unwrap();
1235 assert_eq!(de.extra["role"], serde_json::json!("admin"));
1236 }
1237
1238 #[test]
1239 fn test_meta_empty_not_serialized() {
1240 let cmd = Command::builder("x").build().unwrap();
1241 let json = serde_json::to_string(&cmd).unwrap();
1242 assert!(!json.contains("extra"));
1243 }
1244
1245 #[test]
1246 fn test_semantic_alias_builder() {
1247 let cmd = Command::builder("deploy")
1248 .semantic_alias("release to production")
1249 .semantic_alias("push to environment")
1250 .build()
1251 .unwrap();
1252 assert_eq!(
1253 cmd.semantic_aliases,
1254 vec!["release to production", "push to environment"]
1255 );
1256 }
1257
1258 #[test]
1259 fn test_semantic_aliases_bulk_builder() {
1260 let cmd = Command::builder("deploy")
1261 .semantic_aliases(["release to production", "push to environment"])
1262 .build()
1263 .unwrap();
1264 assert_eq!(
1265 cmd.semantic_aliases,
1266 vec!["release to production", "push to environment"]
1267 );
1268 }
1269
1270 #[test]
1271 fn test_semantic_alias_not_in_canonical_aliases() {
1272 let cmd = Command::builder("deploy")
1273 .alias("d")
1274 .semantic_alias("release to production")
1275 .build()
1276 .unwrap();
1277 // semantic_aliases and aliases are distinct fields
1278 assert_eq!(cmd.aliases, vec!["d"]);
1279 assert_eq!(cmd.semantic_aliases, vec!["release to production"]);
1280 assert!(!cmd.aliases.contains(&"release to production".to_string()));
1281 assert!(!cmd.semantic_aliases.contains(&"d".to_string()));
1282 }
1283
1284 #[test]
1285 fn test_debug_impl_includes_fields() {
1286 let cmd = Command::builder("debug-test")
1287 .alias("dt")
1288 .spelling("DEBUGTEST")
1289 .summary("Test debug")
1290 .description("A debug test command")
1291 .handler(Arc::new(|_| Ok(())))
1292 .build()
1293 .unwrap();
1294 let s = format!("{:?}", cmd);
1295 assert!(s.contains("debug-test"));
1296 assert!(s.contains("dt"));
1297 assert!(s.contains("Test debug"));
1298 // Handler should appear as "<handler>" not a raw pointer
1299 assert!(s.contains("<handler>"));
1300 // spellings should also be shown
1301 assert!(s.contains("DEBUGTEST"));
1302 }
1303
1304 #[test]
1305 fn test_hash_deterministic() {
1306 use std::collections::hash_map::DefaultHasher;
1307 use std::hash::{Hash, Hasher};
1308
1309 let cmd1 = Command::builder("hash-test")
1310 .summary("Hash")
1311 .meta("key", serde_json::json!("value"))
1312 .build()
1313 .unwrap();
1314 let cmd2 = Command::builder("hash-test")
1315 .summary("Hash")
1316 .meta("key", serde_json::json!("value"))
1317 .build()
1318 .unwrap();
1319
1320 let mut h1 = DefaultHasher::new();
1321 cmd1.hash(&mut h1);
1322 let mut h2 = DefaultHasher::new();
1323 cmd2.hash(&mut h2);
1324 assert_eq!(h1.finish(), h2.finish());
1325 }
1326
1327 #[test]
1328 fn test_ord_by_canonical() {
1329 let a = Command::builder("alpha").build().unwrap();
1330 let b = Command::builder("beta").build().unwrap();
1331 assert!(a < b);
1332 assert!(b > a);
1333 assert_eq!(a.partial_cmp(&a), Some(std::cmp::Ordering::Equal));
1334 }
1335
1336 #[test]
1337 fn test_ord_same_canonical_by_summary() {
1338 let a = Command::builder("cmd").summary("Alpha").build().unwrap();
1339 let b = Command::builder("cmd").summary("Beta").build().unwrap();
1340 assert!(a < b);
1341 }
1342
1343 #[test]
1344 fn test_aliases_builder_method() {
1345 let cmd = Command::builder("cmd")
1346 .aliases(["a", "b", "c"])
1347 .build()
1348 .unwrap();
1349 assert_eq!(cmd.aliases, vec!["a", "b", "c"]);
1350 }
1351
1352 #[test]
1353 fn test_spellings_builder_method() {
1354 let cmd = Command::builder("deploy")
1355 .spellings(["DEPLOY", "dploy"])
1356 .build()
1357 .unwrap();
1358 assert_eq!(cmd.spellings, vec!["DEPLOY", "dploy"]);
1359 }
1360
1361 #[test]
1362 fn test_best_practice_and_anti_pattern_builder() {
1363 let cmd = Command::builder("cmd")
1364 .best_practice("Always dry-run first")
1365 .anti_pattern("Deploy on Fridays")
1366 .build()
1367 .unwrap();
1368 assert_eq!(cmd.best_practices, vec!["Always dry-run first"]);
1369 assert_eq!(cmd.anti_patterns, vec!["Deploy on Fridays"]);
1370 }
1371
1372 #[test]
1373 fn test_exclusive_group_too_small() {
1374 use crate::model::BuildError;
1375 let result = Command::builder("cmd")
1376 .flag(Flag::builder("json").build().unwrap())
1377 .exclusive(["json"])
1378 .build();
1379 assert!(matches!(result, Err(BuildError::ExclusiveGroupTooSmall)));
1380 }
1381
1382 #[test]
1383 fn test_exclusive_group_unknown_flag() {
1384 use crate::model::BuildError;
1385 let result = Command::builder("cmd")
1386 .flag(Flag::builder("json").build().unwrap())
1387 .exclusive(["json", "nonexistent"])
1388 .build();
1389 assert!(matches!(
1390 result,
1391 Err(BuildError::ExclusiveGroupUnknownFlag(_))
1392 ));
1393 }
1394
1395 #[test]
1396 fn test_parsed_command_helpers() {
1397 use crate::{Argument, Flag, Parser};
1398 let cmd = Command::builder("serve")
1399 .argument(Argument::builder("host").required().build().unwrap())
1400 .flag(
1401 Flag::builder("port")
1402 .takes_value()
1403 .default_value("8080")
1404 .build()
1405 .unwrap(),
1406 )
1407 .flag(Flag::builder("verbose").build().unwrap())
1408 .build()
1409 .unwrap();
1410 let cmds = vec![cmd];
1411 let parser = Parser::new(&cmds);
1412 let parsed = parser.parse(&["serve", "localhost", "--verbose"]).unwrap();
1413
1414 assert_eq!(parsed.arg("host"), Some("localhost"));
1415 assert_eq!(parsed.arg("missing"), None);
1416 assert_eq!(parsed.flag("port"), Some("8080"));
1417 assert_eq!(parsed.flag("missing"), None);
1418 assert!(parsed.flag_bool("verbose"));
1419 assert!(!parsed.flag_bool("missing"));
1420 assert_eq!(parsed.flag_count("verbose"), 1);
1421 assert_eq!(parsed.flag_count("missing"), 0);
1422 assert_eq!(parsed.flag_values("port"), vec!["8080"]);
1423 assert!(parsed.flag_values("missing").is_empty());
1424 assert!(parsed.has_flag("port"));
1425 assert!(!parsed.has_flag("missing"));
1426
1427 let port: u16 = parsed.flag_as("port").unwrap().unwrap();
1428 assert_eq!(port, 8080);
1429
1430 let port_or: u16 = parsed.flag_as_or("port", 9000);
1431 assert_eq!(port_or, 8080);
1432
1433 let missing_or: u16 = parsed.flag_as_or("missing", 9000);
1434 assert_eq!(missing_or, 9000);
1435
1436 let host: String = parsed.arg_as("host").unwrap().unwrap();
1437 assert_eq!(host, "localhost");
1438
1439 let missing_as: Option<Result<u32, _>> = parsed.arg_as("missing");
1440 assert!(missing_as.is_none());
1441
1442 let arg_or: String = parsed.arg_as_or("host", "default".to_string());
1443 assert_eq!(arg_or, "localhost");
1444
1445 let missing_arg_or: String = parsed.arg_as_or("missing", "default".to_string());
1446 assert_eq!(missing_arg_or, "default");
1447 }
1448
1449 #[test]
1450 fn test_flag_count_false_returns_zero() {
1451 use crate::{Flag, Parser};
1452 let cmd = Command::builder("cmd")
1453 .flag(Flag::builder("verbose").build().unwrap())
1454 .build()
1455 .unwrap();
1456 let cmds = vec![cmd];
1457 let parser = Parser::new(&cmds);
1458 let parsed = parser.parse(&["cmd", "--no-verbose"]).unwrap();
1459 assert_eq!(parsed.flag_count("verbose"), 0);
1460 }
1461
1462 #[test]
1463 fn test_flag_values_json_array() {
1464 use crate::{Flag, Parser};
1465 let cmd = Command::builder("cmd")
1466 .flag(
1467 Flag::builder("tag")
1468 .takes_value()
1469 .repeatable()
1470 .build()
1471 .unwrap(),
1472 )
1473 .build()
1474 .unwrap();
1475 let cmds = vec![cmd];
1476 let parser = Parser::new(&cmds);
1477 let parsed = parser.parse(&["cmd", "--tag=alpha", "--tag=beta"]).unwrap();
1478 let values = parsed.flag_values("tag");
1479 assert_eq!(values, vec!["alpha", "beta"]);
1480 }
1481
1482 #[test]
1483 fn test_mutating_builder_sets_field() {
1484 let non_mutating = Command::builder("list").build().unwrap();
1485 assert!(!non_mutating.mutating, "default should be non-mutating");
1486
1487 let mutating = Command::builder("delete")
1488 .summary("Delete a resource")
1489 .mutating()
1490 .build()
1491 .unwrap();
1492 assert!(mutating.mutating, "mutating() should set mutating to true");
1493 }
1494
1495 #[test]
1496 fn test_mutating_serde_round_trip() {
1497 let cmd = Command::builder("delete")
1498 .summary("Delete a resource")
1499 .mutating()
1500 .build()
1501 .unwrap();
1502 let json = serde_json::to_string(&cmd).unwrap();
1503 assert!(json.contains("\"mutating\":true"), "JSON should include mutating:true");
1504 let de: Command = serde_json::from_str(&json).unwrap();
1505 assert!(de.mutating, "deserialized command should have mutating=true");
1506 }
1507
1508 #[test]
1509 fn test_non_mutating_serde_default() {
1510 let cmd = Command::builder("list").build().unwrap();
1511 let json = serde_json::to_string(&cmd).unwrap();
1512 // With #[serde(default)], false is still serialized (no skip_serializing_if)
1513 let de: Command = serde_json::from_str(&json).unwrap();
1514 assert!(!de.mutating, "deserialized non-mutating command should have mutating=false");
1515 }
1516}