r1 把 `rename` 套在**插值后的整条文本**上,于是用户数据被改坏:账户名恰好是 `ull` 会变成
`unlucky-launcher`,路径 `D:/tmp/ull/.ULL/...` 会变成 `D:/tmp/unlucky-launcher/.ULL/...`,
回显的子命令 `ull` 也会被改名。
改成模板占位符:文案里写 `%prog%`,**只在渲染时**(print_report / print_error / 帮助)替换成
argv[0] 的 basename。占位符只存在于静态模板里,插值进来的账户名 / 路径 / 子命令碰不到它。
用 `%prog%` 而不是 `{prog}`:这些模板要活在 `format!` 里,`{prog}` 会被 format! 当成参数名
编译失败。
同时补上 r1 漏的反方向:**stdout 的正常输出**(Report.human 与 warnings)现在也做替换,
以前只有 stderr 的错误做了,两边不一致。
回归测试:`user_data_that_says_ull_is_never_rewritten` 断言「账户名 `ull`」「路径 `\ull\`」
「未知命令 `ull`」在 fill 之后逐字不变。
占位符替换覆盖 21 个文件的 63 处文案;测试夹具里形如 `ull-mcp-diagnose-<pid>` 的临时目录名
已回滚为字面量(那不是文案)。
342 lines
13 KiB
Rust
342 lines
13 KiB
Rust
//! `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("`%prog% 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;
|