use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "granc", version, about = "Dynamic gRPC CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Call {
#[arg(value_parser = parse_endpoint)]
endpoint: (String, String),
#[arg(long, short = 'u')]
uri: String,
#[arg(long, short = 'b', value_parser = parse_body)]
body: serde_json::Value,
#[arg(short = 'H', long = "header", value_parser = parse_header)]
headers: Vec<(String, String)>,
#[arg(long, short = 'f')]
file_descriptor_set: Option<PathBuf>,
},
List {
#[command(flatten)]
source: SourceSelection,
},
Describe {
#[command(flatten)]
source: SourceSelection,
symbol: String,
},
Doc {
#[command(flatten)]
source: SourceSelection,
symbol: String,
#[arg(long, short = 'o')]
output: PathBuf,
},
}
#[derive(Args, Debug)]
#[group(required = true, multiple = false)] pub struct SourceSelection {
#[arg(long, short = 'u')]
uri: Option<String>,
#[arg(long, short = 'f')]
file_descriptor_set: Option<PathBuf>,
}
pub enum Source {
Uri(String),
File(PathBuf),
}
impl SourceSelection {
pub fn value(self) -> Source {
if let Some(uri) = self.uri {
Source::Uri(uri)
} else if let Some(path) = self.file_descriptor_set {
Source::File(path)
} else {
unreachable!(
"Clap ensures exactly one argument (uri or file) is present via #[group(required = true)]"
)
}
}
}
fn parse_endpoint(value: &str) -> Result<(String, String), String> {
let (service, method) = value.split_once('/').ok_or_else(|| {
format!("Invalid endpoint format: '{value}'. Expected 'package.Service/Method'",)
})?;
if service.trim().is_empty() || method.trim().is_empty() {
return Err("Service and Method names cannot be empty".to_string());
}
Ok((service.to_string(), method.to_string()))
}
fn parse_header(s: &str) -> Result<(String, String), String> {
s.split_once(':')
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
.ok_or_else(|| "Format must be 'key:value'".to_string())
}
fn parse_body(value: &str) -> Result<serde_json::Value, String> {
serde_json::from_str(value).map_err(|e| format!("Invalid JSON: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn test_call_command_reflection() {
let args = vec![
"granc",
"call",
"helloworld.Greeter/SayHello",
"--uri",
"http://localhost:50051",
"--body",
r#"{"name": "Ferris"}"#,
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Call {
endpoint,
uri,
body,
file_descriptor_set,
..
} => {
assert_eq!(
endpoint,
("helloworld.Greeter".to_string(), "SayHello".to_string())
);
assert_eq!(uri, "http://localhost:50051");
assert_eq!(body, serde_json::json!({"name": "Ferris"}));
assert!(file_descriptor_set.is_none());
}
_ => panic!("Expected Call command"),
}
}
#[test]
fn test_call_command_with_file_descriptor() {
let args = vec![
"granc",
"call",
"helloworld.Greeter/SayHello",
"--uri",
"http://localhost:50051",
"--body",
r#"{"name": "Ferris"}"#,
"--file-descriptor-set",
"./descriptors.bin",
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Call {
file_descriptor_set,
..
} => {
assert_eq!(
file_descriptor_set.unwrap().to_str().unwrap(),
"./descriptors.bin"
);
}
_ => panic!("Expected Call command"),
}
}
#[test]
fn test_call_command_short_flags() {
let args = vec![
"granc",
"call",
"svc/mthd",
"-u",
"http://localhost:50051",
"-b",
"{}",
"-f",
"desc.bin",
"-H",
"auth:bearer",
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Call {
uri,
file_descriptor_set,
headers,
body,
..
} => {
assert_eq!(uri, "http://localhost:50051");
assert_eq!(file_descriptor_set.unwrap().to_str().unwrap(), "desc.bin");
assert_eq!(body, serde_json::json!({}));
assert_eq!(headers[0], ("auth".to_string(), "bearer".to_string()));
}
_ => panic!("Expected Call command"),
}
}
#[test]
fn test_list_command_reflection() {
let args = vec!["granc", "list", "--uri", "http://localhost:50051"];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::List { source } => {
assert_eq!(source.uri.unwrap(), "http://localhost:50051");
assert!(source.file_descriptor_set.is_none());
}
_ => panic!("Expected List command"),
}
}
#[test]
fn test_list_command_offline() {
let args = vec!["granc", "list", "--file-descriptor-set", "desc.bin"];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::List { source } => {
assert_eq!(
source.file_descriptor_set.unwrap().to_str().unwrap(),
"desc.bin"
);
assert!(source.uri.is_none());
}
_ => panic!("Expected List command"),
}
}
#[test]
fn test_describe_command() {
let args = vec![
"granc",
"describe",
"helloworld.Greeter",
"--uri",
"http://localhost:50051",
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Describe { symbol, source } => {
assert_eq!(symbol, "helloworld.Greeter");
assert!(source.uri.is_some());
}
_ => panic!("Expected Describe command"),
}
}
#[test]
fn test_doc_command_reflection() {
let args = vec![
"granc",
"doc",
"my.package.Service",
"--uri",
"http://localhost:50051",
"--output",
"./docs",
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Doc {
symbol,
source,
output,
} => {
assert_eq!(symbol, "my.package.Service");
assert_eq!(source.uri.unwrap(), "http://localhost:50051");
assert_eq!(output.to_str().unwrap(), "./docs");
}
_ => panic!("Expected Doc command"),
}
}
#[test]
fn test_doc_command_offline() {
let args = vec![
"granc",
"doc",
"my.package.Service",
"--file-descriptor-set",
"descriptors.bin",
"-o",
"./docs",
];
let cli = Cli::try_parse_from(&args).expect("Parsing failed");
match cli.command {
Commands::Doc {
symbol,
source,
output,
} => {
assert_eq!(symbol, "my.package.Service");
assert_eq!(
source.file_descriptor_set.unwrap().to_str().unwrap(),
"descriptors.bin"
);
assert_eq!(output.to_str().unwrap(), "./docs");
}
_ => panic!("Expected Doc command"),
}
}
#[test]
fn test_fail_invalid_json_body() {
let args = vec!["granc", "call", "s/m", "-u", "x", "--body", "{invalid_json"];
let err = Cli::try_parse_from(&args).unwrap_err();
assert!(err.to_string().contains("Invalid JSON"));
}
#[test]
fn test_fail_invalid_endpoint_format() {
let args = vec![
"granc",
"call",
"OnlyServiceNoMethod", "-u",
"x",
"-b",
"{}",
];
let err = Cli::try_parse_from(&args).unwrap_err();
assert!(err.to_string().contains("Invalid endpoint format"));
}
#[test]
fn test_fail_list_requires_source() {
let args = vec!["granc", "list"];
let err = Cli::try_parse_from(&args).unwrap_err();
assert!(err.kind() == clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn test_fail_list_mutual_exclusion() {
let args = vec![
"granc",
"list",
"--uri",
"http://host",
"--file-descriptor-set",
"file.bin",
];
let err = Cli::try_parse_from(&args).unwrap_err();
assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn test_fail_describe_mutual_exclusion() {
let args = vec![
"granc",
"describe",
"Symbol",
"-u",
"http://host",
"-f",
"file.bin",
];
let err = Cli::try_parse_from(&args).unwrap_err();
assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
}
}