Merge branch 'feat/surface-unknown-launch-placeholders-inst'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
ff1a1677f5
2 files changed
+366
-15
No files matched your search
@@ -2,10 +2,27 @@
|
|||||||
//!
|
//!
|
||||||
//! 占位符清单平移自 `analyse/02` §4.8(`DefaultLauncher.getConfigurations()`):
|
//! 占位符清单平移自 `analyse/02` §4.8(`DefaultLauncher.getConfigurations()`):
|
||||||
//! 版本清单里的 `arguments.*` 用 `${...}` 引用运行时值,启动器负责在组装时替换。
|
//! 版本清单里的 `arguments.*` 用 `${...}` 引用运行时值,启动器负责在组装时替换。
|
||||||
//! 替换语义与 HMCL 一致:**单遍、不递归**;未定义的占位符原样保留(便于发现清单漏项)。
|
//! 替换语义与 HMCL 一致:**单遍、不递归**。
|
||||||
|
//!
|
||||||
|
//! ## 未登记占位符:原样保留,但不再静默(M244)
|
||||||
|
//!
|
||||||
|
//! 未登记键(第三方清单里自造的 `${xxx}`)**仍然原样写进 argv**——第三方清单的兼容性优先,
|
||||||
|
//! 悄悄改写别人给的命令行比留着 `${xxx}` 危险得多。但「静默」是另一回事:argv、
|
||||||
|
//! `--dry-run` 与会话 JSONL 里只有一个 `${xxx}`,没有任何解释。所以渲染时会把这些键收集起来:
|
||||||
|
//!
|
||||||
|
//! - [`PlaceholderMap::render`] 每个键每次启动只往 `launcher.log` 记一行
|
||||||
|
//! ([`crate::launch::launcher_log`] 是 `crates/core` 里唯一 GUI 与 CLI 都写、且事故之后
|
||||||
|
//! 还能翻的落点,见该模块文档);
|
||||||
|
//! - [`PlaceholderMap::render_tracked`] / [`PlaceholderMap::unknown_keys`] 把同一份清单
|
||||||
|
//! **结构化**交出来,给后续接 `AssemblyWarning` / `--dry-run` 留好接口。
|
||||||
|
//!
|
||||||
|
//! 键的匹配是**精确字节相等**:`${Version_Name}`、`${ version_name }` 与 `${version_name}`
|
||||||
|
//! 是三个不同的键(替换本身就不认它们,合并只会掩盖「这个串没被替换」这个事实)。日志行里
|
||||||
|
//! 会提示大小写/空白近似的那一个,方便定位笔误。
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
|
||||||
use crate::launch::platform::PlatformCtx;
|
use crate::launch::platform::PlatformCtx;
|
||||||
use crate::launch::request::LaunchRequest;
|
use crate::launch::request::LaunchRequest;
|
||||||
@@ -40,10 +57,42 @@ pub const PLACEHOLDER_KEYS: &[&str] = &[
|
|||||||
"language",
|
"language",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// 一次渲染的结果。
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct RenderOutcome {
|
||||||
|
/// 替换后的参数串(未登记键原样保留)。
|
||||||
|
pub text: String,
|
||||||
|
/// 本次渲染遇到的未登记键(不含 `${}`);按首次出现顺序去重。
|
||||||
|
pub unknown_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOutcome {
|
||||||
|
/// 记一个未登记键(已记过的不重复记)。
|
||||||
|
fn note_unknown(&mut self, key: &str) {
|
||||||
|
if self.unknown_keys.iter().any(|known| known == key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.unknown_keys.push(key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 占位符表。
|
/// 占位符表。
|
||||||
#[derive(Debug, Clone, Default)]
|
///
|
||||||
|
/// 除了键值对,还记着「渲染时见过哪些未登记键」(去重保序,[`Self::unknown_keys`] 交出)。
|
||||||
|
/// 这份记录是**附加物**:它不参与替换,也不改变替换结果。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
pub struct PlaceholderMap {
|
pub struct PlaceholderMap {
|
||||||
entries: BTreeMap<String, String>,
|
entries: BTreeMap<String, String>,
|
||||||
|
reported: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for PlaceholderMap {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: self.entries.clone(),
|
||||||
|
reported: Mutex::new(self.reported_keys()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaceholderMap {
|
impl PlaceholderMap {
|
||||||
@@ -72,37 +121,89 @@ impl PlaceholderMap {
|
|||||||
self.entries.is_empty()
|
self.entries.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 单遍替换 `${key}`;未定义键原样保留。
|
/// 到目前为止渲染时遇到的未登记键(去重、保序)。
|
||||||
pub fn render(&self, template: &str) -> String {
|
///
|
||||||
if !template.contains("${") {
|
/// 这是给「结构化交出口」用的:接 `AssemblyWarning` / `--dry-run` 时读这份清单即可,
|
||||||
return template.to_string();
|
/// 不必再解析 `launcher.log`。
|
||||||
|
pub fn unknown_keys(&self) -> Vec<String> {
|
||||||
|
self.reported_keys()
|
||||||
}
|
}
|
||||||
let mut out = String::with_capacity(template.len() + 16);
|
|
||||||
|
/// 单遍替换 `${key}`;未登记键原样保留,并记一行 `launcher.log`(每个键只记一次)。
|
||||||
|
///
|
||||||
|
/// 需要同一份结果的**结构化**形式(未登记键清单)时用 [`Self::render_tracked`]。
|
||||||
|
pub fn render(&self, template: &str) -> String {
|
||||||
|
let outcome = self.render_tracked(template);
|
||||||
|
self.report(&outcome.unknown_keys);
|
||||||
|
outcome.text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单遍替换 `${key}`,并把本次遇到的未登记键一并交出(本方法**不**写日志)。
|
||||||
|
pub fn render_tracked(&self, template: &str) -> RenderOutcome {
|
||||||
|
let mut outcome = RenderOutcome {
|
||||||
|
text: String::new(),
|
||||||
|
unknown_keys: Vec::new(),
|
||||||
|
};
|
||||||
|
if !template.contains("${") {
|
||||||
|
outcome.text.push_str(template);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
outcome.text.reserve(template.len() + 16);
|
||||||
let mut rest = template;
|
let mut rest = template;
|
||||||
while let Some(start) = rest.find("${") {
|
while let Some(start) = rest.find("${") {
|
||||||
out.push_str(&rest[..start]);
|
outcome.text.push_str(&rest[..start]);
|
||||||
let after = &rest[start + 2..];
|
let after = &rest[start + 2..];
|
||||||
match after.find('}') {
|
match after.find('}') {
|
||||||
Some(end) => {
|
Some(end) => {
|
||||||
let key = &after[..end];
|
let key = &after[..end];
|
||||||
match self.entries.get(key) {
|
match self.entries.get(key) {
|
||||||
Some(value) => out.push_str(value),
|
Some(value) => outcome.text.push_str(value),
|
||||||
None => {
|
None => {
|
||||||
out.push_str("${");
|
outcome.text.push_str("${");
|
||||||
out.push_str(key);
|
outcome.text.push_str(key);
|
||||||
out.push('}');
|
outcome.text.push('}');
|
||||||
|
outcome.note_unknown(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rest = &after[end + 1..];
|
rest = &after[end + 1..];
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
out.push_str(&rest[start..]);
|
outcome.text.push_str(&rest[start..]);
|
||||||
rest = "";
|
rest = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push_str(rest);
|
outcome.text.push_str(rest);
|
||||||
out
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记本次遇到的未登记键,只把**新**见到的那些写进日志。
|
||||||
|
fn report(&self, unknown: &[String]) {
|
||||||
|
self.report_with(unknown, emit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::report`] 的可注入版本:`sink` 拿到的是「本次**新**见到的键」(去重、保序)。
|
||||||
|
///
|
||||||
|
/// 留这道缝只为一件事:让「哪些键真的交给了日志」有自动化证据(用例用假 sink 断言),
|
||||||
|
/// 而不是只能靠读代码——进程级的 `launcher_log` 在 lib 测试里动不得(见 [`emit`] 的注释)。
|
||||||
|
fn report_with(&self, unknown: &[String], mut sink: impl FnMut(&str)) {
|
||||||
|
let fresh = {
|
||||||
|
let mut seen = self.seen();
|
||||||
|
collect_fresh(&mut seen, unknown)
|
||||||
|
};
|
||||||
|
for key in &fresh {
|
||||||
|
sink(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seen(&self) -> MutexGuard<'_, Vec<String>> {
|
||||||
|
self.reported
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poison| poison.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reported_keys(&self) -> Vec<String> {
|
||||||
|
self.seen().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 由启动请求构造完整占位符表。
|
/// 由启动请求构造完整占位符表。
|
||||||
@@ -159,6 +260,68 @@ impl PlaceholderMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 把 `keys` 登记进 `seen`,返回本次**新**见到的那些(保序;两边都按字节相等去重)。
|
||||||
|
fn collect_fresh(seen: &mut Vec<String>, keys: &[String]) -> Vec<String> {
|
||||||
|
let mut fresh = Vec::new();
|
||||||
|
for key in keys {
|
||||||
|
if seen.iter().any(|known| known == key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.push(key.clone());
|
||||||
|
fresh.push(key.clone());
|
||||||
|
}
|
||||||
|
fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未登记键的 `launcher.log` 行(scope 与时间戳由 `launcher_log` 加)。
|
||||||
|
///
|
||||||
|
/// 键名先做换行消毒:`launcher.log` 是**行导向**的,第三方清单完全可以在 JSON 里塞
|
||||||
|
/// `${evil\n2026-… [launch] 伪造行}`,把一个键变成两行假日志。这里换成一个空格。
|
||||||
|
fn unknown_log_line(key: &str) -> String {
|
||||||
|
let key = key.replace(['\r', '\n'], " ");
|
||||||
|
match near_miss(&key) {
|
||||||
|
Some(known) => format!(
|
||||||
|
"占位符 ${{{key}}} 未登记:已按原样保留;疑似 ${{{known}}} 的笔误\
|
||||||
|
(替换是精确匹配,大小写/空白都算不同的键)"
|
||||||
|
),
|
||||||
|
None => format!("占位符 ${{{key}}} 未登记:已按原样保留(第三方版本清单常带自有占位符)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 大小写/首尾空白不同、但确实登记了的那个键(只用于日志提示,不参与替换与去重)。
|
||||||
|
///
|
||||||
|
/// 候选集用 [`PLACEHOLDER_KEYS`] 而不是 `self.entries`:`from_request` 登记的就是这 26 个
|
||||||
|
/// (`crates/core/tests/launch/cmdline_snapshot.rs` 的 `placeholder_table_is_complete` 守这条),
|
||||||
|
/// 而本函数是纯函数,拿不到表。
|
||||||
|
fn near_miss(key: &str) -> Option<&'static str> {
|
||||||
|
let trimmed = key.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
PLACEHOLDER_KEYS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|known| known.eq_ignore_ascii_case(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一行「未登记占位符」写进 `launcher.log`。
|
||||||
|
///
|
||||||
|
/// 测试构建里是空实现:`launcher_log` 是**进程级单例**(目录 + 200 行缓冲),lib 的几百条
|
||||||
|
/// 用例在同一个进程里并行跑,本模块记的几行会落进 `launcher_log` 自己
|
||||||
|
/// `buffer_is_bounded_before_the_dir_is_known` 的窗口里(实测:`缓冲没有上限:205 行`)。
|
||||||
|
/// 让每个用例都去抢那个单例,等于把 flake 转嫁给别人。三段各有自动化证据:行文本
|
||||||
|
/// [`unknown_log_line`]、「哪些键算新」[`collect_fresh`]、「新键怎么交给 sink」
|
||||||
|
/// [`PlaceholderMap::report_with`](用例用假 sink 断言);只剩最后一句
|
||||||
|
/// `launcher_log::record` 由 `M244` 的一次性探针(`--test-threads=1`,跑完即删)手工验过。
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn emit(key: &str) {
|
||||||
|
crate::launch::launcher_log::record("launch", &unknown_log_line(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 见上面非测试版本(测试里刻意不碰进程级日志单例)。
|
||||||
|
#[cfg(test)]
|
||||||
|
fn emit(_key: &str) {}
|
||||||
|
|
||||||
/// 按平台分隔符拼接 classpath。
|
/// 按平台分隔符拼接 classpath。
|
||||||
pub fn join_classpath(paths: &[std::path::PathBuf], separator: char) -> String {
|
pub fn join_classpath(paths: &[std::path::PathBuf], separator: char) -> String {
|
||||||
paths
|
paths
|
||||||
@@ -171,3 +334,6 @@ pub fn join_classpath(paths: &[std::path::PathBuf], separator: char) -> String {
|
|||||||
fn path_str(path: &Path) -> String {
|
fn path_str(path: &Path) -> String {
|
||||||
path.display().to_string()
|
path.display().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//! 未登记占位符的可观测性(M244 PLACEHOLDERWARN)。
|
||||||
|
//!
|
||||||
|
//! 判据四条:① 已登记键仍正常替换;② 未登记键原样保留且被收集到;③ 同一 key 只报一次;
|
||||||
|
//! ④ 大小写/空白变体按「不同的键」处理,但日志行给出笔误提示。
|
||||||
|
//!
|
||||||
|
//! 日志落盘那一环(`launcher_log::record`)刻意不在单元测试里断言:它是**进程全局**单例
|
||||||
|
//! (`launcher_log` 自己的用例要排他地改目录),在并行跑的 lib 测试里抢它是给别人制造
|
||||||
|
//! flake——实测过一次:本模块的几行把 `launcher_log` 的 `缓冲没有上限:205 行` 顶红了。
|
||||||
|
//! 所以 [`emit`](super::emit) 在测试构建里是空实现,落盘那一环用一次性探针
|
||||||
|
//! (`--test-threads=1`,跑完即删)手工验过。这里断言的是它的上游——行文本
|
||||||
|
//! ([`unknown_log_line`])、「哪些键算新」([`collect_fresh`])、「新键怎么交给 sink」
|
||||||
|
//! ([`PlaceholderMap::report_with`],用假 sink)——三段合起来就是「记什么、记几次」。
|
||||||
|
|
||||||
|
use super::{collect_fresh, near_miss, unknown_log_line, PlaceholderMap};
|
||||||
|
use crate::launch::command_builder::CommandBuilder;
|
||||||
|
|
||||||
|
fn strings(items: &[&str]) -> Vec<String> {
|
||||||
|
items.iter().map(|item| (*item).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_with_version() -> PlaceholderMap {
|
||||||
|
let mut map = PlaceholderMap::new();
|
||||||
|
map.insert("version_name", "1.20.1");
|
||||||
|
map.insert("natives_directory", "C:/mc/natives");
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ① 正例:已登记键照旧替换(嵌入串里也一样)。
|
||||||
|
#[test]
|
||||||
|
fn registered_keys_are_still_substituted() {
|
||||||
|
let map = map_with_version();
|
||||||
|
let outcome = map.render_tracked("--version ${version_name} -Dnatives=${natives_directory}");
|
||||||
|
assert_eq!(outcome.text, "--version 1.20.1 -Dnatives=C:/mc/natives");
|
||||||
|
assert!(outcome.unknown_keys.is_empty(), "{outcome:?}");
|
||||||
|
assert_eq!(map.render("a${version_name}b"), "a1.20.1b");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ② 未登记键原样保留,并且**被收集到**(这条是新增断言:改回空实现就红)。
|
||||||
|
#[test]
|
||||||
|
fn unknown_keys_are_kept_verbatim_and_collected() {
|
||||||
|
let map = map_with_version();
|
||||||
|
let outcome = map.render_tracked("--feature ${third_party_flag} ${version_name}");
|
||||||
|
assert_eq!(
|
||||||
|
outcome.text, "--feature ${third_party_flag} 1.20.1",
|
||||||
|
"未登记键必须原样保留"
|
||||||
|
);
|
||||||
|
assert_eq!(outcome.unknown_keys, strings(&["third_party_flag"]));
|
||||||
|
// 走 `render` 的那条路也会把它记进表里。
|
||||||
|
assert_eq!(map.render("${third_party_flag}"), "${third_party_flag}");
|
||||||
|
assert_eq!(map.unknown_keys(), strings(&["third_party_flag"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ② 的 argv 侧:组装出来的参数串里 `${}` 一个字节都不变(第三方清单兼容性优先)。
|
||||||
|
#[test]
|
||||||
|
fn command_builder_keeps_unknown_placeholders_in_argv() {
|
||||||
|
let mut builder = CommandBuilder::with_placeholders(map_with_version());
|
||||||
|
builder.add("--version");
|
||||||
|
builder.add("${version_name}");
|
||||||
|
builder.add("${third_party_flag}");
|
||||||
|
assert_eq!(
|
||||||
|
builder.args(),
|
||||||
|
&strings(&["--version", "1.20.1", "${third_party_flag}"])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
builder.placeholders().unknown_keys(),
|
||||||
|
strings(&["third_party_flag"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ③ 同一 key 只报一次:一次渲染内去重、跨渲染也只在「新见到」时才写日志。
|
||||||
|
#[test]
|
||||||
|
fn the_same_key_is_reported_once() {
|
||||||
|
let map = PlaceholderMap::new();
|
||||||
|
let outcome = map.render_tracked("${b}${a}${b}${a}");
|
||||||
|
assert_eq!(outcome.text, "${b}${a}${b}${a}");
|
||||||
|
assert_eq!(outcome.unknown_keys, strings(&["b", "a"]), "保序 + 去重");
|
||||||
|
|
||||||
|
// 表级累计:连着渲染两次,清单里还是这两个。
|
||||||
|
let map = PlaceholderMap::new();
|
||||||
|
assert_eq!(map.render("${b}${a}"), "${b}${a}");
|
||||||
|
assert_eq!(map.render("${a}${b}${c}"), "${a}${b}${c}");
|
||||||
|
assert_eq!(map.unknown_keys(), strings(&["b", "a", "c"]));
|
||||||
|
|
||||||
|
// 写日志的那一步:`report` 只把新键交给 `emit`(测试构建里它不碰全局日志)。
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
collect_fresh(&mut seen, &strings(&["a", "a"])),
|
||||||
|
strings(&["a"])
|
||||||
|
);
|
||||||
|
assert!(collect_fresh(&mut seen, &strings(&["a"])).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
collect_fresh(&mut seen, &strings(&["b", "a"])),
|
||||||
|
strings(&["b"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ④ 大小写/空白变体算**不同的键**(替换是精确匹配,合并会掩盖「这个串没被替换」),
|
||||||
|
/// 但日志行把近似的那一个点出来,方便定位笔误。
|
||||||
|
#[test]
|
||||||
|
fn case_and_whitespace_variants_are_distinct_keys_with_a_hint() {
|
||||||
|
let map = map_with_version();
|
||||||
|
let outcome = map.render_tracked("${version_name}|${Version_Name}|${version_name }");
|
||||||
|
assert_eq!(
|
||||||
|
outcome.text, "1.20.1|${Version_Name}|${version_name }",
|
||||||
|
"变体没被替换 ⇒ 必须原样保留(不能悄悄合并成已登记键)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.unknown_keys,
|
||||||
|
strings(&["Version_Name", "version_name "]),
|
||||||
|
"变体是各自独立的键"
|
||||||
|
);
|
||||||
|
assert_eq!(near_miss("Version_Name"), Some("version_name"));
|
||||||
|
assert_eq!(near_miss(" version_name "), Some("version_name"));
|
||||||
|
assert_eq!(near_miss("third_party_flag"), None);
|
||||||
|
|
||||||
|
let hinted = unknown_log_line("Version_Name");
|
||||||
|
assert!(hinted.contains("${Version_Name}"), "{hinted}");
|
||||||
|
assert!(hinted.contains("${version_name}"), "{hinted}");
|
||||||
|
let plain = unknown_log_line("third_party_flag");
|
||||||
|
assert!(plain.contains("${third_party_flag}"), "{plain}");
|
||||||
|
assert!(!plain.contains("笔误"), "{plain}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 替换语义没被顺手改掉的边角:不闭合的 `${` 原样留、单遍不递归。
|
||||||
|
#[test]
|
||||||
|
fn rendering_semantics_are_unchanged() {
|
||||||
|
let mut map = PlaceholderMap::new();
|
||||||
|
map.insert("a", "${b}");
|
||||||
|
map.insert("b", "boom");
|
||||||
|
assert_eq!(map.render("${a}"), "${b}", "单遍、不递归");
|
||||||
|
assert_eq!(map.render("tail ${"), "tail ${");
|
||||||
|
assert_eq!(map.render("no placeholders"), "no placeholders");
|
||||||
|
assert_eq!(
|
||||||
|
PlaceholderMap::new().render_tracked("${}").unknown_keys,
|
||||||
|
strings(&[""]),
|
||||||
|
"空键也是未登记键(原样保留)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ③ 的「真的交给了日志」这一层:`report_with` 只把**本次新**见到的键递给你的 sink。
|
||||||
|
///
|
||||||
|
/// 变异证明:把 `report_with` 里的 `for key in &fresh` 换成 `for key in unknown`(每次渲染都
|
||||||
|
/// 重复写日志)⇒ 本条红(`left: ["a", "a", …]`)——「一个键只报一次、不刷屏」被守住的地方
|
||||||
|
/// 就在这里,而不是在 `collect_fresh` 的用例里。
|
||||||
|
#[test]
|
||||||
|
fn report_emits_only_fresh_keys() {
|
||||||
|
let map = PlaceholderMap::new();
|
||||||
|
let mut delivered: Vec<String> = Vec::new();
|
||||||
|
map.report_with(&strings(&["a", "a"]), |key| delivered.push(key.to_string()));
|
||||||
|
assert_eq!(delivered, strings(&["a"]), "一次渲染内的重复只交一次");
|
||||||
|
map.report_with(&strings(&["a", "b"]), |key| delivered.push(key.to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
delivered,
|
||||||
|
strings(&["a", "b"]),
|
||||||
|
"跨渲染只有新键才交,且保序"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map.unknown_keys(),
|
||||||
|
strings(&["a", "b"]),
|
||||||
|
"表级清单与 sink 同一份"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 顺序口径与 `collect_fresh` 一致(同一份规则的两处实现不能漂)。
|
||||||
|
let mut fresh_seen = Vec::new();
|
||||||
|
assert_eq!(
|
||||||
|
collect_fresh(&mut fresh_seen, &strings(&["a", "a"])),
|
||||||
|
strings(&["a"])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
collect_fresh(&mut fresh_seen, &strings(&["a", "b"])),
|
||||||
|
strings(&["b"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日志是**行导向**的:恶意/畸形清单可以在 JSON 里塞换行,一个键不能变成两行假日志。
|
||||||
|
#[test]
|
||||||
|
fn log_lines_never_break_into_two_lines() {
|
||||||
|
let line = unknown_log_line("evil\n2026-01-01T00:00:00Z [launch] 伪造行\r");
|
||||||
|
assert_eq!(line.lines().count(), 1, "{line}");
|
||||||
|
assert!(!line.contains('\n') && !line.contains('\r'), "{line}");
|
||||||
|
assert!(
|
||||||
|
line.contains("evil 2026-01-01T00:00:00Z [launch] 伪造行"),
|
||||||
|
"{line}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user