vmm_cli: add declarative key=value CLI option parsing#3981
Conversation
The OpenVMM command line uses a compact per-argument mini-language throughout — things like `--disk file:x,ro,on=nvme0` or `--memory size=2G,hugepages=on`. Each such argument had its own hand-written `FromStr` that re-implemented the same mechanics: splitting on commas, splitting on `=`, matching keys, rejecting unknown, duplicate, or empty options, and producing bespoke error messages. This was repetitive, easy to get subtly wrong, and inconsistent between arguments — most visibly, some booleans were bare flags while others required `=on`/`=off`, so users had to remember which was which. This introduces `vmm_cli` and its companion `vmm_cli_derive`, a small internal framework for parsing these option strings. `#[derive(KeyValueArgs)]` generates the parser from an annotated struct, with attributes for renamed keys, defaults, a leading positional token, boolean flags (uniformly accepting bare presence, `=on`, or `=off`, plus tri-state `Option<bool>`), bracket-delimited lists, and a `#[kv(flatten)]` that merges a shared option struct into several parsers. A companion `#[derive(KeyValueGroup)]` expresses mutually-exclusive keys that resolve to a single enum variant. Reusable value types (`MemorySize`, `BracketList`, `BracketRangeList`) cover the common non-trivial value formats, and a conformance test pins the derive's guarantees in one place. The modern comma-separated arguments in openvmm_entry are converted to use the derive. Public CLI types keep their existing shape: where an argument has context-dependent defaults or cross-field validation — disk placement rules, memory hugepage constraints, the vhost-user device-type discrimination — the derive produces a raw options struct and a thin hand-written wrapper applies the remaining semantics, so no downstream consumers change. Arguments built on a different grammar (colon-delimited or discriminated unions such as the serial and network backends) are left as they were. A side benefit is that boolean options are now uniform: every flag accepts bare, `=on`, or `=off`.
There was a problem hiding this comment.
Pull request overview
This PR introduces a new internal CLI mini-language framework (vmm_cli + vmm_cli_derive) to declaratively parse the existing comma-separated key=value option strings used throughout OpenVMM, and migrates multiple openvmm_entry CLI argument parsers to use the derives for more uniform behavior and less duplicated parsing code.
Changes:
- Added
vmm_cli(runtime helpers + common value types) andvmm_cli_derive(#[derive(KeyValueArgs)]/#[derive(KeyValueGroup)]) for declarative key/value parsing. - Migrated several
openvmm_entryoption-string parsers (memory/numa, disk/controller options, PCIe topology options, vfio, vhost-user, etc.) to the new derives with thin validation/wrapping where needed. - Added conformance tests to pin the derive’s expected parsing semantics (unknown/duplicate handling, flags, positional head, bracket lists, flattening, groups).
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| vmm_core/vmm_cli/tests/conformance.rs | New conformance suite covering derive parsing guarantees and edge cases. |
| vmm_core/vmm_cli/src/lib.rs | Runtime parsing helpers (split/kv parsing, toggles, bracketed lists, MemorySize). |
| vmm_core/vmm_cli/Cargo.toml | New crate definition for vmm_cli. |
| vmm_core/vmm_cli_derive/src/lib.rs | Proc-macro derives implementing KeyValueArgs and KeyValueGroup. |
| vmm_core/vmm_cli_derive/Cargo.toml | New proc-macro crate definition for vmm_cli_derive. |
| openvmm/openvmm_entry/src/lib.rs | Adapts config-building to updated parsed CLI types (MemorySize, bracket ranges, etc.). |
| openvmm/openvmm_entry/src/cli_args.rs | Converts multiple FromStr parsers to the derive-based approach + validation wrappers. |
| openvmm/openvmm_entry/Cargo.toml | Adds dependency on vmm_cli. |
| Cargo.toml | Adds workspace path deps for vmm_cli and vmm_cli_derive. |
| Cargo.lock | Locks the newly added crates. |
smalis-msft
left a comment
There was a problem hiding this comment.
I'm surprised this isn't something you can do with just clap derives, but very cool
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
openvmm/openvmm_entry/src/lib.rs:1929
- Avoid panicking on user-provided CLI input:
expect("NUMA memory size was validated")can still panic if validation is bypassed due to a future refactor or bug. Since this closure already returnsanyhow::Result, propagate an error instead.
mem_size: n
.memory
.size
.expect("NUMA memory size was validated")
.0,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
openvmm/openvmm_entry/src/lib.rs:1929
- Using
expect("NUMA memory size was validated")can still panic on malformed/unexpected CLI input (or future refactors that bypass the earlier validation). Since this path already returnsanyhow::Result, prefer propagating an error instead of panicking.
mem_size: n
.memory
.size
.expect("NUMA memory size was validated")
.0,
| for field in &named.named { | ||
| let fname = field.ident.as_ref().unwrap(); | ||
| let ty = &field.ty; | ||
| let fa = parse_field_attr(field)?; | ||
| let key = fa.key.clone().unwrap_or_else(|| fname.to_string()); | ||
|
|
smalis-msft
left a comment
There was a problem hiding this comment.
I'm surprised this isn't something you can do with just clap derives, but very co
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
openvmm/openvmm_entry/src/lib.rs:1929
- Using
.expect("NUMA memory size was validated")will panic on an invariant violation and turn a CLI parse/validation issue into a crash. Since this is ultimately driven by user input, prefer propagating an error here instead of panicking.
mem_size: n
.memory
.size
.expect("NUMA memory size was validated")
.0,
openvmm/openvmm_entry/src/cli_args.rs:1498
parse_memory_configpreviously accepted the specialVMGS_DEFAULTtoken viaparse_memory(). The new bare-shortcut path usesvmm_cli::MemorySizeparsing directly, which will now reject--memory VMGS_DEFAULT(a behavior change). If you want to preserve the old acceptance without teachingMemorySizeaboutVMGS_DEFAULT, wrapparse_memory(s)?intoMemorySizehere.
// Bare shortcut: `--memory 64G` sets only the size.
let memory = if !s.contains('=') && !s.contains(',') {
MemoryCli {
size: Some(s.parse::<vmm_cli::MemorySize>()?),
..Default::default()
| let mut accum_fields = Vec::new(); | ||
| let mut accept_arms = Vec::new(); | ||
| let mut flatten_accepts = Vec::new(); | ||
| let mut append_keys = Vec::new(); | ||
| let mut finish_fields = Vec::new(); | ||
| let mut positional: Option<(syn::Ident, Type, String)> = None; | ||
|
|
||
| for field in &named.named { | ||
| let fname = field.ident.as_ref().unwrap(); | ||
| let ty = &field.ty; | ||
| let fa = parse_field_attr(field)?; | ||
| validate_field_attr(field, &fa)?; | ||
| let key = fa.key.clone().unwrap_or_else(|| fname.to_string()); | ||
|
|
||
| if fa.positional { |
The OpenVMM command line uses a compact per-argument mini-language throughout — things like
--disk file:x,ro,on=nvme0or--memory size=2G,hugepages=on. Each such argument had its own hand-writtenFromStrthat re-implemented the same mechanics: splitting on commas, splitting on=, matching keys, rejecting unknown, duplicate, or empty options, and producing bespoke error messages. This was repetitive, easy to get subtly wrong, and inconsistent between arguments — most visibly, some booleans were bare flags while others required=on/=off, so users had to remember which was which.This introduces
vmm_cliand its companionvmm_cli_derive, a small internal framework for parsing these option strings.#[derive(KeyValueArgs)]generates the parser from an annotated struct, with attributes for renamed keys, defaults, a leading positional token, boolean flags (uniformly accepting bare presence,=on, or=off, plus tri-stateOption<bool>), bracket-delimited lists, and a#[kv(flatten)]that merges a shared option struct into several parsers. A companion#[derive(KeyValueGroup)]expresses mutually-exclusive keys that resolve to a single enum variant. Reusable value types (MemorySize,BracketList,BracketRangeList) cover the common non-trivial value formats, and a conformance test pins the derive's guarantees in one place.The modern comma-separated arguments in openvmm_entry are converted to use the derive. Public CLI types keep their existing shape: where an argument has context-dependent defaults or cross-field validation — disk placement rules, memory hugepage constraints, the vhost-user device-type discrimination — the derive produces a raw options struct and a thin hand-written wrapper applies the remaining semantics, so no downstream consumers change. Arguments built on a different grammar (colon-delimited or discriminated unions such as the serial and network backends) are left as they were.
A side benefit is that boolean options are now uniform: every flag accepts bare,
=on, or=off.