use std::path::PathBuf;
use crate::common_args::OutputFormat;
use crate::common_subcommands::add_common_subcommands;
use crate::error::CliError;
pub trait CliPort: Send + Sync {
fn parse(&self, args: &[&str]) -> Result<ParsedInvocation, CliError>;
fn help(&self) -> String;
fn version(&self) -> &str;
fn name(&self) -> &str;
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GlobalOptions {
pub config: Option<PathBuf>,
pub verbose: u8,
pub quiet: bool,
pub output: OutputFormat,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedCli {
Init {
path: PathBuf,
force: bool,
template: String,
},
Validate { path: PathBuf, strict: bool },
Version,
Other { name: String, args: Vec<String> },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedInvocation {
pub globals: GlobalOptions,
pub command: ParsedCli,
}
pub struct ClapBasedCli {
name: String,
about: String,
version: String,
author: Option<String>,
custom: Vec<(String, String, String)>,
}
impl ClapBasedCli {
pub fn new(
name: impl Into<String>,
about: impl Into<String>,
version: impl Into<String>,
) -> Self {
Self {
name: name.into(),
about: about.into(),
version: version.into(),
author: None,
custom: Vec::new(),
}
}
pub fn with_author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
pub fn with_subcommand(
mut self,
name: impl Into<String>,
about: impl Into<String>,
help: impl Into<String>,
) -> Self {
self.custom.push((name.into(), about.into(), help.into()));
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn version(&self) -> &str {
&self.version
}
pub fn about(&self) -> &str {
&self.about
}
pub fn build_command(&self) -> clap::Command {
let mut cmd = clap::Command::new(self.name.clone())
.about(self.about.clone())
.version(self.version.clone())
.subcommand_required(true)
.arg_required_else_help(true)
.arg(
clap::Arg::new("config")
.short('c')
.long("config")
.value_name("CONFIG")
.help("Path to the config file")
.env("PHENOTYPE_CONFIG")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
clap::Arg::new("verbose")
.short('v')
.long("verbose")
.action(clap::ArgAction::Count)
.help("Increase log verbosity (-v, -vv, -vvv)"),
)
.arg(
clap::Arg::new("quiet")
.short('q')
.long("quiet")
.action(clap::ArgAction::SetTrue)
.conflicts_with("verbose")
.help("Suppress non-error output"),
)
.arg(
clap::Arg::new("output")
.long("output")
.value_name("OUTPUT")
.value_parser(clap::value_parser!(OutputFormat))
.default_value("human")
.help("Output format (human, json, yaml)"),
);
if let Some(author) = &self.author {
cmd = cmd.author(author.clone());
}
for (name, about, help) in &self.custom {
cmd = cmd.subcommand(
clap::Command::new(name.clone()).about(about.clone()).arg(
clap::Arg::new("args")
.num_args(0..)
.trailing_var_arg(true)
.allow_hyphen_values(true)
.help(help.clone()),
),
);
}
add_common_subcommands(cmd)
}
}
impl CliPort for ClapBasedCli {
fn parse(&self, args: &[&str]) -> Result<ParsedInvocation, CliError> {
let mut full: Vec<&str> = Vec::with_capacity(args.len() + 1);
full.push(&self.name);
full.extend(args.iter().copied());
let matches = self
.build_command()
.try_get_matches_from(&full)
.map_err(|e| CliError::Parse(e.to_string()))?;
let globals = GlobalOptions {
config: matches.get_one::<PathBuf>("config").cloned(),
verbose: matches.get_count("verbose"),
quiet: matches.get_flag("quiet"),
output: *matches
.get_one::<OutputFormat>("output")
.unwrap_or(&OutputFormat::Human),
};
let command = match matches.subcommand() {
Some(("init", m)) => ParsedCli::Init {
path: m
.get_one::<PathBuf>("path")
.cloned()
.unwrap_or_else(|| PathBuf::from(".")),
force: m.get_flag("force"),
template: m
.get_one::<String>("template")
.cloned()
.unwrap_or_else(|| "default".to_string()),
},
Some(("validate", m)) => {
let path = m.get_one::<PathBuf>("path").cloned().ok_or_else(|| {
CliError::Parse("validate requires a <path> argument".to_string())
})?;
ParsedCli::Validate {
path,
strict: m.get_flag("strict"),
}
}
Some(("version", _)) => ParsedCli::Version,
Some((name, m)) => {
let args: Vec<String> = m
.get_many::<String>("args")
.map(|v| v.cloned().collect())
.unwrap_or_default();
ParsedCli::Other {
name: name.to_string(),
args,
}
}
None => {
return Err(CliError::Parse(
"a subcommand is required (try --help)".to_string(),
));
}
};
Ok(ParsedInvocation { globals, command })
}
fn help(&self) -> String {
self.build_command()
.color(clap::ColorChoice::Never)
.render_help()
.to_string()
}
fn version(&self) -> &str {
&self.version
}
fn name(&self) -> &str {
&self.name
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cli() -> ClapBasedCli {
ClapBasedCli::new("test-cli", "test about", "0.1.0")
.with_author("Phenotype")
.with_subcommand("serve", "Start server", "Server args")
}
#[test]
fn parses_init_with_defaults() {
let inv = cli().parse(&["init"]).unwrap();
assert_eq!(inv.globals.verbose, 0);
assert!(!inv.globals.quiet);
assert_eq!(inv.globals.output, OutputFormat::Human);
assert_eq!(
inv.command,
ParsedCli::Init {
path: PathBuf::from("."),
force: false,
template: "default".to_string(),
}
);
}
#[test]
fn parses_init_with_overrides() {
let inv = cli()
.parse(&["init", "/tmp/proj", "-f", "-t", "rust"])
.unwrap();
assert_eq!(
inv.command,
ParsedCli::Init {
path: PathBuf::from("/tmp/proj"),
force: true,
template: "rust".to_string(),
}
);
}
#[test]
fn parses_validate_strict() {
let inv = cli().parse(&["validate", "/etc/cfg", "--strict"]).unwrap();
assert_eq!(
inv.command,
ParsedCli::Validate {
path: PathBuf::from("/etc/cfg"),
strict: true,
}
);
}
#[test]
fn validate_requires_path() {
let err = cli().parse(&["validate"]).unwrap_err();
assert!(matches!(err, CliError::Parse(_)));
}
#[test]
fn parses_version_subcommand() {
let inv = cli().parse(&["version"]).unwrap();
assert_eq!(inv.command, ParsedCli::Version);
}
#[test]
fn parses_custom_subcommand() {
let inv = cli().parse(&["serve", "8080", "--workers", "4"]).unwrap();
match inv.command {
ParsedCli::Other { name, args } => {
assert_eq!(name, "serve");
assert_eq!(args, vec!["8080", "--workers", "4"]);
}
_ => panic!("expected Other, got {:?}", inv.command),
}
}
#[test]
fn parses_global_flags() {
let inv = cli()
.parse(&["-vv", "-c", "/tmp/cfg.yaml", "--output", "json", "version"])
.unwrap();
assert_eq!(inv.globals.verbose, 2);
assert_eq!(inv.globals.config, Some(PathBuf::from("/tmp/cfg.yaml")));
assert_eq!(inv.globals.output, OutputFormat::Json);
assert_eq!(inv.command, ParsedCli::Version);
}
#[test]
fn quiet_conflicts_with_verbose() {
let err = cli().parse(&["-v", "-q", "version"]).unwrap_err();
assert!(matches!(err, CliError::Parse(_)));
}
#[test]
fn missing_subcommand_errors() {
let err = cli().parse(&[]).unwrap_err();
assert!(matches!(err, CliError::Parse(_)));
}
#[test]
fn help_contains_subcommands_and_flags() {
let h = cli().help();
assert!(h.contains("init"), "help should list init; got: {h}");
assert!(
h.contains("validate"),
"help should list validate; got: {h}"
);
assert!(h.contains("version"), "help should list version; got: {h}");
assert!(
h.contains("serve"),
"help should list custom subcommand; got: {h}"
);
assert!(
h.contains("--config"),
"help should describe --config; got: {h}"
);
assert!(
h.contains("--verbose"),
"help should describe --verbose; got: {h}"
);
}
#[test]
fn metadata_accessors() {
let c = cli();
assert_eq!(c.name(), "test-cli");
assert_eq!(c.version(), "0.1.0");
assert_eq!(c.about(), "test about");
let port: &dyn CliPort = &c;
assert_eq!(port.name(), "test-cli");
assert_eq!(port.version(), "0.1.0");
}
#[test]
fn trait_is_object_safe() {
let c: std::sync::Arc<dyn CliPort> = std::sync::Arc::new(cli());
let inv = c.parse(&["version"]).unwrap();
assert_eq!(inv.command, ParsedCli::Version);
}
}