Skip to main content

xyz_rust/spec/
command.rs

1// Command 是单条命令定义的构建器 + 三套 Define-time 配置提示。
2// define 打开一条定义,链上 transport 提示后调用 entry/register。
3//
4// handler 的错误类型 E 是开放泛型(任意 std::error::Error):Invoke 在
5// 构建时把它擦掉(errors::upgrade 沿 source 链保留原分类)。
6
7use 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/// CliHints 是 CLI 前端的命令级配置。字段级绑定可以放在入参 struct 的
22/// cli 属性上,也可以放在 Fields(按字段 JSON 名或 Rust 名索引)。Fields
23/// 条目合并覆盖属性绑定——零值 hint 字段保留属性值,因此可以只在属性里
24/// 设短名、只在构建器里改默认值。
25#[derive(Debug, Clone, Default)]
26pub struct CliHints {
27    /// 单行调用式,如 "add <name>"。
28    pub usage: String,
29    /// 等价的子命令拼写。
30    pub aliases: Vec<String>,
31    /// 逐命令的默认输出格式(spec §10.7 第三层):""=沿用全局;
32    /// 取 auto|text|json|jsonl|markdown。低于命令行两层、高于 Config。
33    pub format: String,
34    /// 从帮助列表里隐藏。
35    pub hidden: bool,
36    /// 使该命令成为其父节点的默认子命令:首段不是已注册命令段(且不是
37    /// flag)时,整串参数不消费地转发给它(udf image.tar ⇔
38    /// udf extract image.tar,当 extract 标记了 default)。
39    pub default: bool,
40    /// 从 CLI 通道整体移除该命令:不建子命令、别名不生效、不出现在
41    /// completion。与 hidden 的区别:hidden 只藏帮助、仍可执行。
42    pub skip: bool,
43    /// 声明「长驻命令」:handler 阻塞到 ctx 取消再返回。语义:隐含
44    /// HTTP/MCP 双排除(通道层面不消费);执行时不渲染返回值(handler
45    /// 的 error 照常分类);ctx 取消即优雅关停、退出 0。
46    pub daemon: bool,
47    /// `-h` 帮助的自定义文本块:分别插在帮助最前(description 之前)与
48    /// 最后(Global Flags 之后)。原样输出(多行、缩进自控;结尾换行归一)。
49    /// 空 = 不插入。仅叶子命令生效(中间节点没有 CliHints)。
50    pub before: String,
51    pub after: String,
52    pub fields: HashMap<String, CliFieldHint>,
53}
54
55/// Define 期的 CLI 字段级配置。
56#[derive(Debug, Clone, Default)]
57pub struct CliFieldHint {
58    /// 单字符短名,如 'a'。
59    pub shorthand: Option<String>,
60    pub positional: bool,
61    pub hidden: bool,
62    /// 对 CLI 前端不可见(属性等价物:cli = "-")。
63    pub skip: bool,
64    /// 未显式提供时回退到这个环境变量。
65    pub env_var: Option<String>,
66    /// CLI-only 默认值;对 CLI 前端覆盖全局 default 属性。
67    pub default: Option<Value>,
68}
69
70/// HTTPHints 是 HTTP 前端的命令级配置。
71#[derive(Debug, Clone, Default)]
72pub struct HTTPHints {
73    pub method: String,
74    /// 路由模板,如 "/users/{name}"。
75    pub path: String,
76    /// 每请求超时覆盖;0 保持前端默认。
77    pub timeout: Duration,
78    /// 从 HTTP 通道整体移除该命令:不注册路由、不进 /openapi.json。
79    pub skip: bool,
80    pub fields: HashMap<String, HTTPFieldHint>,
81}
82
83/// Define 期的 HTTP 字段级配置。
84#[derive(Debug, Clone, Default)]
85pub struct HTTPFieldHint {
86    /// "" | query | path | header | form | body。
87    pub location: String,
88    /// 线上名覆盖(通常是 header 名)。
89    pub name: Option<String>,
90    pub default: Option<Value>,
91}
92
93/// MCPHints 是 MCP 前端的命令级配置。
94#[derive(Debug, Clone, Default)]
95pub struct MCPHints {
96    /// MCP 工具名覆写(spec §12.4a):空=沿用点分注册名;非空则该名字是
97    /// tools/list 通告与 tools/call 接受的唯一名字(CLI/HTTP 命名不动,
98    /// 两通道前缀可各自独立)。须满足 §3.1 名字文法。
99    pub name: String,
100    /// 工具描述覆写(spec §12.4):空=summary+description 的 §3.3 合并。
101    pub description: String,
102    /// 人类友好显示名(spec §12.4):写入 MCP 的 annotations.title;
103    /// 空=沿用 `title:…` 注解字符串(若有)。
104    pub title: String,
105    /// 任意工具级 `_meta` 键值(spec §12.4):并入工具的保留 `_meta`。
106    pub meta: serde_json::Map<String, serde_json::Value>,
107    /// 形如 "read"、"write"、"destructive"、"title:创建用户"。
108    pub annotations: Vec<String>,
109    /// 从 MCP 通道整体移除该命令:不成为工具。
110    pub skip: bool,
111    pub fields: HashMap<String, MCPFieldHint>,
112}
113
114/// Define 期的 MCP 字段级配置。MCP 的覆盖同时替换 inputSchema 里的
115/// default。
116#[derive(Debug, Clone, Default)]
117pub struct MCPFieldHint {
118    pub default: Option<Value>,
119    /// 字段描述覆写(spec §12.4;空=沿用 desc)。
120    pub description: Option<String>,
121}
122
123/// Command 是一条命令的定义构造器。
124pub 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    /// 打开一条命令定义。name 必须匹配 ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$
149    /// (MCP tool 名兼容);检查在 entry() 执行,所以 define 本身不会失败。
150    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    /// 单行描述(帮助文本与 MCP 工具列表使用)。
164    pub fn summary(mut self, s: impl Into<String>) -> Self {
165        self.summary = s.into();
166        self
167    }
168
169    /// 更长的解释(完整帮助与工具文档展示)。
170    pub fn description(mut self, s: impl Into<String>) -> Self {
171        self.description = s.into();
172        self
173    }
174
175    /// 附加命令级 CLI 配置。
176    pub fn cli(mut self, h: CliHints) -> Self {
177        self.cli = h;
178        self
179    }
180
181    /// 附加命令级 HTTP 配置。
182    pub fn http(mut self, h: HTTPHints) -> Self {
183        self.http = h;
184        self
185    }
186
187    /// 附加命令级 MCP 配置。
188    pub fn mcp(mut self, h: MCPHints) -> Self {
189        self.mcp = h;
190        self
191    }
192
193    /// 注册名。
194    pub fn name(&self) -> &str {
195        &self.name
196    }
197
198    /// 分析一次入参类型并构建前端可见的 Entry(不注册)。所有定义错误
199    /// (坏名字、坏 tag、不支持的形状)都在这里浮出,而不是首次调用时。
200    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        // MCP 工具名覆写(spec §12.4a)同样须满足 §3.1 文法。
205        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        // xyz_spec 的递归护栏用 panic 表达;注册期把它捕获成错误。
217        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        // 每个 Entry 一份可变的元数据树(hints 合并写在这份上)。
238        let mut fields: Vec<FieldMeta> = meta;
239        apply_hints(&self.name, &mut fields, &self.cli, &self.http, &self.mcp)?;
240
241        // Define-time 默认值的注册期校验(对齐 Go 的 normalizeHintDefault)。
242        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        // 共享管线:map -> 强类型(默认/required/枚举)-> 校验 -> handler。
276        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                        // 沿用户错误链保留既有分类(Go:handler 的错误类型
286                        // 直接参与 classify);未分类才兜底 internal。
287                        // §8.5:包装壳同时继承链上富化层(Go 直接透传
288                        // CodedError 的等价物),code/detail/status 不丢。
289                        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    /// 构建 Entry 并注册到 r。
336    pub fn register(self, r: &registry::Registry) -> errors::Result<std::sync::Arc<Entry>> {
337        let e = self.entry()?;
338        r.add(Arc::new(e))
339    }
340}
341
342/// entry 名的同构检查:MCP tool 名兼容(^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$),
343/// 同时约束 CLI 子命令名与 HTTP 路由段。
344pub 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
362/// hints 合并(Go applyHints):Fields 键为顶层字段的 JSON 名或 Rust 名;
363/// 零值 hint 字段保留属性绑定。
364pub 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
407/// JSON 名 → Rust 名(精确)→ Rust 名(忽略大小写);点号键拒绝
408/// (嵌套配置暂不支持)。返回字段索引供调用方取可变引用。
409pub 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        // 在 build_schema 之前应用(entry() 顺序保证),覆写进 inputSchema。
480        f.description = d.clone();
481    }
482    Ok(())
483}