use std::collections::{BTreeMap, HashMap};
use std::pin::Pin;
use std::sync::Arc;
use futures::Stream;
use serde_json::Value;
use tokio::sync::{Mutex as TokioMutex, RwLock};
use crate::errors::FieldError;
use crate::middleware::{self, MiddlewareContext, MiddlewareFn};
use crate::output::*;
use crate::schema::FieldMeta;
use crate::streaming;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseMode {
Argv,
Split,
Flat,
}
#[derive(Debug, Clone)]
pub struct Example {
pub command: String,
pub description: Option<String>,
}
pub struct CommandDef {
pub name: String,
pub description: Option<String>,
pub args_fields: Vec<FieldMeta>,
pub options_fields: Vec<FieldMeta>,
pub env_fields: Vec<FieldMeta>,
pub aliases: HashMap<String, char>,
pub examples: Vec<Example>,
pub hint: Option<String>,
pub format: Option<Format>,
pub output_policy: Option<OutputPolicy>,
pub handler: Box<dyn CommandHandler>,
pub middleware: Vec<MiddlewareFn>,
pub output_schema: Option<Value>,
}
impl CommandDef {
pub fn build(
name: impl Into<String>,
handler: impl CommandHandler + 'static,
) -> CommandBuilder {
CommandBuilder {
def: CommandDef {
name: name.into(),
description: None,
args_fields: Vec::new(),
options_fields: Vec::new(),
env_fields: Vec::new(),
aliases: HashMap::new(),
examples: Vec::new(),
hint: None,
format: None,
output_policy: None,
handler: Box::new(handler),
middleware: Vec::new(),
output_schema: None,
},
}
}
}
pub struct CommandBuilder {
def: CommandDef,
}
impl CommandBuilder {
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.def.description = Some(desc.into());
self
}
pub fn args<T: crate::schema::IncurSchema>(mut self) -> Self {
self.def.args_fields = T::fields();
self
}
pub fn options<T: crate::schema::IncurSchema>(mut self) -> Self {
let fields = T::fields();
for field in &fields {
if let Some(alias) = field.alias {
self.def.aliases.insert(field.name.to_string(), alias);
}
}
self.def.options_fields = fields;
self
}
pub fn env<T: crate::schema::IncurSchema>(mut self) -> Self {
self.def.env_fields = T::fields();
self
}
pub fn examples(mut self, examples: Vec<Example>) -> Self {
self.def.examples = examples;
self
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
self.def.hint = Some(hint.into());
self
}
pub fn format(mut self, format: crate::output::Format) -> Self {
self.def.format = Some(format);
self
}
pub fn done(self) -> CommandDef {
self.def
}
}
#[async_trait::async_trait]
pub trait CommandHandler: Send + Sync {
async fn run(&self, ctx: CommandContext) -> CommandResult;
}
pub struct CommandContext {
pub agent: bool,
pub args: Value,
pub env: Value,
pub options: Value,
pub format: Format,
pub format_explicit: bool,
pub name: String,
pub vars: Value,
pub version: Option<String>,
}
pub struct ExecuteOptions {
pub agent: bool,
pub argv: Vec<String>,
pub defaults: Option<BTreeMap<String, Value>>,
pub env_fields: Vec<FieldMeta>,
pub env_source: HashMap<String, String>,
pub format: Format,
pub format_explicit: bool,
pub input_options: BTreeMap<String, Value>,
pub middlewares: Vec<MiddlewareFn>,
pub name: String,
pub parse_mode: ParseMode,
pub path: String,
pub vars_fields: Vec<FieldMeta>,
pub version: Option<String>,
}
pub enum InternalResult {
Ok { data: Value, cta: Option<CtaBlock> },
Error {
code: String,
message: String,
retryable: Option<bool>,
field_errors: Option<Vec<FieldError>>,
cta: Option<CtaBlock>,
exit_code: Option<i32>,
},
Stream(Pin<Box<dyn Stream<Item = Value> + Send>>),
}
pub async fn execute(command: Arc<CommandDef>, options: ExecuteOptions) -> InternalResult {
let ExecuteOptions {
agent,
argv,
defaults,
env_fields,
env_source,
format,
format_explicit,
input_options,
middlewares,
name,
parse_mode,
path,
vars_fields: _,
version,
} = options;
let env_source_for_cli = env_source.clone();
let name_for_mw = name.clone();
let version_for_mw = version.clone();
let vars_map = Arc::new(RwLock::new(serde_json::Map::new()));
let result: Arc<TokioMutex<Option<InternalResult>>> = Arc::new(TokioMutex::new(None));
let (stream_consumed_tx, stream_consumed_rx) = tokio::sync::oneshot::channel::<()>();
let stream_consumed_tx = Arc::new(tokio::sync::Mutex::new(Some(stream_consumed_tx)));
let (result_ready_tx, result_ready_rx) = tokio::sync::oneshot::channel::<()>();
let result_ready_tx = Arc::new(tokio::sync::Mutex::new(Some(result_ready_tx)));
let result_inner = Arc::clone(&result);
let result_ready_inner = Arc::clone(&result_ready_tx);
let stream_consumed_inner = Arc::clone(&stream_consumed_tx);
let vars_map_inner = Arc::clone(&vars_map);
let has_middleware = !middlewares.is_empty();
let command_inner = Arc::clone(&command);
let run_command = move || -> middleware::BoxFuture<()> {
let command = command_inner;
Box::pin(async move {
let (args, parsed_options) = match parse_mode {
ParseMode::Argv => {
parse_argv_mode(
&argv,
&command.args_fields,
&command.options_fields,
&command.aliases,
&defaults,
)
}
ParseMode::Split => {
let args = parse_args_from_argv(&argv, &command.args_fields);
let parsed_options = input_options_to_value(&input_options);
(args, parsed_options)
}
ParseMode::Flat => {
split_flat_params(
&input_options,
&command.args_fields,
&command.options_fields,
)
}
};
let command_env = parse_env_fields(&command.env_fields, &env_source);
let vars_value = {
let vars_guard = vars_map_inner.read().await;
Value::Object(vars_guard.clone())
};
let ctx = CommandContext {
agent,
args,
env: command_env,
options: parsed_options,
format,
format_explicit,
name: name.clone(),
vars: vars_value,
version: version.clone(),
};
let handler_result = command.handler.run(ctx).await;
match handler_result {
CommandResult::Ok { data, cta } => {
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Ok { data, cta });
}
CommandResult::Error {
code,
message,
retryable,
exit_code,
cta,
} => {
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Error {
code,
message,
retryable: if retryable { Some(true) } else { None },
field_errors: None,
cta,
exit_code,
});
}
CommandResult::Stream(stream) => {
if has_middleware {
let signal = {
let mut tx = stream_consumed_inner.lock().await;
tx.take()
};
let wrapped = if let Some(signal) = signal {
streaming::wrap_stream_with_signal(stream, signal)
} else {
stream
};
{
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Stream(wrapped));
}
if let Some(tx) = result_ready_inner.lock().await.take() {
let _ = tx.send(());
}
let _ = stream_consumed_rx.await;
} else {
let mut result_guard = result_inner.lock().await;
*result_guard = Some(InternalResult::Stream(stream));
}
}
}
})
};
let cli_env = parse_env_fields(&env_fields, &env_source_for_cli);
if !middlewares.is_empty() {
let mw_ctx = MiddlewareContext {
agent,
command: path,
env: cli_env,
format,
format_explicit,
name: name_for_mw,
vars: Arc::clone(&vars_map),
version: version_for_mw,
};
let chain = middleware::compose(&middlewares, mw_ctx, run_command);
tokio::select! {
_ = chain => {},
_ = result_ready_rx => {},
}
} else {
run_command().await;
}
let result_guard = result.lock().await;
match result_guard.as_ref() {
Some(_) => {
drop(result_guard);
let mut result_guard = result.lock().await;
result_guard.take().unwrap_or(InternalResult::Ok {
data: Value::Null,
cta: None,
})
}
None => InternalResult::Ok {
data: Value::Null,
cta: None,
},
}
}
fn parse_argv_mode(
argv: &[String],
args_fields: &[FieldMeta],
options_fields: &[FieldMeta],
aliases: &HashMap<String, char>,
defaults: &Option<BTreeMap<String, Value>>,
) -> (Value, Value) {
let mut args_map = serde_json::Map::new();
let mut opts_map = serde_json::Map::new();
let reverse_aliases: HashMap<char, &str> = aliases
.iter()
.map(|(name, &ch)| (ch, name.as_str()))
.collect();
let option_names: HashMap<String, &FieldMeta> = options_fields
.iter()
.map(|f| (f.cli_name.clone(), f))
.collect();
let mut positional_idx = 0;
let mut i = 0;
while i < argv.len() {
let token = &argv[i];
if let Some(name) = token.strip_prefix("--") {
if let Some(eq_pos) = name.find('=') {
let key = &name[..eq_pos];
let value = &name[eq_pos + 1..];
let snake_key = crate::schema::to_snake(key);
opts_map.insert(snake_key, Value::String(value.to_string()));
i += 1;
continue;
}
let snake_name = crate::schema::to_snake(name);
if let Some(field) = option_names.get(name) {
match &field.field_type {
crate::schema::FieldType::Boolean => {
opts_map.insert(snake_name, Value::Bool(true));
i += 1;
continue;
}
crate::schema::FieldType::Count => {
let current = opts_map
.get(&snake_name)
.and_then(|v| v.as_i64())
.unwrap_or(0);
opts_map.insert(snake_name, Value::from(current + 1));
i += 1;
continue;
}
_ => {}
}
}
if i + 1 < argv.len() {
let value = &argv[i + 1];
opts_map.insert(snake_name, parse_option_value(value));
i += 2;
} else {
opts_map.insert(snake_name, Value::Bool(true));
i += 1;
}
} else if token.starts_with('-') && token.len() == 2 {
let ch = token.chars().nth(1).unwrap();
if let Some(&field_name) = reverse_aliases.get(&ch) {
let snake_name = crate::schema::to_snake(field_name);
let is_bool = option_names
.get(field_name)
.map(|f| matches!(f.field_type, crate::schema::FieldType::Boolean))
.unwrap_or(false);
if is_bool {
opts_map.insert(snake_name, Value::Bool(true));
i += 1;
} else if i + 1 < argv.len() {
let value = &argv[i + 1];
opts_map.insert(snake_name, parse_option_value(value));
i += 2;
} else {
opts_map.insert(snake_name, Value::Bool(true));
i += 1;
}
} else {
if positional_idx < args_fields.len() {
let field = &args_fields[positional_idx];
args_map.insert(field.name.to_string(), Value::String(token.clone()));
positional_idx += 1;
}
i += 1;
}
} else {
if positional_idx < args_fields.len() {
let field = &args_fields[positional_idx];
args_map.insert(field.name.to_string(), parse_option_value(token));
positional_idx += 1;
}
i += 1;
}
}
if let Some(defaults) = defaults {
for (key, value) in defaults {
let snake_key = crate::schema::to_snake(key);
if !opts_map.contains_key(&snake_key) {
opts_map.insert(snake_key, value.clone());
}
}
}
for field in options_fields {
let key = field.name.to_string();
if !opts_map.contains_key(&key)
&& let Some(default) = &field.default
{
opts_map.insert(key, default.clone());
}
}
(Value::Object(args_map), Value::Object(opts_map))
}
fn parse_args_from_argv(argv: &[String], args_fields: &[FieldMeta]) -> Value {
let mut args_map = serde_json::Map::new();
for (i, token) in argv.iter().enumerate() {
if i < args_fields.len() {
let field = &args_fields[i];
args_map.insert(field.name.to_string(), parse_option_value(token));
}
}
Value::Object(args_map)
}
fn input_options_to_value(options: &BTreeMap<String, Value>) -> Value {
let map: serde_json::Map<String, Value> = options
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Value::Object(map)
}
fn split_flat_params(
params: &BTreeMap<String, Value>,
args_fields: &[FieldMeta],
options_fields: &[FieldMeta],
) -> (Value, Value) {
let arg_names: std::collections::HashSet<&str> = args_fields.iter().map(|f| f.name).collect();
let _option_names: std::collections::HashSet<&str> =
options_fields.iter().map(|f| f.name).collect();
let mut args_map = serde_json::Map::new();
let mut opts_map = serde_json::Map::new();
for (key, value) in params {
let snake_key = crate::schema::to_snake(key);
if arg_names.contains(snake_key.as_str()) {
args_map.insert(snake_key, value.clone());
} else {
opts_map.insert(snake_key, value.clone());
}
}
(Value::Object(args_map), Value::Object(opts_map))
}
fn parse_env_fields(env_fields: &[FieldMeta], env_source: &HashMap<String, String>) -> Value {
let mut env_map = serde_json::Map::new();
for field in env_fields {
let env_name = field.env_name.unwrap_or(field.name);
if let Some(value) = env_source.get(env_name) {
env_map.insert(
field.name.to_string(),
parse_env_value(value, &field.field_type),
);
} else if let Some(default) = &field.default {
env_map.insert(field.name.to_string(), default.clone());
}
}
Value::Object(env_map)
}
fn parse_env_value(value: &str, field_type: &crate::schema::FieldType) -> Value {
match field_type {
crate::schema::FieldType::Boolean => Value::Bool(matches!(value, "1" | "true" | "yes")),
crate::schema::FieldType::Number => {
if let Ok(n) = value.parse::<f64>() {
Value::from(n)
} else {
Value::String(value.to_string())
}
}
_ => Value::String(value.to_string()),
}
}
fn parse_option_value(value: &str) -> Value {
if let Ok(n) = value.parse::<i64>() {
return Value::from(n);
}
if let Ok(n) = value.parse::<f64>() {
return Value::from(n);
}
match value {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
Value::String(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_option_value() {
assert_eq!(parse_option_value("42"), Value::from(42));
assert_eq!(parse_option_value("3.14"), Value::from(3.14));
assert_eq!(parse_option_value("true"), Value::Bool(true));
assert_eq!(parse_option_value("false"), Value::Bool(false));
assert_eq!(
parse_option_value("hello"),
Value::String("hello".to_string())
);
}
#[test]
fn test_split_flat_params() {
let mut params = BTreeMap::new();
params.insert("name".to_string(), Value::String("alice".to_string()));
params.insert("verbose".to_string(), Value::Bool(true));
let args_fields = vec![FieldMeta {
name: "name",
cli_name: "name".to_string(),
description: None,
field_type: crate::schema::FieldType::String,
required: true,
default: None,
alias: None,
deprecated: false,
env_name: None,
}];
let options_fields = vec![FieldMeta {
name: "verbose",
cli_name: "verbose".to_string(),
description: None,
field_type: crate::schema::FieldType::Boolean,
required: false,
default: None,
alias: None,
deprecated: false,
env_name: None,
}];
let (args, opts) = split_flat_params(¶ms, &args_fields, &options_fields);
assert_eq!(args["name"], Value::String("alice".to_string()));
assert_eq!(opts["verbose"], Value::Bool(true));
}
#[test]
fn test_parse_env_fields() {
let fields = vec![FieldMeta {
name: "api_key",
cli_name: "api-key".to_string(),
description: Some("API key"),
field_type: crate::schema::FieldType::String,
required: true,
default: None,
alias: None,
deprecated: false,
env_name: Some("API_KEY"),
}];
let mut env_source = HashMap::new();
env_source.insert("API_KEY".to_string(), "secret123".to_string());
let result = parse_env_fields(&fields, &env_source);
assert_eq!(result["api_key"], Value::String("secret123".to_string()));
}
}