use crate::codegen::shared::to_kebab_case;
use crate::codegen::shared::type_to_string;
use crate::codegen::validation;
use crate::model::ParamSource;
use crate::model::RouterDef;
use crate::model::ToolDef;
use proc_macro2::TokenStream;
use quote::quote;
pub fn generate_cli_methods(router: &RouterDef) -> TokenStream {
let struct_type = &router.struct_type;
let create_command_method = generate_create_cli_command(router);
let execute_cli_method = generate_execute_cli(router);
let completions_method = generate_completions_method();
quote! {
impl #struct_type {
#create_command_method
#execute_cli_method
#completions_method
}
}
}
fn generate_create_cli_command(router: &RouterDef) -> TokenStream {
let cli_name = router
.metadata
.cli_config
.as_ref()
.and_then(|c| c.name.as_ref())
.cloned()
.unwrap_or_else(|| {
router
.struct_type
.segments
.last()
.map(|s| s.ident.to_string().to_lowercase())
.unwrap_or_else(|| "tools".to_string())
});
let about = router
.metadata
.cli_config
.as_ref()
.and_then(|c| c.description.as_ref())
.map(|d| d.as_str())
.unwrap_or("Tool suite generated by UTF");
let subcommands: Vec<_> = router.tools.iter().map(generate_tool_subcommand).collect();
if std::env::var("UTF_DEBUG").is_ok() {
eprintln!("Generating {} subcommands", subcommands.len());
}
let global_args = if let Some(cli_config) = &router.metadata.cli_config {
if !cli_config.global_output_formats.is_empty() {
let formats = cli_config.global_output_formats.clone();
quote! {
.arg(
Arg::new("output-format")
.long("output")
.short('o')
.help("Output format")
.value_parser([#(#formats),*])
.default_value("text")
.global(true)
)
}
} else {
quote! {}
}
} else {
quote! {}
};
let standard_args = if let Some(cli_config) = &router.metadata.cli_config {
if cli_config.standard_global_args {
quote! {
.arg(
Arg::new("dry-run")
.long("dry-run")
.help("Run without making changes")
.action(ArgAction::SetTrue)
.global(true)
)
.arg(
Arg::new("yes")
.long("yes")
.short('y')
.help("Skip confirmation prompts")
.action(ArgAction::SetTrue)
.global(true)
)
.arg(
Arg::new("quiet")
.long("quiet")
.short('q')
.help("Suppress non-error output")
.action(ArgAction::SetTrue)
.global(true)
)
.arg(
Arg::new("verbose")
.long("verbose")
.short('v')
.help("Increase verbosity")
.action(ArgAction::Count)
.global(true)
)
}
} else {
quote! {}
}
} else {
quote! {}
};
let has_global_args = router
.metadata
.cli_config
.as_ref()
.map(|c| !c.global_output_formats.is_empty())
.unwrap_or(false);
let has_standard_args = router
.metadata
.cli_config
.as_ref()
.map(|c| c.standard_global_args)
.unwrap_or(false);
let cmd_init = match (has_global_args, has_standard_args) {
(true, true) => quote! {
let mut cmd = Command::new(#cli_name)
.about(#about)
.version(env!("CARGO_PKG_VERSION"))
#global_args
#standard_args;
},
(true, false) => quote! {
let mut cmd = Command::new(#cli_name)
.about(#about)
.version(env!("CARGO_PKG_VERSION"))
#global_args;
},
(false, true) => quote! {
let mut cmd = Command::new(#cli_name)
.about(#about)
.version(env!("CARGO_PKG_VERSION"))
#standard_args;
},
(false, false) => quote! {
let mut cmd = Command::new(#cli_name)
.about(#about)
.version(env!("CARGO_PKG_VERSION"));
},
};
quote! {
pub fn create_cli_command(&self) -> ::universal_tool_core::cli::clap::Command {
use ::universal_tool_core::cli::clap::{Command, Arg, ArgAction};
#cmd_init
#(#subcommands)*
cmd
}
}
}
fn generate_tool_subcommand(tool: &ToolDef) -> TokenStream {
let cli_name_str = tool
.metadata
.cli_config
.as_ref()
.and_then(|c| c.name.as_ref())
.map(|n| to_kebab_case(n))
.unwrap_or_else(|| {
if !tool.tool_name.is_empty() {
to_kebab_case(&tool.tool_name)
} else {
to_kebab_case(&tool.method_name.to_string())
}
});
let about = &tool.metadata.description;
let args: Vec<_> = tool
.params
.iter()
.filter(|p| matches!(p.source, ParamSource::Body))
.map(validation::generate_cli_arg_config)
.collect();
let alias_calls = if let Some(cli_config) = &tool.metadata.cli_config {
cli_config
.aliases
.iter()
.map(|alias| {
quote! { .alias(#alias) }
})
.collect::<Vec<_>>()
} else {
vec![]
};
if std::env::var("UTF_DEBUG").is_ok() {
eprintln!(
"Building subcommand for tool: {} with {} args",
cli_name_str,
args.len()
);
}
let mut subcommand = quote! {
Command::new(#cli_name_str)
.about(#about)
};
for alias_call in &alias_calls {
subcommand = quote! { #subcommand #alias_call };
}
if let Some(cli_config) = &tool.metadata.cli_config
&& cli_config.hidden
{
subcommand = quote! { #subcommand .hide(true) };
}
for (i, arg) in args.iter().enumerate() {
if std::env::var("UTF_DEBUG").is_ok() {
eprintln!(" Adding arg {i}: {arg}");
}
subcommand = quote! { #subcommand .arg(#arg) };
}
quote! {
cmd = cmd.subcommand(#subcommand);
}
}
fn generate_execute_cli(router: &RouterDef) -> TokenStream {
let match_arms: Vec<_> = router
.tools
.iter()
.map(|tool| generate_execute_match_arm(tool, router))
.collect();
quote! {
pub async fn execute_cli(&self, matches: ::universal_tool_core::cli::clap::ArgMatches) -> Result<(), ::universal_tool_core::prelude::ToolError> {
match matches.subcommand() {
#(#match_arms,)*
Some((cmd, _)) => {
eprintln!("Error: Unknown command '{}'", cmd);
eprintln!("Try '--help' for more information.");
Err(::universal_tool_core::prelude::ToolError::new(
::universal_tool_core::prelude::ErrorCode::NotFound,
format!("Unknown command: {}", cmd)
))
},
None => {
eprintln!("No command specified");
eprintln!("Try '--help' for more information.");
Err(::universal_tool_core::prelude::ToolError::new(
::universal_tool_core::prelude::ErrorCode::InvalidArgument,
"No command specified"
))
}
}
}
}
}
fn generate_execute_match_arm(tool: &ToolDef, router: &RouterDef) -> TokenStream {
let cli_name_str = tool
.metadata
.cli_config
.as_ref()
.and_then(|c| c.name.as_ref())
.map(|n| to_kebab_case(n))
.unwrap_or_else(|| {
if !tool.tool_name.is_empty() {
to_kebab_case(&tool.tool_name)
} else {
to_kebab_case(&tool.method_name.to_string())
}
});
let param_extractions = validation::generate_params_extraction(tool, "cli");
let param_names: Vec<_> = tool.params.iter().map(|p| &p.name).collect();
let has_output_formats = tool
.metadata
.cli_config
.as_ref()
.map(|c| !c.output_formats.is_empty())
.unwrap_or(false);
let _output_handling = if has_output_formats
&& router
.metadata
.cli_config
.as_ref()
.map(|c| !c.global_output_formats.is_empty())
.unwrap_or(false)
{
quote! {
let output_format = matches.get_one::<String>("output-format")
.map(|s| ::universal_tool_core::cli::OutputFormat::from_str(s).unwrap_or(::universal_tool_core::cli::OutputFormat::Text))
.unwrap_or(::universal_tool_core::cli::OutputFormat::Text);
use ::universal_tool_core::cli::CliFormatter;
let formatted = result.format_output(output_format)?;
println!("{}", formatted);
}
} else {
let ty_str = type_to_string(&tool.return_type);
if ty_str.contains("String") {
quote! {
println!("{}", result);
}
} else {
quote! {
println!("{}", ::serde_json::to_string_pretty(&result)?);
}
}
};
let _confirm_check = if let Some(cli_config) = &tool.metadata.cli_config {
if let Some(confirm_msg) = &cli_config.confirm {
quote! {
if !matches.get_flag("yes") {
let count = sub_matches.ids()
.filter_map(|id| sub_matches.get_many::<String>(id.as_str()))
.map(|values| values.len())
.sum::<usize>();
let confirm_msg = #confirm_msg.replace("{count}", &count.to_string());
if !::universal_tool_core::cli::interactive::confirm(&confirm_msg, false)? {
println!("Operation cancelled.");
return Ok(());
}
}
}
} else {
quote! {}
}
} else {
quote! {}
};
let param_args_vec: Vec<TokenStream> =
param_names.iter().map(|name| quote! { #name }).collect();
let method_call = crate::codegen::shared::generate_normalized_method_call(
tool,
quote! { self },
param_args_vec,
);
let _has_confirm = tool
.metadata
.cli_config
.as_ref()
.and_then(|c| c.confirm.as_ref())
.is_some();
quote! {
Some((#cli_name_str, sub_matches)) => {
#( #param_extractions )*
let result = #method_call?;
match ::serde_json::to_string_pretty(&result) {
Ok(json) => {
println!("{}", json);
Ok(())
},
Err(e) => Err(::universal_tool_core::prelude::ToolError::new(
::universal_tool_core::prelude::ErrorCode::SerializationError,
format!("Failed to serialize result: {}", e)
))
}
}
}
}
fn generate_completions_method() -> TokenStream {
quote! {
pub fn generate_completions(&self, shell: ::universal_tool_core::cli::clap_complete::Shell) -> String {
use std::io::Cursor;
let mut cmd = self.create_cli_command();
let name = cmd.get_name().to_string();
let mut buffer = Cursor::new(Vec::new());
::universal_tool_core::cli::clap_complete::generate(shell, &mut cmd, name, &mut buffer);
String::from_utf8(buffer.into_inner())
.expect("Shell completion generation should produce valid UTF-8")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_kebab_case() {
assert_eq!(to_kebab_case("camelCase"), "camel-case");
assert_eq!(to_kebab_case("snake_case"), "snake-case");
assert_eq!(to_kebab_case("PascalCase"), "pascal-case");
assert_eq!(to_kebab_case("lowercase"), "lowercase");
assert_eq!(to_kebab_case("UPPERCASE"), "uppercase");
}
}