use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use serde_json::Value;
use crate::ctx::Ctx;
use crate::errors;
use crate::registry;
use crate::spec::field::FieldMeta;
use crate::spec::schema::build_schema;
use crate::spec::{Entry, JsonMap, XyzArgs, XyzSchema};
#[derive(Debug, Clone, Default)]
pub struct CliHints {
pub usage: String,
pub aliases: Vec<String>,
pub format: String,
pub hidden: bool,
pub default: bool,
pub skip: bool,
pub daemon: bool,
pub before: String,
pub after: String,
pub fields: HashMap<String, CliFieldHint>,
}
#[derive(Debug, Clone, Default)]
pub struct CliFieldHint {
pub shorthand: Option<String>,
pub positional: bool,
pub hidden: bool,
pub skip: bool,
pub env_var: Option<String>,
pub default: Option<Value>,
}
#[derive(Debug, Clone, Default)]
pub struct HTTPHints {
pub method: String,
pub path: String,
pub timeout: Duration,
pub skip: bool,
pub fields: HashMap<String, HTTPFieldHint>,
}
#[derive(Debug, Clone, Default)]
pub struct HTTPFieldHint {
pub location: String,
pub name: Option<String>,
pub default: Option<Value>,
}
#[derive(Debug, Clone, Default)]
pub struct MCPHints {
pub name: String,
pub description: String,
pub title: String,
pub meta: serde_json::Map<String, serde_json::Value>,
pub annotations: Vec<String>,
pub skip: bool,
pub fields: HashMap<String, MCPFieldHint>,
}
#[derive(Debug, Clone, Default)]
pub struct MCPFieldHint {
pub default: Option<Value>,
pub description: Option<String>,
}
pub struct Command<T, R, F, E>
where
T: XyzArgs,
R: XyzSchema + Serialize,
F: Fn(&Ctx, &T) -> std::result::Result<R, E> + Send + Sync + 'static,
E: std::error::Error + Send + Sync + 'static,
{
pub(crate) name: String,
pub(crate) summary: String,
pub(crate) description: String,
pub(crate) handler: Arc<F>,
pub(crate) cli: CliHints,
pub(crate) http: HTTPHints,
pub(crate) mcp: MCPHints,
pub(crate) _marker: std::marker::PhantomData<(T, R, E)>,
}
impl<T, R, F, E> Command<T, R, F, E>
where
T: XyzArgs,
R: XyzSchema + Serialize,
F: Fn(&Ctx, &T) -> std::result::Result<R, E> + Send + Sync + 'static,
E: std::error::Error + Send + Sync + 'static,
{
pub fn new(name: &str, handler: F) -> Self {
Command {
name: name.to_string(),
summary: String::new(),
description: String::new(),
handler: Arc::new(handler),
cli: CliHints::default(),
http: HTTPHints::default(),
mcp: MCPHints::default(),
_marker: std::marker::PhantomData,
}
}
pub fn summary(mut self, s: impl Into<String>) -> Self {
self.summary = s.into();
self
}
pub fn description(mut self, s: impl Into<String>) -> Self {
self.description = s.into();
self
}
pub fn cli(mut self, h: CliHints) -> Self {
self.cli = h;
self
}
pub fn http(mut self, h: HTTPHints) -> Self {
self.http = h;
self
}
pub fn mcp(mut self, h: MCPHints) -> Self {
self.mcp = h;
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn entry(&self) -> errors::Result<Entry> {
check_entry_name(&self.name).map_err(|e| {
errors::Error::new(e.kind(), format!("spec: command {:?}: {}", self.name, e))
})?;
if !self.mcp.name.is_empty() {
check_entry_name(&self.mcp.name).map_err(|e| {
errors::Error::new(
e.kind(),
format!(
"spec: command {:?}: mcp name override {:?}: {}",
self.name, self.mcp.name, e
),
)
})?;
}
let meta = match std::panic::catch_unwind(T::xyz_meta) {
Ok(Ok(meta)) => meta.to_vec(),
Ok(Err(e)) => {
return Err(errors::Error::new(
e.kind(),
format!("spec: command {:?}: {}", self.name, e),
));
}
Err(panic) => {
let msg = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "argument type analysis failed".to_string());
return Err(errors::Error::new(
errors::Kind::Internal,
format!("spec: command {:?}: {msg}", self.name),
));
}
};
let mut fields: Vec<FieldMeta> = meta;
apply_hints(&self.name, &mut fields, &self.cli, &self.http, &self.mcp)?;
for (idx, f) in fields.iter().enumerate() {
if let Some(d) = &f.cli.default {
T::xyz_type_check(idx, &fields, d).map_err(|e| {
errors::Error::new(
e.kind(),
format!("spec: command {:?}: cli field {:?}: {e}", self.name, f.name),
)
})?;
}
if let Some(d) = &f.http.default {
T::xyz_type_check(idx, &fields, d).map_err(|e| {
errors::Error::new(
e.kind(),
format!(
"spec: command {:?}: http field {:?}: {e}",
self.name, f.name
),
)
})?;
}
if let Some(d) = &f.mcp.default {
T::xyz_type_check(idx, &fields, d).map_err(|e| {
errors::Error::new(
e.kind(),
format!("spec: command {:?}: mcp field {:?}: {e}", self.name, f.name),
)
})?;
}
}
let input_schema = build_schema(&fields);
let meta_fields = fields.clone();
let h = Arc::clone(&self.handler);
#[allow(clippy::type_complexity)]
let invoke: Box<dyn Fn(&Ctx, &JsonMap) -> errors::Result<Value> + Send + Sync> =
Box::new(move |ctx: &Ctx, args: &JsonMap| {
let decoded = T::xyz_decode(args, &meta_fields)?;
T::xyz_validate(&decoded, &meta_fields)?;
let out = match (h)(ctx, &decoded) {
Ok(out) => out,
Err(e) => {
let rich = errors::rich_fields(&e);
return Err(match errors::classify(&e) {
Some(kind) => errors::Error::wrap(kind, e).inherit_values(rich),
None => errors::Error::upgrade(e).inherit_values(rich),
});
}
};
serde_json::to_value(out).map_err(|e| {
errors::Error::new(errors::Kind::Internal, format!("result serialization: {e}"))
})
});
let root = FieldMeta {
name: String::new(),
json_name: String::new(),
kind: crate::spec::field::FieldKind::Struct,
description: String::new(),
required: false,
secret: false,
validate: String::new(),
rules: Vec::new(),
enum_values: Vec::new(),
default: None,
skip: false,
cli: Default::default(),
http: Default::default(),
mcp: Default::default(),
elem: None,
children: fields,
union: None,
};
Ok(Entry {
name: self.name.clone(),
summary: self.summary.clone(),
description: self.description.clone(),
input_schema,
output_schema: R::xyz_schema(),
root,
cli: self.cli.clone(),
http: self.http.clone(),
mcp: self.mcp.clone(),
invoke,
})
}
pub fn register(self, r: ®istry::Registry) -> errors::Result<std::sync::Arc<Entry>> {
let e = self.entry()?;
r.add(Arc::new(e))
}
}
pub fn check_entry_name(name: &str) -> errors::Result<()> {
let mut chars = name.chars();
let ok = match chars.next() {
Some(c) => c.is_ascii_alphanumeric(),
None => false,
} && name.len() <= 128
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c));
if !ok {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("name must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,127}}$, got {name:?}"),
));
}
Ok(())
}
pub fn apply_hints(
cmd_name: &str,
fields: &mut [FieldMeta],
cli: &CliHints,
http: &HTTPHints,
mcp: &MCPHints,
) -> errors::Result<()> {
let names = fields
.iter()
.map(|f| format!("{:?}", f.json_name))
.collect::<Vec<_>>()
.join(", ");
for (key, hint) in &cli.fields {
let Some(i) = lookup_top_field(fields, key) else {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("cli field {key:?}: unknown field {key:?} (fields: {names})"),
));
};
apply_cli_hint(&mut fields[i], key, hint)?;
}
for (key, hint) in &http.fields {
let Some(i) = lookup_top_field(fields, key) else {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("http field {key:?}: unknown field {key:?} (fields: {names})"),
));
};
apply_http_hint(&mut fields[i], key, hint)?;
}
for (key, hint) in &mcp.fields {
let Some(i) = lookup_top_field(fields, key) else {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("mcp field {key:?}: unknown field {key:?} (fields: {names})"),
));
};
apply_mcp_hint(&mut fields[i], key, hint)?;
}
let _ = cmd_name;
Ok(())
}
pub fn lookup_top_field(fields: &[FieldMeta], key: &str) -> Option<usize> {
if key.contains('.') {
return None;
}
for (i, f) in fields.iter().enumerate() {
if f.json_name == key || f.name == key {
return Some(i);
}
}
fields.iter().position(|f| f.name.eq_ignore_ascii_case(key))
}
pub fn apply_cli_hint(f: &mut FieldMeta, key: &str, h: &CliFieldHint) -> errors::Result<()> {
if let Some(sh) = &h.shorthand {
let mut chars = sh.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => f.cli.shorthand = Some(c),
_ => {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("cli field {key:?}: shorthand must be one character, got {sh:?}"),
));
}
}
}
if h.positional {
f.cli.positional = true;
}
if h.hidden {
f.cli.hidden = true;
}
if h.skip {
f.cli.skip = true;
}
if let Some(ev) = &h.env_var {
f.cli.env_var = Some(ev.clone());
}
if let Some(d) = &h.default {
f.cli.default = Some(d.clone());
}
Ok(())
}
pub fn apply_http_hint(f: &mut FieldMeta, key: &str, h: &HTTPFieldHint) -> errors::Result<()> {
if !h.location.is_empty() && !crate::spec::field::valid_http_location(&h.location) {
return Err(errors::Error::new(
errors::Kind::Internal,
format!(
"http field {key:?}: unknown location {:?} (want query|path|header|form|body)",
h.location
),
));
}
if !h.location.is_empty() {
f.http.location = h.location.clone();
}
if let Some(n) = &h.name {
f.http.name = n.clone();
}
if let Some(d) = &h.default {
f.http.default = Some(d.clone());
}
Ok(())
}
pub fn apply_mcp_hint(f: &mut FieldMeta, _key: &str, h: &MCPFieldHint) -> errors::Result<()> {
if let Some(d) = &h.default {
f.mcp.default = Some(d.clone());
}
if let Some(d) = &h.description {
f.description = d.clone();
}
Ok(())
}