diff --git a/proxmox-api/Cargo.toml b/proxmox-api/Cargo.toml index 1bb7f83e..f2245576 100644 --- a/proxmox-api/Cargo.toml +++ b/proxmox-api/Cargo.toml @@ -12,6 +12,7 @@ http = "0.1" hyper = { version = "0.13.0-alpha.1" } proxmox-tools = { version = "0.1", path = "../proxmox-tools" } regex = "1.0" +rustyline = "5.0.4" serde = "1.0" serde_derive = "1.0" serde_json = "1.0" diff --git a/proxmox-api/src/cli.rs b/proxmox-api/src/cli.rs new file mode 100644 index 00000000..7d683eaf --- /dev/null +++ b/proxmox-api/src/cli.rs @@ -0,0 +1,160 @@ +//! Tools to create command line parsers +//! +//! This crate provides convenient helpers to create command line +//! parsers using Schema definitions. +//! +//! ## Features +//! +//! - Use declarative API schema to define the CLI +//! - Automatic parameter verification +//! - Automatically generate documentation and manual pages +//! - Automatically generate bash completion helpers +//! - Ability to create interactive commands (using ``rustyline``) +//! - Supports complex/nested commands + +mod environment; +pub use environment::*; + +mod shellword; +pub use shellword::*; + +mod format; +pub use format::*; + +mod completion; +pub use completion::*; + +mod getopts; +pub use getopts::*; + +mod command; +pub use command::*; + +mod readline; +pub use readline::*; + +use std::collections::HashMap; + +use crate::ApiMethod; + +/// Completion function for single parameters. +/// +/// Completion functions gets the current parameter value, and should +/// return a list of all possible values. +pub type CompletionFunction = fn(&str, &HashMap) -> Vec; + +/// Define a simple CLI command. +pub struct CliCommand { + /// The Schema definition. + pub info: &'static ApiMethod, + /// Argument parameter list. + /// + /// Those parameters are expected to be passed as command line + /// arguments in the specified order. All other parameters needs + /// to be specified as ``--option `` pairs. + pub arg_param: &'static [&'static str], + /// Predefined parameters. + pub fixed_param: HashMap<&'static str, String>, + /// Completion functions. + /// + /// Each parameter may have an associated completion function, + /// which is called by the shell completion handler. + pub completion_functions: HashMap, +} + +impl CliCommand { + + /// Create a new instance. + pub fn new(info: &'static ApiMethod) -> Self { + Self { + info, arg_param: &[], + fixed_param: HashMap::new(), + completion_functions: HashMap::new(), + } + } + + /// Set argument parameter list. + pub fn arg_param(mut self, names: &'static [&'static str]) -> Self { + self.arg_param = names; + self + } + + /// Set fixed parameters. + pub fn fixed_param(mut self, key: &'static str, value: String) -> Self { + self.fixed_param.insert(key, value); + self + } + + /// Set completion functions. + pub fn completion_cb(mut self, param_name: &str, cb: CompletionFunction) -> Self { + self.completion_functions.insert(param_name.into(), cb); + self + } +} + +/// Define nested CLI commands. +pub struct CliCommandMap { + /// Each command has an unique name. The map associates names with + /// command definitions. + pub commands: HashMap, +} + +impl CliCommandMap { + + /// Create a new instance. + pub fn new() -> Self { + Self { commands: HashMap:: new() } + } + + /// Insert another command. + pub fn insert>(mut self, name: S, cli: CommandLineInterface) -> Self { + self.commands.insert(name.into(), cli); + self + } + + /// Insert the help command. + pub fn insert_help(mut self) -> Self { + self.commands.insert(String::from("help"), help_command_def().into()); + self + } + + fn find_command(&self, name: &str) -> Option<(String, &CommandLineInterface)> { + + if let Some(sub_cmd) = self.commands.get(name) { + return Some((name.to_string(), sub_cmd)); + }; + + let mut matches: Vec<&str> = vec![]; + + for cmd in self.commands.keys() { + if cmd.starts_with(name) { + matches.push(cmd); } + } + + if matches.len() != 1 { return None; } + + if let Some(sub_cmd) = self.commands.get(matches[0]) { + return Some((matches[0].to_string(), sub_cmd)); + }; + + None + } +} + +/// Define Complex command line interfaces. +pub enum CommandLineInterface { + Simple(CliCommand), + Nested(CliCommandMap), +} + +impl From for CommandLineInterface { + fn from(cli_cmd: CliCommand) -> Self { + CommandLineInterface::Simple(cli_cmd) + } +} + +impl From for CommandLineInterface { + fn from(list: CliCommandMap) -> Self { + CommandLineInterface::Nested(list) + } +} diff --git a/proxmox-api/src/cli/command.rs b/proxmox-api/src/cli/command.rs new file mode 100644 index 00000000..a878790e --- /dev/null +++ b/proxmox-api/src/cli/command.rs @@ -0,0 +1,259 @@ +use failure::*; +use serde_json::Value; +use std::sync::Arc; +use std::cell::RefCell; + +use crate::*; +use crate::format::*; +use crate::schema::*; + +use super::environment::CliEnvironment; + +use super::getopts; +use super::{CommandLineInterface, CliCommand, CliCommandMap, completion::*}; +use super::format::*; + +/// Schema definition for ``--output-format`` parameter. +/// +/// - ``text``: command specific text format. +/// - ``json``: JSON, single line. +/// - ``json-pretty``: JSON, human readable. +/// +pub const OUTPUT_FORMAT: Schema = + StringSchema::new("Output format.") + .format(&ApiStringFormat::Enum(&["text", "json", "json-pretty"])) + .schema(); + +fn handle_simple_command( + prefix: &str, + cli_cmd: &CliCommand, + args: Vec, +) -> Result<(), Error> { + + let (params, rest) = match getopts::parse_arguments( + &args, cli_cmd.arg_param, &cli_cmd.info.parameters) { + Ok((p, r)) => (p, r), + Err(err) => { + let err_msg = err.to_string(); + print_simple_usage_error(prefix, cli_cmd, &err_msg); + return Err(format_err!("{}", err_msg)); + } + }; + + if !rest.is_empty() { + let err_msg = format!("got additional arguments: {:?}", rest); + print_simple_usage_error(prefix, cli_cmd, &err_msg); + return Err(format_err!("{}", err_msg)); + } + + let mut rpcenv = CliEnvironment::new(); + + match cli_cmd.info.handler { + ApiHandler::Sync(handler) => { + match (handler)(params, &cli_cmd.info, &mut rpcenv) { + Ok(value) => { + if value != Value::Null { + println!("Result: {}", serde_json::to_string_pretty(&value).unwrap()); + } + } + Err(err) => { + eprintln!("Error: {}", err); + return Err(err); + } + } + } + ApiHandler::AsyncHttp(_) => { + let err_msg = + "CliHandler does not support ApiHandler::AsyncHttp - internal error"; + print_simple_usage_error(prefix, cli_cmd, err_msg); + return Err(format_err!("{}", err_msg)); + } + } + + Ok(()) +} + +fn handle_nested_command( + prefix: &str, + def: &CliCommandMap, + mut args: Vec, +) -> Result<(), Error> { + + if args.len() < 1 { + let mut cmds: Vec<&String> = def.commands.keys().collect(); + cmds.sort(); + + let list = cmds.iter().fold(String::new(),|mut s,item| { + if !s.is_empty() { s+= ", "; } + s += item; + s + }); + + let err_msg = format!("no command specified.\nPossible commands: {}", list); + print_nested_usage_error(prefix, def, &err_msg); + return Err(format_err!("{}", err_msg)); + } + + let command = args.remove(0); + + let (_, sub_cmd) = match def.find_command(&command) { + Some(cmd) => cmd, + None => { + let err_msg = format!("no such command '{}'", command); + print_nested_usage_error(prefix, def, &err_msg); + return Err(format_err!("{}", err_msg)); + } + }; + + let new_prefix = format!("{} {}", prefix, command); + + match sub_cmd { + CommandLineInterface::Simple(cli_cmd) => { + handle_simple_command(&new_prefix, cli_cmd, args)?; + } + CommandLineInterface::Nested(map) => { + handle_nested_command(&new_prefix, map, args)?; + } + } + + Ok(()) +} + +const API_METHOD_COMMAND_HELP: ApiMethod = ApiMethod::new( + &ApiHandler::Sync(&help_command), + &ObjectSchema::new( + "Get help about specified command (or sub-command).", + &[ + ( "command", + true, + &ArraySchema::new( + "Command. This may be a list in order to spefify nested sub-commands.", + &StringSchema::new("Name.").schema() + ).schema() + ), + ( "verbose", + true, + &BooleanSchema::new("Verbose help.").schema() + ), + ], + ) +); + +std::thread_local! { + static HELP_CONTEXT: RefCell>> = RefCell::new(None); +} + +fn help_command( + param: Value, + _info: &ApiMethod, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result { + + let command: Vec = param["command"].as_array().unwrap_or(&Vec::new()) + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + + + let verbose = param["verbose"].as_bool(); + + HELP_CONTEXT.with(|ctx| { + match &*ctx.borrow() { + Some(def) => { + print_help(def, String::from(""), &command, verbose); + } + None => { + eprintln!("Sorry, help context not set - internal error."); + } + } + }); + + Ok(Value::Null) +} + +fn set_help_context(def: Option>) { + HELP_CONTEXT.with(|ctx| { *ctx.borrow_mut() = def; }); +} + +pub(crate) fn help_command_def() -> CliCommand { + CliCommand::new(&API_METHOD_COMMAND_HELP) + .arg_param(&["command"]) +} + +/// Handle command invocation. +/// +/// This command gets the command line ``args`` and tries to invoke +/// the corresponding API handler. +pub fn handle_command( + def: Arc, + prefix: &str, + args: Vec, +) -> Result<(), Error> { + + set_help_context(Some(def.clone())); + + let result = match &*def { + CommandLineInterface::Simple(ref cli_cmd) => { + handle_simple_command(&prefix, &cli_cmd, args) + } + CommandLineInterface::Nested(ref map) => { + handle_nested_command(&prefix, &map, args) + } + }; + + set_help_context(None); + + result +} + +/// Helper to get arguments and invoke the command. +/// +/// This helper reads arguments with ``std::env::args()``. The first +/// argument is assumed to be the program name, and is passed as ``prefix`` to +/// ``handle_command()``. +/// +/// This helper automatically add the help command, and two special +/// sub-command: +/// +/// - ``bashcomplete``: Output bash completions instead of running the command. +/// - ``printdoc``: Output ReST documentation. +/// +pub fn run_cli_command(def: CommandLineInterface) { + + let def = match def { + CommandLineInterface::Simple(cli_cmd) => CommandLineInterface::Simple(cli_cmd), + CommandLineInterface::Nested(map) => + CommandLineInterface::Nested(map.insert_help().into()), + }; + + let mut args = std::env::args(); + + let prefix = args.next().unwrap(); + let prefix = prefix.rsplit('/').next().unwrap(); // without path + + let args: Vec = args.collect(); + + if !args.is_empty() { + if args[0] == "bashcomplete" { + print_bash_completion(&def); + return; + } + + if args[0] == "printdoc" { + let usage = match def { + CommandLineInterface::Simple(cli_cmd) => { + generate_usage_str(&prefix, &cli_cmd, DocumentationFormat::ReST, "") + } + CommandLineInterface::Nested(map) => { + generate_nested_usage(&prefix, &map, DocumentationFormat::ReST) + } + }; + println!("{}", usage); + return; + } + } + + if let Err(_) = handle_command(Arc::new(def), &prefix, args) { + std::process::exit(-1); + } +} diff --git a/proxmox-api/src/cli/completion.rs b/proxmox-api/src/cli/completion.rs new file mode 100644 index 00000000..03d47b69 --- /dev/null +++ b/proxmox-api/src/cli/completion.rs @@ -0,0 +1,257 @@ +use super::*; + +use crate::schema::*; + +fn record_done_argument( + done: &mut HashMap, + parameters: &ObjectSchema, + key: &str, + value: &str +) { + + if let Some((_, schema)) = parameters.lookup(key) { + match schema { + Schema::Array(_) => { /* do nothing ?? */ } + _ => { done.insert(key.to_owned(), value.to_owned()); } + } + } +} + +fn get_property_completion( + schema: &Schema, + name: &str, + completion_functions: &HashMap, + arg: &str, + param: &HashMap, +) -> Vec { + + if let Some(callback) = completion_functions.get(name) { + let list = (callback)(arg, param); + let mut completions = Vec::new(); + for value in list { + if value.starts_with(arg) { + completions.push(value); + } + } + return completions; + } + + if let Schema::String(StringSchema { format: Some(format), ..} ) = schema { + if let ApiStringFormat::Enum(list) = format { + let mut completions = Vec::new(); + for value in list.iter() { + if value.starts_with(arg) { + completions.push(value.to_string()); + } + } + return completions; + } + } + return Vec::new(); +} + +fn get_simple_completion( + cli_cmd: &CliCommand, + done: &mut HashMap, + arg_param: &[&str], // we remove done arguments + args: &[String], +) -> Vec { + // fixme: arg_param, fixed_param + //eprintln!("COMPL: {:?} {:?} {}", arg_param, args, args.len()); + + if !arg_param.is_empty() { + let prop_name = arg_param[0]; + if args.len() > 1 { + record_done_argument(done, cli_cmd.info.parameters, prop_name, &args[0]); + return get_simple_completion(cli_cmd, done, &arg_param[1..], &args[1..]); + } else if args.len() == 1 { + record_done_argument(done, cli_cmd.info.parameters, prop_name, &args[0]); + if let Some((_, schema)) = cli_cmd.info.parameters.lookup(prop_name) { + return get_property_completion(schema, prop_name, &cli_cmd.completion_functions, &args[0], done); + } + } + return Vec::new(); + } + if args.is_empty() { return Vec::new(); } + + // Try to parse all argumnets but last, record args already done + if args.len() > 1 { + let mut errors = ParameterError::new(); // we simply ignore any parsing errors here + let (data, _rest) = getopts::parse_argument_list(&args[0..args.len()-1], &cli_cmd.info.parameters, &mut errors); + for (key, value) in &data { + record_done_argument(done, &cli_cmd.info.parameters, key, value); + } + } + + let prefix = &args[args.len()-1]; // match on last arg + + // complete option-name or option-value ? + if !prefix.starts_with("-") && args.len() > 1 { + let last = &args[args.len()-2]; + if last.starts_with("--") && last.len() > 2 { + let prop_name = &last[2..]; + if let Some((_, schema)) = cli_cmd.info.parameters.lookup(prop_name) { + return get_property_completion(schema, prop_name, &cli_cmd.completion_functions, &prefix, done); + } + return Vec::new(); + } + } + + let mut completions = Vec::new(); + for (name, _optional, _schema) in cli_cmd.info.parameters.properties { + if done.contains_key(*name) { continue; } + if cli_cmd.arg_param.contains(name) { continue; } + let option = String::from("--") + name; + if option.starts_with(prefix) { + completions.push(option); + } + } + completions +} + +fn get_help_completion( + def: &CommandLineInterface, + help_cmd: &CliCommand, + args: &[String], +) -> Vec { + + let mut done = HashMap::new(); + + match def { + CommandLineInterface::Simple(_) => { + return get_simple_completion(help_cmd, &mut done, &[], args); + } + CommandLineInterface::Nested(map) => { + if args.is_empty() { + let mut completions = Vec::new(); + for cmd in map.commands.keys() { + completions.push(cmd.to_string()); + } + return completions; + } + + let first = &args[0]; + if args.len() > 1 { + if let Some(sub_cmd) = map.commands.get(first) { // do exact match here + return get_help_completion(sub_cmd, help_cmd, &args[1..]); + } + return Vec::new(); + } + + if first.starts_with("-") { + return get_simple_completion(help_cmd, &mut done, &[], args); + } + + let mut completions = Vec::new(); + for cmd in map.commands.keys() { + if cmd.starts_with(first) { + completions.push(cmd.to_string()); + } + } + return completions; + } + } +} + +fn get_nested_completion( + def: &CommandLineInterface, + args: &[String], +) -> Vec { + + match def { + CommandLineInterface::Simple(cli_cmd) => { + let mut done: HashMap = HashMap::new(); + cli_cmd.fixed_param.iter().for_each(|(key, value)| { + record_done_argument(&mut done, &cli_cmd.info.parameters, &key, &value); + }); + return get_simple_completion(cli_cmd, &mut done, &cli_cmd.arg_param, args); + } + CommandLineInterface::Nested(map) => { + if args.is_empty() { + let mut completions = Vec::new(); + for cmd in map.commands.keys() { + completions.push(cmd.to_string()); + } + return completions; + } + let first = &args[0]; + if args.len() > 1 { + if let Some((_, sub_cmd)) = map.find_command(first) { + return get_nested_completion(sub_cmd, &args[1..]); + } + return Vec::new(); + } + let mut completions = Vec::new(); + for cmd in map.commands.keys() { + if cmd.starts_with(first) { + completions.push(cmd.to_string()); + } + } + return completions; + } + } +} + +/// Helper to generate bash completions. +/// +/// This helper extracts the command line from environment variable +/// set by ``bash``, namely ``COMP_LINE`` and ``COMP_POINT``. This is +/// passed to ``get_completions()``. Returned values are printed to +/// ``stdout``. +pub fn print_bash_completion(def: &CommandLineInterface) { + + let comp_point: usize = match std::env::var("COMP_POINT") { + Ok(val) => { + match usize::from_str_radix(&val, 10) { + Ok(i) => i, + Err(_) => return, + } + } + Err(_) => return, + }; + + let cmdline = match std::env::var("COMP_LINE") { + Ok(val) => val[0..comp_point].to_owned(), + Err(_) => return, + }; + + let (_start, completions) = super::get_completions(def, &cmdline, true); + + for item in completions { + println!("{}", item); + } +} + +/// Compute possible completions for a partial command +pub fn get_completions( + cmd_def: &CommandLineInterface, + line: &str, + skip_first: bool, +) -> (usize, Vec) { + + let (mut args, start ) = match shellword_split_unclosed(line, false) { + (mut args, None) => { + args.push("".into()); + (args, line.len()) + } + (mut args, Some((start , arg, _quote))) => { + args.push(arg); + (args, start) + } + }; + + if skip_first { + + if args.len() == 0 { return (0, Vec::new()); } + + args.remove(0); // no need for program name + } + + let completions = if !args.is_empty() && args[0] == "help" { + get_help_completion(cmd_def, &help_command_def(), &args[1..]) + } else { + get_nested_completion(cmd_def, &args) + }; + + (start, completions) +} diff --git a/proxmox-api/src/cli/environment.rs b/proxmox-api/src/cli/environment.rs new file mode 100644 index 00000000..4ce433f8 --- /dev/null +++ b/proxmox-api/src/cli/environment.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; +use serde_json::Value; + +use crate::{RpcEnvironment, RpcEnvironmentType}; + +/// `RpcEnvironmet` implementation for command line tools +pub struct CliEnvironment { + result_attributes: HashMap, + user: Option, +} + +impl CliEnvironment { + pub fn new() -> Self { + Self { + result_attributes: HashMap::new(), + user: None, + } + } +} + +impl RpcEnvironment for CliEnvironment { + + fn set_result_attrib(&mut self, name: &str, value: Value) { + self.result_attributes.insert(name.into(), value); + } + + fn get_result_attrib(&self, name: &str) -> Option<&Value> { + self.result_attributes.get(name) + } + + fn env_type(&self) -> RpcEnvironmentType { + RpcEnvironmentType::CLI + } + + fn set_user(&mut self, user: Option) { + self.user = user; + } + + fn get_user(&self) -> Option { + self.user.clone() + } +} diff --git a/proxmox-api/src/cli/format.rs b/proxmox-api/src/cli/format.rs new file mode 100644 index 00000000..0b396734 --- /dev/null +++ b/proxmox-api/src/cli/format.rs @@ -0,0 +1,209 @@ +use serde_json::Value; + +use std::collections::HashSet; + +use crate::schema::*; +use crate::format::*; + +use super::{CommandLineInterface, CliCommand, CliCommandMap}; + +/// Helper function to format and print result. +/// +/// This is implemented for machine generatable formats 'json' and +/// 'json-pretty'. The 'text' format needs to be handled somewhere +/// else. +pub fn format_and_print_result( + result: &Value, + output_format: &str, +) { + + if output_format == "json-pretty" { + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + } else if output_format == "json" { + println!("{}", serde_json::to_string(&result).unwrap()); + } else { + unimplemented!(); + } +} + +/// Helper to generate command usage text for simple commands. +pub fn generate_usage_str( + prefix: &str, + cli_cmd: &CliCommand, + format: DocumentationFormat, + indent: &str) -> String { + + let arg_param = cli_cmd.arg_param; + let fixed_param = &cli_cmd.fixed_param; + let schema = cli_cmd.info.parameters; + + let mut done_hash = HashSet::<&str>::new(); + let mut args = String::new(); + + for positional_arg in arg_param { + match schema.lookup(positional_arg) { + Some((optional, param_schema)) => { + args.push(' '); + + let is_array = if let Schema::Array(_) = param_schema { true } else { false }; + if optional { args.push('['); } + if is_array { args.push('{'); } + args.push('<'); args.push_str(positional_arg); args.push('>'); + if is_array { args.push('}'); } + if optional { args.push(']'); } + + done_hash.insert(positional_arg); + } + None => panic!("no such property '{}' in schema", positional_arg), + } + } + + let mut arg_descr = String::new(); + for positional_arg in arg_param { + let (_optional, param_schema) = schema.lookup(positional_arg).unwrap(); + let param_descr = get_property_description( + positional_arg, param_schema, ParameterDisplayStyle::Fixed, format); + arg_descr.push_str(¶m_descr); + } + + let mut options = String::new(); + + for (prop, optional, param_schema) in schema.properties { + if done_hash.contains(prop) { continue; } + if fixed_param.contains_key(prop) { continue; } + + let type_text = get_schema_type_text(param_schema, ParameterDisplayStyle::Arg); + + if *optional { + + if options.len() > 0 { options.push('\n'); } + options.push_str(&get_property_description(prop, param_schema, ParameterDisplayStyle::Arg, format)); + + } else { + args.push_str(" --"); args.push_str(prop); + args.push(' '); + args.push_str(&type_text); + } + + done_hash.insert(prop); + } + + let option_indicator = if options.len() > 0 { " [OPTIONS]" } else { "" }; + + let mut text = match format { + DocumentationFormat::Short => { + return format!("{}{}{}{}\n\n", indent, prefix, args, option_indicator); + } + DocumentationFormat::Long => { + format!("{}{}{}{}\n\n", indent, prefix, args, option_indicator) + } + DocumentationFormat::Full => { + format!("{}{}{}{}\n\n{}\n\n", indent, prefix, args, option_indicator, schema.description) + } + DocumentationFormat::ReST => { + format!("``{}{}{}``\n\n{}\n\n", prefix, args, option_indicator, schema.description) + } + }; + + if arg_descr.len() > 0 { + text.push_str(&arg_descr); + text.push('\n'); + } + if options.len() > 0 { + text.push_str(&options); + text.push('\n'); + } + text +} + +/// Print command usage for simple commands to ``stderr``. +pub fn print_simple_usage_error( + prefix: &str, + cli_cmd: &CliCommand, + err_msg: &str, +) { + let usage = generate_usage_str(prefix, cli_cmd, DocumentationFormat::Long, ""); + eprint!("Error: {}\nUsage: {}", err_msg, usage); +} + +/// Print command usage for nested commands to ``stderr``. +pub fn print_nested_usage_error( + prefix: &str, + def: &CliCommandMap, + err_msg: &str, +) { + let usage = generate_nested_usage(prefix, def, DocumentationFormat::Short); + eprintln!("Error: {}\n\nUsage:\n\n{}", err_msg, usage); +} + +/// Helper to generate command usage text for nested commands. +pub fn generate_nested_usage( + prefix: &str, + def: &CliCommandMap, + format: DocumentationFormat +) -> String { + + let mut cmds: Vec<&String> = def.commands.keys().collect(); + cmds.sort(); + + let mut usage = String::new(); + + for cmd in cmds { + let new_prefix = format!("{} {}", prefix, cmd); + + match def.commands.get(cmd).unwrap() { + CommandLineInterface::Simple(cli_cmd) => { + if usage.len() > 0 && format == DocumentationFormat::ReST { + usage.push_str("----\n\n"); + } + usage.push_str(&generate_usage_str(&new_prefix, cli_cmd, format, "")); + } + CommandLineInterface::Nested(map) => { + usage.push_str(&generate_nested_usage(&new_prefix, map, format)); + } + } + } + + usage +} + +/// Print help text to ``stderr``. +pub fn print_help( + top_def: &CommandLineInterface, + mut prefix: String, + args: &Vec, + verbose: Option, +) { + let mut iface = top_def; + + for cmd in args { + if let CommandLineInterface::Nested(map) = iface { + if let Some((full_name, subcmd)) = map.find_command(cmd) { + iface = subcmd; + if !prefix.is_empty() { prefix.push(' '); } + prefix.push_str(&full_name); + continue; + } + } + if prefix.is_empty() { + eprintln!("no such command '{}'", cmd); + } else { + eprintln!("no such command '{} {}'", prefix, cmd); + } + return; + } + + let format = match verbose.unwrap_or(false) { + true => DocumentationFormat::Full, + false => DocumentationFormat::Short, + }; + + match iface { + CommandLineInterface::Nested(map) => { + println!("Usage:\n\n{}", generate_nested_usage(&prefix, map, format)); + } + CommandLineInterface::Simple(cli_cmd) => { + println!("Usage: {}", generate_usage_str(&prefix, cli_cmd, format, "")); + } + } +} diff --git a/proxmox-api/src/cli/getopts.rs b/proxmox-api/src/cli/getopts.rs new file mode 100644 index 00000000..b20eb517 --- /dev/null +++ b/proxmox-api/src/cli/getopts.rs @@ -0,0 +1,255 @@ +use failure::*; +use serde_json::Value; + +use crate::schema::*; + +#[derive(Debug)] +enum RawArgument { + Separator, + Argument { value: String }, + Option { name: String, value: Option }, +} + +fn parse_argument(arg: &str) -> RawArgument { + let bytes = arg.as_bytes(); + + let length = bytes.len(); + + if length < 2 || bytes[0] != b'-' { + return RawArgument::Argument { + value: arg.to_string(), + }; + } + + let mut first = 1; + + if bytes[1] == b'-' { + if length == 2 { + return RawArgument::Separator; + } + first = 2; + } + + for start in first..length { + if bytes[start] == b'=' { + // Since we take a &str, we know the contents of it are valid utf8. + // Since bytes[start] == b'=', we know the byte beginning at start is a single-byte + // code pointer. We also know that 'first' points exactly after a single-byte code + // point as it points to the first byte after a hyphen. + // Therefore we know arg[first..start] is valid utf-8, therefore it is safe to use + // get_unchecked() to speed things up. + return RawArgument::Option { + name: unsafe { arg.get_unchecked(first..start).to_string() }, + value: Some(unsafe { arg.get_unchecked((start + 1)..).to_string() }), + }; + } + } + + RawArgument::Option { + name: unsafe { arg.get_unchecked(first..).to_string() }, + value: None, + } +} + +/// parse as many arguments as possible into a Vec. This does not +/// verify the schema. +/// Returns parsed data and the rest as separate array +pub (crate) fn parse_argument_list>( + args: &[T], + schema: &ObjectSchema, + errors: &mut ParameterError, +) -> (Vec<(String, String)>, Vec) { + + let mut data: Vec<(String, String)> = vec![]; + let mut rest: Vec = vec![]; + + let mut pos = 0; + + while pos < args.len() { + match parse_argument(args[pos].as_ref()) { + RawArgument::Separator => { + break; + } + RawArgument::Option { name, value } => match value { + None => { + let mut want_bool = false; + let mut can_default = false; + if let Some((_optional, param_schema)) = schema.lookup(&name) { + if let Schema::Boolean(boolean_schema) = param_schema { + want_bool = true; + if let Some(default) = boolean_schema.default { + if default == false { + can_default = true; + } + } else { + can_default = true; + } + } + } + + let mut next_is_argument = false; + let mut next_is_bool = false; + + if (pos + 1) < args.len() { + let next = args[pos + 1].as_ref(); + if let RawArgument::Argument { .. } = parse_argument(next) { + next_is_argument = true; + if let Ok(_) = parse_boolean(next) { + next_is_bool = true; + } + } + } + + if want_bool { + if next_is_bool { + pos += 1; + data.push((name, args[pos].as_ref().to_string())); + } else if can_default { + data.push((name, "true".to_string())); + } else { + errors.push(format_err!("parameter '{}': {}", name, + "missing boolean value.")); + } + + } else if next_is_argument { + pos += 1; + data.push((name, args[pos].as_ref().to_string())); + } else { + errors.push(format_err!("parameter '{}': {}", name, + "missing parameter value.")); + } + } + Some(v) => { + data.push((name, v)); + } + }, + RawArgument::Argument { value } => { + rest.push(value); + } + } + + pos += 1; + } + + rest.reserve(args.len() - pos); + for i in &args[pos..] { + rest.push(i.as_ref().to_string()); + } + + (data, rest) +} + +/// Parses command line arguments using a `Schema` +/// +/// Returns parsed options as json object, together with the +/// list of additional command line arguments. +pub fn parse_arguments>( + args: &[T], + arg_param: &[&str], + schema: &ObjectSchema, +) -> Result<(Value, Vec), ParameterError> { + let mut errors = ParameterError::new(); + + // first check if all arg_param exists in schema + + let mut last_arg_param_is_optional = false; + let mut last_arg_param_is_array = false; + + for i in 0..arg_param.len() { + let name = arg_param[i]; + if let Some((optional, param_schema)) = schema.lookup(&name) { + if i == arg_param.len() -1 { + last_arg_param_is_optional = optional; + if let Schema::Array(_) = param_schema { + last_arg_param_is_array = true; + } + } else if optional { + panic!("positional argument '{}' may not be optional", name); + } + } else { + panic!("no such property '{}' in schema", name); + } + } + + let (mut data, mut rest) = parse_argument_list(args, schema, &mut errors); + + for i in 0..arg_param.len() { + + let name = arg_param[i]; + let is_last_arg_param = i == (arg_param.len() - 1); + + if rest.len() == 0 { + if !(is_last_arg_param && last_arg_param_is_optional) { + errors.push(format_err!("missing argument '{}'", name)); + } + } else if is_last_arg_param && last_arg_param_is_array { + for value in rest { + data.push((name.to_string(), value)); + } + rest = vec![]; + } else { + data.push((name.to_string(), rest.remove(0))); + } + } + + if errors.len() > 0 { + return Err(errors); + } + + let options = parse_parameter_strings(&data, schema, true)?; + + Ok((options, rest)) +} + +#[test] +fn test_boolean_arg() { + + const PARAMETERS: ObjectSchema = ObjectSchema::new( + "Parameters:", + &[ ("enable", false, &BooleanSchema::new("Enable").schema()) ], + ); + + let mut variants: Vec<(Vec<&str>, bool)> = vec![]; + variants.push((vec!["-enable"], true)); + variants.push((vec!["-enable=1"], true)); + variants.push((vec!["-enable", "yes"], true)); + variants.push((vec!["-enable", "Yes"], true)); + variants.push((vec!["--enable", "1"], true)); + variants.push((vec!["--enable", "ON"], true)); + variants.push((vec!["--enable", "true"], true)); + + variants.push((vec!["--enable", "0"], false)); + variants.push((vec!["--enable", "no"], false)); + variants.push((vec!["--enable", "off"], false)); + variants.push((vec!["--enable", "false"], false)); + + for (args, expect) in variants { + let res = parse_arguments(&args, &vec![], &PARAMETERS); + assert!(res.is_ok()); + if let Ok((options, rest)) = res { + assert!(options["enable"] == expect); + assert!(rest.len() == 0); + } + } +} + +#[test] +fn test_argument_paramenter() { + + const PARAMETERS: ObjectSchema = ObjectSchema::new( + "Parameters:", + &[ + ("enable", false, &BooleanSchema::new("Enable.").schema()), + ("storage", false, &StringSchema::new("Storage.").schema()), + ], + ); + + let args = vec!["-enable", "local"]; + let res = parse_arguments(&args, &vec!["storage"], &PARAMETERS); + assert!(res.is_ok()); + if let Ok((options, rest)) = res { + assert!(options["enable"] == true); + assert!(options["storage"] == "local"); + assert!(rest.len() == 0); + } +} diff --git a/proxmox-api/src/cli/readline.rs b/proxmox-api/src/cli/readline.rs new file mode 100644 index 00000000..79217263 --- /dev/null +++ b/proxmox-api/src/cli/readline.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use super::*; + +/// Helper trait implementation for ``rustyline``. +/// +/// This can be used to generate interactive commands using +/// ``rustyline`` (readline implementation). +/// +pub struct CliHelper { + cmd_def: Arc, +} + +impl CliHelper { + + pub fn new(cmd_def: CommandLineInterface) -> Self { + Self { cmd_def: Arc::new(cmd_def) } + } + + pub fn cmd_def(&self) -> Arc { + self.cmd_def.clone() + } +} + +impl rustyline::completion::Completer for CliHelper { + type Candidate = String; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + + let line = &line[..pos]; + + let (start, completions) = super::get_completions(&*self.cmd_def, line, false); + + return Ok((start, completions)); + } +} + +impl rustyline::hint::Hinter for CliHelper {} +impl rustyline::highlight::Highlighter for CliHelper {} +impl rustyline::Helper for CliHelper {} diff --git a/proxmox-api/src/cli/shellword.rs b/proxmox-api/src/cli/shellword.rs new file mode 100644 index 00000000..5248fc22 --- /dev/null +++ b/proxmox-api/src/cli/shellword.rs @@ -0,0 +1,160 @@ +use failure::*; +use rustyline::completion::Quote; + +#[derive(PartialEq)] +enum ParseMode { + Space, + DoubleQuote, + EscapeNormal, + EscapeInDoubleQuote, + Normal, + SingleQuote, +} + +/// Parsing strings as they would be interpreted by the UNIX Bourne shell. +/// +/// - ``finalize``: assume this is a complete command line. Set this +/// to false for the 'completion' helper, which needs to get +/// information about the last unfinished parameter. +/// +/// Returns the list of fully parsed words (unescaped and quotes +/// removed). If there are unclosed quotes, the start of that +/// parameter, the parameter value (unescaped and quotes removed), and +/// the quote type are returned. +pub fn shellword_split_unclosed(s: &str, finalize: bool) -> (Vec, Option<(usize, String, Quote)>) { + + let char_indices = s.char_indices(); + let mut args: Vec = Vec::new(); + let mut field_start = None; + let mut field = String::new(); + let mut mode = ParseMode::Space; + + let space_chars = [' ', '\t', '\n']; + + for (index, c) in char_indices { + match mode { + ParseMode::Space => match c { + '"' => { + mode = ParseMode::DoubleQuote; + field_start = Some((index, Quote::Double)); + } + '\\' => { + mode = ParseMode::EscapeNormal; + field_start = Some((index, Quote::None)); + } + '\'' => { + mode = ParseMode::SingleQuote; + field_start = Some((index, Quote::Single)); + } + c if space_chars.contains(&c) => (), // skip space + c => { + mode = ParseMode::Normal; + field_start = Some((index, Quote::None)); + field.push(c); + } + } + ParseMode::EscapeNormal => { + mode = ParseMode::Normal; + field.push(c); + } + ParseMode::EscapeInDoubleQuote => { + // Within double quoted strings, backslashes are only + // treated as metacharacters when followed by one of + // the following characters: $ ' " \ newline + match c { + '$' | '\'' | '"' | '\\' | '\n' => (), + _ => field.push('\\'), + } + field.push(c); + mode = ParseMode::DoubleQuote; + } + ParseMode::Normal => match c { + '"' => mode = ParseMode::DoubleQuote, + '\'' => mode = ParseMode::SingleQuote, + '\\' => mode = ParseMode::EscapeNormal, + c if space_chars.contains(&c) => { + mode = ParseMode::Space; + let (_start, _quote) = field_start.take().unwrap(); + args.push(field.split_off(0)); + } + c => field.push(c), // continue + } + ParseMode::DoubleQuote => match c { + '"' => mode = ParseMode::Normal, + '\\' => mode = ParseMode::EscapeInDoubleQuote, + c => field.push(c), // continue + } + ParseMode::SingleQuote => match c { + // Note: no escape in single quotes + '\'' => mode = ParseMode::Normal, + c => field.push(c), // continue + } + } + } + + if finalize && mode == ParseMode::Normal { + let (_start, _quote) = field_start.take().unwrap(); + args.push(field.split_off(0)); + } + + match field_start { + Some ((start, quote)) => { + (args, Some((start, field, quote))) + } + None => { + (args, None) + } + } +} + +/// Splits a string into a vector of words in the same way the UNIX Bourne shell does. +/// +/// Return words unescaped and without quotes. +pub fn shellword_split(s: &str) -> Result, Error> { + + let (args, unclosed_field) = shellword_split_unclosed(s, true); + if !unclosed_field.is_none() { + bail!("shellword split failed - found unclosed quote."); + } + Ok(args) +} + +#[test] +fn test_shellword_split() { + + let expect = [ "ls", "/etc" ]; + let expect: Vec = expect.iter().map(|v| v.to_string()).collect(); + + assert_eq!(expect, shellword_split("ls /etc").unwrap()); + assert_eq!(expect, shellword_split("ls \"/etc\"").unwrap()); + assert_eq!(expect, shellword_split("ls '/etc'").unwrap()); + assert_eq!(expect, shellword_split("ls '/etc'").unwrap()); + + assert_eq!(expect, shellword_split("ls /e\"t\"c").unwrap()); + assert_eq!(expect, shellword_split("ls /e'tc'").unwrap()); + assert_eq!(expect, shellword_split("ls /e't''c'").unwrap()); + + let expect = [ "ls", "/etc 08x" ]; + let expect: Vec = expect.iter().map(|v| v.to_string()).collect(); + assert_eq!(expect, shellword_split("ls /etc\\ \\08x").unwrap()); + + let expect = [ "ls", "/etc \\08x" ]; + let expect: Vec = expect.iter().map(|v| v.to_string()).collect(); + assert_eq!(expect, shellword_split("ls \"/etc \\08x\"").unwrap()); +} + +#[test] +fn test_shellword_split_unclosed() { + + let expect = [ "ls".to_string() ].to_vec(); + assert_eq!( + (expect, Some((3, "./File1 name with spaces".to_string(), Quote::Single))), + shellword_split_unclosed("ls './File1 name with spaces", false) + ); + + let expect = [ "ls".to_string() ].to_vec(); + assert_eq!( + (expect, Some((3, "./File2 name with spaces".to_string(), Quote::Double))), + shellword_split_unclosed("ls \"./File2 \"name\" with spaces", false) + ); +} diff --git a/proxmox-api/src/lib.rs b/proxmox-api/src/lib.rs index 5b0dbe6c..428aecb9 100644 --- a/proxmox-api/src/lib.rs +++ b/proxmox-api/src/lib.rs @@ -20,6 +20,8 @@ pub mod router; pub mod rpc_environment; pub mod schema; +pub mod cli; + use schema::{ObjectSchema, Schema}; #[doc(inline)]