use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressMode {
Dots,
Bar,
None,
Verbose,
}
impl std::str::FromStr for ProgressMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"dots" => Ok(Self::Dots),
"bar" => Ok(Self::Bar),
"none" => Ok(Self::None),
_ => Ok(Self::Dots),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogFormat {
Console,
Json,
JUnit,
Allure,
}
#[derive(Parser, Debug)]
#[command(name = "grpctestify")]
#[command(author = "grpctestify team")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Test gRPC services with simple .gctf files", long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
#[command(flatten)]
pub run_args: RunArgs,
#[arg(short = 'v', long, global = true, default_value_t = false)]
pub verbose: bool,
#[arg(long, value_name = "SHELL_TYPE", value_parser = ["bash", "zsh", "fish", "elvish", "powershell"])]
pub completion: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Run(Box<RunArgs>),
Call(CallArgs),
Gen(GenArgs),
Reflect(ReflectArgs),
Fmt(FmtArgs),
Check(CheckArgs),
Inspect(InspectArgs),
Explain(ExplainArgs),
Grpcurl(GrpcurlArgs),
List(ListArgs),
Lsp(LspArgs),
}
#[derive(Args, Debug, Clone)]
pub struct GrpcurlArgs {
#[arg(required = true)]
pub file: PathBuf,
#[arg(long)]
pub doc_index: Option<usize>,
#[arg(long, default_value = "text")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub struct LspArgs {
#[arg(long, default_value_t = true)]
pub stdio: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ListArgs {
#[arg(required = false)]
pub path: Option<PathBuf>,
#[arg(long, default_value = "json")]
pub format: String,
#[arg(long, default_value_t = false)]
pub with_range: bool,
}
#[derive(Args, Debug, Clone)]
pub struct InspectArgs {
#[arg(required = true)]
pub file: PathBuf,
#[arg(long, default_value = "text")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub struct ExplainArgs {
#[arg(required = true)]
pub file: PathBuf,
#[arg(long, default_value = "text")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub struct CheckArgs {
#[arg(required = true)]
pub files: Vec<PathBuf>,
#[arg(long, default_value = "text")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub struct RunArgs {
#[arg(required = false)]
pub test_paths: Vec<PathBuf>,
#[arg(long = "exclude", value_name = "PATTERN")]
pub exclude: Vec<String>,
#[arg(long = "tags", value_name = "TAGS")]
pub tags: Vec<String>,
#[arg(long = "skip-tags", value_name = "TAGS")]
pub skip_tags: Vec<String>,
#[arg(short = 'p', long, default_value = "auto")]
pub parallel: String,
#[arg(short = 'd', long, default_value_t = false)]
pub dry_run: bool,
#[arg(short = 's', long, default_value = "path")]
pub sort: String,
#[arg(long, value_name = "FORMAT")]
pub log_format: Option<String>,
#[arg(long, value_name = "OUTPUT_FILE")]
pub log_output: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub stream: bool,
#[arg(short = 't', long, default_value_t = 30)]
pub timeout: u64,
#[arg(short = 'r', long, default_value_t = 0)]
pub retry: u32,
#[arg(long, default_value_t = 1.0)]
pub retry_delay: f64,
#[arg(long, default_value_t = false)]
pub no_retry: bool,
#[arg(long, default_value = "auto")]
pub progress: String,
#[arg(long, default_value_t = false)]
pub no_assert: bool,
#[arg(long, default_value_t = false)]
pub coverage: bool,
#[arg(long, default_value = "text")]
pub coverage_format: String,
#[arg(short = 'w', long, default_value_t = false)]
pub write: bool,
}
#[derive(Args, Debug, Clone)]
pub struct ReflectArgs {
pub symbol: Option<String>,
#[arg(long)]
pub address: Option<String>,
#[arg(long, default_value_t = false)]
pub plaintext: bool,
}
#[derive(Args, Debug, Clone)]
pub struct FmtArgs {
#[arg(required = true)]
pub files: Vec<PathBuf>,
#[arg(short = 'w', long, default_value_t = false)]
pub write: bool,
}
#[derive(Args, Debug, Clone)]
pub struct CallArgs {
#[arg(required = true)]
pub file: PathBuf,
#[arg(long)]
pub doc_index: Option<usize>,
#[arg(short = 'i', long, default_value_t = false)]
pub include: bool,
#[arg(short = 'v', long, default_value_t = false)]
pub verbose: bool,
#[arg(long = "vv", default_value_t = false)]
pub very_verbose: bool,
#[arg(short = 'o', long)]
pub output: Option<PathBuf>,
#[arg(short = 'D', long)]
pub dump_header: Option<PathBuf>,
#[arg(short = 's', long, default_value_t = false)]
pub silent: bool,
#[arg(short = 'S', long, default_value_t = false)]
pub show_error: bool,
#[arg(long, default_value_t = 30)]
pub connect_timeout: u64,
#[arg(long, default_value_t = 60)]
pub max_time: u64,
}
#[derive(Args, Debug, Clone)]
pub struct GenArgs {
#[arg(short = 'o', long)]
pub output: Option<PathBuf>,
#[command(subcommand)]
pub source: GenSource,
}
#[derive(Subcommand, Debug, Clone)]
pub enum GenSource {
Grpcurl(GenGrpcurlArgs),
}
#[derive(Args, Debug, Clone)]
#[command(trailing_var_arg = true)]
pub struct GenGrpcurlArgs {
#[arg(short = 'e', long, default_value_t = false)]
pub execute: bool,
#[arg(required = true, allow_hyphen_values = true)]
pub grpcurl_args: Vec<String>,
}
impl Cli {
pub fn parallel_jobs(&self) -> usize {
let parallel = match &self.command {
Some(Commands::Run(args)) => &args.parallel,
_ => &self.run_args.parallel,
};
if parallel == "auto" {
std::thread::available_parallelism()
.ok()
.map(|n| n.get())
.unwrap_or(4)
} else {
parallel.parse().unwrap_or(1)
}
}
pub fn progress_mode(&self) -> ProgressMode {
let progress = match &self.command {
Some(Commands::Run(args)) => &args.progress,
_ => &self.run_args.progress,
};
match progress.as_str() {
"dots" => ProgressMode::Dots,
"bar" => ProgressMode::Bar,
"none" => ProgressMode::None,
"auto" => {
if self.verbose {
ProgressMode::Verbose
} else {
ProgressMode::Dots
}
}
_ => ProgressMode::Dots,
}
}
pub fn log_format_mode(&self) -> Option<LogFormat> {
let log_format = match &self.command {
Some(Commands::Run(args)) => &args.log_format,
_ => &self.run_args.log_format,
};
log_format.as_ref().map(|fmt| match fmt.as_str() {
"junit" => LogFormat::JUnit,
"json" => LogFormat::Json,
"allure" => LogFormat::Allure,
_ => LogFormat::Console,
})
}
pub fn get_run_args(&self) -> &RunArgs {
match &self.command {
Some(Commands::Run(args)) => args,
_ => &self.run_args,
}
}
}
fn is_json_format(value: &str) -> bool {
value.eq_ignore_ascii_case("json")
}
pub trait HasFormat {
fn format(&self) -> &str;
fn is_json(&self) -> bool {
is_json_format(self.format())
}
}
impl HasFormat for ListArgs {
fn format(&self) -> &str {
&self.format
}
}
impl HasFormat for InspectArgs {
fn format(&self) -> &str {
&self.format
}
}
impl HasFormat for ExplainArgs {
fn format(&self) -> &str {
&self.format
}
}
impl HasFormat for GrpcurlArgs {
fn format(&self) -> &str {
&self.format
}
}
impl HasFormat for CheckArgs {
fn format(&self) -> &str {
&self.format
}
}
impl RunArgs {
pub fn is_json_coverage(&self) -> bool {
is_json_format(&self.coverage_format)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn parse_call_defaults() {
let cli = Cli::parse_from(["grpctestify", "call", "test.gctf"]);
let Some(Commands::Call(call)) = cli.command else {
panic!("expected call command");
};
assert_eq!(call.file, PathBuf::from("test.gctf"));
assert_eq!(call.doc_index, None);
assert!(!call.include);
assert!(!call.verbose);
assert!(!call.very_verbose);
assert!(!call.silent);
assert!(!call.show_error);
assert_eq!(call.connect_timeout, 30);
assert_eq!(call.max_time, 60);
}
#[test]
fn parse_call_verbose_flags() {
let cli = Cli::parse_from(["grpctestify", "call", "-v", "test.gctf"]);
let Some(Commands::Call(call)) = cli.command else {
panic!()
};
assert!(call.verbose);
assert!(!call.very_verbose);
let cli = Cli::parse_from(["grpctestify", "call", "--vv", "test.gctf"]);
let Some(Commands::Call(call)) = cli.command else {
panic!()
};
assert!(!call.verbose);
assert!(call.very_verbose);
}
#[test]
fn parse_call_include_and_dump_header() {
let cli = Cli::parse_from(["grpctestify", "call", "-i", "-D", "/tmp/h.txt", "test.gctf"]);
let Some(Commands::Call(call)) = cli.command else {
panic!()
};
assert!(call.include);
assert_eq!(call.dump_header, Some(PathBuf::from("/tmp/h.txt")));
}
#[test]
fn parse_call_silent_and_show_error() {
let cli = Cli::parse_from(["grpctestify", "call", "-s", "-S", "test.gctf"]);
let Some(Commands::Call(call)) = cli.command else {
panic!()
};
assert!(call.silent);
assert!(call.show_error);
}
#[test]
fn parse_gen_with_output_before_source() {
let cli = Cli::parse_from([
"grpctestify",
"gen",
"-o",
"out.gctf",
"grpcurl",
"-plaintext",
"localhost:4770",
"svc.Method/Call",
]);
let Some(Commands::Gen(gen_args)) = cli.command else {
panic!("expected gen command");
};
assert_eq!(gen_args.output, Some(PathBuf::from("out.gctf")));
let GenSource::Grpcurl(grpcurl) = gen_args.source;
assert_eq!(
grpcurl.grpcurl_args,
vec![
"-plaintext".to_string(),
"localhost:4770".to_string(),
"svc.Method/Call".to_string()
]
);
}
#[test]
fn parse_gen_grpcurl_preserves_hyphen_args() {
let cli = Cli::parse_from([
"grpctestify",
"gen",
"grpcurl",
"-H",
"x-api-key: abc",
"-d",
"{}",
"localhost:4770",
"svc.Method/Call",
]);
let Some(Commands::Gen(gen_args)) = cli.command else {
panic!("expected gen command");
};
let GenSource::Grpcurl(grpcurl) = gen_args.source;
assert_eq!(grpcurl.grpcurl_args[0], "-H");
assert_eq!(grpcurl.grpcurl_args[2], "-d");
assert_eq!(grpcurl.grpcurl_args[3], "{}");
assert_eq!(grpcurl.grpcurl_args[4], "localhost:4770");
}
}