Files
Unlucky-Launcher/crates/cli/src/commands/config.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

342 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `config get|set`:走 `core::setting` 的 schema API(单一真相源)。
//!
//! # 落点(M40:与数据根解析同源)
//!
//! - **数据根键**([`DATA_ROOT_KEYS`]:`core.data_mode` / `core.data_custom_root` /
//! `core.launcher_home` / `core.game_home`)→ 便携目录那份 `launcher.json`
//! (`<exe 目录>/.ULL/config/launcher.json`)。这正是 `Ctx::resolve` 决定数据根时读的
//! 同一份文件(落点由 `Ctx::setting_path_for` 给出),所以 `config get core.data_mode`
//! 展示的就是真正生效的值,`config set` 也真的影响下一次解析;
//! - 其余键 → 解析后的数据根(`<data>/.ULL/config/launcher.json`)。
//!
//! 之前两条路各看一份文件:`--data-dir` / standard 模式下「改了 `core.data_mode` 没反应、
//! `config get` 还报另一份文件里的值」(finding `…-improve-ull-config-get-set-launcher-json`)。
//!
//! 实例级配置(`config/instances/<id>.json`)留给实例管理 mission,这里不半途实现。
//!
//! **凭据纪律**(D9 / `02-ai-agent-design.md` §3.1):schema 标 `High` 的敏感键
//! (`ai.api_key` / `download.proxy_url` / `java.extra_args` …)在 read 路径上一律掩码——
//! 单键 get、无键全量 dump、set 的旧值回显**三条**都要掩,`--show-secret` 才放行明文。
//! 判定叠两道:schema 的 `Sensitivity::High` + agent 的 `redact::is_sensitive_key` 词表。
use serde_json::{json, Map, Value};
use unlucky_core::setting::catalog::SettingSchema;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::Sensitivity;
use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason};
use crate::context::{is_data_root_key, Ctx, DATA_ROOT_KEYS};
use crate::error::CliError;
use crate::output::Report;
/// 敏感值在输出里的占位。
const MASK: &str = "<redacted>";
/// `ull config get [<key>] [--show-secret]`。
///
/// # Errors
///
/// 未知键、配置不可读时返回用户错误。
pub fn get(ctx: &Ctx, key: Option<&str>, show_secret: bool) -> Result<Report, CliError> {
match key {
Some(key) => get_one(ctx, key, show_secret),
None => dump(ctx, show_secret),
}
}
/// 单键 get:读该键自己的落点文件(数据根键走便携目录那份)。
fn get_one(ctx: &Ctx, key: &str, show_secret: bool) -> Result<Report, CliError> {
let path = ctx.setting_path_for(key);
let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone();
let Some(value) = store.effective(key) else {
return Err(
CliError::user("unknown_key", format!("配置键 `{key}` 不在 schema 里"))
.with_hint("`ull config get`(不带键)可以看到全部生效键"),
);
};
let sensitive = is_sensitive(&schema, key);
let hidden = sensitive && !show_secret;
let shown = if hidden {
Value::String(MASK.to_string())
} else {
value.clone()
};
let problems = render_problems(&store);
let data = json!({
"key": key,
"value": shown,
"redacted": hidden,
"sensitive": sensitive,
"path": path.display().to_string(),
"problems": problems,
});
let mut human = vec![format!(
"{key} = {}{}",
render_value(&shown),
if hidden {
"(敏感键;加 --show-secret 显示明文)"
} else {
""
}
)];
human.push(format!("来自 {}", path.display()));
for problem in &problems {
human.push(format!("问题:{problem}"));
}
human.extend(source_note(ctx, key));
Ok(Report::new(data, human))
}
/// 无键全量 dump:以数据根那份为底,`core.*` 数据根键换成便携目录那份的生效值。
fn dump(ctx: &Ctx, show_secret: bool) -> Result<Report, CliError> {
let path = ctx.launcher_config();
let store = SettingStore::load(&path, launcher_schema());
let schema = store.doc().schema().clone();
let core_path = ctx
.portable_launcher_config()
.filter(|core| core.as_path() != path.as_path());
let core_store = core_path
.as_ref()
.map(|core| SettingStore::load(core, launcher_schema()));
let mut document = store.doc().to_value();
if let Some(core) = &core_store {
// 只叠加**便携目录那份里真有的键**(默认值不落盘,dump 也只展示文件里有的键);
// 生效数据根在下面的 `data_root` 里单独给,不靠这里的默认值推。
for key in DATA_ROOT_KEYS {
if let Some(value) = core.doc().get(key) {
overlay(&mut document, key, value.clone());
}
}
}
let mut problems = render_problems(&store);
if let Some(core) = &core_store {
problems.extend(render_problems(core));
}
let (document, masked) = if show_secret {
(document, false)
} else {
mask_document(&schema, &document)
};
let text = match serde_json::to_string_pretty(&document) {
Ok(text) => text,
Err(error) => return Err(CliError::internal("config_serialize", error.to_string())),
};
let mut human: Vec<String> = Vec::new();
human.push(format!("配置 {}({:?})", path.display(), store.compat()));
if let Some(core) = &core_path {
human.push(format!("数据根键(core.*)读的是 {}", core.display()));
}
human.push(format!(
"数据根:{}(launcher home {},游戏目录 {})",
ctx.data.mode.as_str(),
ctx.data.launcher_home.display(),
ctx.data.game_home.display()
));
human.extend(text.lines().map(str::to_string));
for problem in &problems {
human.push(format!("问题:{problem}"));
}
if masked {
human.push("(敏感值已掩码;加 --show-secret 显示明文)".to_string());
}
Ok(Report::new(
json!({
"path": path.display().to_string(),
"core_path": core_path.as_ref().map(|core| core.display().to_string()),
"config": document,
"redacted": masked,
"problems": problems,
"data_root": {
"mode": ctx.data.mode.as_str(),
"launcher_home": ctx.data.launcher_home.display().to_string(),
"game_home": ctx.data.game_home.display().to_string(),
},
}),
human,
))
}
/// `ull config set <key> <value>`。
///
/// 敏感键的**旧值回显要掩码**(新值是用户刚敲进来的,照常回显)。
///
/// # Errors
///
/// 键不在 schema、值不合法、文件只读/不可读、写盘失败时返回用户错误。
pub fn set(ctx: &Ctx, key: &str, raw: &str) -> Result<Report, CliError> {
let path = ctx.setting_path_for(key);
let mut store = SettingStore::load(&path, launcher_schema());
let sensitive = is_sensitive(store.doc().schema(), key);
let value = parse_value(raw);
let old = store.effective(key).cloned().map(|old| {
if sensitive {
Value::String(MASK.to_string())
} else {
old
}
});
store.set(key, value.clone()).map_err(|error| {
CliError::user("config_set", error.to_string())
.with_hint(format!("键与取值范围见 {}", path.display()))
})?;
let outcome = store.save().map_err(|error| {
CliError::user(
"config_save",
format!("写 {} 失败:{error}", path.display()),
)
})?;
match &outcome {
SaveOutcome::Saved(saved) => {
let data = json!({
"key": key,
"old": old,
"new": value,
"saved": saved.display().to_string(),
});
let mut human = vec![
format!(
"{key} = {}(原值 {})",
render_value(&value),
render_value(&old.unwrap_or(Value::Null))
),
format!("已保存:{}", saved.display()),
];
human.extend(source_note(ctx, key));
Ok(Report::new(data, human))
}
SaveOutcome::Skipped(SkipReason::NotDirty) => Ok(Report::new(
json!({ "key": key, "new": value, "saved": null }),
vec![format!("{key} 已经是该值,未写盘")],
)),
SaveOutcome::Skipped(reason) => {
let error = CliError::user(
"config_read_only",
format!("{} 当前不可写({})", path.display(), reason.as_str()),
);
// 不可写常常不是权限问题,而是文件内容让加载降级(`$schema` 认不出 / 版本更高)。
let problems = render_problems(&store);
Err(if problems.is_empty() {
error.with_hint("修好文件权限/内容后重试")
} else {
error.with_hint(problems.join(";"))
})
}
}
}
/// 数据根键的落点说明(其余键不需要:它们的落点就是数据根本身)。
fn source_note(ctx: &Ctx, key: &str) -> Option<String> {
if !is_data_root_key(key) {
return None;
}
if ctx.data_dir_forced() {
return Some(
"本次由 --data-dir 指定数据根:该键在下次不带 --data-dir 运行时生效".to_string(),
);
}
if ctx.setting_path_for(key) != ctx.launcher_config() {
return Some("该键决定数据根,落点是便携目录那份(与数据根解析同源)".to_string());
}
None
}
/// 加载问题 → 人读一行(`problems` 通道与人类可读输出共用)。
fn render_problems(store: &SettingStore) -> Vec<String> {
store.problems().iter().map(ToString::to_string).collect()
}
/// 把 `key` 的生效值写进 dump 文档(点分键 → 嵌套对象)。
fn overlay(root: &mut Value, key: &str, value: Value) {
let mut segments = key.split('.').filter(|part| !part.is_empty());
let Some(head) = segments.next() else {
return;
};
let rest: Vec<&str> = segments.collect();
let Some(map) = root.as_object_mut() else {
return;
};
if rest.is_empty() {
map.insert(head.to_string(), value);
return;
}
overlay(
map.entry(head.to_string())
.or_insert_with(|| Value::Object(Map::new())),
&rest.join("."),
value,
);
}
/// 值先按 JSON 解析(数字/布尔/对象),失败按字符串。
fn parse_value(raw: &str) -> Value {
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}
/// 敏感键:schema 标 `High`,或键名本身命中 agent 的敏感词表(双保险)。
fn is_sensitive(schema: &SettingSchema, key: &str) -> bool {
matches!(schema.sensitivity(key), Some(Sensitivity::High))
|| unlucky_agent::redact::is_sensitive_key(key)
}
/// 递归掩码渲染后的配置文档,返回(掩码后的文档,是否掩过)。
///
/// `to_value()` 是**嵌套对象**(点分键展开成层级),所以按路径下钻;
/// 命中敏感路径时整棵子树一起掩码,数组元素沿用父路径继续下钻。
fn mask_document(schema: &SettingSchema, document: &Value) -> (Value, bool) {
mask_at(schema, "", document)
}
fn mask_at(schema: &SettingSchema, path: &str, value: &Value) -> (Value, bool) {
if !path.is_empty() && is_sensitive(schema, path) {
return (Value::String(MASK.to_string()), true);
}
match value {
Value::Object(map) => {
let mut out = Map::with_capacity(map.len());
let mut masked = false;
for (key, child) in map {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
let (child, child_masked) = mask_at(schema, &child_path, child);
masked |= child_masked;
out.insert(key.clone(), child);
}
(Value::Object(out), masked)
}
Value::Array(items) => {
let mut masked = false;
let redacted: Vec<Value> = items
.iter()
.map(|item| {
let (item, item_masked) = mask_at(schema, path, item);
masked |= item_masked;
item
})
.collect();
(Value::Array(redacted), masked)
}
other => (other.clone(), false),
}
}
fn render_value(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests;