1use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use serde::Serialize;
12use serde_json::Value;
13
14use crate::ctx::Ctx;
15use crate::errors;
16use crate::registry;
17use crate::spec::field::FieldMeta;
18use crate::spec::schema::build_schema;
19use crate::spec::{Entry, JsonMap, XyzArgs, XyzSchema};
20
21#[derive(Debug, Clone, Default)]
26pub struct CliHints {
27 pub usage: String,
29 pub aliases: Vec<String>,
31 pub format: String,
34 pub hidden: bool,
36 pub default: bool,
40 pub skip: bool,
43 pub daemon: bool,
47 pub before: String,
51 pub after: String,
52 pub fields: HashMap<String, CliFieldHint>,
53}
54
55#[derive(Debug, Clone, Default)]
57pub struct CliFieldHint {
58 pub shorthand: Option<String>,
60 pub positional: bool,
61 pub hidden: bool,
62 pub skip: bool,
64 pub env_var: Option<String>,
66 pub default: Option<Value>,
68}
69
70#[derive(Debug, Clone, Default)]
72pub struct HTTPHints {
73 pub method: String,
74 pub path: String,
76 pub timeout: Duration,
78 pub skip: bool,
80 pub fields: HashMap<String, HTTPFieldHint>,
81}
82
83#[derive(Debug, Clone, Default)]
85pub struct HTTPFieldHint {
86 pub location: String,
88 pub name: Option<String>,
90 pub default: Option<Value>,
91}
92
93#[derive(Debug, Clone, Default)]
95pub struct MCPHints {
96 pub name: String,
100 pub description: String,
102 pub title: String,
105 pub meta: serde_json::Map<String, serde_json::Value>,
107 pub annotations: Vec<String>,
109 pub skip: bool,
111 pub fields: HashMap<String, MCPFieldHint>,
112}
113
114#[derive(Debug, Clone, Default)]
117pub struct MCPFieldHint {
118 pub default: Option<Value>,
119 pub description: Option<String>,
121}
122
123pub struct Command<T, R, F, E>
125where
126 T: XyzArgs,
127 R: XyzSchema + Serialize,
128 F: Fn(&Ctx, &T) -> std::result::Result<R, E> + Send + Sync + 'static,
129 E: std::error::Error + Send + Sync + 'static,
130{
131 pub(crate) name: String,
132 pub(crate) summary: String,
133 pub(crate) description: String,
134 pub(crate) handler: Arc<F>,
135 pub(crate) cli: CliHints,
136 pub(crate) http: HTTPHints,
137 pub(crate) mcp: MCPHints,
138 pub(crate) _marker: std::marker::PhantomData<(T, R, E)>,
139}
140
141impl<T, R, F, E> Command<T, R, F, E>
142where
143 T: XyzArgs,
144 R: XyzSchema + Serialize,
145 F: Fn(&Ctx, &T) -> std::result::Result<R, E> + Send + Sync + 'static,
146 E: std::error::Error + Send + Sync + 'static,
147{
148 pub fn new(name: &str, handler: F) -> Self {
151 Command {
152 name: name.to_string(),
153 summary: String::new(),
154 description: String::new(),
155 handler: Arc::new(handler),
156 cli: CliHints::default(),
157 http: HTTPHints::default(),
158 mcp: MCPHints::default(),
159 _marker: std::marker::PhantomData,
160 }
161 }
162
163 pub fn summary(mut self, s: impl Into<String>) -> Self {
165 self.summary = s.into();
166 self
167 }
168
169 pub fn description(mut self, s: impl Into<String>) -> Self {
171 self.description = s.into();
172 self
173 }
174
175 pub fn cli(mut self, h: CliHints) -> Self {
177 self.cli = h;
178 self
179 }
180
181 pub fn http(mut self, h: HTTPHints) -> Self {
183 self.http = h;
184 self
185 }
186
187 pub fn mcp(mut self, h: MCPHints) -> Self {
189 self.mcp = h;
190 self
191 }
192
193 pub fn name(&self) -> &str {
195 &self.name
196 }
197
198 pub fn entry(&self) -> errors::Result<Entry> {
201 check_entry_name(&self.name).map_err(|e| {
202 errors::Error::new(e.kind(), format!("spec: command {:?}: {}", self.name, e))
203 })?;
204 if !self.mcp.name.is_empty() {
206 check_entry_name(&self.mcp.name).map_err(|e| {
207 errors::Error::new(
208 e.kind(),
209 format!(
210 "spec: command {:?}: mcp name override {:?}: {}",
211 self.name, self.mcp.name, e
212 ),
213 )
214 })?;
215 }
216 let meta = match std::panic::catch_unwind(T::xyz_meta) {
218 Ok(Ok(meta)) => meta.to_vec(),
219 Ok(Err(e)) => {
220 return Err(errors::Error::new(
221 e.kind(),
222 format!("spec: command {:?}: {}", self.name, e),
223 ));
224 }
225 Err(panic) => {
226 let msg = panic
227 .downcast_ref::<&str>()
228 .map(|s| s.to_string())
229 .or_else(|| panic.downcast_ref::<String>().cloned())
230 .unwrap_or_else(|| "argument type analysis failed".to_string());
231 return Err(errors::Error::new(
232 errors::Kind::Internal,
233 format!("spec: command {:?}: {msg}", self.name),
234 ));
235 }
236 };
237 let mut fields: Vec<FieldMeta> = meta;
239 apply_hints(&self.name, &mut fields, &self.cli, &self.http, &self.mcp)?;
240
241 for (idx, f) in fields.iter().enumerate() {
243 if let Some(d) = &f.cli.default {
244 T::xyz_type_check(idx, &fields, d).map_err(|e| {
245 errors::Error::new(
246 e.kind(),
247 format!("spec: command {:?}: cli field {:?}: {e}", self.name, f.name),
248 )
249 })?;
250 }
251 if let Some(d) = &f.http.default {
252 T::xyz_type_check(idx, &fields, d).map_err(|e| {
253 errors::Error::new(
254 e.kind(),
255 format!(
256 "spec: command {:?}: http field {:?}: {e}",
257 self.name, f.name
258 ),
259 )
260 })?;
261 }
262 if let Some(d) = &f.mcp.default {
263 T::xyz_type_check(idx, &fields, d).map_err(|e| {
264 errors::Error::new(
265 e.kind(),
266 format!("spec: command {:?}: mcp field {:?}: {e}", self.name, f.name),
267 )
268 })?;
269 }
270 }
271
272 let input_schema = build_schema(&fields);
273 let meta_fields = fields.clone();
274
275 let h = Arc::clone(&self.handler);
277 #[allow(clippy::type_complexity)]
278 let invoke: Box<dyn Fn(&Ctx, &JsonMap) -> errors::Result<Value> + Send + Sync> =
279 Box::new(move |ctx: &Ctx, args: &JsonMap| {
280 let decoded = T::xyz_decode(args, &meta_fields)?;
281 T::xyz_validate(&decoded, &meta_fields)?;
282 let out = match (h)(ctx, &decoded) {
283 Ok(out) => out,
284 Err(e) => {
285 let rich = errors::rich_fields(&e);
290 return Err(match errors::classify(&e) {
291 Some(kind) => errors::Error::wrap(kind, e).inherit_values(rich),
292 None => errors::Error::upgrade(e).inherit_values(rich),
293 });
294 }
295 };
296 serde_json::to_value(out).map_err(|e| {
297 errors::Error::new(errors::Kind::Internal, format!("result serialization: {e}"))
298 })
299 });
300
301 let root = FieldMeta {
302 name: String::new(),
303 json_name: String::new(),
304 kind: crate::spec::field::FieldKind::Struct,
305 description: String::new(),
306 required: false,
307 secret: false,
308 validate: String::new(),
309 rules: Vec::new(),
310 enum_values: Vec::new(),
311 default: None,
312 skip: false,
313 cli: Default::default(),
314 http: Default::default(),
315 mcp: Default::default(),
316 elem: None,
317 children: fields,
318 union: None,
319 };
320
321 Ok(Entry {
322 name: self.name.clone(),
323 summary: self.summary.clone(),
324 description: self.description.clone(),
325 input_schema,
326 output_schema: R::xyz_schema(),
327 root,
328 cli: self.cli.clone(),
329 http: self.http.clone(),
330 mcp: self.mcp.clone(),
331 invoke,
332 })
333 }
334
335 pub fn register(self, r: ®istry::Registry) -> errors::Result<std::sync::Arc<Entry>> {
337 let e = self.entry()?;
338 r.add(Arc::new(e))
339 }
340}
341
342pub fn check_entry_name(name: &str) -> errors::Result<()> {
345 let mut chars = name.chars();
346 let ok = match chars.next() {
347 Some(c) => c.is_ascii_alphanumeric(),
348 None => false,
349 } && name.len() <= 128
350 && name
351 .chars()
352 .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c));
353 if !ok {
354 return Err(errors::Error::new(
355 errors::Kind::Internal,
356 format!("name must match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,127}}$, got {name:?}"),
357 ));
358 }
359 Ok(())
360}
361
362pub fn apply_hints(
365 cmd_name: &str,
366 fields: &mut [FieldMeta],
367 cli: &CliHints,
368 http: &HTTPHints,
369 mcp: &MCPHints,
370) -> errors::Result<()> {
371 let names = fields
372 .iter()
373 .map(|f| format!("{:?}", f.json_name))
374 .collect::<Vec<_>>()
375 .join(", ");
376 for (key, hint) in &cli.fields {
377 let Some(i) = lookup_top_field(fields, key) else {
378 return Err(errors::Error::new(
379 errors::Kind::Internal,
380 format!("cli field {key:?}: unknown field {key:?} (fields: {names})"),
381 ));
382 };
383 apply_cli_hint(&mut fields[i], key, hint)?;
384 }
385 for (key, hint) in &http.fields {
386 let Some(i) = lookup_top_field(fields, key) else {
387 return Err(errors::Error::new(
388 errors::Kind::Internal,
389 format!("http field {key:?}: unknown field {key:?} (fields: {names})"),
390 ));
391 };
392 apply_http_hint(&mut fields[i], key, hint)?;
393 }
394 for (key, hint) in &mcp.fields {
395 let Some(i) = lookup_top_field(fields, key) else {
396 return Err(errors::Error::new(
397 errors::Kind::Internal,
398 format!("mcp field {key:?}: unknown field {key:?} (fields: {names})"),
399 ));
400 };
401 apply_mcp_hint(&mut fields[i], key, hint)?;
402 }
403 let _ = cmd_name;
404 Ok(())
405}
406
407pub fn lookup_top_field(fields: &[FieldMeta], key: &str) -> Option<usize> {
410 if key.contains('.') {
411 return None;
412 }
413 for (i, f) in fields.iter().enumerate() {
414 if f.json_name == key || f.name == key {
415 return Some(i);
416 }
417 }
418 fields.iter().position(|f| f.name.eq_ignore_ascii_case(key))
419}
420
421pub fn apply_cli_hint(f: &mut FieldMeta, key: &str, h: &CliFieldHint) -> errors::Result<()> {
422 if let Some(sh) = &h.shorthand {
423 let mut chars = sh.chars();
424 match (chars.next(), chars.next()) {
425 (Some(c), None) => f.cli.shorthand = Some(c),
426 _ => {
427 return Err(errors::Error::new(
428 errors::Kind::Internal,
429 format!("cli field {key:?}: shorthand must be one character, got {sh:?}"),
430 ));
431 }
432 }
433 }
434 if h.positional {
435 f.cli.positional = true;
436 }
437 if h.hidden {
438 f.cli.hidden = true;
439 }
440 if h.skip {
441 f.cli.skip = true;
442 }
443 if let Some(ev) = &h.env_var {
444 f.cli.env_var = Some(ev.clone());
445 }
446 if let Some(d) = &h.default {
447 f.cli.default = Some(d.clone());
448 }
449 Ok(())
450}
451
452pub fn apply_http_hint(f: &mut FieldMeta, key: &str, h: &HTTPFieldHint) -> errors::Result<()> {
453 if !h.location.is_empty() && !crate::spec::field::valid_http_location(&h.location) {
454 return Err(errors::Error::new(
455 errors::Kind::Internal,
456 format!(
457 "http field {key:?}: unknown location {:?} (want query|path|header|form|body)",
458 h.location
459 ),
460 ));
461 }
462 if !h.location.is_empty() {
463 f.http.location = h.location.clone();
464 }
465 if let Some(n) = &h.name {
466 f.http.name = n.clone();
467 }
468 if let Some(d) = &h.default {
469 f.http.default = Some(d.clone());
470 }
471 Ok(())
472}
473
474pub fn apply_mcp_hint(f: &mut FieldMeta, _key: &str, h: &MCPFieldHint) -> errors::Result<()> {
475 if let Some(d) = &h.default {
476 f.mcp.default = Some(d.clone());
477 }
478 if let Some(d) = &h.description {
479 f.description = d.clone();
481 }
482 Ok(())
483}