Visitar URL original
feat(parse): parse Args without an enclosing CLI (#1419) · jdx/usage@d07e50e · GitHub
Skip to content

Commit d07e50e

Browse files
authored
feat(parse): parse Args without an enclosing CLI (#1419)
Adapters and embedded command surfaces can now parse one derived `Args` type without constructing or parsing the application’s root CLI. This lets callers reuse the command’s compiled flags, positional arguments, defaults, validation, subcommand routing, and typed construction directly. ```rust let install = usage::parse_args_from::<Install>(&args)?; let install = usage::parse_args_from_argv::<Install>(&argv)?; ``` The second form follows the full-argv convention and strips argv0. Both return ordinary usage parse errors, including help requests, for the selected command. Validation: `mise run ci` (Rust workspace tests and doctests, Clippy, formatting, semver checks, Prettier, and Go tests). *AI-assisted — Tool: Codex; model: openai/gpt-5; version: unavailable.* <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Additive public API on the existing parser path; behavior is covered by new integration tests with no changes to root CLI parsing. > > **Overview** > Adds **`parse_args_from`** and **`parse_args_from_argv`** so a derived **`CommandArgs`** / **`Args`** type can be parsed without a root **`Cli`**. Parsing starts at that command’s tables (flags, positionals, defaults, validation, nested subcommands) and returns the same **`Error`** variants as full-app parsing, including help, version, and missing-args help. > > **`parse_args_from`** treats the slice as that command’s words only; **`parse_args_from_argv`** strips argv0 first. Both are re-exported from **`usage-rs`** (via **`usage_argv`**) behind the **`spec`** feature. Docs and facade tests cover success paths, choices, help, and subcommand routing. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9149f89. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added standalone argument parsing for individual command definitions without requiring a complete CLI application. - Supports defaults, positional arguments, choice validation, help, version, required arguments, and nested subcommands. - Exposed public parsing entry points for adapter and integration use. - **Documentation** - Added guidance and examples for standalone argument parsing. - **Tests** - Added coverage for successful parsing, defaults, choices, positional arguments, nested commands, help, and invalid or missing input. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent cf27852 commit d07e50e

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

‎argv/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ pub mod run;
177177
#[cfg(feature = "spec")]
178178
pub mod spec;
179179
#[cfg(feature = "spec")]
180+
pub use spec::{parse_args_from, parse_args_from_argv};
181+
#[cfg(feature = "spec")]
180182
pub mod warn;
181183

182184
pub use run::{Run, RunAsync, RunAsyncWith, RunWith};

‎argv/src/spec.rs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
//! `help_heading` went in that way.
2323
//!
2424
use core::fmt::Write as _;
25+
use std::ffi::OsStr;
2526

2627
use crate::UnknownFlags;
2728
use crate::{Arg, Command, DoubleDash, Flag};
@@ -3790,6 +3791,70 @@ pub struct ArgumentState {
37903791
pub satisfied: bool,
37913792
}
37923793

3794+
/// Parse arguments directly into a derived [`CommandArgs`] type.
3795+
///
3796+
/// Unlike a root CLI's `parse_from`, this needs no enclosing CLI declaration: it
3797+
/// starts at the command represented by `T` and treats `argv` as that command's
3798+
/// arguments. This is useful for adapters and embedded command surfaces that
3799+
/// need one command's exact parser without parsing and dispatching the complete
3800+
/// application again.
3801+
pub fn parse_args_from<'v, T>(argv: &[&'v OsStr]) -> Result<T, crate::Error<'static, 'v>>
3802+
where
3803+
T: CommandArgs,
3804+
{
3805+
let mut partial = T::start();
3806+
let mut parser = crate::Parser::new(T::COMMAND, argv);
3807+
while let Some(event) = parser.next_event() {
3808+
let event = event?;
3809+
if let crate::Event::Flag { flag, .. } = &event {
3810+
if crate::is_help_flag(flag) {
3811+
if flag.action == crate::ArgAction::HelpAll {
3812+
return Err(crate::Error::HelpAll {
3813+
cmd: parser.command(),
3814+
});
3815+
}
3816+
let long = match flag.action {
3817+
crate::ArgAction::HelpShort => false,
3818+
crate::ArgAction::HelpLong => true,
3819+
crate::ArgAction::Help => !flag.longs.is_empty(),
3820+
_ => false,
3821+
};
3822+
return Err(crate::Error::Help {
3823+
cmd: parser.command(),
3824+
long,
3825+
});
3826+
}
3827+
if crate::is_version_flag(flag) {
3828+
return Err(crate::Error::Version {
3829+
long: !flag.longs.is_empty(),
3830+
});
3831+
}
3832+
}
3833+
T::apply(&mut partial, &event);
3834+
}
3835+
3836+
if parser.command().arg_required_else_help && parser.command_start() == argv.len() {
3837+
return Err(crate::Error::MissingArgsHelp {
3838+
cmd: parser.command(),
3839+
});
3840+
}
3841+
3842+
T::check(&mut partial)?;
3843+
T::build(partial)
3844+
}
3845+
3846+
/// Parse a full argv into a derived [`CommandArgs`] type, stripping argv0.
3847+
///
3848+
/// This is the full-argv counterpart to [`parse_args_from`]. The first element
3849+
/// identifies the embedding command and is not parsed as one of its arguments.
3850+
pub fn parse_args_from_argv<'v, T>(argv: &[&'v OsStr]) -> Result<T, crate::Error<'static, 'v>>
3851+
where
3852+
T: CommandArgs,
3853+
{
3854+
let words = argv.split_first().map_or(argv, |(_, words)| words);
3855+
parse_args_from(words)
3856+
}
3857+
37933858
pub trait CommandArgs: Sized {
37943859
/// Values collected so far. Partly-filled by construction, since a parse can
37953860
/// stop early.

‎usage-rs/src/lib.rs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,34 @@
6262
//! assert_eq!(ex.file.as_deref(), Some(std::path::Path::new("input.txt")));
6363
//! # }
6464
//! ```
65+
//!
66+
//! A derived [`Args`] type can also be parsed on its own. This lets an adapter
67+
//! reuse one command's exact argument contract without constructing or parsing
68+
//! the application's root CLI:
69+
//!
70+
//! ```
71+
//! use usage_rs as usage;
72+
//! # #[cfg(feature = "spec")]
73+
//! use usage::Args;
74+
//! #
75+
//! # #[cfg(not(feature = "spec"))]
76+
//! # fn main() {}
77+
//! # #[cfg(feature = "spec")]
78+
//! # fn main() {
79+
//!
80+
//! #[derive(Debug, Args)]
81+
//! struct Install {
82+
//! #[usage(short, long)]
83+
//! force: bool,
84+
//! tools: Vec<String>,
85+
//! }
86+
//!
87+
//! let argv = ["--force", "node@24"].map(std::ffi::OsStr::new);
88+
//! let install = usage::parse_args_from::<Install>(&argv).unwrap();
89+
//! assert!(install.force);
90+
//! assert_eq!(install.tools, ["node@24"]);
91+
//! # }
92+
//! ```
6593
6694
#![forbid(unsafe_code)]
6795

‎usage-rs/tests/facade.rs‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,94 @@ enum RepeatedGlobalCommand {
618618
Run(RepeatedGlobalChild),
619619
}
620620

621+
#[derive(Debug, Args, PartialEq, Eq)]
622+
struct StandaloneInstall {
623+
#[usage(short, long)]
624+
force: bool,
625+
#[usage(long, default = "4")]
626+
jobs: usize,
627+
#[usage(long, default = "fast", choices("fast", "slow"))]
628+
mode: String,
629+
tools: Vec<String>,
630+
}
631+
632+
#[derive(Debug, Args, PartialEq, Eq)]
633+
struct StandaloneNested {
634+
#[usage(subcommand)]
635+
command: StandaloneNestedCommand,
636+
}
637+
638+
#[derive(Debug, Subcommands, PartialEq, Eq)]
639+
enum StandaloneNestedCommand {
640+
Run(StandaloneRun),
641+
}
642+
643+
#[derive(Debug, Args, PartialEq, Eq)]
644+
struct StandaloneRun {
645+
#[usage(long)]
646+
profile: String,
647+
task: String,
648+
}
649+
650+
#[test]
651+
fn args_parse_without_an_enclosing_cli() {
652+
let words = ["--force", "--jobs", "2", "node@24", "python@3.14"].map(OsStr::new);
653+
assert_eq!(
654+
usage::parse_args_from::<StandaloneInstall>(&words).unwrap(),
655+
StandaloneInstall {
656+
force: true,
657+
jobs: 2,
658+
mode: "fast".to_string(),
659+
tools: vec!["node@24".to_string(), "python@3.14".to_string()],
660+
}
661+
);
662+
663+
let argv = ["install", "--mode", "slow", "ruby@4"].map(OsStr::new);
664+
assert_eq!(
665+
usage::parse_args_from_argv::<StandaloneInstall>(&argv).unwrap(),
666+
StandaloneInstall {
667+
force: false,
668+
jobs: 4,
669+
mode: "slow".to_string(),
670+
tools: vec!["ruby@4".to_string()],
671+
}
672+
);
673+
}
674+
675+
#[test]
676+
fn standalone_args_return_their_normal_parse_errors() {
677+
let invalid = ["--mode", "turbo"].map(OsStr::new);
678+
let invalid = usage::parse_args_from::<StandaloneInstall>(&invalid).unwrap_err();
679+
assert!(matches!(invalid, usage::Error::InvalidChoice { .. }));
680+
681+
let help = ["--help"].map(OsStr::new);
682+
let help = usage::parse_args_from::<StandaloneInstall>(&help).unwrap_err();
683+
let usage::Error::Help { cmd, .. } = help else {
684+
panic!("expected a help request, got {help:?}");
685+
};
686+
assert_eq!(cmd.name, "standalone-install");
687+
}
688+
689+
#[test]
690+
fn standalone_args_route_and_validate_subcommands() {
691+
let words = ["run", "--profile", "release", "build"].map(OsStr::new);
692+
assert_eq!(
693+
usage::parse_args_from::<StandaloneNested>(&words).unwrap(),
694+
StandaloneNested {
695+
command: StandaloneNestedCommand::Run(StandaloneRun {
696+
profile: "release".to_string(),
697+
task: "build".to_string(),
698+
}),
699+
}
700+
);
701+
702+
let missing_profile = ["run", "build"].map(OsStr::new);
703+
assert!(matches!(
704+
usage::parse_args_from::<StandaloneNested>(&missing_profile),
705+
Err(usage::Error::MissingRequired { name: "profile" })
706+
));
707+
}
708+
621709
#[derive(Cli)]
622710
#[usage(bin = "repeated-global")]
623711
struct RepeatedGlobal {

0 commit comments

Comments
 (0)