Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
//! 工具注册表:整个 agent 的**唯一真相源**。
//!
//! 一个 `ToolSpec`(名称 + 描述 + serde JSON Schema)投影两份:
//! - 内置客户端的 rig `DynamicTool` 视图;
//! - 对外 MCP server 的 rmcp `ToolRouter` 视图。
//!
//! 两份投影必须一致,用契约测试固定(同 name / 同 description / 同 schema)。
//! 设计依据:`DevDocs/PlanDoc/02-ai-agent-design.md` §1。
//!
//! 工具 v1 清单(M16 起,M39 扩到十个):`get_config` / `set_config` / `get_config_schema` /
//! `list_instances` / `list_versions` / `get_java_status` / `list_accounts` /
//! `list_game_sessions` / `read_game_log` / `diagnose_crash`。
//!
//! 安全策略随工具走:敏感键分级确认、YOLO 模式标记、审计留痕(02 §3.1 / §4b)。
//!
//! ## 落地形态
//!
//! ```text
//! registry(Vec<ToolSpec>)
//! ├─ definitions() → rig `DynamicTool::new` (内置 agent,`brain::rig`)
//! └─ definitions() → rmcp `ToolRoute::new_dyn` (外部 MCP,`mcp::projection`)
//! ```
//!
//! 防漂移的关键一招(research/02 §6.2 建议进 CI):**契约测试**遍历注册表,断言两侧
//! 投影拿到的 `(name, description, schema)` 逐字节相等。因为两侧都只吃
//! [`ToolDefinition`],这条约束在本 crate 里天然成立——测试只需要锁住
//! 「definition 是纯投影」+「schema 根必须是 object」+「名字不重复」。
//!
//! 注册表**不持有任何端口**:工具闭包在构造时就捕获了 `Arc<dyn …Port>`,注册表只是索引。
pub mod schema;
pub mod spec;
pub mod validate;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
pub use spec::{
Access, Confirmation, ToolCtx, ToolDefinition, ToolError, ToolFn, ToolOutput, ToolSpec,
};
/// 注册失败原因。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryError {
/// 工具名为空或重复。
DuplicateName(String),
/// schema 不合法(根必须是 `type: object`,否则 MCP 侧直接拒收)。
InvalidSchema {
/// 工具名。
tool: String,
/// 原因。
reason: String,
},
/// 说明为空(模型靠 description 选工具,空说明等于没有这个工具)。
EmptyDescription(String),
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateName(name) => write!(f, "工具名重复或为空:`{name}`"),
Self::InvalidSchema { tool, reason } => {
write!(f, "工具 `{tool}` 的 schema 不合法:{reason}")
}
Self::EmptyDescription(name) => write!(f, "工具 `{name}` 缺少说明"),
}
}
}
impl std::error::Error for RegistryError {}
/// 工具注册表。
#[derive(Debug, Default, Clone)]
pub struct ToolRegistry {
specs: Vec<Arc<ToolSpec>>,
index: BTreeMap<String, usize>,
}
impl ToolRegistry {
/// 空注册表。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 注册一个工具。注册即校验名/schema/说明,**运行时不再做二次判断**。
///
/// # Errors
/// 名字重复或为空、schema 根不是 object、说明为空时返回 [`RegistryError`]。
pub fn register(&mut self, spec: ToolSpec) -> Result<(), RegistryError> {
let name = spec.name.trim();
if name.is_empty() || self.index.contains_key(name) {
return Err(RegistryError::DuplicateName(name.to_string()));
}
if spec.description.trim().is_empty() {
return Err(RegistryError::EmptyDescription(name.to_string()));
}
if spec.input_schema.get("type").and_then(|t| t.as_str()) != Some("object") {
return Err(RegistryError::InvalidSchema {
tool: name.to_string(),
reason: "根类型必须是 object(rig 与 rmcp 的入参都是对象 schema)".to_string(),
});
}
self.index.insert(name.to_string(), self.specs.len());
self.specs.push(Arc::new(spec));
Ok(())
}
/// 按名查找。
#[must_use]
pub fn get(&self, name: &str) -> Option<&Arc<ToolSpec>> {
self.index.get(name).and_then(|i| self.specs.get(*i))
}
/// 全部工具(按注册顺序,模型看到的顺序稳定)。
#[must_use]
pub fn specs(&self) -> &[Arc<ToolSpec>] {
&self.specs
}
/// 工具名列表(错误提示/审计用)。
#[must_use]
pub fn names(&self) -> Vec<&str> {
self.specs.iter().map(|s| s.name).collect()
}
/// 工具数量。
#[must_use]
pub fn len(&self) -> usize {
self.specs.len()
}
/// 是否没有工具。
#[must_use]
pub fn is_empty(&self) -> bool {
self.specs.is_empty()
}
/// **投影 1/2 的公共载荷**:`(name, description, input_schema)`。
#[must_use]
pub fn definitions(&self) -> Vec<ToolDefinition> {
self.specs.iter().map(|s| s.definition()).collect()
}
/// 只读子集(P1 的 `--readonly` 外部 MCP 模式用)。
#[must_use]
pub fn read_only_definitions(&self) -> Vec<ToolDefinition> {
self.specs
.iter()
.filter(|s| s.access == Access::ReadOnly)
.map(|s| s.definition())
.collect()
}
/// **只读模式**的注册表副本(外部 MCP 默认形态)。
///
/// 返回的是「物理上不含写工具」的新注册表:`tools/list` 里看不到 `set_config`,
/// 硬调它也会在编排层得到 `unknown_tool`(并留一条审计),而不是静默执行。
/// 这比「列表过滤 + 调用拦截」两套逻辑更不容易漏——只有一份子集,两侧天然一致。
#[must_use]
pub fn read_only_subset(&self) -> Self {
let mut subset = Self::new();
for spec in self.specs.iter().filter(|s| s.access == Access::ReadOnly) {
let name = spec.name.to_string();
if subset.index.insert(name, subset.specs.len()).is_none() {
subset.specs.push(Arc::clone(spec));
}
}
subset
}
/// 名 → access(装配处检查白名单/只读模式)。
#[must_use]
pub fn access_of(&self, name: &str) -> Option<Access> {
self.get(name).map(|s| s.access)
}
/// 去重后的名字集合(契约测试用)。
#[must_use]
pub fn unique_names(&self) -> BTreeSet<&str> {
self.specs.iter().map(|s| s.name).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{non_empty_string, object};
use serde_json::{json, Value};
fn echo(name: &'static str) -> ToolSpec {
ToolSpec::read_only(
name,
"回显",
object(&[("text", non_empty_string("文本"))], &["text"]),
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))),
)
}
#[test]
fn register_and_lookup() {
let mut reg = ToolRegistry::new();
reg.register(echo("echo")).unwrap();
assert_eq!(reg.len(), 1);
assert_eq!(reg.names(), vec!["echo"]);
assert!(reg.get("echo").is_some());
assert!(reg.get("nope").is_none());
assert_eq!(reg.access_of("echo"), Some(Access::ReadOnly));
}
#[test]
fn rejects_duplicate_empty_and_non_object_schema() {
let mut reg = ToolRegistry::new();
reg.register(echo("echo")).unwrap();
assert_eq!(
reg.register(echo("echo")),
Err(RegistryError::DuplicateName("echo".into()))
);
let bad = ToolSpec::read_only(
"bad",
"x",
json!({ "type": "array" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
);
assert!(matches!(
reg.register(bad),
Err(RegistryError::InvalidSchema { .. })
));
let no_desc = ToolSpec::read_only(
"nodesc",
" ",
json!({ "type": "object" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
);
assert!(matches!(
reg.register(no_desc),
Err(RegistryError::EmptyDescription(_))
));
}
#[test]
fn definitions_are_stable_and_filterable() {
let mut reg = ToolRegistry::new();
reg.register(echo("a")).unwrap();
reg.register(ToolSpec::mutating(
"b",
"写",
json!({ "type": "object" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "b"))),
))
.unwrap();
assert_eq!(reg.definitions().len(), 2);
assert_eq!(reg.read_only_definitions().len(), 1);
assert_eq!(reg.definitions()[0].name, "a");
assert_eq!(reg.unique_names().len(), 2);
}
}
+116
View File
@@ -0,0 +1,116 @@
//! JSON Schema 构造helper。
//!
//! 工具参数 schema 一律**手写 JSON**(research/02 §6.2:`rig` 的 `DynamicTool::new` 与
//! rmcp 的 `ToolRoute::new_dyn` 吃的都是裸 `serde_json::Value`),所以不需要 schemars 派生。
//! 手写的好处是同一份 schema 直接喂两方,坏处是啰嗦——这个模块把啰嗦收敛到几个函数。
//!
//! 生成的 schema 一律按 **JSON Schema 2020-12** 的写法(rmcp 支持该版本;
//! `additionalProperties: false` 让模型少给野参数,[`super::validate`] 会执行它)。
use serde_json::{json, Map, Value};
/// 对象 schema。`properties` 按给定顺序写入(JSON 对象保序,UI 展示稳定)。
#[must_use]
pub fn object(properties: &[(&str, Value)], required: &[&str]) -> Value {
let mut props = Map::new();
for (name, schema) in properties {
props.insert((*name).to_string(), schema.clone());
}
json!({
"type": "object",
"properties": Value::Object(props),
"required": required,
"additionalProperties": false,
})
}
/// 任意 JSON 值(不校验类型)。
#[must_use]
pub fn any() -> Value {
json!({})
}
/// 字符串。
#[must_use]
pub fn string(description: &str) -> Value {
json!({ "type": "string", "description": description })
}
/// 非空字符串(`minLength: 1`,省掉一堆「路径不能为空」的运行时判断)。
#[must_use]
pub fn non_empty_string(description: &str) -> Value {
json!({ "type": "string", "description": description, "minLength": 1 })
}
/// 枚举字符串。
#[must_use]
pub fn string_enum(values: &[&str], description: &str) -> Value {
json!({ "type": "string", "description": description, "enum": values })
}
/// 布尔。
#[must_use]
pub fn boolean(description: &str) -> Value {
json!({ "type": "boolean", "description": description })
}
/// 带范围约束的整数。
#[must_use]
pub fn integer(description: &str, minimum: i64, maximum: i64) -> Value {
json!({
"type": "integer",
"description": description,
"minimum": minimum,
"maximum": maximum,
})
}
/// 字符串数组(可选条数上限)。
#[must_use]
pub fn string_array(description: &str, max_items: u64) -> Value {
json!({
"type": "array",
"description": description,
"items": { "type": "string" },
"maxItems": max_items,
})
}
/// 枚举字符串数组(如 `kinds: ["stderr","exit"]`)。
#[must_use]
pub fn enum_string_array(values: &[&str], description: &str, max_items: u64) -> Value {
json!({
"type": "array",
"description": description,
"items": { "type": "string", "enum": values },
"maxItems": max_items,
"uniqueItems": true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::validate::validate;
#[test]
fn object_records_required_and_forbids_extras() {
let schema = object(
&[("path", non_empty_string("键路径")), ("value", any())],
&["path", "value"],
);
assert_eq!(schema["type"], json!("object"));
assert_eq!(schema["additionalProperties"], json!(false));
assert_eq!(schema["required"], json!(["path", "value"]));
assert!(validate(&schema, &json!({ "path": "a", "value": 1 })).is_ok());
}
#[test]
fn integer_builder_carries_bounds() {
let schema = integer("条数", 1, 500);
assert_eq!(schema["minimum"], json!(1));
assert_eq!(schema["maximum"], json!(500));
assert!(validate(&schema, &json!(0)).is_err());
assert!(validate(&schema, &json!(500)).is_ok());
}
}
+262
View File
@@ -0,0 +1,262 @@
//! 工具的**唯一定义类型**([`ToolSpec`])与它的两份投影载荷([`ToolDefinition`])。
//!
//! 双投影的支点在 research/02 §6.2:`rig::tool::DynamicTool::new(name, description,
//! parameters: serde_json::Value, ..)` 与 `rmcp::ToolRoute::new_dyn(name, description,
//! schema, handler)` 的入参**都是裸 JSON Schema**。所以本 crate 只维护一份
//! [`ToolSpec`],由 [`ToolSpec::definition`] 生成两侧共用的 `(name, description, schema)`。
//!
//! 工具实现是**同步**的:端口调用全是本地文件/内存,没有网络 IO;异步只存在于
//! provider 边界([`crate::brain::provider::ChatProvider`])。P1 若出现网络类工具
//! (mod 搜索/版本安装)再升成 `Fn -> BoxFuture`,不影响调用方语义。
use std::fmt;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::confirm::Sensitivity;
/// 工具的副作用等级(外部 MCP 只读模式、P1 的 `--readonly` 都按它筛)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Access {
/// 只读:可以给外部 agent、可以在无确认的情况下调用。
ReadOnly,
/// 会改用户数据(写配置),受白名单 + 分级确认约束。
Mutating,
}
/// 工具定义的三元组:**双投影就投这个**。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDefinition {
/// 工具名(模型可见,`snake_case`)。
pub name: String,
/// 一句话说明(模型可见)。
pub description: String,
/// 参数 JSON Schema(2020-12,根类型必须 `object`)。
pub input_schema: Value,
}
/// 工具调用上下文:编排层注入,工具只读。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCtx {
/// agent 对话会话 id(审计用;与游戏 session_id 不是一回事)。
pub agent_session_id: String,
/// 是否已获确认。**只有编排层能置 true**(YOLO 或用户点了允许),
/// 工具据此决定「先报价再执行」还是「直接执行」。
pub confirmed: bool,
}
/// 工具的返回。
#[derive(Debug, Clone, PartialEq)]
pub struct ToolOutput {
/// 给模型/审计的结构化结果(编排层序列化成 JSON 字符串回灌)。
pub content: Value,
/// 一句话摘要(UI 展示 + 审计 summary)。
pub summary: String,
/// 非空表示「这次没执行,先要确认」([`crate::confirm::ConfirmRequest`] 的原料)。
pub confirmation: Option<Confirmation>,
}
/// 待确认的操作详情(dry-run 的产物)。
#[derive(Debug, Clone, PartialEq)]
pub struct Confirmation {
/// 一句话请求(「AI 请求把 X 从 A 改为 B」)。
pub summary: String,
/// 结构化 diff(`{"path":…,"old":…,"new":…}`)。
pub details: Value,
/// 敏感等级(决定 YOLO 能不能跳过)。
pub sensitivity: Sensitivity,
}
impl ToolOutput {
/// 成功返回。
#[must_use]
pub fn ok(content: Value, summary: impl Into<String>) -> Self {
Self {
content,
summary: summary.into(),
confirmation: None,
}
}
/// 需要确认(本次**没有**产生副作用)。
#[must_use]
pub fn needs_confirmation(
summary: impl Into<String>,
details: Value,
sensitivity: Sensitivity,
) -> Self {
let summary = summary.into();
Self {
content: serde_json::json!({
"status": "needs_confirmation",
"summary": summary,
}),
confirmation: Some(Confirmation {
summary: summary.clone(),
details,
sensitivity,
}),
summary,
}
}
}
/// 工具错误。**全部面向用户可读**——它们会作为工具结果回到模型,
/// 由模型决定重试还是换方案(rmcp 的 `Ok(CallToolResult::error(..))` 同义,
/// 见 research/02 §3.2:协议级 `Err` 会被客户端吞掉,不能用来报业务错误)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolError {
/// 参数语义不合法(schema 校验之外的规则,如路径不在白名单)。
NotAllowed(String),
/// 找不到对象(会话/键/文件)。
NotFound(String),
/// 端口(M12/M15/M18)报错,文本原样透传。
Port(String),
/// 工具自身内部错误(不该发生)。
Internal(String),
}
impl ToolError {
/// 一句话文案(写日志/审计用)。
#[must_use]
pub fn message(&self) -> &str {
match self {
Self::NotAllowed(m) | Self::NotFound(m) | Self::Port(m) | Self::Internal(m) => m,
}
}
/// 错误码(审计与 UI 分类用)。
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::NotAllowed(_) => "not_allowed",
Self::NotFound(_) => "not_found",
Self::Port(_) => "port_error",
Self::Internal(_) => "internal",
}
}
}
impl fmt::Display for ToolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for ToolError {}
/// 工具处理函数。
pub type ToolFn = Arc<dyn Fn(&ToolCtx, &Value) -> Result<ToolOutput, ToolError> + Send + Sync>;
/// 一个工具的完整定义 + 实现。
#[derive(Clone)]
pub struct ToolSpec {
/// 工具名(`snake_case`,同时是 MCP 侧的工具名)。
pub name: &'static str,
/// 说明。
pub description: &'static str,
/// 参数 schema(根必须 object)。
pub input_schema: Value,
/// 副作用等级。
pub access: Access,
/// 实现。
pub handler: ToolFn,
}
impl fmt::Debug for ToolSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolSpec")
.field("name", &self.name)
.field("access", &self.access)
.finish_non_exhaustive()
}
}
impl ToolSpec {
/// 建一个只读工具。
#[must_use]
pub fn read_only(
name: &'static str,
description: &'static str,
input_schema: Value,
handler: ToolFn,
) -> Self {
Self {
name,
description,
input_schema,
access: Access::ReadOnly,
handler,
}
}
/// 建一个会改数据的工具。
#[must_use]
pub fn mutating(
name: &'static str,
description: &'static str,
input_schema: Value,
handler: ToolFn,
) -> Self {
Self {
name,
description,
input_schema,
access: Access::Mutating,
handler,
}
}
/// 投影成两侧共用的 `(name, description, schema)`。
#[must_use]
pub fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.name.to_string(),
description: self.description.to_string(),
input_schema: self.input_schema.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{non_empty_string, object};
use serde_json::json;
fn echo_handler() -> ToolFn {
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo")))
}
#[test]
fn definition_is_a_pure_projection() {
let spec = ToolSpec::read_only(
"echo",
"回显",
object(&[("text", non_empty_string("文本"))], &["text"]),
echo_handler(),
);
let def = spec.definition();
assert_eq!(def.name, "echo");
assert_eq!(def.description, "回显");
assert_eq!(def.input_schema, spec.input_schema);
assert_eq!(spec.access, Access::ReadOnly);
}
#[test]
fn confirmation_carries_details() {
let out = ToolOutput::needs_confirmation(
"AI 请求修改 x",
json!({ "path": "x" }),
Sensitivity::High,
);
assert_eq!(out.content["status"], json!("needs_confirmation"));
let confirmation = out.confirmation.expect("必须有待确认详情");
assert_eq!(confirmation.sensitivity, Sensitivity::High);
assert_eq!(confirmation.summary, out.summary);
assert_eq!(confirmation.details, json!({ "path": "x" }));
}
}
+332
View File
@@ -0,0 +1,332 @@
//! 参数校验:JSON Schema 2020-12 的**最小子集**实现。
//!
//! 为什么不引 `jsonschema` crate:agent 的 schema 全是自己手写的([`super::schema`]),
//! 用到的关键字只有 `type/required/properties/additionalProperties/enum/minimum/maximum/
//! minLength/minItems/maxItems/uniqueItems/items`。三百行的校验器换掉一棵依赖树,
//! 对 Win7 单文件形态(D12)值得。
//!
//! 校验失败的文案是给**模型**看的(它会据此改正参数重试),所以要具体:
//! 「参数 `limit` 必须是 1~500 的整数,实际是 2000」,不是 `schema validation failed`。
use serde_json::Value;
/// 校验入口。返回全部错误(一次给全,省模型来回试错)。
///
/// # Errors
/// 参数不符合 schema 时返回人类可读的错误列表。
pub fn validate(schema: &Value, args: &Value) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
validate_at(schema, args, "", &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
/// 只取第一条错误(日志/审计摘要用)。
#[must_use]
pub fn first_error(schema: &Value, args: &Value) -> Option<String> {
validate(schema, args)
.err()
.and_then(|errors| errors.into_iter().next())
}
fn validate_at(schema: &Value, value: &Value, path: &str, errors: &mut Vec<String>) {
let Some(schema) = schema.as_object() else {
return; // `{}` = 任意值
};
if let Some(expected) = schema.get("type").and_then(Value::as_str) {
if !type_matches(expected, value) {
errors.push(format!(
"参数 {} 应为 {},实际是 {}",
label(path),
expected,
type_name(value)
));
return; // 类型都不对,后面的范围检查没有意义
}
}
if let Some(allowed) = schema.get("enum").and_then(Value::as_array) {
if !allowed.iter().any(|v| v == value) {
let list: Vec<String> = allowed.iter().map(render).collect();
errors.push(format!(
"参数 {} 只能取 [{}],实际是 {}",
label(path),
list.join(", "),
render(value)
));
}
}
check_bounds(schema, value, path, errors);
match value {
Value::Object(map) => {
let required = schema.get("required").and_then(Value::as_array);
for name in required.into_iter().flatten().filter_map(Value::as_str) {
if !map.contains_key(name) {
errors.push(format!("缺少必填参数 {}", label(&join(path, name))));
}
}
let properties = schema.get("properties").and_then(Value::as_object);
for (key, entry) in map {
match properties.and_then(|p| p.get(key)) {
Some(sub) => validate_at(sub, entry, &join(path, key), errors),
None => {
if schema.get("additionalProperties").and_then(Value::as_bool)
== Some(false)
{
let known: Vec<&str> = properties
.map(|p| p.keys().map(String::as_str).collect())
.unwrap_or_default();
errors.push(format!(
"参数 {} 不是可接受的参数名(可用:{})",
label(&join(path, key)),
if known.is_empty() {
"无".to_string()
} else {
known.join(", ")
}
));
}
}
}
}
}
Value::Array(items) => {
if let Some(sub) = schema.get("items") {
for (i, item) in items.iter().enumerate() {
validate_at(sub, item, &format!("{path}[{i}]"), errors);
}
}
}
_ => {}
}
}
fn check_bounds(
schema: &serde_json::Map<String, Value>,
value: &Value,
path: &str,
errors: &mut Vec<String>,
) {
let number = value.as_f64();
let minimum = schema.get("minimum").and_then(Value::as_f64);
let maximum = schema.get("maximum").and_then(Value::as_f64);
if let (Some(n), Some(min), Some(max)) = (number, minimum, maximum) {
if n < min || n > max {
errors.push(format!(
"参数 {} 必须在 {}~{} 之间,实际是 {}",
label(path),
render_number(min),
render_number(max),
render_number(n)
));
return;
}
} else if let (Some(n), Some(min)) = (number, minimum) {
if n < min {
errors.push(format!(
"参数 {} 不能小于 {},实际是 {}",
label(path),
render_number(min),
render_number(n)
));
}
} else if let (Some(n), Some(max)) = (number, maximum) {
if n > max {
errors.push(format!(
"参数 {} 不能大于 {},实际是 {}",
label(path),
render_number(max),
render_number(n)
));
}
}
if let Some(text) = value.as_str() {
if let Some(min_len) = schema.get("minLength").and_then(Value::as_u64) {
if (text.chars().count() as u64) < min_len {
errors.push(format!(
"参数 {} 不能为空(最少 {} 个字符)",
label(path),
min_len
));
}
}
}
if let Some(items) = value.as_array() {
if let Some(max_items) = schema.get("maxItems").and_then(Value::as_u64) {
if items.len() as u64 > max_items {
errors.push(format!(
"参数 {} 最多 {} 项,实际 {} 项",
label(path),
max_items,
items.len()
));
}
}
if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) {
if (items.len() as u64) < min_items {
errors.push(format!(
"参数 {} 至少 {} 项,实际 {} 项",
label(path),
min_items,
items.len()
));
}
}
if schema.get("uniqueItems").and_then(Value::as_bool) == Some(true) {
if let Some(duplicate) = first_duplicate(items) {
errors.push(format!(
"参数 {} 不能有重复项({} 出现了多次)",
label(path),
render(duplicate)
));
}
}
}
}
/// 数组里第一个重复出现的元素(`uniqueItems` 用;`null` 也算一个值)。
fn first_duplicate(items: &[Value]) -> Option<&Value> {
items
.iter()
.enumerate()
.find(|(index, item)| items.iter().take(*index).any(|prev| prev == *item))
.map(|(_, item)| item)
}
fn type_matches(expected: &str, value: &Value) -> bool {
match expected {
"object" => value.is_object(),
"array" => value.is_array(),
"string" => value.is_string(),
"boolean" => value.is_boolean(),
"null" => value.is_null(),
"integer" => value.as_f64().is_some_and(|n| n.fract() == 0.0),
"number" => value.is_number(),
_ => true, // 未知关键字不拦(前向兼容)
}
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(n) if n.as_f64().is_some_and(|f| f.fract() == 0.0) => "integer",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn label(path: &str) -> String {
if path.is_empty() {
"(根)".to_string()
} else {
format!("`{path}`")
}
}
fn join(path: &str, key: &str) -> String {
if path.is_empty() {
key.to_string()
} else {
format!("{path}.{key}")
}
}
fn render(value: &Value) -> String {
match value {
Value::String(s) => format!("\"{s}\""),
other => other.to_string(),
}
}
fn render_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
n.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{
enum_string_array, integer, non_empty_string, object, string_array, string_enum,
};
use serde_json::json;
fn errors(schema: &Value, args: &Value) -> Vec<String> {
validate(schema, args).unwrap_err()
}
#[test]
fn accepts_valid_and_reports_missing_required() {
let schema = object(
&[("session_id", non_empty_string("会话 id"))],
&["session_id"],
);
assert!(validate(&schema, &json!({ "session_id": "20261029T124501-9f3c2a" })).is_ok());
let errs = errors(&schema, &json!({}));
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("缺少必填参数 `session_id`"), "{errs:?}");
}
#[test]
fn rejects_empty_string_and_wrong_type() {
let schema = object(
&[("session_id", non_empty_string("会话 id"))],
&["session_id"],
);
assert!(errors(&schema, &json!({ "session_id": "" }))[0].contains("不能为空"));
assert!(errors(&schema, &json!({ "session_id": 1 }))[0].contains("应为 string"));
}
#[test]
fn rejects_out_of_range_and_unknown_keys() {
let schema = object(
&[
("limit", integer("条数", 1, 500)),
("kinds", string_array("类型", 8)),
],
&[],
);
let errs = errors(&schema, &json!({ "limit": 2000 }));
assert!(errs[0].contains("1~500"), "{errs:?}");
let errs = errors(&schema, &json!({ "limit": 1, "evil": true }));
assert!(errs[0].contains("不是可接受的参数名"), "{errs:?}");
}
#[test]
fn checks_enum_and_nested_objects() {
let schema = object(&[("level", string_enum(&["error", "warn"], "级别"))], &[]);
assert!(validate(&schema, &json!({ "level": "error" })).is_ok());
assert!(errors(&schema, &json!({ "level": "fatal" }))[0].contains("只能取"));
}
#[test]
fn rejects_duplicate_items_when_unique() {
let schema = object(
&[("kinds", enum_string_array(&["stderr", "exit"], "类型", 4))],
&[],
);
let errs = errors(&schema, &json!({ "kinds": ["stderr", "exit", "stderr"] }));
assert!(errs[0].contains("不能有重复项"), "{errs:?}");
assert!(validate(&schema, &json!({ "kinds": ["stderr", "exit"] })).is_ok());
}
#[test]
fn integer_type_rejects_fraction() {
assert!(validate(&json!({ "type": "integer" }), &json!(1.5)).is_err());
assert!(validate(&json!({ "type": "integer" }), &json!(2.0)).is_ok());
}
}