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 开发中。
332 lines
11 KiB
Rust
332 lines
11 KiB
Rust
//! 配置键元数据:类型/范围、默认值、AI 可写白名单、敏感分级。
|
||
//!
|
||
//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §3(schema 化 + AI 可写键 = schema 子集)、
|
||
//! `DevDocs/PlanDoc/02-ai-agent-design.md` §3.1(AI 安全边界:白名单 + 类型/范围校验 + 分级确认)。
|
||
//!
|
||
//! 这份元数据是「唯一真相源」:UI 设置页、AI 的 `get_config`/`set_config`、审计日志都读它。
|
||
|
||
use serde_json::{json, Value};
|
||
|
||
/// 键的值类型与约束。
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum ValueKind {
|
||
/// 布尔。
|
||
Bool,
|
||
/// 整数(含闭区间范围)。
|
||
Int {
|
||
/// 下界(含)。
|
||
min: i64,
|
||
/// 上界(含)。
|
||
max: i64,
|
||
},
|
||
/// 浮点(含闭区间范围)。
|
||
Float {
|
||
/// 下界(含)。
|
||
min: f64,
|
||
/// 上界(含)。
|
||
max: f64,
|
||
},
|
||
/// 文本(长度上限,按字符数)。
|
||
Text {
|
||
/// 最大字符数。
|
||
max_len: usize,
|
||
},
|
||
/// 枚举(只允许列出的字面量)。
|
||
Enum(&'static [&'static str]),
|
||
/// 路径(非空字符串;绝对/相对都由业务层决定)。
|
||
Path,
|
||
/// 字符串列表。
|
||
TextList,
|
||
/// 枚举字面量数组:每个元素都必须落在白名单里(`overrides` 这类键用)。
|
||
EnumList(&'static [&'static str]),
|
||
/// 任意 JSON(数据结构本身不参与校验,用于档案列表这类「数据而非配置」的键)。
|
||
Any,
|
||
}
|
||
|
||
impl ValueKind {
|
||
/// 校验;返回中文原因便于直接给用户看。
|
||
pub fn check(&self, value: &Value) -> Result<(), String> {
|
||
match self {
|
||
Self::Bool => value
|
||
.is_boolean()
|
||
.then_some(())
|
||
.ok_or_else(|| format!("期望布尔,实际是 {value}")),
|
||
Self::Int { min, max } => {
|
||
let Some(number) = value.as_i64() else {
|
||
return Err(format!("期望整数,实际是 {value}"));
|
||
};
|
||
if number < *min || number > *max {
|
||
return Err(format!("{number} 超出范围 [{min}, {max}]"));
|
||
}
|
||
Ok(())
|
||
}
|
||
Self::Float { min, max } => {
|
||
let Some(number) = value.as_f64() else {
|
||
return Err(format!("期望数字,实际是 {value}"));
|
||
};
|
||
if number < *min || number > *max {
|
||
return Err(format!("{number} 超出范围 [{min}, {max}]"));
|
||
}
|
||
Ok(())
|
||
}
|
||
Self::Text { max_len } => {
|
||
let Some(text) = value.as_str() else {
|
||
return Err(format!("期望文本,实际是 {value}"));
|
||
};
|
||
let len = text.chars().count();
|
||
if len > *max_len {
|
||
return Err(format!("文本 {len} 字符,超过上限 {max_len}"));
|
||
}
|
||
Ok(())
|
||
}
|
||
Self::Enum(allowed) => {
|
||
let Some(text) = value.as_str() else {
|
||
return Err(format!("期望枚举字面量,实际是 {value}"));
|
||
};
|
||
if allowed.contains(&text) {
|
||
Ok(())
|
||
} else {
|
||
Err(format!("{text:?} 不在 {allowed:?} 之中"))
|
||
}
|
||
}
|
||
Self::Path => match value.as_str() {
|
||
Some(text) if !text.trim().is_empty() => Ok(()),
|
||
_ => Err(format!("期望非空路径字符串,实际是 {value}")),
|
||
},
|
||
Self::TextList => {
|
||
let Some(items) = value.as_array() else {
|
||
return Err(format!("期望字符串数组,实际是 {value}"));
|
||
};
|
||
if items.iter().all(Value::is_string) {
|
||
Ok(())
|
||
} else {
|
||
Err("数组里含非字符串元素".to_owned())
|
||
}
|
||
}
|
||
Self::EnumList(allowed) => {
|
||
let Some(items) = value.as_array() else {
|
||
return Err(format!("期望枚举字面量数组,实际是 {value}"));
|
||
};
|
||
for item in items {
|
||
let Some(text) = item.as_str() else {
|
||
return Err(format!("数组里含非字符串元素: {item}"));
|
||
};
|
||
if !allowed.contains(&text) {
|
||
return Err(format!("{text:?} 不在 {allowed:?} 之中"));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
Self::Any => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// 转成 JSON Schema 片段(喂给 M16 的 `ToolSpec`)。
|
||
#[must_use]
|
||
pub(crate) fn to_json_schema(&self) -> Value {
|
||
match self {
|
||
Self::Bool => json!({ "type": "boolean" }),
|
||
Self::Int { min, max } => json!({ "type": "integer", "minimum": min, "maximum": max }),
|
||
Self::Float { min, max } => json!({ "type": "number", "minimum": min, "maximum": max }),
|
||
Self::Text { max_len } => json!({ "type": "string", "maxLength": max_len }),
|
||
Self::Enum(allowed) => json!({ "type": "string", "enum": allowed }),
|
||
Self::Path => json!({ "type": "string", "minLength": 1 }),
|
||
Self::TextList => json!({ "type": "array", "items": { "type": "string" } }),
|
||
Self::EnumList(allowed) => {
|
||
json!({ "type": "array", "items": { "type": "string", "enum": allowed } })
|
||
}
|
||
Self::Any => json!({}),
|
||
}
|
||
}
|
||
}
|
||
|
||
// AI 边界类型从 [`super::policy`] 拆出去(`spec.rs` 摸到 400 行硬上限),
|
||
// 路径保持不变:`setting::spec::AiPolicy` 仍是可用名。
|
||
pub use super::policy::{AiPolicy, Sensitivity};
|
||
|
||
/// 键废弃信息:替代键 + 旧值映射表。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Deprecation {
|
||
/// 替代键(写永远走这里)。
|
||
pub replaced_by: &'static str,
|
||
/// 旧值 → 新值映射(读兼容);表里没有的值原样带过去。
|
||
pub aliases: &'static [(&'static str, &'static str)],
|
||
}
|
||
|
||
impl Deprecation {
|
||
/// 把旧值翻译成新键能接受的值(非字符串或表里没有的值原样返回)。
|
||
#[must_use]
|
||
pub fn translate(&self, legacy: &Value) -> Value {
|
||
match legacy.as_str() {
|
||
Some(text) => self
|
||
.aliases
|
||
.iter()
|
||
.find(|(old, _)| *old == text)
|
||
.map_or_else(|| legacy.clone(), |(_, new)| Value::from(*new)),
|
||
None => legacy.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一个配置键的完整元数据。
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct SettingSpec {
|
||
/// 点分键名(如 `download.concurrency`)。
|
||
pub key: &'static str,
|
||
/// 类型与约束。
|
||
pub kind: ValueKind,
|
||
/// 默认值;`Value::Null` 表示「无默认」(如用户必须先选的路径)。
|
||
pub default: Value,
|
||
/// AI 策略。
|
||
pub ai: AiPolicy,
|
||
/// 一句话说明(UI 提示 + AI 工具描述)。
|
||
pub doc: &'static str,
|
||
/// 废弃信息:有值表示这个键只做**读兼容**,写必须走替代键。
|
||
pub deprecated: Option<Deprecation>,
|
||
}
|
||
|
||
impl SettingSpec {
|
||
/// 完全手工构造。
|
||
#[must_use]
|
||
pub const fn new(
|
||
key: &'static str,
|
||
kind: ValueKind,
|
||
default: Value,
|
||
ai: AiPolicy,
|
||
doc: &'static str,
|
||
) -> Self {
|
||
Self {
|
||
key,
|
||
kind,
|
||
default,
|
||
ai,
|
||
doc,
|
||
deprecated: None,
|
||
}
|
||
}
|
||
|
||
/// 标记为「已废弃,读兼容到 `replaced_by`」,并按 `aliases` 翻译旧值。
|
||
///
|
||
/// 用于键改名/语义变更(如 `ui.theme` → `appearance.theme_mode`,tower 键冲突裁决):
|
||
/// 旧文件照读、下次保存只写新键,AI 白名单与工具 JSON Schema 自动排除本键。
|
||
#[must_use]
|
||
pub const fn deprecated(
|
||
mut self,
|
||
replaced_by: &'static str,
|
||
aliases: &'static [(&'static str, &'static str)],
|
||
) -> Self {
|
||
self.deprecated = Some(Deprecation {
|
||
replaced_by,
|
||
aliases,
|
||
});
|
||
self
|
||
}
|
||
|
||
/// 该键是否已废弃。
|
||
#[must_use]
|
||
pub const fn is_deprecated(&self) -> bool {
|
||
self.deprecated.is_some()
|
||
}
|
||
|
||
/// 布尔键。
|
||
#[must_use]
|
||
pub fn bool(key: &'static str, default: bool, ai: AiPolicy, doc: &'static str) -> Self {
|
||
Self::new(key, ValueKind::Bool, Value::Bool(default), ai, doc)
|
||
}
|
||
|
||
/// 整数键(闭区间)。
|
||
#[must_use]
|
||
pub fn int(
|
||
key: &'static str,
|
||
default: i64,
|
||
(min, max): (i64, i64),
|
||
ai: AiPolicy,
|
||
doc: &'static str,
|
||
) -> Self {
|
||
Self::new(
|
||
key,
|
||
ValueKind::Int { min, max },
|
||
Value::from(default),
|
||
ai,
|
||
doc,
|
||
)
|
||
}
|
||
|
||
/// 浮点键(闭区间)。
|
||
#[must_use]
|
||
pub fn float(
|
||
key: &'static str,
|
||
default: f64,
|
||
(min, max): (f64, f64),
|
||
ai: AiPolicy,
|
||
doc: &'static str,
|
||
) -> Self {
|
||
Self::new(
|
||
key,
|
||
ValueKind::Float { min, max },
|
||
Value::from(default),
|
||
ai,
|
||
doc,
|
||
)
|
||
}
|
||
|
||
/// 文本键。
|
||
#[must_use]
|
||
pub fn text(
|
||
key: &'static str,
|
||
default: &str,
|
||
max_len: usize,
|
||
ai: AiPolicy,
|
||
doc: &'static str,
|
||
) -> Self {
|
||
Self::new(
|
||
key,
|
||
ValueKind::Text { max_len },
|
||
Value::from(default),
|
||
ai,
|
||
doc,
|
||
)
|
||
}
|
||
|
||
/// 枚举键。
|
||
#[must_use]
|
||
pub fn enumeration(
|
||
key: &'static str,
|
||
default: &'static str,
|
||
allowed: &'static [&'static str],
|
||
ai: AiPolicy,
|
||
doc: &'static str,
|
||
) -> Self {
|
||
Self::new(key, ValueKind::Enum(allowed), Value::from(default), ai, doc)
|
||
}
|
||
|
||
/// 路径键(无默认值,未设置时为 `null`)。
|
||
#[must_use]
|
||
pub fn path(key: &'static str, ai: AiPolicy, doc: &'static str) -> Self {
|
||
Self::new(key, ValueKind::Path, Value::Null, ai, doc)
|
||
}
|
||
|
||
/// 枚举字面量数组键(默认空数组;元素逐个校验)。
|
||
#[must_use]
|
||
pub fn enum_list(
|
||
key: &'static str,
|
||
allowed: &'static [&'static str],
|
||
ai: AiPolicy,
|
||
doc: &'static str,
|
||
) -> Self {
|
||
Self::new(
|
||
key,
|
||
ValueKind::EnumList(allowed),
|
||
Value::Array(Vec::new()),
|
||
ai,
|
||
doc,
|
||
)
|
||
}
|
||
|
||
/// 任意 JSON 键(数据结构不参与校验)。
|
||
#[must_use]
|
||
pub fn any(key: &'static str, ai: AiPolicy, doc: &'static str) -> Self {
|
||
Self::new(key, ValueKind::Any, Value::Null, ai, doc)
|
||
}
|
||
}
|