router: new cli parser with global option support

This one does *explicitly* *not* support long options with a single
dash because it is too ambiguous if we want to add support for short
options at some point.

The parsing of the command line and invoking of the command is
separated. `CommandLine::parse` returns an `Invocation` which is
called and consumed via its `call` method.
This allows updating the CLI environment between parsing and invoking
the command, in order to allow *handling* the global options in
between those two steps if desired.

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller 2024-06-13 13:31:41 +02:00
parent 59f1bdbe85
commit eb5614adf1
3 changed files with 403 additions and 11 deletions

View File

@ -91,22 +91,23 @@ async fn handle_simple_command_future(
Ok(())
}
fn handle_simple_command(
pub(crate) fn handle_simple_command(
prefix: &str,
cli_cmd: &CliCommand,
args: Vec<String>,
mut rpcenv: CliEnvironment,
rpcenv: &mut CliEnvironment,
run: Option<fn(ApiFuture) -> Result<Value, Error>>,
) -> Result<(), Error> {
let params = parse_arguments(prefix, cli_cmd, args)?;
let result = match cli_cmd.info.handler {
ApiHandler::Sync(handler) => (handler)(params, cli_cmd.info, &mut rpcenv),
ApiHandler::StreamingSync(handler) => (handler)(params, cli_cmd.info, &mut rpcenv)
.and_then(|r| r.to_value().map_err(Error::from)),
ApiHandler::Sync(handler) => (handler)(params, cli_cmd.info, rpcenv),
ApiHandler::StreamingSync(handler) => {
(handler)(params, cli_cmd.info, rpcenv).and_then(|r| r.to_value().map_err(Error::from))
}
ApiHandler::Async(handler) => match run {
Some(run) => {
let future = (handler)(params, cli_cmd.info, &mut rpcenv);
let future = (handler)(params, cli_cmd.info, rpcenv);
(run)(future)
}
None => {
@ -260,7 +261,10 @@ pub(crate) fn help_command_def() -> CliCommand {
CliCommand::new(&API_METHOD_COMMAND_HELP).arg_param(&["command"])
}
fn replace_aliases(args: &mut Vec<String>, aliases: &[(Vec<&'static str>, Vec<&'static str>)]) {
pub(crate) fn replace_aliases(
args: &mut Vec<String>,
aliases: &[(Vec<&'static str>, Vec<&'static str>)],
) {
for (old, new) in aliases {
if args.len() < old.len() {
continue;
@ -314,19 +318,19 @@ pub fn handle_command(
def: Arc<CommandLineInterface>,
prefix: &str,
mut args: Vec<String>,
rpcenv: CliEnvironment,
mut rpcenv: CliEnvironment,
run: Option<fn(ApiFuture) -> Result<Value, Error>>,
) -> Result<(), Error> {
set_help_context(Some(def.clone()));
let result = match &*def {
CommandLineInterface::Simple(ref cli_cmd) => {
handle_simple_command(prefix, cli_cmd, args, rpcenv, run)
handle_simple_command(prefix, cli_cmd, args, &mut rpcenv, run)
}
CommandLineInterface::Nested(ref map) => {
let mut prefix = prefix.to_string();
let cli_cmd = parse_nested_command(&mut prefix, map, &mut args)?;
handle_simple_command(&prefix, cli_cmd, args, rpcenv, run)
handle_simple_command(&prefix, cli_cmd, args, &mut rpcenv, run)
}
};

View File

@ -256,3 +256,137 @@ fn test_argument_paramenter() {
assert!(remaining.is_empty());
}
}
pub(crate) struct ParseOptions<'t, 'o> {
target: &'t mut Vec<(String, String)>,
option_schemas: &'o HashMap<&'o str, &'static Schema>,
stop_at_positional: bool,
deny_unknown: bool,
}
impl<'t, 'o> ParseOptions<'t, 'o> {
/// Create a new option parser.
pub fn new(
target: &'t mut Vec<(String, String)>,
option_schemas: &'o HashMap<&'o str, &'static Schema>,
) -> Self {
Self {
target,
option_schemas,
stop_at_positional: false,
deny_unknown: false,
}
}
/// Builder style option to deny unknown parameters.
pub fn deny_unknown(mut self, deny: bool) -> Self {
self.deny_unknown = deny;
self
}
/// Builder style option to set whether to stop at positional parameters.
/// The `parse()` method will return the rest of the parameters including the first positional one.
pub fn stop_at_positional(mut self, stop: bool) -> Self {
self.stop_at_positional = stop;
self
}
/// Parse arguments with the current configuration.
/// Returns the positional parameters.
/// If `stop_at_positional` is set, non-positional parameters after the first positional one
/// are also included in the returned list.
pub fn parse<A, AI>(self, args: A) -> Result<Vec<AI>, ParameterError>
where
A: IntoIterator<Item = AI>,
AI: AsRef<str>,
{
parse_parameters(args, self)
}
}
pub(crate) fn parse_parameters<A, AI>(
args: A,
parse_opts: ParseOptions<'_, '_>,
) -> Result<Vec<AI>, ParameterError>
where
A: IntoIterator<Item = AI>,
AI: AsRef<str>,
{
let mut errors = ParameterError::new();
let mut positional = Vec::new();
let mut args = args.into_iter().peekable();
while let Some(orig_arg) = args.next() {
let arg = orig_arg.as_ref();
if arg == "--" {
break;
}
let option = match arg.strip_prefix("--") {
Some(opt) => opt,
None => {
positional.push(orig_arg);
if parse_opts.stop_at_positional {
break;
}
continue;
}
};
if let Some(eq) = option.find('=') {
let (option, argument) = (&option[..eq], &option[(eq + 1)..]);
if parse_opts.deny_unknown && !parse_opts.option_schemas.contains_key(option) {
errors.push(option.to_string(), format_err!("unknown option {option:?}"));
}
parse_opts
.target
.push((option.to_string(), argument.to_string()));
continue;
}
if parse_opts.deny_unknown && !parse_opts.option_schemas.contains_key(option) {
errors.push(option.to_string(), format_err!("unknown option {option:?}"));
}
match parse_opts.option_schemas.get(option) {
Some(Schema::Boolean(schema)) => {
if let Some(value) = args.next_if(|v| parse_boolean(v.as_ref()).is_ok()) {
parse_opts
.target
.push((option.to_string(), value.as_ref().to_string()));
} else {
// next parameter is not a boolean value
if schema.default == Some(true) {
// default-true booleans cannot be passed without values:
errors.push(option.to_string(), format_err!("missing boolean value."));
}
parse_opts
.target
.push((option.to_string(), "true".to_string()))
}
}
_ => {
// no schema, assume `--key value`.
let next = match args.next() {
Some(next) => next.as_ref().to_string(),
None => {
errors.push(option.to_string(), format_err!("missing parameter value."));
break;
}
};
parse_opts
.target
.push((option.to_string(), next.to_string()));
continue;
}
}
}
if !errors.is_empty() {
return Err(errors);
}
positional.extend(args);
Ok(positional)
}

View File

@ -12,12 +12,19 @@
//! - Ability to create interactive commands (using ``rustyline``)
//! - Supports complex/nested commands
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::io::{self, Write};
use anyhow::{bail, Error};
use crate::ApiMethod;
use anyhow::{format_err, Error};
use serde::Deserialize;
use serde_json::Value;
use proxmox_schema::{ApiType, Schema};
use crate::{ApiFuture, ApiMethod};
mod environment;
pub use environment::*;
@ -229,6 +236,9 @@ pub struct CliCommandMap {
pub aliases: Vec<(Vec<&'static str>, Vec<&'static str>)>,
/// List of options to suppress in generate_usage
pub usage_skip_options: &'static [&'static str],
/// A set of options common to all subcommands. Only object schemas can be used here.
pub(crate) global_options: HashMap<TypeId, OptionEntry>,
}
impl CliCommandMap {
@ -283,6 +293,27 @@ impl CliCommandMap {
None
}
/// Builder style method to set extra options for the entire set of subcommands.
/// Can be used multiple times.
pub fn global_option<T>(mut self) -> Self
where
T: Send + Sync + Any + ApiType + for<'a> Deserialize<'a>,
{
if self
.global_options
.insert(TypeId::of::<T>(), OptionEntry::of::<T>())
.is_some()
{
panic!("cannot add same option struct multiple times to command line interface");
}
self
}
/// Finish the command line interface.
pub fn build(self) -> CommandLineInterface {
self.into()
}
}
/// Define Complex command line interfaces.
@ -302,3 +333,226 @@ impl From<CliCommandMap> for CommandLineInterface {
CommandLineInterface::Nested(list)
}
}
/// Options covering an entire hierarchy set of subcommands.
pub(crate) struct OptionEntry {
schema: &'static Schema,
parse: fn(env: &mut CliEnvironment, &mut HashMap<String, String>) -> Result<(), Error>,
}
impl OptionEntry {
/// Get an entry for an API type `T`.
fn of<T>() -> Self
where
T: Send + Sync + Any + ApiType + for<'a> Deserialize<'a>,
{
return Self {
schema: &T::API_SCHEMA,
parse: parse_option_entry::<T>,
};
/// Extract known parameters from the current argument hash and store the parsed `T` in the
/// `CliEnvironment`'s extra args.
fn parse_option_entry<T>(
env: &mut CliEnvironment,
args: &mut HashMap<String, String>,
) -> Result<(), Error>
where
T: Send + Sync + Any + ApiType + for<'a> Deserialize<'a>,
{
let schema: proxmox_schema::ParameterSchema = match &T::API_SCHEMA {
Schema::Object(s) => s.into(),
Schema::AllOf(s) => s.into(),
// FIXME: ParameterSchema should impl `TryFrom<&'static Schema>`
_ => panic!("non-object schema in command line interface"),
};
let mut params = Vec::new();
for (name, _optional, _schema) in T::API_SCHEMA
.any_object()
.expect("non-object schema in command line interface")
.properties()
{
let name = *name;
if let Some(value) = args.remove(name) {
params.push((name.to_string(), value));
}
}
let value = schema.parse_parameter_strings(&params, true)?;
let value: T = serde_json::from_value(value)?;
env.global_options
.insert(TypeId::of::<T>(), Box::new(value));
Ok(())
}
}
/// Get an `Iterator` over the properties of `T`.
fn properties(&self) -> impl Iterator<Item = (&'static str, &'static Schema)> {
self.schema
.any_object()
.expect("non-object schema in command line interface")
.properties()
.map(|(name, _optional, schema)| (*name, *schema))
}
}
pub struct CommandLine<'a> {
interface: &'a CommandLineInterface,
async_run: Option<fn(ApiFuture) -> Result<Value, Error>>,
prefix: String,
global_option_schemas: HashMap<&'static str, &'static Schema>,
global_option_values: HashMap<String, String>,
global_option_types: HashMap<TypeId, &'a OptionEntry>,
}
impl<'cli> CommandLine<'cli> {
pub fn new(interface: &'cli CommandLineInterface) -> Self {
Self {
interface,
async_run: None,
prefix: String::new(),
global_option_schemas: HashMap::new(),
global_option_values: HashMap::new(),
global_option_types: HashMap::new(),
}
}
pub fn with_async(mut self, async_run: fn(ApiFuture) -> Result<Value, Error>) -> Self {
self.async_run = Some(async_run);
self
}
pub fn parse<A>(
mut self,
rpcenv: &mut CliEnvironment,
args: A,
) -> Result<Invocation<'cli>, Error>
where
A: IntoIterator<Item = String>,
{
let cli = self.interface;
let mut args = args.into_iter();
self.prefix = args
.next()
.expect("no parameters passed to CommandLine::parse");
self.parse_do(cli, rpcenv, args.collect())
}
fn parse_do(
self,
cli: &'cli CommandLineInterface,
rpcenv: &mut CliEnvironment,
args: Vec<String>,
) -> Result<Invocation<'cli>, Error> {
match cli {
CommandLineInterface::Simple(cli) => self.parse_simple(cli, rpcenv, args),
CommandLineInterface::Nested(cli) => self.parse_nested(cli, rpcenv, args),
}
}
/// Parse out the current global options and return the remaining `args`.
fn handle_current_global_options(&mut self, args: Vec<String>) -> Result<Vec<String>, Error> {
let mut global_args = Vec::new();
let args = getopts::ParseOptions::new(&mut global_args, &self.global_option_schemas)
.stop_at_positional(true)
.deny_unknown(true)
.parse(args)?;
// and merge them into the hash map
for (option, argument) in global_args {
self.global_option_values.insert(option, argument);
}
Ok(args)
}
fn parse_nested(
mut self,
cli: &'cli CliCommandMap,
rpcenv: &mut CliEnvironment,
mut args: Vec<String>,
) -> Result<Invocation<'cli>, Error> {
use std::fmt::Write as _;
command::replace_aliases(&mut args, &cli.aliases);
// handle possible "global" parameters for the current level:
// first add the global args of this level to the known list:
for entry in cli.global_options.values() {
self.global_option_types
.extend(cli.global_options.iter().map(|(id, entry)| (*id, entry)));
for (name, schema) in entry.properties() {
if self.global_option_schemas.insert(name, schema).is_some() {
panic!(
"duplicate option {name:?} in nested command line interface global options"
);
}
}
}
let mut args = self.handle_current_global_options(args)?;
// now deal with the actual subcommand list
if args.is_empty() {
let mut cmds: Vec<&str> = cli.commands.keys().map(|s| s.as_str()).collect();
cmds.sort();
let list = cmds.join(", ");
let err_msg = format!("no command specified.\nPossible commands: {}", list);
print_nested_usage_error(&self.prefix, cli, &err_msg);
return Err(format_err!("{}", err_msg));
}
let (_, sub_cmd) = match cli.find_command(&args[0]) {
Some(cmd) => cmd,
None => {
let err_msg = format!("no such command '{}'", args[0]);
print_nested_usage_error(&self.prefix, cli, &err_msg);
return Err(format_err!("{}", err_msg));
}
};
let _ = write!(&mut self.prefix, " {}", args.remove(0));
self.parse_do(sub_cmd, rpcenv, args)
}
fn parse_simple(
mut self,
cli: &'cli CliCommand,
rpcenv: &mut CliEnvironment,
args: Vec<String>,
) -> Result<Invocation<'cli>, Error> {
let args = self.handle_current_global_options(args)?;
self.build_global_options(&mut *rpcenv)?;
Ok(Invocation {
call: Box::new(move |rpcenv| {
command::handle_simple_command(&self.prefix, cli, args, rpcenv, self.async_run)
}),
})
}
fn build_global_options(&mut self, env: &mut CliEnvironment) -> Result<(), Error> {
for entry in self.global_option_types.values() {
(entry.parse)(env, &mut self.global_option_values)?;
}
Ok(())
}
}
type InvocationFn<'cli> =
Box<dyn FnOnce(&mut CliEnvironment) -> Result<(), Error> + Send + Sync + 'cli>;
/// After parsing the command line, this is responsible for calling the API method with its
/// parameters, and gives the user a chance to adapt the RPC environment before doing so.
pub struct Invocation<'cli> {
call: InvocationFn<'cli>,
}
impl Invocation<'_> {
pub fn call(self, rpcenv: &mut CliEnvironment) -> Result<(), Error> {
(self.call)(rpcenv)
}
}