use crate::parser::{ParseError, Parser};
use crate::query::Registry;
use crate::render::{DefaultRenderer, Renderer};
use crate::resolver::Resolver;
#[derive(Debug, thiserror::Error)]
pub enum CliError {
#[error(transparent)]
Parse(#[from] ParseError),
#[error("command `{0}` has no handler registered")]
NoHandler(String),
#[error("handler error: {0}")]
Handler(#[from] Box<dyn std::error::Error + Send + Sync>),
}
pub struct Cli {
registry: Registry,
app_name: String,
version: Option<String>,
middlewares: Vec<Box<dyn crate::middleware::Middleware>>,
renderer: Box<dyn Renderer>,
query_support: bool,
warn_missing_dry_run: bool,
}
impl Cli {
pub fn new(commands: Vec<crate::model::Command>) -> Self {
Self {
registry: Registry::new(commands),
app_name: String::new(),
version: None,
middlewares: vec![],
renderer: Box::new(DefaultRenderer),
query_support: false,
warn_missing_dry_run: false,
}
}
pub fn app_name(mut self, name: impl Into<String>) -> Self {
self.app_name = name.into();
self
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn with_middleware<M: crate::middleware::Middleware + 'static>(mut self, m: M) -> Self {
self.middlewares.push(Box::new(m));
self
}
pub fn with_renderer<R: Renderer + 'static>(mut self, renderer: R) -> Self {
self.renderer = Box::new(renderer);
self
}
pub fn with_query_support(mut self) -> Self {
self.query_support = true;
let query_cmd = crate::model::Command::builder("query")
.summary("Query command metadata (agent discovery)")
.description(
"Structured JSON output for agent discovery. \
`query commands` lists all commands; `query <name>` returns metadata for one. \
Use `--fields <csv>` to request only specific top-level fields, reducing output \
size for agents that only need a subset of command metadata.",
)
.flag(
crate::model::Flag::builder("fields")
.description(
"Comma-separated list of top-level fields to include in JSON output \
(e.g. `canonical,summary,examples`). When omitted all fields are returned.",
)
.takes_value()
.build()
.expect("built-in fields flag should always build"),
)
.example(crate::model::Example::new(
"query commands",
"List all commands as JSON",
))
.example(crate::model::Example::new(
"query deploy",
"Get metadata for the deploy command",
))
.example(crate::model::Example::new(
"query deploy --fields canonical,summary,examples",
"Get only canonical name, summary, and examples for the deploy command",
))
.example(crate::model::Example::new(
"query commands --fields canonical,summary",
"List all commands showing only canonical name and summary",
))
.build()
.expect("built-in query command should always build");
self.registry.push(query_cmd);
self
}
pub fn warn_missing_dry_run(mut self, enabled: bool) -> Self {
self.warn_missing_dry_run = enabled;
self
}
pub fn run(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> Result<(), CliError> {
let argv: Vec<String> = args.into_iter().map(|a| a.as_ref().to_owned()).collect();
let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
if self.query_support && argv_refs.first().copied() == Some("query") {
return self.handle_query(&argv_refs[1..]);
}
if argv_refs.iter().any(|a| *a == "--help" || *a == "-h") {
let remaining: Vec<&str> = argv_refs
.iter()
.copied()
.filter(|a| *a != "--help" && *a != "-h")
.collect();
let help_text = self.resolve_help_text(&remaining);
print!("{}", help_text);
return Ok(());
}
if argv_refs.iter().any(|a| *a == "--version" || *a == "-V") {
match &self.version {
Some(v) if !self.app_name.is_empty() => println!("{} {}", self.app_name, v),
Some(v) => println!("{}", v),
None => println!("(no version set)"),
}
return Ok(());
}
if argv_refs.is_empty() {
print!(
"{}",
self.renderer
.render_subcommand_list(self.registry.commands())
);
return Ok(());
}
let parser = Parser::new(self.registry.commands());
match parser.parse(&argv_refs) {
Ok(parsed) => {
if self.warn_missing_dry_run
&& parsed.command.mutating
&& !parsed.command.flags.iter().any(|f| f.name == "dry-run")
{
eprintln!(
"warning: mutating command '{}' has no --dry-run flag defined",
parsed.command.canonical
);
}
for mw in &self.middlewares {
mw.before_dispatch(&parsed).map_err(CliError::Handler)?;
}
let handler_result = match &parsed.command.handler {
Some(handler) => {
handler(&parsed).map_err(|e| {
let msg = e.to_string();
let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
CliError::Handler(boxed)
})
}
None => Err(CliError::NoHandler(parsed.command.canonical.to_string())),
};
let handler_result_for_mw: Result<(), Box<dyn std::error::Error + Send + Sync>> =
match &handler_result {
Ok(()) => Ok(()),
Err(e) => Err(Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
)),
};
for mw in &self.middlewares {
mw.after_dispatch(&parsed, &handler_result_for_mw);
}
handler_result
}
Err(parse_err) => {
for mw in &self.middlewares {
mw.on_parse_error(&parse_err);
}
eprintln!("error: {}", parse_err);
if let crate::parser::ParseError::Resolve(
crate::resolver::ResolveError::Unknown {
ref suggestions, ..
},
) = parse_err
{
if !suggestions.is_empty() {
eprintln!("Did you mean one of: {}", suggestions.join(", "));
}
}
let help_text = self.resolve_help_text(&argv_refs);
eprint!("{}", help_text);
Err(CliError::Parse(parse_err))
}
}
}
pub fn run_env_args(&self) -> Result<(), CliError> {
self.run(std::env::args().skip(1))
}
pub fn run_and_exit(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> ! {
match self.run(args) {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("error: {}", e);
std::process::exit(1);
}
}
}
pub fn run_env_args_and_exit(&self) -> ! {
self.run_and_exit(std::env::args().skip(1))
}
#[cfg(feature = "async")]
pub async fn run_async_and_exit(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> ! {
match self.run_async(args).await {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("error: {}", e);
std::process::exit(1);
}
}
}
#[cfg(feature = "async")]
pub async fn run_env_args_async_and_exit(&self) -> ! {
self.run_async_and_exit(std::env::args().skip(1)).await
}
#[cfg(feature = "async")]
pub async fn run_async(
&self,
args: impl IntoIterator<Item = impl AsRef<str>>,
) -> Result<(), CliError> {
let args: Vec<String> = args.into_iter().map(|a| a.as_ref().to_string()).collect();
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
if self.query_support && argv.first().copied() == Some("query") {
let refs: Vec<&str> = argv.to_vec();
return self.handle_query(&refs[1..]);
}
if argv.iter().any(|a| *a == "--help" || *a == "-h") {
let remaining: Vec<&str> = argv
.iter()
.copied()
.filter(|a| *a != "--help" && *a != "-h")
.collect();
let help_text = self.resolve_help_text(&remaining);
print!("{}", help_text);
return Ok(());
}
if argv.iter().any(|a| *a == "--version" || *a == "-V") {
match &self.version {
Some(v) if !self.app_name.is_empty() => println!("{} {}", self.app_name, v),
Some(v) => println!("{}", v),
None => println!("(no version set)"),
}
return Ok(());
}
if argv.is_empty() {
print!(
"{}",
self.renderer
.render_subcommand_list(self.registry.commands())
);
return Ok(());
}
let parser = Parser::new(self.registry.commands());
match parser.parse(&argv) {
Ok(parsed) => {
if self.warn_missing_dry_run
&& parsed.command.mutating
&& !parsed.command.flags.iter().any(|f| f.name == "dry-run")
{
eprintln!(
"warning: mutating command '{}' has no --dry-run flag defined",
parsed.command.canonical
);
}
for mw in &self.middlewares {
mw.before_dispatch(&parsed).map_err(CliError::Handler)?;
}
let handler_result = if let Some(ref async_handler) = parsed.command.async_handler {
async_handler(&parsed).await.map_err(|e| {
let msg = e.to_string();
let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
CliError::Handler(boxed)
})
} else if let Some(ref handler) = parsed.command.handler {
handler(&parsed).map_err(|e| {
let msg = e.to_string();
let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
CliError::Handler(boxed)
})
} else {
Err(CliError::NoHandler(parsed.command.canonical.clone()))
};
let handler_result_for_mw: Result<(), Box<dyn std::error::Error + Send + Sync>> =
match &handler_result {
Ok(()) => Ok(()),
Err(e) => Err(Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
)),
};
for mw in &self.middlewares {
mw.after_dispatch(&parsed, &handler_result_for_mw);
}
handler_result
}
Err(parse_err) => {
for mw in &self.middlewares {
mw.on_parse_error(&parse_err);
}
eprintln!("error: {}", parse_err);
if let crate::parser::ParseError::Resolve(
crate::resolver::ResolveError::Unknown {
ref suggestions, ..
},
) = parse_err
{
if !suggestions.is_empty() {
eprintln!("Did you mean one of: {}", suggestions.join(", "));
}
}
let help_text = self.resolve_help_text(&argv);
eprint!("{}", help_text);
Err(CliError::Parse(parse_err))
}
}
}
#[cfg(feature = "async")]
pub async fn run_env_args_async(&self) -> Result<(), CliError> {
self.run_async(std::env::args().skip(1)).await
}
fn handle_query(&self, args: &[&str]) -> Result<(), CliError> {
let mut stream = false;
let mut fields_opt: Option<String> = None;
let mut positional: Vec<&str> = Vec::new();
let mut iter = args.iter().copied().peekable();
while let Some(arg) = iter.next() {
if arg == "--json" {
} else if arg == "--stream" {
stream = true;
} else if arg == "--fields" {
if let Some(val) = iter.next() {
fields_opt = Some(val.to_owned());
}
} else if let Some(val) = arg.strip_prefix("--fields=") {
fields_opt = Some(val.to_owned());
} else {
positional.push(arg);
}
}
let args = positional.as_slice();
let field_strings: Vec<String> = fields_opt
.as_deref()
.unwrap_or("")
.split(',')
.map(|f| f.trim().to_owned())
.filter(|f| !f.is_empty())
.collect();
let fields: Vec<&str> = field_strings.iter().map(String::as_str).collect();
match args.first().copied() {
None | Some("commands") => {
if stream {
let ndjson = self
.registry
.to_ndjson_with_fields(&fields)
.map_err(|e| {
CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
))
})?;
print!("{}", ndjson);
} else {
let json = self
.registry
.to_json_with_fields(&fields)
.map_err(|e| {
CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
))
})?;
println!("{}", json);
}
Ok(())
}
Some("examples") => {
let name = args.get(1).copied().ok_or_else(|| {
CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
"usage: query examples <command-name>",
))
})?;
let cmd = self
.registry
.get_command(name)
.or_else(|| {
let resolver = crate::resolver::Resolver::new(self.registry.commands());
resolver.resolve(name).ok()
})
.ok_or_else(|| {
CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
format!("unknown command: `{}`", name),
))
})?;
let json = serde_json::to_string_pretty(&cmd.examples).map_err(|e| {
CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
))
})?;
println!("{}", json);
Ok(())
}
Some(name) => {
let cmd = self.registry.get_command(name);
if let Some(cmd) = cmd {
if stream {
let line = crate::query::command_to_ndjson(cmd).map_err(|e| {
CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
))
})?;
println!("{}", line);
} else {
let json =
crate::query::command_to_json_with_fields(cmd, &fields).map_err(|e| {
CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
))
})?;
println!("{}", json);
}
return Ok(());
}
let resolver = crate::resolver::Resolver::new(self.registry.commands());
match resolver.resolve(name) {
Ok(cmd) => {
if stream {
let line = crate::query::command_to_ndjson(cmd).map_err(|e| {
CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
))
})?;
println!("{}", line);
} else {
let json = crate::query::command_to_json_with_fields(cmd, &fields)
.map_err(|e| {
CliError::Handler(
Box::<dyn std::error::Error + Send + Sync>::from(
e.to_string(),
),
)
})?;
println!("{}", json);
}
Ok(())
}
Err(crate::resolver::ResolveError::Ambiguous { input, candidates }) => {
let json = serde_json::json!({
"error": "ambiguous",
"input": input,
"candidates": candidates,
});
println!("{}", json);
Ok(())
}
Err(crate::resolver::ResolveError::Unknown { .. }) => Err(CliError::Handler(
Box::<dyn std::error::Error + Send + Sync>::from(format!(
"unknown command: `{}`",
name
)),
)),
}
}
}
}
fn resolve_help_text(&self, argv: &[&str]) -> String {
if argv.is_empty() {
return self
.renderer
.render_subcommand_list(self.registry.commands());
}
let words: Vec<&str> = argv
.iter()
.copied()
.filter(|a| !a.starts_with('-'))
.collect();
if words.is_empty() {
return self
.renderer
.render_subcommand_list(self.registry.commands());
}
let resolver = Resolver::new(self.registry.commands());
let top_cmd = match resolver.resolve(words[0]) {
Ok(cmd) => cmd,
Err(_) => {
return self
.renderer
.render_subcommand_list(self.registry.commands())
}
};
let mut current = top_cmd;
for word in words.iter().skip(1) {
if current.subcommands.is_empty() {
break;
}
let sub_resolver = Resolver::new(¤t.subcommands);
match sub_resolver.resolve(word) {
Ok(sub) => current = sub,
Err(_) => break,
}
}
self.renderer.render_help(current)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Command;
use std::sync::{Arc, Mutex};
fn make_cli_no_handler() -> Cli {
let cmd = Command::builder("greet")
.summary("Say hello")
.build()
.unwrap();
Cli::new(vec![cmd]).app_name("testapp").version("1.2.3")
}
fn make_cli_with_handler(called: Arc<Mutex<bool>>) -> Cli {
let cmd = Command::builder("greet")
.summary("Say hello")
.handler(Arc::new(move |_parsed| {
*called.lock().unwrap() = true;
Ok(())
}))
.build()
.unwrap();
Cli::new(vec![cmd]).app_name("testapp").version("1.2.3")
}
#[test]
fn test_run_empty_args() {
let cli = make_cli_no_handler();
let result = cli.run(std::iter::empty::<&str>());
assert!(result.is_ok(), "empty args should return Ok");
}
#[test]
fn test_run_help_flag() {
let cli = make_cli_no_handler();
let result = cli.run(["--help"]);
assert!(result.is_ok(), "--help should return Ok");
}
#[test]
fn test_run_help_flag_short() {
let cli = make_cli_no_handler();
let result = cli.run(["-h"]);
assert!(result.is_ok(), "-h should return Ok");
}
#[test]
fn test_run_version_flag() {
let cli = make_cli_no_handler();
let result = cli.run(["--version"]);
assert!(result.is_ok(), "--version should return Ok");
}
#[test]
fn test_run_version_flag_short() {
let cli = make_cli_no_handler();
let result = cli.run(["-V"]);
assert!(result.is_ok(), "-V should return Ok");
}
#[test]
fn test_run_no_handler() {
let cli = make_cli_no_handler();
let result = cli.run(["greet"]);
assert!(
matches!(result, Err(CliError::NoHandler(ref name)) if name == "greet"),
"expected NoHandler(\"greet\"), got {:?}",
result
);
}
#[test]
fn test_run_with_handler() {
let called = Arc::new(Mutex::new(false));
let cli = make_cli_with_handler(called.clone());
let result = cli.run(["greet"]);
assert!(result.is_ok(), "handler should succeed, got {:?}", result);
assert!(*called.lock().unwrap(), "handler should have been called");
}
#[test]
fn test_run_unknown_command() {
let cli = make_cli_no_handler();
let result = cli.run(["unknowncmd"]);
assert!(
matches!(result, Err(CliError::Parse(_))),
"unknown command should yield Parse error, got {:?}",
result
);
}
#[test]
fn test_run_handler_error_wrapped() {
use std::sync::Arc;
let cmd = crate::model::Command::builder("fail")
.handler(Arc::new(|_| {
Err(Box::<dyn std::error::Error>::from("something went wrong"))
}))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]);
let result = cli.run(["fail"]);
assert!(result.is_err());
match result {
Err(super::CliError::Handler(e)) => {
assert!(e.to_string().contains("something went wrong"));
}
other => panic!("expected CliError::Handler, got {:?}", other),
}
}
#[test]
fn test_run_command_named_help_dispatches_correctly() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let called = Arc::new(AtomicBool::new(false));
let called2 = called.clone();
let cmd = crate::model::Command::builder("help")
.handler(Arc::new(move |_| {
called2.store(true, Ordering::SeqCst);
Ok(())
}))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]);
cli.run(["help"]).unwrap();
assert!(
called.load(Ordering::SeqCst),
"handler should have been called"
);
}
#[test]
fn test_middleware_before_dispatch_called() {
use crate::middleware::Middleware;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
struct Flag(Arc<AtomicBool>);
impl Middleware for Flag {
fn before_dispatch(
&self,
_: &crate::model::ParsedCommand<'_>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.0.store(true, Ordering::SeqCst);
Ok(())
}
}
let called = Arc::new(AtomicBool::new(false));
let handler_called = Arc::new(AtomicBool::new(false));
let handler_called2 = handler_called.clone();
let cmd = crate::model::Command::builder("run")
.handler(std::sync::Arc::new(move |_| {
handler_called2.store(true, Ordering::SeqCst);
Ok(())
}))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]).with_middleware(Flag(called.clone()));
cli.run(["run"]).unwrap();
assert!(called.load(Ordering::SeqCst));
assert!(handler_called.load(Ordering::SeqCst));
}
#[test]
fn test_middleware_can_abort_dispatch() {
use crate::middleware::Middleware;
struct Aborter;
impl Middleware for Aborter {
fn before_dispatch(
&self,
_: &crate::model::ParsedCommand<'_>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err("aborted by middleware".into())
}
}
let cmd = crate::model::Command::builder("run")
.handler(std::sync::Arc::new(|_| panic!("should not be called")))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]).with_middleware(Aborter);
assert!(cli.run(["run"]).is_err());
}
#[test]
fn test_query_commands_outputs_json() {
use crate::model::Command;
let cli = super::Cli::new(vec![
Command::builder("deploy")
.summary("Deploy")
.build()
.unwrap(),
Command::builder("status")
.summary("Status")
.build()
.unwrap(),
])
.with_query_support();
assert!(cli.run(["query", "commands"]).is_ok());
}
#[test]
fn test_query_named_command_outputs_json() {
use crate::model::Command;
let cli = super::Cli::new(vec![Command::builder("deploy")
.summary("Deploy svc")
.build()
.unwrap()])
.with_query_support();
assert!(cli.run(["query", "deploy"]).is_ok());
}
#[test]
fn test_query_unknown_command_errors() {
use crate::model::Command;
let cli =
super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
assert!(cli.run(["query", "nonexistent"]).is_err());
}
#[test]
fn test_query_meta_command_appears_in_registry() {
use crate::model::Command;
let cli =
super::Cli::new(vec![Command::builder("run").build().unwrap()]).with_query_support();
assert!(cli.registry.get_command("query").is_some());
}
#[test]
fn test_query_with_json_flag() {
use crate::model::Command;
let cli = super::Cli::new(vec![Command::builder("deploy")
.summary("Deploy")
.build()
.unwrap()])
.with_query_support();
assert!(cli.run(["query", "deploy", "--json"]).is_ok());
assert!(cli.run(["query", "commands", "--json"]).is_ok());
}
#[test]
fn test_query_ambiguous_returns_structured_json() {
use crate::model::Command;
let cli = super::Cli::new(vec![
Command::builder("deploy")
.summary("Deploy")
.build()
.unwrap(),
Command::builder("describe")
.summary("Describe")
.build()
.unwrap(),
])
.with_query_support();
let result = cli.run(["query", "dep"]);
assert!(
result.is_ok(),
"ambiguous query should return Ok(()) with JSON on stdout, got {:?}",
result
);
}
#[test]
fn test_query_examples_returns_examples() {
use crate::model::{Command, Example};
let cli = super::Cli::new(vec![Command::builder("deploy")
.summary("Deploy svc")
.example(Example::new(
"Deploy to production",
"deploy api --env prod",
))
.build()
.unwrap()])
.with_query_support();
let result = cli.run(["query", "examples", "deploy"]);
assert!(
result.is_ok(),
"query examples for known command should return Ok(()), got {:?}",
result
);
}
#[test]
fn test_query_examples_unknown_errors() {
use crate::model::Command;
let cli =
super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
let result = cli.run(["query", "examples", "nonexistent"]);
assert!(
result.is_err(),
"query examples for unknown command should return Err, got {:?}",
result
);
}
#[test]
fn test_warn_missing_dry_run_enabled_dispatches_ok() {
use crate::model::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let called = Arc::new(AtomicBool::new(false));
let called2 = called.clone();
let cmd = Command::builder("delete")
.summary("Delete a resource")
.mutating()
.handler(Arc::new(move |_| {
called2.store(true, Ordering::SeqCst);
Ok(())
}))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]).warn_missing_dry_run(true);
let result = cli.run(["delete"]);
assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
assert!(called.load(Ordering::SeqCst), "handler should have been called");
}
#[test]
fn test_warn_missing_dry_run_with_dry_run_flag_no_warn() {
use crate::model::{Command, Flag};
use std::sync::Arc;
let cmd = Command::builder("delete")
.summary("Delete a resource")
.mutating()
.flag(Flag::builder("dry-run").description("Simulate").build().unwrap())
.handler(Arc::new(|_| Ok(())))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]).warn_missing_dry_run(true);
let result = cli.run(["delete"]);
assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
}
#[test]
fn test_warn_missing_dry_run_disabled_no_effect() {
use crate::model::Command;
use std::sync::Arc;
let cmd = Command::builder("delete")
.summary("Delete a resource")
.mutating()
.handler(Arc::new(|_| Ok(())))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]);
let result = cli.run(["delete"]);
assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_run_async_empty_args() {
let cli = make_cli_no_handler();
let result = cli.run_async(std::iter::empty::<&str>()).await;
assert!(
result.is_ok(),
"empty args should return Ok, got {:?}",
result
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_run_async_help_flag() {
let cli = make_cli_no_handler();
let result = cli.run_async(["--help"]).await;
assert!(result.is_ok(), "--help should return Ok, got {:?}", result);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_run_async_version_flag() {
let cli = make_cli_no_handler();
let result = cli.run_async(["--version"]).await;
assert!(
result.is_ok(),
"--version should return Ok, got {:?}",
result
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_run_async_with_handler() {
use std::sync::atomic::{AtomicBool, Ordering};
let called = Arc::new(AtomicBool::new(false));
let called2 = called.clone();
let cmd = Command::builder("greet")
.summary("Say hello")
.handler(Arc::new(move |_parsed| {
called2.store(true, Ordering::SeqCst);
Ok(())
}))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd])
.app_name("testapp")
.version("1.2.3");
let result = cli.run_async(["greet"]).await;
assert!(result.is_ok(), "handler should succeed, got {:?}", result);
assert!(
called.load(Ordering::SeqCst),
"handler should have been called"
);
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_run_async_unknown_command() {
let cli = make_cli_no_handler();
let result = cli.run_async(["unknowncmd"]).await;
assert!(
matches!(result, Err(CliError::Parse(_))),
"unknown command should yield Parse error, got {:?}",
result
);
}
#[test]
fn test_version_without_app_name() {
let cmd = Command::builder("greet").build().unwrap();
let cli = super::Cli::new(vec![cmd]).version("2.0.0");
assert!(cli.run(["--version"]).is_ok());
}
#[test]
fn test_version_not_set() {
let cmd = Command::builder("greet").build().unwrap();
let cli = super::Cli::new(vec![cmd]);
assert!(cli.run(["--version"]).is_ok());
}
#[test]
fn test_middleware_after_dispatch_called_on_success() {
use crate::middleware::Middleware;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
struct AfterFlag(Arc<AtomicBool>);
impl Middleware for AfterFlag {
fn after_dispatch(
&self,
_: &crate::model::ParsedCommand<'_>,
_: &Result<(), Box<dyn std::error::Error + Send + Sync>>,
) {
self.0.store(true, Ordering::SeqCst);
}
}
let called = Arc::new(AtomicBool::new(false));
let cmd = Command::builder("run")
.handler(Arc::new(|_| Ok(())))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]).with_middleware(AfterFlag(called.clone()));
cli.run(["run"]).unwrap();
assert!(called.load(Ordering::SeqCst));
}
#[test]
fn test_middleware_after_dispatch_called_on_error() {
use crate::middleware::Middleware;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
struct AfterFlag(Arc<AtomicBool>);
impl Middleware for AfterFlag {
fn after_dispatch(
&self,
_: &crate::model::ParsedCommand<'_>,
_: &Result<(), Box<dyn std::error::Error + Send + Sync>>,
) {
self.0.store(true, Ordering::SeqCst);
}
}
let called = Arc::new(AtomicBool::new(false));
let cmd = Command::builder("run")
.handler(Arc::new(|_| Err("handler error".into())))
.build()
.unwrap();
let cli = super::Cli::new(vec![cmd]).with_middleware(AfterFlag(called.clone()));
let _ = cli.run(["run"]);
assert!(called.load(Ordering::SeqCst));
}
#[test]
fn test_middleware_on_parse_error_called() {
use crate::middleware::Middleware;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
struct OnErrFlag(Arc<AtomicBool>);
impl Middleware for OnErrFlag {
fn on_parse_error(&self, _: &crate::parser::ParseError) {
self.0.store(true, Ordering::SeqCst);
}
}
let called = Arc::new(AtomicBool::new(false));
let cmd = Command::builder("run").build().unwrap();
let cli = super::Cli::new(vec![cmd]).with_middleware(OnErrFlag(called.clone()));
let _ = cli.run(["unknown_xyz"]);
assert!(called.load(Ordering::SeqCst));
}
#[test]
fn test_unknown_command_with_suggestions() {
let cmd = Command::builder("greet").build().unwrap();
let cli = super::Cli::new(vec![cmd]);
let result = cli.run(["gree"]);
assert!(result.is_err());
}
#[test]
fn test_help_for_subcommand() {
let sub = Command::builder("rollback")
.summary("Roll back")
.build()
.unwrap();
let parent = Command::builder("deploy")
.summary("Deploy")
.subcommand(sub)
.build()
.unwrap();
let cli = super::Cli::new(vec![parent]);
let result = cli.run(["deploy", "rollback", "--help"]);
assert!(result.is_ok());
}
#[test]
fn test_help_with_only_flags() {
let cmd = Command::builder("greet").build().unwrap();
let cli = super::Cli::new(vec![cmd]);
let result = cli.run(["--flag", "--help"]);
assert!(result.is_ok());
}
#[test]
fn test_help_for_unknown_command() {
let cmd = Command::builder("greet").build().unwrap();
let cli = super::Cli::new(vec![cmd]);
let result = cli.run(["unknowncmd", "--help"]);
assert!(result.is_ok());
}
#[test]
fn test_query_with_no_arg_outputs_json() {
use crate::model::Command;
let cli =
super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
assert!(cli.run(["query"]).is_ok());
}
#[test]
fn test_query_examples_via_resolver() {
use crate::model::{Command, Example};
let cli = super::Cli::new(vec![Command::builder("deploy")
.summary("Deploy")
.example(Example::new("prod", "deploy prod"))
.build()
.unwrap()])
.with_query_support();
let result = cli.run(["query", "examples", "dep"]);
assert!(
result.is_ok(),
"query examples via prefix should succeed, got {:?}",
result
);
}
#[test]
fn test_query_named_command_via_resolver() {
use crate::model::Command;
let cli = super::Cli::new(vec![Command::builder("deploy")
.summary("Deploy")
.build()
.unwrap()])
.with_query_support();
let result = cli.run(["query", "dep"]);
assert!(
result.is_ok(),
"query prefix-resolved name should succeed, got {:?}",
result
);
}
#[test]
fn test_query_examples_no_name_errors() {
use crate::model::Command;
let cli =
super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
let result = cli.run(["query", "examples"]);
assert!(result.is_err(), "query examples with no name should error");
}
#[test]
fn test_query_commands_stream_succeeds() {
use crate::model::Command;
let cli = super::Cli::new(vec![
Command::builder("deploy").summary("Deploy").build().unwrap(),
Command::builder("status").summary("Status").build().unwrap(),
])
.with_query_support();
let result = cli.run(["query", "commands", "--stream"]);
assert!(
result.is_ok(),
"query commands --stream should return Ok, got {:?}",
result
);
}
#[test]
fn test_query_commands_stream_with_fields_succeeds() {
use crate::model::Command;
let cli = super::Cli::new(vec![
Command::builder("deploy").summary("Deploy").build().unwrap(),
])
.with_query_support();
let result = cli.run(["query", "commands", "--stream", "--fields", "canonical,summary"]);
assert!(
result.is_ok(),
"query commands --stream --fields should return Ok, got {:?}",
result
);
}
#[test]
fn test_query_named_command_stream_succeeds() {
use crate::model::Command;
let cli = super::Cli::new(vec![Command::builder("deploy")
.summary("Deploy svc")
.build()
.unwrap()])
.with_query_support();
let result = cli.run(["query", "deploy", "--stream"]);
assert!(
result.is_ok(),
"query <name> --stream should return Ok, got {:?}",
result
);
}
#[test]
fn test_query_named_command_via_resolver_stream_succeeds() {
use crate::model::Command;
let cli = super::Cli::new(vec![Command::builder("deploy")
.summary("Deploy svc")
.build()
.unwrap()])
.with_query_support();
let result = cli.run(["query", "dep", "--stream"]);
assert!(
result.is_ok(),
"query prefix-resolved --stream should return Ok, got {:?}",
result
);
}
#[test]
fn test_query_stream_bare_query_succeeds() {
use crate::model::Command;
let cli =
super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
let result = cli.run(["query", "--stream"]);
assert!(
result.is_ok(),
"query --stream (no subcommand) should return Ok, got {:?}",
result
);
}
}