use crate::{write_binary_output, write_output, OutputDestination, OutputMode};
use clap::{Arg, ArgAction, ArgMatches, Command};
use std::path::PathBuf;
use super::{AppBuilder, PendingCommand};
use crate::cli::dispatch::{
dispatch, extract_command_path, get_deepest_matches, has_subcommand, insert_default_command,
DispatchOutput,
};
use crate::cli::group::{ErasedConfigRecipe, GroupBuilder, GroupEntry};
use crate::cli::handler::{CommandContext, RunResult};
use crate::cli::hooks::{RenderedOutput, TextOutput};
use crate::SetupError;
impl AppBuilder {
pub fn commands<F>(mut self, configure: F) -> Result<Self, SetupError>
where
F: FnOnce(GroupBuilder) -> GroupBuilder,
{
let builder = configure(GroupBuilder::new());
if let Some(ref default_cmd) = builder.default_command {
self.default_command = Some(default_cmd.clone());
}
for (name, entry) in builder.entries {
match entry {
GroupEntry::Command { mut handler } => {
let template = handler
.template()
.map(String::from)
.unwrap_or_else(|| self.resolve_template(&name));
if let Some(hooks) = handler.take_hooks() {
self.command_hooks.insert(name.clone(), hooks);
}
let recipe = ErasedConfigRecipe::from_handler(handler);
if self.pending_commands.borrow().contains_key(&name) {
return Err(SetupError::DuplicateCommand(name));
}
self.pending_commands.borrow_mut().insert(
name,
PendingCommand {
recipe: Box::new(recipe),
template,
},
);
}
GroupEntry::Group { builder: nested } => {
self.register_group(&name, nested)?;
}
}
}
Ok(self)
}
pub fn dispatch(&self, matches: ArgMatches, output_mode: OutputMode) -> RunResult {
self.ensure_commands_finalized();
let path = extract_command_path(&matches);
let path_str = path.join(".");
let commands = self.get_commands();
if let Some(dispatch_fn) = commands.get(&path_str) {
let mut ctx = CommandContext::new(path, self.app_state.clone());
let hooks = self.command_hooks.get(&path_str);
if let Some(hooks) = hooks {
if let Err(e) = hooks.run_pre_dispatch(&matches, &mut ctx) {
return RunResult::Handled(format!("Hook error: {}", e));
}
}
let sub_matches = get_deepest_matches(&matches);
let default_theme = crate::Theme::default();
let theme = self.theme.as_ref().unwrap_or(&default_theme);
let dispatch_output =
match dispatch(dispatch_fn, sub_matches, &ctx, hooks, output_mode, theme) {
Ok(output) => output,
Err(e) => return RunResult::Handled(e),
};
let output = match dispatch_output {
DispatchOutput::Text { formatted, raw } => {
RenderedOutput::Text(TextOutput::new(formatted, raw))
}
DispatchOutput::Binary(b, f) => RenderedOutput::Binary(b, f),
DispatchOutput::Silent => RenderedOutput::Silent,
};
let mut final_output = if let Some(hooks) = hooks {
match hooks.run_post_output(&matches, &ctx, output) {
Ok(o) => o,
Err(e) => return RunResult::Handled(format!("Hook error: {}", e)),
}
} else {
output
};
if self.output_file_flag.is_some() {
if let Some(path_str) = matches
.try_get_one::<String>("_output_file_path")
.unwrap_or(None)
{
let path = PathBuf::from(path_str);
let dest = OutputDestination::File(path);
match &final_output {
RenderedOutput::Text(t) => {
if let Err(e) = write_output(&t.raw, &dest) {
return RunResult::Handled(format!("Error writing output: {}", e));
}
final_output = RenderedOutput::Silent;
}
RenderedOutput::Binary(b, _) => {
if let Err(e) = write_binary_output(b, &dest) {
return RunResult::Handled(format!("Error writing output: {}", e));
}
final_output = RenderedOutput::Silent;
}
RenderedOutput::Silent => {}
}
}
}
match final_output {
RenderedOutput::Text(t) => RunResult::Handled(t.formatted),
RenderedOutput::Binary(b, f) => RunResult::Binary(b, f),
RenderedOutput::Silent => RunResult::Handled(String::new()),
}
} else {
RunResult::NoMatch(matches)
}
}
pub fn dispatch_from<I, T>(&self, cmd: Command, args: I) -> RunResult
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let args: Vec<String> = args
.into_iter()
.map(|a| a.into().to_string_lossy().into_owned())
.collect();
let augmented_cmd = self.augment_command_for_dispatch(cmd.clone());
let matches = match augmented_cmd.try_get_matches_from(&args) {
Ok(m) => m,
Err(e) => {
return RunResult::Handled(e.to_string());
}
};
let matches = if let Some(default_cmd) = &self.default_command {
if has_subcommand(&matches) {
matches
} else {
let new_args = insert_default_command(args, default_cmd);
let augmented_cmd = self.augment_command_for_dispatch(cmd);
match augmented_cmd.try_get_matches_from(&new_args) {
Ok(m) => m,
Err(e) => return RunResult::Handled(e.to_string()),
}
}
} else {
matches
};
let output_mode = if self.output_flag.is_some() {
match matches
.get_one::<String>("_output_mode")
.map(|s| s.as_str())
{
Some("term") => OutputMode::Term,
Some("text") => OutputMode::Text,
Some("term-debug") => OutputMode::TermDebug,
Some("json") => OutputMode::Json,
Some("yaml") => OutputMode::Yaml,
Some("xml") => OutputMode::Xml,
Some("csv") => OutputMode::Csv,
_ => OutputMode::Auto,
}
} else {
OutputMode::Auto
};
self.dispatch(matches, output_mode)
}
pub fn run<I, T>(&self, cmd: Command, args: I) -> bool
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
match self.dispatch_from(cmd, args) {
RunResult::Handled(output) => {
if !output.is_empty() {
println!("{}", output);
}
true
}
RunResult::Binary(bytes, filename) => {
if let Err(e) = std::fs::write(&filename, &bytes) {
eprintln!("Error writing {}: {}", filename, e);
} else {
eprintln!("Wrote {} bytes to {}", bytes.len(), filename);
}
true
}
RunResult::Silent => true, RunResult::NoMatch(_) => false,
}
}
pub fn run_to_string<I, T>(&self, cmd: Command, args: I) -> RunResult
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
self.dispatch_from(cmd, args)
}
pub(crate) fn augment_command_for_dispatch(&self, mut cmd: Command) -> Command {
if let Some(ref flag_name) = self.output_flag {
let flag: &'static str = Box::leak(flag_name.clone().into_boxed_str());
cmd = cmd.arg(
Arg::new("_output_mode")
.long(flag)
.value_name("MODE")
.global(true)
.value_parser([
"auto",
"term",
"text",
"term-debug",
"json",
"yaml",
"xml",
"csv",
])
.default_value("auto")
.help("Output format"),
);
}
if let Some(ref flag_name) = self.output_file_flag {
let flag: &'static str = Box::leak(flag_name.clone().into_boxed_str());
cmd = cmd.arg(
Arg::new("_output_file_path")
.long(flag)
.value_name("PATH")
.global(true)
.action(ArgAction::Set)
.help("Write output to file instead of stdout"),
);
}
cmd
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::handler::HandlerResult;
use crate::cli::handler::Output as HandlerOutput;
use crate::cli::hooks::{HookError, Hooks, RenderedOutput};
#[test]
fn test_dispatch_macro_simple() {
use crate::dispatch;
use serde_json::json;
let builder = AppBuilder::new()
.commands(dispatch! {
list => |_m, _ctx| Ok(HandlerOutput::Render(json!({"items": ["a", "b"]})))
})
.unwrap();
assert!(builder.has_command("list"));
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Json);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("items"));
}
#[test]
fn test_dispatch_macro_with_groups() {
use crate::dispatch;
use serde_json::json;
let builder = AppBuilder::new()
.commands(dispatch! {
db: {
migrate => |_m, _ctx| Ok(HandlerOutput::Render(json!({"migrated": true}))),
backup => |_m, _ctx| Ok(HandlerOutput::Render(json!({"backed_up": true}))),
},
version => |_m, _ctx| Ok(HandlerOutput::Render(json!({"v": "1.0"}))),
})
.unwrap();
assert!(builder.has_command("db.migrate"));
assert!(builder.has_command("db.backup"));
assert!(builder.has_command("version"));
let cmd = Command::new("app")
.subcommand(
Command::new("db")
.subcommand(Command::new("migrate"))
.subcommand(Command::new("backup")),
)
.subcommand(Command::new("version"));
let matches = cmd
.clone()
.try_get_matches_from(["app", "db", "migrate"])
.unwrap();
let result = builder.dispatch(matches, OutputMode::Json);
assert!(result.is_handled());
assert!(result.output().unwrap().contains("migrated"));
}
#[test]
fn test_dispatch_macro_with_template() {
use crate::dispatch;
use serde_json::json;
let builder = AppBuilder::new()
.commands(dispatch! {
list => {
handler: |_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 42}))),
template: "Count: {{ count }}",
}
})
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some("Count: 42"));
}
#[test]
fn test_dispatch_macro_with_hooks() {
use crate::dispatch;
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let hook_called = Arc::new(AtomicBool::new(false));
let hook_called_clone = hook_called.clone();
let builder = AppBuilder::new()
.commands(dispatch! {
list => {
handler: |_m, _ctx| Ok(HandlerOutput::Render(json!({"ok": true}))),
template: "{{ ok }}",
pre_dispatch: move |_, _| {
hook_called_clone.store(true, Ordering::SeqCst);
Ok(())
},
}
})
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert!(hook_called.load(Ordering::SeqCst));
}
#[test]
fn test_dispatch_macro_deeply_nested() {
use crate::dispatch;
use serde_json::json;
let builder = AppBuilder::new()
.commands(dispatch! {
app: {
config: {
get => |_m, _ctx| Ok(HandlerOutput::Render(json!({"key": "value"}))),
set => |_m, _ctx| Ok(HandlerOutput::Render(json!({"ok": true}))),
},
start => |_m, _ctx| Ok(HandlerOutput::Render(json!({"started": true}))),
},
})
.unwrap();
assert!(builder.has_command("app.config.get"));
assert!(builder.has_command("app.config.set"));
assert!(builder.has_command("app.start"));
}
#[test]
fn test_dispatch_to_handler() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 42}))),
"Count: {{ count }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some("Count: 42"));
}
#[test]
fn test_dispatch_unhandled_fallthrough() {
use serde_json::json;
let builder = AppBuilder::new()
.command("list", |_m, _ctx| Ok(HandlerOutput::Render(json!({}))), "")
.unwrap();
let cmd = Command::new("app")
.subcommand(Command::new("list"))
.subcommand(Command::new("other"));
let matches = cmd.try_get_matches_from(["app", "other"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(!result.is_handled());
assert!(result.matches().is_some());
}
#[test]
fn test_dispatch_json_output() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"name": "test", "value": 123}))),
"{{ name }}: {{ value }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Json);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("\"name\": \"test\""));
assert!(output.contains("\"value\": 123"));
}
#[test]
fn test_dispatch_nested_command() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"config.get",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"key": "value"}))),
"{{ key }}",
)
.unwrap();
let cmd =
Command::new("app").subcommand(Command::new("config").subcommand(Command::new("get")));
let matches = cmd.try_get_matches_from(["app", "config", "get"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some("value"));
}
#[test]
fn test_dispatch_silent_result() {
let builder = AppBuilder::new()
.command("quiet", |_m, _ctx| Ok(HandlerOutput::<()>::Silent), "")
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("quiet"));
let matches = cmd.try_get_matches_from(["app", "quiet"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some(""));
}
#[test]
fn test_dispatch_error_result() {
let builder = AppBuilder::new()
.command(
"fail",
|_m, _ctx| Err::<HandlerOutput<()>, _>(anyhow::anyhow!("something went wrong")),
"",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("fail"));
let matches = cmd.try_get_matches_from(["app", "fail"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("Error:"));
assert!(output.contains("something went wrong"));
}
#[test]
fn test_dispatch_from_basic() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"items": ["a", "b"]}))),
"Items: {{ items }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "list"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some("Items: [\"a\", \"b\"]"));
}
#[test]
fn test_dispatch_from_with_json_flag() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 5}))),
"Count: {{ count }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "--output=json", "list"]);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("\"count\": 5"));
}
#[test]
fn test_dispatch_from_unhandled() {
use serde_json::json;
let builder = AppBuilder::new()
.command("list", |_m, _ctx| Ok(HandlerOutput::Render(json!({}))), "")
.unwrap();
let cmd = Command::new("app")
.subcommand(Command::new("list"))
.subcommand(Command::new("other"));
let result = builder.dispatch_from(cmd, ["app", "other"]);
assert!(!result.is_handled());
}
#[test]
fn test_dispatch_with_pre_dispatch_hook() {
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let hook_called = Arc::new(AtomicBool::new(false));
let hook_called_clone = hook_called.clone();
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 1}))),
"{{ count }}",
)
.unwrap()
.hooks(
"list",
Hooks::new().pre_dispatch(move |_, _ctx| {
hook_called_clone.store(true, Ordering::SeqCst);
Ok(())
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert!(hook_called.load(Ordering::SeqCst));
assert_eq!(result.output(), Some("1"));
}
#[test]
fn test_dispatch_pre_dispatch_hook_abort() {
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| -> HandlerResult<()> {
panic!("Handler should not be called");
},
"",
)
.unwrap()
.hooks(
"list",
Hooks::new()
.pre_dispatch(|_, _ctx| Err(HookError::pre_dispatch("blocked by hook"))),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("Hook error"));
assert!(output.contains("blocked by hook"));
}
#[test]
fn test_dispatch_with_post_output_hook() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"msg": "hello"}))),
"{{ msg }}",
)
.unwrap()
.hooks(
"list",
Hooks::new().post_output(|_, _ctx, output| {
if let RenderedOutput::Text(text_output) = output {
Ok(RenderedOutput::Text(TextOutput::new(
text_output.formatted.to_uppercase(),
text_output.raw.to_uppercase(),
)))
} else {
Ok(output)
}
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some("HELLO"));
}
#[test]
fn test_dispatch_post_output_hook_chain() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"msg": "test"}))),
"{{ msg }}",
)
.unwrap()
.hooks(
"list",
Hooks::new()
.post_output(|_, _ctx, output| {
if let RenderedOutput::Text(text_output) = output {
Ok(RenderedOutput::Text(TextOutput::new(
format!("[{}]", text_output.formatted),
format!("[{}]", text_output.raw),
)))
} else {
Ok(output)
}
})
.post_output(|_, _ctx, output| {
if let RenderedOutput::Text(text_output) = output {
Ok(RenderedOutput::Text(TextOutput::new(
text_output.formatted.to_uppercase(),
text_output.raw.to_uppercase(),
)))
} else {
Ok(output)
}
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some("[TEST]"));
}
#[test]
fn test_dispatch_post_output_hook_abort() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"msg": "hello"}))),
"{{ msg }}",
)
.unwrap()
.hooks(
"list",
Hooks::new().post_output(|_, _ctx, _output| {
Err(HookError::post_output("post-processing failed"))
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("Hook error"));
assert!(output.contains("post-processing failed"));
}
#[test]
fn test_dispatch_hooks_for_nested_command() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"config.get",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"value": "secret"}))),
"{{ value }}",
)
.unwrap()
.hooks(
"config.get",
Hooks::new().post_output(|_, _ctx, output| {
if let RenderedOutput::Text(_) = output {
Ok(RenderedOutput::Text(TextOutput::plain("***".into())))
} else {
Ok(output)
}
}),
);
let cmd =
Command::new("app").subcommand(Command::new("config").subcommand(Command::new("get")));
let matches = cmd.try_get_matches_from(["app", "config", "get"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some("***"));
}
#[test]
fn test_dispatch_no_hooks_for_command() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"msg": "list"}))),
"{{ msg }}",
)
.unwrap()
.command(
"other",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"msg": "other"}))),
"{{ msg }}",
)
.unwrap()
.hooks(
"list",
Hooks::new().post_output(|_, _ctx, _| {
panic!("Should not be called for 'other' command");
}),
);
let cmd = Command::new("app")
.subcommand(Command::new("list"))
.subcommand(Command::new("other"));
let matches = cmd.try_get_matches_from(["app", "other"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some("other"));
}
#[test]
fn test_dispatch_binary_output_with_hook() {
let builder = AppBuilder::new()
.command(
"export",
|_m, _ctx| -> HandlerResult<()> {
Ok(HandlerOutput::Binary {
data: vec![1, 2, 3],
filename: "out.bin".into(),
})
},
"",
)
.unwrap()
.hooks(
"export",
Hooks::new().post_output(|_, _ctx, output| {
if let RenderedOutput::Binary(mut bytes, filename) = output {
bytes.push(4);
Ok(RenderedOutput::Binary(bytes, filename))
} else {
Ok(output)
}
}),
);
let cmd = Command::new("app").subcommand(Command::new("export"));
let matches = cmd.try_get_matches_from(["app", "export"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_binary());
let (bytes, filename) = result.binary().unwrap();
assert_eq!(bytes, &[1, 2, 3, 4]);
assert_eq!(filename, "out.bin");
}
#[test]
fn test_hooks_passed_to_built_standout() {
let standout = AppBuilder::new()
.hooks("list", Hooks::new().pre_dispatch(|_, _| Ok(())))
.build()
.unwrap();
assert!(standout.get_hooks("list").is_some());
assert!(standout.get_hooks("other").is_none());
}
#[test]
fn test_run_command_with_hooks() {
use serde::Serialize;
#[derive(Serialize)]
struct Data {
value: i32,
}
let standout = AppBuilder::new()
.hooks(
"test",
Hooks::new().post_output(|_, _ctx, output| {
if let RenderedOutput::Text(text_output) = output {
Ok(RenderedOutput::Text(TextOutput::new(
format!("wrapped: {}", text_output.formatted),
format!("wrapped: {}", text_output.raw),
)))
} else {
Ok(output)
}
}),
)
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let matches = cmd.try_get_matches_from(["app", "test"]).unwrap();
let sub_matches = matches.subcommand_matches("test").unwrap();
let result = standout.run_command(
"test",
sub_matches,
|_m, _ctx| Ok(HandlerOutput::Render(Data { value: 42 })),
"{{ value }}",
);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.as_text(), Some("wrapped: 42"));
}
#[test]
fn test_run_command_pre_dispatch_abort() {
let standout = AppBuilder::new()
.hooks(
"test",
Hooks::new().pre_dispatch(|_, _ctx| Err(HookError::pre_dispatch("access denied"))),
)
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let matches = cmd.try_get_matches_from(["app", "test"]).unwrap();
let sub_matches = matches.subcommand_matches("test").unwrap();
let result = standout.run_command::<_, ()>(
"test",
sub_matches,
|_m, _ctx| {
panic!("Handler should not be called");
},
"",
);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("access denied"));
}
#[test]
fn test_run_command_without_hooks() {
use serde::Serialize;
#[derive(Serialize)]
struct Data {
msg: String,
}
let standout = AppBuilder::new().build().unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let matches = cmd.try_get_matches_from(["app", "test"]).unwrap();
let sub_matches = matches.subcommand_matches("test").unwrap();
let result = standout.run_command(
"test",
sub_matches,
|_m, _ctx| {
Ok(HandlerOutput::Render(Data {
msg: "hello".into(),
}))
},
"{{ msg }}",
);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_text(), Some("hello"));
}
#[test]
fn test_run_command_silent() {
let standout = AppBuilder::new().build().unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let matches = cmd.try_get_matches_from(["app", "test"]).unwrap();
let sub_matches = matches.subcommand_matches("test").unwrap();
let result = standout.run_command::<_, ()>(
"test",
sub_matches,
|_m, _ctx| Ok(HandlerOutput::Silent),
"",
);
assert!(result.is_ok());
assert!(result.unwrap().is_silent());
}
#[test]
fn test_run_command_binary() {
let standout = AppBuilder::new()
.hooks(
"export",
Hooks::new().post_output(|_, _ctx, output| {
assert!(output.is_binary());
Ok(output)
}),
)
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("export"));
let matches = cmd.try_get_matches_from(["app", "export"]).unwrap();
let sub_matches = matches.subcommand_matches("export").unwrap();
let result = standout.run_command::<_, ()>(
"export",
sub_matches,
|_m, _ctx| {
Ok(HandlerOutput::Binary {
data: vec![0xDE, 0xAD],
filename: "data.bin".into(),
})
},
"",
);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.is_binary());
let (bytes, filename) = output.as_binary().unwrap();
assert_eq!(bytes, &[0xDE, 0xAD]);
assert_eq!(filename, "data.bin");
}
#[test]
fn test_dispatch_with_post_dispatch_hook() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 5}))),
"Count: {{ count }}, Modified: {{ modified }}",
)
.unwrap()
.hooks(
"list",
Hooks::new().post_dispatch(|_, _ctx, mut data| {
if let Some(obj) = data.as_object_mut() {
obj.insert("modified".into(), json!(true));
}
Ok(data)
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("Count: 5"));
assert!(output.contains("Modified: true"));
}
#[test]
fn test_dispatch_post_dispatch_hook_abort() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"items": []}))),
"{{ items }}",
)
.unwrap()
.hooks(
"list",
Hooks::new().post_dispatch(|_, _ctx, data| {
if data
.get("items")
.and_then(|v| v.as_array())
.map(|a| a.is_empty())
== Some(true)
{
return Err(HookError::post_dispatch("no items to display"));
}
Ok(data)
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("Hook error"));
assert!(output.contains("no items to display"));
}
#[test]
fn test_dispatch_post_dispatch_chain() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"value": 1}))),
"{{ value }}",
)
.unwrap()
.hooks(
"list",
Hooks::new()
.post_dispatch(|_, _ctx, mut data| {
if let Some(v) = data.get_mut("value") {
*v = json!(v.as_i64().unwrap_or(0) * 2);
}
Ok(data)
})
.post_dispatch(|_, _ctx, mut data| {
if let Some(v) = data.get_mut("value") {
*v = json!(v.as_i64().unwrap_or(0) + 10);
}
Ok(data)
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(result.output(), Some("12"));
}
#[test]
fn test_dispatch_all_three_hooks() {
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let call_order = Arc::new(AtomicUsize::new(0));
let pre_order = call_order.clone();
let post_dispatch_order = call_order.clone();
let post_output_order = call_order.clone();
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"msg": "hello"}))),
"{{ msg }}",
)
.unwrap()
.hooks(
"list",
Hooks::new()
.pre_dispatch(move |_, _ctx| {
assert_eq!(pre_order.fetch_add(1, Ordering::SeqCst), 0);
Ok(())
})
.post_dispatch(move |_, _ctx, data| {
assert_eq!(post_dispatch_order.fetch_add(1, Ordering::SeqCst), 1);
Ok(data)
})
.post_output(move |_, _ctx, output| {
assert_eq!(post_output_order.fetch_add(1, Ordering::SeqCst), 2);
Ok(output)
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let matches = cmd.try_get_matches_from(["app", "list"]).unwrap();
let result = builder.dispatch(matches, OutputMode::Text);
assert!(result.is_handled());
assert_eq!(call_order.load(Ordering::SeqCst), 3);
}
#[test]
fn test_run_command_with_post_dispatch_hook() {
use serde::Serialize;
use serde_json::json;
#[derive(Serialize)]
struct Data {
value: i32,
}
let standout = AppBuilder::new()
.hooks(
"test",
Hooks::new().post_dispatch(|_, _ctx, mut data| {
if let Some(obj) = data.as_object_mut() {
obj.insert("added_by_hook".into(), json!("yes"));
}
Ok(data)
}),
)
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let matches = cmd.try_get_matches_from(["app", "test"]).unwrap();
let sub_matches = matches.subcommand_matches("test").unwrap();
let result = standout.run_command(
"test",
sub_matches,
|_m, _ctx| Ok(HandlerOutput::Render(Data { value: 42 })),
"value={{ value }}, added={{ added_by_hook }}",
);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.as_text(), Some("value=42, added=yes"));
}
#[test]
fn test_run_command_post_dispatch_abort() {
use crate::cli::hooks::HookPhase;
use serde::Serialize;
#[derive(Serialize)]
struct Data {
valid: bool,
}
let standout = AppBuilder::new()
.hooks(
"test",
Hooks::new().post_dispatch(|_, _ctx, data| {
if data.get("valid") == Some(&serde_json::json!(false)) {
return Err(HookError::post_dispatch("invalid data"));
}
Ok(data)
}),
)
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let matches = cmd.try_get_matches_from(["app", "test"]).unwrap();
let sub_matches = matches.subcommand_matches("test").unwrap();
let result = standout.run_command(
"test",
sub_matches,
|_m, _ctx| Ok(HandlerOutput::Render(Data { valid: false })),
"{{ valid }}",
);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.message, "invalid data");
assert_eq!(err.phase, HookPhase::PostDispatch);
}
#[test]
fn test_default_command_builder() {
let builder = AppBuilder::new().default_command("list");
assert_eq!(builder.default_command, Some("list".to_string()));
}
#[test]
fn test_default_command_naked_invocation() {
use serde_json::json;
let builder = AppBuilder::new()
.default_command("list")
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"items": ["a", "b"]}))),
"Items: {{ items }}",
)
.unwrap()
.command(
"add",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"added": true}))),
"Added: {{ added }}",
)
.unwrap();
let cmd = Command::new("app")
.subcommand(Command::new("list"))
.subcommand(Command::new("add"));
let result = builder.dispatch_from(cmd, ["app"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some("Items: [\"a\", \"b\"]"));
}
#[test]
fn test_default_command_with_options() {
use serde_json::json;
let builder = AppBuilder::new()
.default_command("list")
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 42}))),
"Count: {{ count }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "--output=json"]);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(output.contains("\"count\": 42"));
}
#[test]
fn test_default_command_explicit_command_overrides() {
use serde_json::json;
let builder = AppBuilder::new()
.default_command("list")
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"cmd": "list"}))),
"{{ cmd }}",
)
.unwrap()
.command(
"add",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"cmd": "add"}))),
"{{ cmd }}",
)
.unwrap();
let cmd = Command::new("app")
.subcommand(Command::new("list"))
.subcommand(Command::new("add"));
let result = builder.dispatch_from(cmd, ["app", "add"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some("add"));
}
#[test]
fn test_default_command_no_default_set() {
use serde_json::json;
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"items": []}))),
"Items: {{ items }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app"]);
assert!(!result.is_handled());
}
#[test]
fn test_dispatch_with_output_file_flag() {
use serde_json::json;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("output.txt");
let path_str = file_path.to_str().unwrap();
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 42}))),
"Count: {{ count }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "--output-file-path", path_str, "list"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some(""));
let content = std::fs::read_to_string(file_path).unwrap();
assert_eq!(content, "Count: 42");
}
#[test]
fn test_dispatch_with_custom_output_file_flag() {
use serde_json::json;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("out.txt");
let path_str = file_path.to_str().unwrap();
let builder = AppBuilder::new()
.output_file_flag(Some("save-to"))
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 99}))),
"{{ count }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "--save-to", path_str, "list"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some(""));
let content = std::fs::read_to_string(file_path).unwrap();
assert_eq!(content, "99");
}
#[test]
fn test_dispatch_with_output_file_json_mode() {
use serde_json::json;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("output.json");
let path_str = file_path.to_str().unwrap();
let builder = AppBuilder::new()
.command(
"show",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"name": "test", "count": 42}))),
"unused",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("show"));
let result = builder.dispatch_from(
cmd,
[
"app",
"--output",
"json",
"--output-file-path",
path_str,
"show",
],
);
assert!(result.is_handled());
assert_eq!(result.output(), Some(""));
let content = std::fs::read_to_string(file_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["name"], "test");
assert_eq!(parsed["count"], 42);
}
#[test]
fn test_dispatch_with_output_file_text_mode() {
use serde_json::json;
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("output.txt");
let path_str = file_path.to_str().unwrap();
let builder = AppBuilder::new()
.command(
"show",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"name": "Alice"}))),
"Hello {{ name }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("show"));
let result = builder.dispatch_from(
cmd,
[
"app",
"--output",
"text",
"--output-file-path",
path_str,
"show",
],
);
assert!(result.is_handled());
assert_eq!(result.output(), Some(""));
let content = std::fs::read_to_string(file_path).unwrap();
assert_eq!(content, "Hello Alice");
}
#[test]
fn test_dispatch_without_output_file_flag() {
use serde_json::json;
let builder = AppBuilder::new()
.no_output_file_flag()
.command(
"show",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"count": 42}))),
"Count: {{ count }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("show"));
let result = builder.dispatch_from(cmd, ["app", "show"]);
assert!(result.is_handled());
assert!(result.output().unwrap().contains("Count: 42"));
}
#[test]
fn test_theme_ordering_command_before_theme() {
use crate::Theme;
use console::Style;
use serde_json::json;
let theme = Theme::new().add("late", Style::new().bold());
let builder = AppBuilder::new()
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"name": "test"}))),
"[late]{{ name }}[/late]",
)
.unwrap()
.theme(theme);
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "--output=term", "list"]);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(
!output.contains("[late?]"),
"ORDERING BUG: Theme set after .command() was not applied - output: {}",
output
);
}
#[test]
fn test_theme_passed_to_dispatch_closure() {
use crate::Theme;
use console::Style;
use serde_json::json;
let theme = Theme::new().add("test_style", Style::new().bold());
let builder = AppBuilder::new()
.theme(theme)
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"name": "test"}))),
"[test_style]{{ name }}[/test_style]",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "--output=term", "list"]);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(
!output.contains("[test_style?]"),
"Theme was not passed to dispatch - output: {}",
output
);
}
#[test]
fn test_styles_and_default_theme_with_command() {
use serde_json::json;
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("dark.yaml"),
r#"
header:
fg: blue
bold: true
"#,
)
.unwrap();
let app = AppBuilder::new()
.styles_dir(temp_dir.path())
.unwrap()
.default_theme("dark")
.command(
"list",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"title": "Results"}))),
"[header]{{ title }}[/header]",
)
.unwrap()
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = app.dispatch_from(cmd, ["app", "--output=term", "list"]);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(
!output.contains("[header?]"),
"ORDERING BUG: .styles() + .default_theme() not applied - output: {}",
output
);
}
#[test]
fn test_builder_ordering_theme_before_command() {
use crate::Theme;
use console::Style;
use serde_json::json;
let theme = Theme::new().add("mystyle", Style::new().bold());
let app = AppBuilder::new()
.theme(theme)
.command(
"test",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"x": "value"}))),
"[mystyle]{{ x }}[/mystyle]",
)
.unwrap()
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let result = app.dispatch_from(cmd, ["app", "--output=term", "test"]);
assert!(
!result.output().unwrap().contains("[mystyle?]"),
"theme -> command ordering failed"
);
}
#[test]
fn test_builder_ordering_command_before_theme() {
use crate::Theme;
use console::Style;
use serde_json::json;
let theme = Theme::new().add("mystyle", Style::new().bold());
let app = AppBuilder::new()
.command(
"test",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"x": "value"}))),
"[mystyle]{{ x }}[/mystyle]",
)
.unwrap()
.theme(theme)
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let result = app.dispatch_from(cmd, ["app", "--output=term", "test"]);
assert!(
!result.output().unwrap().contains("[mystyle?]"),
"command -> theme ordering failed"
);
}
#[test]
fn test_builder_ordering_styles_default_theme_command() {
use serde_json::json;
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("mytheme.yaml"),
"mystyle: { bold: true }",
)
.unwrap();
let app = AppBuilder::new()
.styles_dir(temp_dir.path())
.unwrap()
.default_theme("mytheme")
.command(
"test",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"x": "value"}))),
"[mystyle]{{ x }}[/mystyle]",
)
.unwrap()
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let result = app.dispatch_from(cmd, ["app", "--output=term", "test"]);
assert!(
!result.output().unwrap().contains("[mystyle?]"),
"styles -> default_theme -> command ordering failed"
);
}
#[test]
fn test_builder_ordering_command_before_styles() {
use serde_json::json;
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("mytheme.yaml"),
"mystyle: { bold: true }",
)
.unwrap();
let app = AppBuilder::new()
.command(
"test",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"x": "value"}))),
"[mystyle]{{ x }}[/mystyle]",
)
.unwrap()
.styles_dir(temp_dir.path())
.unwrap()
.default_theme("mytheme")
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let result = app.dispatch_from(cmd, ["app", "--output=term", "test"]);
assert!(
!result.output().unwrap().contains("[mystyle?]"),
"command -> styles -> default_theme ordering failed"
);
}
#[test]
fn test_builder_ordering_default_theme_before_styles() {
use serde_json::json;
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("mytheme.yaml"),
"mystyle: { bold: true }",
)
.unwrap();
let app = AppBuilder::new()
.default_theme("mytheme")
.styles_dir(temp_dir.path())
.unwrap()
.command(
"test",
|_m, _ctx| Ok(HandlerOutput::Render(json!({"x": "value"}))),
"[mystyle]{{ x }}[/mystyle]",
)
.unwrap()
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("test"));
let result = app.dispatch_from(cmd, ["app", "--output=term", "test"]);
assert!(
!result.output().unwrap().contains("[mystyle?]"),
"default_theme -> styles -> command ordering failed"
);
}
#[test]
fn test_builder_ordering_all_permutations_with_explicit_theme() {
use crate::Theme;
use console::Style;
use serde_json::json;
fn make_theme() -> Theme {
Theme::new().add("perm", Style::new().italic())
}
fn make_handler() -> impl Fn(
&clap::ArgMatches,
&crate::cli::handler::CommandContext,
) -> HandlerResult<serde_json::Value> {
|_m, _ctx| Ok(HandlerOutput::Render(json!({"val": "test"})))
}
let template = "[perm]{{ val }}[/perm]";
let app1 = AppBuilder::new()
.theme(make_theme())
.command("test", make_handler(), template)
.unwrap()
.context("extra", minijinja::Value::from("x"))
.build()
.unwrap();
let app2 = AppBuilder::new()
.command("test", make_handler(), template)
.unwrap()
.theme(make_theme())
.context("extra", minijinja::Value::from("x"))
.build()
.unwrap();
let app3 = AppBuilder::new()
.context("extra", minijinja::Value::from("x"))
.command("test", make_handler(), template)
.unwrap()
.theme(make_theme())
.build()
.unwrap();
let app4 = AppBuilder::new()
.context("extra", minijinja::Value::from("x"))
.theme(make_theme())
.command("test", make_handler(), template)
.unwrap()
.build()
.unwrap();
let app5 = AppBuilder::new()
.command("test", make_handler(), template)
.unwrap()
.context("extra", minijinja::Value::from("x"))
.theme(make_theme())
.build()
.unwrap();
let app6 = AppBuilder::new()
.theme(make_theme())
.context("extra", minijinja::Value::from("x"))
.command("test", make_handler(), template)
.unwrap()
.build()
.unwrap();
for (i, app) in [app1, app2, app3, app4, app5, app6].into_iter().enumerate() {
let cmd = Command::new("app").subcommand(Command::new("test"));
let result = app.dispatch_from(cmd, ["app", "--output=term", "test"]);
assert!(
!result.output().unwrap().contains("[perm?]"),
"Permutation {} failed: style not found",
i + 1
);
}
}
#[test]
fn test_dispatch_with_app_state() {
use serde_json::json;
struct Database {
url: String,
}
let builder = AppBuilder::new()
.app_state(Database {
url: "postgres://localhost".into(),
})
.command(
"list",
|_m, ctx| {
let db = ctx.app_state.get::<Database>().unwrap();
Ok(HandlerOutput::Render(json!({"db_url": db.url.clone()})))
},
"{{ db_url }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "list"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some("postgres://localhost"));
}
#[test]
fn test_dispatch_app_state_get_required() {
use serde_json::json;
struct Config {
debug: bool,
}
let builder = AppBuilder::new()
.app_state(Config { debug: true })
.command(
"list",
|_m, ctx| {
let config = ctx.app_state.get_required::<Config>()?;
Ok(HandlerOutput::Render(json!({"debug": config.debug})))
},
"debug={{ debug }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "list"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some("debug=true"));
}
#[test]
fn test_dispatch_app_state_missing_type_error() {
use serde_json::json;
struct NotProvided;
let builder = AppBuilder::new()
.command(
"list",
|_m, ctx| {
let _missing = ctx.app_state.get_required::<NotProvided>()?;
Ok(HandlerOutput::Render(json!({})))
},
"",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "list"]);
assert!(result.is_handled());
let output = result.output().unwrap();
assert!(
output.contains("Extension missing"),
"Expected 'Extension missing' in error, got: {}",
output
);
assert!(
output.contains("Extension missing"),
"Expected 'Extension missing' in error, got: {}",
output
);
}
#[test]
fn test_dispatch_app_state_with_multiple_types() {
use serde_json::json;
struct Database {
name: String,
}
struct Config {
version: i32,
}
let builder = AppBuilder::new()
.app_state(Database {
name: "mydb".into(),
})
.app_state(Config { version: 42 })
.command(
"info",
|_m, ctx| {
let db = ctx.app_state.get_required::<Database>()?;
let config = ctx.app_state.get_required::<Config>()?;
Ok(HandlerOutput::Render(json!({
"db": db.name,
"version": config.version
})))
},
"db={{ db }}, version={{ version }}",
)
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("info"));
let result = builder.dispatch_from(cmd, ["app", "info"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some("db=mydb, version=42"));
}
#[test]
fn test_dispatch_app_state_and_extensions_together() {
use serde_json::json;
struct Database {
name: String,
}
struct UserScope {
user_id: String,
}
let builder = AppBuilder::new()
.app_state(Database {
name: "maindb".into(),
})
.command(
"list",
|_m, ctx| {
let db = ctx.app_state.get_required::<Database>()?;
let scope = ctx.extensions.get_required::<UserScope>()?;
Ok(HandlerOutput::Render(json!({
"db": db.name,
"user": scope.user_id
})))
},
"db={{ db }}, user={{ user }}",
)
.unwrap()
.hooks(
"list",
Hooks::new().pre_dispatch(|_, ctx| {
ctx.extensions.insert(UserScope {
user_id: "user123".into(),
});
Ok(())
}),
);
let cmd = Command::new("app").subcommand(Command::new("list"));
let result = builder.dispatch_from(cmd, ["app", "list"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some("db=maindb, user=user123"));
}
#[test]
fn test_built_app_dispatch_with_app_state() {
use serde_json::json;
struct ApiConfig {
base_url: String,
}
let app = AppBuilder::new()
.app_state(ApiConfig {
base_url: "https://api.example.com".into(),
})
.command(
"fetch",
|_m, ctx| {
let config = ctx.app_state.get_required::<ApiConfig>()?;
Ok(HandlerOutput::Render(json!({"url": config.base_url})))
},
"{{ url }}",
)
.unwrap()
.build()
.unwrap();
let cmd = Command::new("app").subcommand(Command::new("fetch"));
let result = app.dispatch_from(cmd, ["app", "fetch"]);
assert!(result.is_handled());
assert_eq!(result.output(), Some("https://api.example.com"));
}
}