feat(mods): 本地 Mod 列表与启停(M224 MODTOGGLE)

三层一次做完:core 读写目录 → presenter DTO/host → 实例详情页 MODS 区。
r1 复审后 rebase 到 main 71144df(M223 已合入),并修掉四条文档疵。

core(crates/core/src/addon/mods/)
- suffix:`.disabled` / `.old` 的**唯一判据**(四态枚举 + 展示名 + 双向改名目标名),
  收敛原先两份同构实现——`export/scan.rs::is_disabled_path` 与 `import/plan/paths.rs::suffix_variant`
  改为委派/再导出,签名与行为不变。
- list:单根语义(入口是 derive_game_dir 派生出的运行目录)列 `<run>/mods/`,不按扩展名过滤,
  符号链接/目录/怪名字逐条登记原因;`mods/` 本身是符号链接时拒(不跟随)。
- toggle:启停 = 在 `mods/` 里改名。五条守卫(裸文件名 / 目录可用 / 普通文件 / 不覆盖 / 不做
  copy+delete 兜底)+ 幂等;`.disabled` 只插在 `.old` 之前。

presenter
- dto/mods:ModsCommand / ModListView / ModFailureView(不拼文案,只给结构 + OS 原文)。
- host/mods:后台线程列目录与启停,运行目录走与启动同一份 D20 派生、不写任何设置;
  游戏在跑时 host 侧直接挡。
- state/mods:快照整体替换、迟到结果丢弃、在飞标记;`AppState::mods`。

app-iced(实例详情页)
- MODS 区(`pages/instances/mods.rs`):名字 + 大小 + `.old` 徽标 + Switch;运行中/在飞只读。
- 进入详情页即后台列一次(不走 3 s 观察窗,避免打红 M192 的「空闲不烧帧」守卫)。
- MODS 卡排在启动参数**之前**(详情列是滚动区,排在后面 1180×1000 下永远看不见)。
- i18n:19 条中英文案(`catalog/instances.rs` + 金表)。

rebase 冲突解法(三个文件,全是"两侧同段新增",都保留):
- `i18n/catalog/instances.rs` + `i18n/catalog/golden.rs`:M223 的 Adopt* 段在前、M224 的
  InstancesMods* 段在后(= `Key::ALL` 顺序 = 金表顺序)。
- `presenter/src/state.rs`:`adopt` 与 `mods` 字段/初始化/apply_core 分支都留;`tiles()` 归
  `state/tiles.rs`(本分支搬的),`apply_agent`/`agent_rail_visible` 归 `state/agent.rs`
  (M223 搬的)——两处搬移不重叠,state.rs 里都不再出现。

r1 四条文档疵:
1. `pages/instances/mods.rs` 模块文档:挂载位置写反("启动参数卡之后"→实际之前),并补上为什么;
2. `state/mods.rs`:引用不存在的 `CoreEvent::ModsChanged/ModsFailed` → 改成 `CoreEvent::Mods`
   + 文字说明 Ok 落快照 / Err 落具名失败;
3. `app/state/mods_shot.rs`:文档说"目录串取真实派生结果"但代码留空 → 改成"故意留空";
4. `app/state/refresh.rs::note_mods_failure`:文档说"连点两次看见两次红字"与去重机制不符 →
   改成按 `(实例, 文件, 大类)` 三元组去重(防刷屏),换文件/换实例/换大类仍会再弹。

顺带(r1 已确认的 main 红):`pages/download/options/view.rs:88-89` 两条
`clippy::doc_lazy_continuation` 只缩进修掉,文字未动。
This commit is contained in:
tower-worker committed 2026-11-10 21:09:37 +08:00
1 parent 71144dfca8
commit a0f61d8bb5
40 files changed
+2762 -91

No files matched your search

+4 -3
View File
@@ -181,9 +181,10 @@ fn relative_name(root: &Path, path: &Path) -> String {
}
/// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。
///
/// M224 收敛:判据只有 [`crate::addon::mods::suffix`] 一处(启停也要用它,见
/// `DevDocs/PlanDoc/08-mod-and-recommend.md` §3)。这里保留函数名与签名,行为逐字不变。
#[must_use]
pub fn is_disabled_path(path: &str) -> bool {
let lowered = path.trim_end().to_ascii_lowercase();
let without_old = lowered.strip_suffix(".old").unwrap_or(&lowered);
without_old.ends_with(".disabled")
crate::addon::mods::is_disabled_path(path)
}
+7 -35
View File
@@ -6,45 +6,17 @@
use super::super::report::{keys, ImportIssue};
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuffixVariant {
/// `mods/x.jar`。
Plain,
/// `mods/x.jar.disabled`(官方 Modrinth 的禁用表达)。
Disabled,
/// `mods/x.jar.old`(HMCL 生态常见:旧版本残留)。
Old,
/// `mods/x.jar.disabled.old`。
DisabledOld,
}
impl SuffixVariant {
/// 是否禁用态。
#[must_use]
pub const fn is_disabled(self) -> bool {
matches!(self, Self::Disabled | Self::DisabledOld)
}
/// 是否带 `.old`(只用于报告,不附加清理语义)。
#[must_use]
pub const fn is_old(self) -> bool {
matches!(self, Self::Old | Self::DisabledOld)
}
}
///
/// M224 收敛:类型本体只有 [`crate::addon::mods::suffix`] 一处,这里原样再导出,于是
/// `paths::SuffixVariant` 与启停用的是**同一个类型**(零行为变化,调用点不用改)。
pub use crate::addon::mods::SuffixVariant;
/// 识别四种路径后缀变体。
///
/// 委派到唯一判据(见模块文档);保留函数名与签名。
#[must_use]
pub fn suffix_variant(path: &str) -> SuffixVariant {
let lowered = path.trim_end().to_ascii_lowercase();
let stripped = lowered.strip_suffix(".old").unwrap_or(&lowered);
let old = stripped.len() != lowered.len();
let disabled = stripped.ends_with(".disabled");
match (disabled, old) {
(false, false) => SuffixVariant::Plain,
(true, false) => SuffixVariant::Disabled,
(false, true) => SuffixVariant::Old,
(true, true) => SuffixVariant::DisabledOld,
}
crate::addon::mods::suffix_variant(path)
}
/// 后缀注记(`.disabled` / `.old`):进报告,告诉用户"这个名字我们是认得的"。
+10 -1
View File
@@ -25,11 +25,16 @@
//! sha1 + sha512**;远端条目补全按 sha512 反查 sha1,补不出照旧如实计数。
//! CurseForge 需要 `x-api-key`,本轮不碰(见模块文档的「留的缝」)。
//!
//! 规划中:本地 mod 管理(D10 §3)、CurseForge 客户端(§5)。
//! - [`mods`](P2 · M224):**本地 Mod 列表与启停**——`.disabled` / `.old` 后缀的唯一判据
//! (导出扫描与导入计划两侧委派到这里)、`<实例运行目录>/mods/` 的单根列表、启停 = 在
//! `mods/` 里改名(五条守卫、幂等、不覆盖)。
//!
//! 规划中:CurseForge 客户端(§5)。
pub mod export;
pub mod import;
pub mod loader;
pub mod mods;
pub mod online;
pub mod pack;
@@ -47,6 +52,10 @@ pub use loader::{
LoaderError, LoaderErrorKind, LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease,
LoaderSource, LoaderSupport,
};
pub use mods::{
ModEntry, ModError, ModScan, ModSkip, ModToggle, SkipReason, SuffixVariant as ModSuffixVariant,
MODS_DIR,
};
pub use online::{
download_verified, ArtifactFetcher, ContentType, Downloaded, EngineArtifactFetcher,
EngineTransport, Modrinth, OnlineError, OnlineErrorKind, ProjectDetail, ProjectHit, SearchPage,
+92
View File
@@ -0,0 +1,92 @@
//! 本地 Mod 读写的失败形状(CLI/UI 据此给具名原因,不折叠成一句「失败」)。
use std::fmt;
use std::path::PathBuf;
/// 一次列目录 / 启停失败。
#[derive(Debug)]
pub enum ModError {
/// 文件名不是**裸文件名**(含分隔符 / `..` / 盘符 / NUL)。
///
/// 判据直接复用整合包侧的七条路径规则([`crate::addon::pack::path::normalize_file`]),
/// 再要求结果里没有 `/`:启停只接受 `mods/` 下**直接**的一个文件,
/// 不收相对路径——那等于让 UI 决定落到哪个子目录。
UnsafeName {
/// 调用方给的名字。
file: String,
/// 人读原因。
detail: String,
},
/// `mods` 目录本身不可用:是符号链接 / 不是目录 / 读不了。
///
/// 符号链接**不跟随**:`<run>/mods` 指到别处时,改名会落到实例目录之外。
ModsDirUnusable {
/// 那个目录。
dir: PathBuf,
/// 人读原因。
detail: String,
},
/// 要启停的文件不在 `mods/` 里。
NotFound {
/// 文件名。
file: String,
/// 找过的目录。
dir: PathBuf,
},
/// 目标存在但不是普通文件(目录 / 符号链接 / 设备)。
NotARegularFile {
/// 文件名。
file: String,
/// 人读原因。
detail: String,
},
/// 目标名字已被占用(**不覆盖**:那可能是另一个 mod)。
Collision {
/// 原名字。
file: String,
/// 想要的(已存在的)目标名。
target: String,
/// 目录。
dir: PathBuf,
},
/// 改名本身失败(占用 / 权限 / 磁盘异常)。
Rename {
/// 原名字。
file: String,
/// 目标名。
target: String,
/// 底层原因。
detail: String,
},
}
impl fmt::Display for ModError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsafeName { file, detail } => {
write!(f, "文件名不可用于启停({file:?}):{detail}")
}
Self::ModsDirUnusable { dir, detail } => {
write!(f, "mods 目录不可用({}):{detail}", dir.display())
}
Self::NotFound { file, dir } => {
write!(f, "mods 目录里没有 {file}({})", dir.display())
}
Self::NotARegularFile { file, detail } => {
write!(f, "{file} 不是普通文件:{detail}")
}
Self::Collision { file, target, dir } => write!(
f,
"{file} 改不成 {target}:同名文件已存在({})——不覆盖,请先处理那个文件",
dir.join(target).display()
),
Self::Rename {
file,
target,
detail,
} => write!(f, "{file} → {target} 改名失败:{detail}"),
}
}
}
impl std::error::Error for ModError {}
+204
View File
@@ -0,0 +1,204 @@
//! 列一个实例 `mods/` 目录(**只读**、只看这一个目录)。
//!
//! # 单根语义(D20)
//!
//! 入口是**已经派生好的运行目录**(`game::isolation::derive_game_dir` 的结果),本模块只往下
//! 取一层 `mods/`:不自己去猜游戏根、不做「隔离侧 + 共享侧合并」。派生是启动与列表**同一个**
//! 答案的唯一来源,第二处派生就是第二套真相。
//!
//! # 收哪些条目
//!
//! 与导出扫描同一条纪律:`mods/` 下**不按扩展名过滤**(`.jar` / `.jar.disabled` / `.zip` /
//! 认不出的后缀一律列出)。丢掉"我不认识的东西"正是用户点名不许的——启停开关对认不出的
//! 文件同样有意义(禁用它 = 让游戏别加载它)。
//!
//! 不收的东西只有三类,且都**逐条登记原因**(不是静默跳过):
//! 符号链接(不跟随)、非普通文件(目录 / 设备)、名字过不了路径规则([`crate::addon::pack::path`])。
use std::path::{Path, PathBuf};
use super::error::ModError;
use super::suffix::{display_name, suffix_variant, SuffixVariant};
/// `mods` 目录名(游戏与社区一致的口径)。
pub const MODS_DIR: &str = "mods";
/// 某个运行目录下的 `mods/`(不建、不检查——纯路径拼接)。
#[must_use]
pub fn mods_dir(run_dir: &Path) -> PathBuf {
run_dir.join(MODS_DIR)
}
/// 列表里的一条。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModEntry {
/// 磁盘上的文件名(含 `.disabled` / `.old` 后缀,原样)。
pub file: String,
/// 展示名(去掉两个后缀,不做 `-版本号` 猜解)。
pub name: String,
/// 是否启用(`.disabled` 缺席即启用)。
pub enabled: bool,
/// 字节数。
pub bytes: u64,
/// 后缀变体(UI 用它区分"旧残留")。
pub variant: SuffixVariant,
}
/// 没能进列表的一条。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModSkip {
/// 文件名(读不到时给能拿到的原样串)。
pub file: String,
/// 为什么没列。
pub reason: SkipReason,
}
/// 没列出来的原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
/// 符号链接(不跟随:跟随会把实例外的东西算成实例内容)。
SymbolicLink,
/// 不是普通文件(目录 / 设备 / 管道)。
NotAFile,
/// 名字过不了路径规则(含 `\`、`:`、`..` 之类)。
UnsafeName,
/// 元数据读不出来。
Unreadable,
}
/// 一次列目录的结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModScan {
/// 被列的那个 `mods/` 目录。
pub dir: PathBuf,
/// 目录存不存在(`false` = 这个实例还没装过 mod,不是错误)。
pub exists: bool,
/// 列表(按展示名排序,大小写不敏感)。
pub entries: Vec<ModEntry>,
/// 跳过的条目与原因。
pub skipped: Vec<ModSkip>,
}
impl ModScan {
/// 启用中的条数。
#[must_use]
pub fn enabled_count(&self) -> usize {
self.entries.iter().filter(|entry| entry.enabled).count()
}
/// 禁用中的条数。
#[must_use]
pub fn disabled_count(&self) -> usize {
self.entries.len() - self.enabled_count()
}
}
/// 列一个运行目录下的 `mods/`。
///
/// 目录不存在 → `exists = false` 的空结果(空实例不是错误)。
///
/// # Errors
///
/// `mods` 存在但不可用(符号链接 / 不是目录 / 读不了)时给 [`ModError::ModsDirUnusable`]。
pub fn scan(run_dir: &Path) -> Result<ModScan, ModError> {
let dir = mods_dir(run_dir);
let meta = match std::fs::symlink_metadata(&dir) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(ModScan {
dir,
exists: false,
entries: Vec::new(),
skipped: Vec::new(),
});
}
Err(error) => {
return Err(ModError::ModsDirUnusable {
dir,
detail: error.to_string(),
});
}
};
if meta.file_type().is_symlink() {
return Err(ModError::ModsDirUnusable {
dir,
detail: "是符号链接(不跟随:改名会落到实例目录之外)".to_string(),
});
}
if !meta.is_dir() {
return Err(ModError::ModsDirUnusable {
dir,
detail: "不是目录".to_string(),
});
}
let read = std::fs::read_dir(&dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
let mut children: Vec<_> =
read.collect::<Result<Vec<_>, _>>()
.map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
children.sort_by_key(std::fs::DirEntry::file_name);
let mut entries = Vec::new();
let mut skipped = Vec::new();
for child in children {
let raw = child.file_name().to_string_lossy().to_string();
let Ok(file_type) = child.file_type() else {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::Unreadable,
});
continue;
};
if file_type.is_symlink() {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::SymbolicLink,
});
continue;
}
if !file_type.is_file() {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::NotAFile,
});
continue;
}
if crate::addon::pack::path::normalize_file(&raw).is_err() || raw.contains('/') {
skipped.push(ModSkip {
file: raw,
reason: SkipReason::UnsafeName,
});
continue;
}
let bytes = child.metadata().map(|meta| meta.len()).unwrap_or(0);
entries.push(ModEntry {
name: display_name(&raw).to_string(),
enabled: !suffix_variant(&raw).is_disabled(),
variant: suffix_variant(&raw),
file: raw,
bytes,
});
}
// 展示名排序(大小写不敏感;同名时按磁盘名字典序兜底,保证两帧之间不抖)。
entries.sort_by(|left, right| {
left.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.file.cmp(&right.file))
});
skipped.sort_by(|left, right| left.file.cmp(&right.file));
Ok(ModScan {
dir,
exists: true,
entries,
skipped,
})
}
+60
View File
@@ -0,0 +1,60 @@
//! 本地 Mod 的**列表**与**启停**(M224 · MODTOGGLE)。
//!
//! 设计定稿:`DevDocs/PlanDoc/08-mod-and-recommend.md` §3(三条结论原样落地)。
//!
//! # 三层里的第一层
//!
//! §3 的结论是「Mod 启停 = 新建三层(core 读写目录 + presenter DTO + 实例详情页),不是给
//! 内容页接一个开关」,并特别点名**不要**给内容页加第五个 `ContentKind`(会打断 4 处穷尽 match)。
//! 本模块就是那第一层,另外两层见 `crates/presenter/src/dto/mods.rs` 与
//! `crates/app-iced/src/pages/instances/mods.rs`。
//!
//! # 唯一 `.disabled` 判据
//!
//! 收敛前仓里有两份逐字同构的后缀判据(`addon::export::scan::is_disabled_path` 与
//! `addon::import::plan::paths::suffix_variant`)。M218 的要求是「做这块时第一步先收敛成一份,
//! 别让第三份出生」,所以判据只有 [`suffix`] 一处,导出/导入两侧改为**委派**(签名与行为不变,
//! 收敛 diff 为零行为变化)。启停要改写文件名,比"判一下"多两件事——保住原始大小写、保住
//! `.old` 的位置——这两件也只能和判据同源,否则会出现「判禁用用一套、改名字用另一套」。
//!
//! # 单根语义(D20)
//!
//! 入口一律是**派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
//! 本模块只碰 `<run_dir>/mods/`:
//!
//! - 不自己去猜游戏根,也不做「隔离侧 `versions/<id>/mods` + 共享侧 `.minecraft/mods` 合并」
//! 这种双根视图——派生是启动与列表**同一个**答案的唯一来源;
//! - 只往下取一层:`mods/` 下的子目录不递归(游戏也不递归);
//! - `mods/` 本身是符号链接时**拒绝**(不跟随),否则改名会落到实例目录之外。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`suffix`] | `.disabled` / `.old` 的唯一判据与改名目标名(含四态枚举) |
//! | [`list`] | 列 `<run_dir>/mods/`(只读、逐条登记跳过原因) |
//! | [`toggle`] | 启停 = 在 `mods/` 里改名(五条守卫、幂等、不覆盖) |
//!
//! # 已知边界
//!
//! - **不判断游戏是否在跑**:进程正持有这些 jar(Windows 上 rename 会失败),失败按
//! [`error::ModError::Rename`] 如实报出来。要不要在上层挡「运行中不许改」是宿主层的策略
//! (presenter 侧已挡,见 `host::mods`)。
//! - **不解析 jar**:列表给的是文件名,`-版本号` 猜解仍归 `diagnose::environment::ModRecord`,
//! 这里不复制第二份启发式。
mod error;
mod list;
mod suffix;
mod toggle;
pub use error::ModError;
pub use list::{mods_dir, scan, ModEntry, ModScan, ModSkip, SkipReason, MODS_DIR};
pub use suffix::{
disabled_name, display_name, enabled_name, is_disabled_path, renamed_for, suffix_variant,
SuffixVariant, DISABLED_SUFFIX, OLD_SUFFIX,
};
pub use toggle::{ensure_dir, set_enabled, ModToggle};
#[cfg(test)]
mod tests;
+263
View File
@@ -0,0 +1,263 @@
//! `.disabled` / `.old` 后缀的**唯一判据**(M224 · MODTOGGLE)。
//!
//! # 为什么这里是一份而不是三份
//!
//! 收敛前仓里有两份同构实现,各自 lowercase → 剥 `.old` → `ends_with(".disabled")`:
//!
//! | 位置 | 形状 | 用途 |
//! |---|---|---|
//! | `addon::export::scan::is_disabled_path` | 只给 bool | 导出扫描判禁用位 |
//! | `addon::import::plan::paths::suffix_variant` | 四态枚举 | 导入计划打注记 |
//!
//! 两份逐字同构,而 M218 的结论是「做启停时第一步先收敛成一份,别让第三份出生」
//! (`DevDocs/PlanDoc/08-mod-and-recommend.md` §3)。启停要**改写**文件名,比"判一下"多两件事:
//! ①必须保住原始大小写(`X.JAR.DISABLED` 禁用后不能变全小写);②必须保住 `.old` 的位置
//! (`.disabled` 只能出现在 `.old` **之前**,否则 [`suffix_variant`] 认不出来)。
//!
//! 所以本模块是唯一出口:判据([`suffix_variant`] / [`is_disabled_path`])与改写目标名
//! ([`renamed_for`])用**同一套剥离逻辑**,不会出现「判禁用用一套、改名字用另一套」。
//! 导出与导入两侧改为委派到本模块(函数名与签名保持不变)。
//!
//! # 认哪些形状(写死四种)
//!
//! | 磁盘上的名字 | 变体 | 禁用 | `.old` |
//! |---|---|---|---|
//! | `x.jar` | [`SuffixVariant::Plain`] | ❌ | ❌ |
//! | `x.jar.disabled` | [`SuffixVariant::Disabled`] | ✅ | ❌ |
//! | `x.jar.old` | [`SuffixVariant::Old`] | ❌ | ✅ |
//! | `x.jar.disabled.old` | [`SuffixVariant::DisabledOld`] | ✅ | ✅ |
//!
//! 大小写不敏感(Windows 上 `X.JAR.DISABLED` 与 `x.jar.disabled` 是同一个文件);`.old` 只
//! 识别与保留(不清除、不改名——它是 HMCL 生态的旧版本残留,删掉等于替用户做决定)。
/// 禁用位的路径后缀(官方 Modrinth 导出的表达,也是唯一跨启动器保真的那条)。
pub const DISABLED_SUFFIX: &str = ".disabled";
/// 旧版本残留的路径后缀(只识别与保留)。
pub const OLD_SUFFIX: &str = ".old";
/// 路径后缀变体(`.disabled` / `.old` 的四种组合)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SuffixVariant {
/// `mods/x.jar`。
Plain,
/// `mods/x.jar.disabled`(官方 Modrinth 的禁用表达)。
Disabled,
/// `mods/x.jar.old`(HMCL 生态常见:旧版本残留)。
Old,
/// `mods/x.jar.disabled.old`。
DisabledOld,
}
impl SuffixVariant {
/// 是否禁用态。
#[must_use]
pub const fn is_disabled(self) -> bool {
matches!(self, Self::Disabled | Self::DisabledOld)
}
/// 是否带 `.old`(只用于报告,不附加清理语义)。
#[must_use]
pub const fn is_old(self) -> bool {
matches!(self, Self::Old | Self::DisabledOld)
}
}
/// 识别四种路径后缀变体。
#[must_use]
pub fn suffix_variant(path: &str) -> SuffixVariant {
let lowered = path.trim_end().to_ascii_lowercase();
let stripped = lowered.strip_suffix(OLD_SUFFIX).unwrap_or(&lowered);
let old = stripped.len() != lowered.len();
let disabled = stripped.ends_with(DISABLED_SUFFIX);
match (disabled, old) {
(false, false) => SuffixVariant::Plain,
(true, false) => SuffixVariant::Disabled,
(false, true) => SuffixVariant::Old,
(true, true) => SuffixVariant::DisabledOld,
}
}
/// 路径后缀是否带 `.disabled`(允许 `x.jar.disabled.old` 这种"禁用 + 旧残留"组合)。
#[must_use]
pub fn is_disabled_path(path: &str) -> bool {
suffix_variant(path).is_disabled()
}
/// `enabled = true` 时该改成什么名字(`None` = 已经是启用态,不用动)。
///
/// `x.jar.disabled` → `x.jar`;`x.jar.disabled.old` → `x.jar.old`(`.old` 原位保留)。
/// 原始大小写按**逐字符比较**剥掉后缀,所以 `X.JAR.DISABLED` → `X.JAR`,不会被整串小写化。
#[must_use]
pub fn enabled_name(name: &str) -> Option<String> {
let trimmed = name.trim_end();
// **先剥 `.old` 再剥 `.disabled`**:反过来会漏 `x.jar.disabled.old`(结尾是 `.old`,
// 直接找结尾的 `.disabled` 找不到 ⇒ 明明禁用着却说"已是启用态")。这一条由
// `toggling_round_trips_every_variant` / `enabling_only_strips_the_disabled_suffix` 钉住。
let (body, old) = match strip_ascii_ci(trimmed, OLD_SUFFIX) {
Some(base) => (base, Some(&trimmed[base.len()..])),
None => (trimmed, None),
};
let stripped = strip_ascii_ci(body, DISABLED_SUFFIX)?;
// 剥完只剩空串或只剩 `.old` 都不是文件名(`.disabled` / `.old.disabled` 这种畸形)。
if stripped.is_empty() || stripped.eq_ignore_ascii_case(OLD_SUFFIX) {
return None;
}
Some(match old {
Some(old) => format!("{stripped}{old}"),
None => stripped.to_string(),
})
}
/// `enabled = false` 时该改成什么名字(`None` = 已经是禁用态,不用动)。
///
/// `x.jar` → `x.jar.disabled`;`x.jar.old` → `x.jar.disabled.old`——`.disabled` **插在** `.old`
/// 之前,否则 [`suffix_variant`] 读回来是 `Old`("有旧残留但没禁用"),禁用位就丢了。
#[must_use]
pub fn disabled_name(name: &str) -> Option<String> {
let trimmed = name.trim_end();
if trimmed.is_empty() {
return None;
}
if suffix_variant(trimmed).is_disabled() {
return None;
}
// 只认原串里那个 `.old`(大小写可能不同),按字节位置插进去,保住其余部分原样。
if let Some(base) = strip_ascii_ci(trimmed, OLD_SUFFIX) {
if base.is_empty() {
return None;
}
return Some(format!("{base}{DISABLED_SUFFIX}{OLD_SUFFIX}"));
}
Some(format!("{trimmed}{DISABLED_SUFFIX}"))
}
/// 启停后应当落成的文件名(`None` = 已经是这个状态,无需 rename)。
#[must_use]
pub fn renamed_for(name: &str, enabled: bool) -> Option<String> {
if enabled {
enabled_name(name)
} else {
disabled_name(name)
}
}
/// 展示名(去掉 `.disabled` / `.old` 两个后缀,其余原样)。
///
/// **不**做 `-版本号` 的猜解:那是 `diagnose::environment::ModRecord` 的活,这里再猜一次就是
/// 第二份启发式。列表要的是"这个文件叫什么",不是"它可能是哪个 mod"。
#[must_use]
pub fn display_name(name: &str) -> &str {
let trimmed = name.trim_end();
// 与 [`enabled_name`] 同一个顺序:先 `.old`、再 `.disabled`(`x.jar.disabled.old` 两个都要剥)。
let without_old = strip_ascii_ci(trimmed, OLD_SUFFIX).unwrap_or(trimmed);
strip_ascii_ci(without_old, DISABLED_SUFFIX).unwrap_or(without_old)
}
/// 去掉**结尾**的 ASCII 后缀(大小写不敏感),保住前半段原始大小写。
fn strip_ascii_ci<'a>(text: &'a str, suffix: &str) -> Option<&'a str> {
let cut = text.len().checked_sub(suffix.len())?;
if !text.is_char_boundary(cut) {
return None;
}
text.get(cut..)
.filter(|tail| tail.eq_ignore_ascii_case(suffix))
.map(|_| &text[..cut])
}
#[cfg(test)]
mod tests {
use super::*;
/// 四种形状都认得,且禁用位只看 `.disabled`(`.old` 单独出现不是禁用)。
#[test]
fn recognises_four_variants() {
assert_eq!(suffix_variant("mods/x.jar"), SuffixVariant::Plain);
assert_eq!(
suffix_variant("mods/x.jar.disabled"),
SuffixVariant::Disabled
);
assert_eq!(suffix_variant("mods/x.jar.old"), SuffixVariant::Old);
assert_eq!(
suffix_variant("mods/x.jar.disabled.old"),
SuffixVariant::DisabledOld
);
assert!(is_disabled_path("mods/x.jar.disabled"));
assert!(is_disabled_path("mods/x.jar.disabled.old"));
assert!(!is_disabled_path("mods/x.jar.old"));
assert!(!is_disabled_path("mods/x.jar"));
}
/// 大小写不敏感(Windows 上 `X.JAR.DISABLED` 就是禁用),尾随空白不算名字的一部分。
#[test]
fn suffix_matching_ignores_ascii_case() {
assert_eq!(suffix_variant("X.JAR.DISABLED"), SuffixVariant::Disabled);
assert_eq!(suffix_variant("X.Disabled.Old"), SuffixVariant::DisabledOld);
assert_eq!(suffix_variant("x.jar.disabled \n"), SuffixVariant::Disabled);
// 不是子串匹配:`.disabled` 必须真在结尾。
assert_eq!(suffix_variant("x.disabled.jar"), SuffixVariant::Plain);
assert_eq!(suffix_variant("x.jar.notdisabled"), SuffixVariant::Plain);
}
/// 启用 = 剥 `.disabled`,`.old` 原位保留,**原始大小写不动**。
#[test]
fn enabling_only_strips_the_disabled_suffix() {
assert_eq!(enabled_name("x.jar.disabled").as_deref(), Some("x.jar"));
assert_eq!(
enabled_name("x.jar.disabled.old").as_deref(),
Some("x.jar.old")
);
assert_eq!(enabled_name("X.JAR.DISABLED").as_deref(), Some("X.JAR"));
// 已经是启用态 / 只剩畸形后缀 ⇒ None(不猜、不改)。
assert_eq!(enabled_name("x.jar"), None);
assert_eq!(enabled_name(".disabled"), None);
assert_eq!(enabled_name(".old.disabled"), None);
}
/// 禁用 = 把 `.disabled` **插在** `.old` 之前(插在后面判据读不回来)。
#[test]
fn disabling_inserts_before_the_old_suffix() {
assert_eq!(disabled_name("x.jar").as_deref(), Some("x.jar.disabled"));
assert_eq!(
disabled_name("x.jar.old").as_deref(),
Some("x.jar.disabled.old")
);
assert_eq!(
disabled_name("X.JAR.OLD").as_deref(),
Some("X.JAR.disabled.old")
);
assert_eq!(disabled_name("x.jar.disabled"), None);
assert_eq!(disabled_name("x.jar.disabled.old"), None);
assert_eq!(disabled_name(""), None);
assert_eq!(disabled_name(".old"), None);
}
/// 两个方向互为逆(同一条剥离逻辑的自证):往返回来名字一模一样。
#[test]
fn toggling_round_trips_every_variant() {
for plain in ["x.jar", "X.JAR", "风.jar", "a.b.c"] {
let off = renamed_for(plain, false).expect("禁用目标名");
assert_eq!(suffix_variant(&off), SuffixVariant::Disabled);
assert_eq!(renamed_for(&off, true).as_deref(), Some(plain));
}
// `.old` 变体往返也不掉 `.old`。
let off = renamed_for("x.jar.old", false).expect("禁用目标名");
assert_eq!(off, "x.jar.disabled.old");
assert_eq!(renamed_for(&off, true).as_deref(), Some("x.jar.old"));
// 已经在目标状态 ⇒ None(幂等,不产生多余 rename)。
assert_eq!(renamed_for("x.jar", true), None);
assert_eq!(renamed_for("x.jar.disabled", false), None);
}
/// 展示名剥两个后缀,但**不**猜 `-版本号`。
#[test]
fn display_name_strips_suffixes_only() {
assert_eq!(
display_name("sodium-0.5.8.jar.disabled"),
"sodium-0.5.8.jar"
);
assert_eq!(display_name("x.jar.disabled.old"), "x.jar");
assert_eq!(display_name("jei.jar"), "jei.jar");
assert_eq!(display_name("X.JAR.DISABLED"), "X.JAR");
}
}
+254
View File
@@ -0,0 +1,254 @@
//! 列表与启停的用例:**真的建盘上文件**再读回来(不 mock 文件系统)。
//!
//! 每条用例都写在「抓得住什么 / 抓不住什么」的口径里:抓得住的是判据与守卫本身,
//! 抓不住的是 Windows 与 Linux 上 rename 的细节差异(那条靠真实运行,见 handoff)。
use std::path::Path;
use super::suffix::SuffixVariant;
use super::*;
use crate::addon::temp::TempRoot;
/// 建一个运行目录 + `mods/`,并把给定文件写进去。
fn fixture(tag: &str, files: &[(&str, &[u8])]) -> TempRoot {
let root = TempRoot::under(tag, "run");
let mods = mods_dir(&root);
std::fs::create_dir_all(&mods).expect("建 mods");
for (name, body) in files {
std::fs::write(mods.join(name), body).expect("写 mod 文件");
}
root
}
/// 列目录:四种后缀变体都认、禁用位只看 `.disabled`、目录不进列表。
#[test]
fn scan_lists_every_variant_and_skips_directories() {
let root = fixture(
"mods-scan",
&[
("sodium.jar", b"a"),
("jei.jar.disabled", b"bb"),
("opt.jar.old", b"ccc"),
("aether.jar.disabled.old", b"dddd"),
("notes.txt", b"whatever"),
],
);
std::fs::create_dir(root.join("mods/sub")).expect("建子目录");
let scan = scan(&root).expect("列目录");
assert!(scan.exists);
assert_eq!(scan.dir, root.join("mods"));
let files: Vec<&str> = scan
.entries
.iter()
.map(|entry| entry.file.as_str())
.collect();
assert_eq!(
files,
[
"aether.jar.disabled.old",
"jei.jar.disabled",
"notes.txt",
"opt.jar.old",
"sodium.jar"
]
);
let entry = |file: &str| {
scan.entries
.iter()
.find(|entry| entry.file == file)
.unwrap_or_else(|| panic!("{file} 不在列表里"))
};
assert!(entry("sodium.jar").enabled);
assert!(!entry("jei.jar.disabled").enabled);
assert!(entry("opt.jar.old").enabled, "`.old` 单独出现不是禁用");
assert!(!entry("aether.jar.disabled.old").enabled);
// 展示名剥两个后缀(不做 -版本号 猜解)。
assert_eq!(entry("aether.jar.disabled.old").name, "aether.jar");
assert_eq!(entry("sodium.jar").variant, SuffixVariant::Plain);
assert_eq!(entry("jei.jar.disabled").bytes, 2);
assert_eq!(scan.skipped.len(), 1);
assert_eq!(scan.skipped[0].file, "sub");
assert_eq!(scan.skipped[0].reason, SkipReason::NotAFile);
assert_eq!(scan.enabled_count(), 3);
assert_eq!(scan.disabled_count(), 2);
}
/// 空实例(没有 `mods/`)不是错误:`exists = false` 的空结果。
#[test]
fn scan_treats_a_missing_mods_dir_as_empty() {
let root = TempRoot::under("mods-empty", "run");
let scan = scan(&root).expect("列目录");
assert!(!scan.exists);
assert!(scan.entries.is_empty());
assert!(scan.skipped.is_empty());
}
/// `mods` 是符号链接就拒(不跟随)。Windows 上建目录链接要特权,建不出来就跳过这一条。
#[test]
fn scan_refuses_a_symlinked_mods_dir() {
let root = TempRoot::under("mods-link", "run");
let outside = root.join("elsewhere");
std::fs::create_dir_all(&outside).expect("建外部目录");
std::fs::write(outside.join("x.jar"), b"x").expect("写外部文件");
let linked = link_dir(&outside, &mods_dir(&root));
if !linked {
eprintln!("[skip] 本机建不出目录符号链接(Windows 需要开发者模式/特权)");
return;
}
let error = scan(&root).expect_err("必须拒绝符号链接");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
// 启停同样拒(同一条守卫,不另记一遍)。
let error = set_enabled(&root, "x.jar", false).expect_err("必须拒绝符号链接");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
}
/// 启停往返:改名真的发生、读回来状态对、重复点幂等。
#[test]
fn toggling_renames_on_disk_and_is_idempotent() {
let root = fixture("mods-toggle", &[("sodium.jar", b"jar")]);
let off = set_enabled(&root, "sodium.jar", false).expect("禁用");
assert!(off.renamed);
assert_eq!(off.to, "sodium.jar.disabled");
assert!(!off.enabled);
assert!(root.join("mods/sodium.jar.disabled").is_file());
assert!(!root.join("mods/sodium.jar").exists());
// 再禁一次:不是错误,也不改名。
let again = set_enabled(&root, "sodium.jar.disabled", false).expect("幂等");
assert!(!again.renamed);
assert_eq!(again.from, again.to);
assert!(!again.enabled);
// 启用回来:名字与字节都回到原样。
let on = set_enabled(&root, "sodium.jar.disabled", true).expect("启用");
assert!(on.renamed);
assert_eq!(on.to, "sodium.jar");
assert!(on.enabled);
assert_eq!(
std::fs::read(root.join("mods/sodium.jar")).expect("读回"),
b"jar"
);
let scan = scan(&root).expect("列目录");
assert_eq!(scan.entries.len(), 1);
assert!(scan.entries[0].enabled);
}
/// `.disabled` 只能插在 `.old` **之前**,否则读回来丢禁用位(往返用例抓的就是这个)。
#[test]
fn toggling_keeps_the_old_suffix_in_place() {
let root = fixture("mods-old", &[("jei.jar.old", b"old")]);
let off = set_enabled(&root, "jei.jar.old", false).expect("禁用");
assert_eq!(off.to, "jei.jar.disabled.old");
let listed = scan(&root).expect("列目录");
assert_eq!(listed.entries[0].variant, SuffixVariant::DisabledOld);
assert!(!listed.entries[0].enabled, "禁用位被 `.old` 吃掉了");
let on = set_enabled(&root, "jei.jar.disabled.old", true).expect("启用");
assert_eq!(on.to, "jei.jar.old");
assert_eq!(
scan(&root).expect("列目录").entries[0].variant,
SuffixVariant::Old
);
}
/// 目标名已被占用 ⇒ 报冲突,**不覆盖**那个文件。
#[test]
fn toggling_refuses_to_overwrite_an_existing_file() {
let root = fixture(
"mods-collision",
&[("x.jar", b"one"), ("x.jar.disabled", b"two")],
);
let error = set_enabled(&root, "x.jar", false).expect_err("必须报冲突");
assert!(matches!(error, ModError::Collision { .. }), "{error}");
// 两个文件都还在、内容没被踩。
assert_eq!(
std::fs::read(root.join("mods/x.jar")).expect("读 a"),
b"one"
);
assert_eq!(
std::fs::read(root.join("mods/x.jar.disabled")).expect("读 b"),
b"two"
);
}
/// 路径类名字一律拒:启停只接受 `mods/` 下的裸文件名。
#[test]
fn toggling_rejects_anything_that_is_not_a_bare_file_name() {
let root = fixture("mods-names", &[("x.jar", b"x")]);
for bad in [
"",
"../x.jar",
"mods/x.jar",
"sub/x.jar",
r"mods\x.jar",
"C:/x.jar",
".hidden/../x.jar",
] {
let error = set_enabled(&root, bad, false)
.err()
.unwrap_or_else(|| panic!("{bad:?} 必须被拒"));
assert!(
matches!(
error,
ModError::UnsafeName { .. } | ModError::NotFound { .. }
),
"{bad:?} → {error}"
);
}
// 越界改名一次都没发生。
assert!(root.join("mods/x.jar").is_file());
}
/// 不存在的文件、目录、符号链接都不是"可启停的 mod"。
#[test]
fn toggling_refuses_missing_and_non_file_targets() {
let root = fixture("mods-targets", &[("x.jar", b"x")]);
std::fs::create_dir(root.join("mods/adir")).expect("建目录");
let missing = set_enabled(&root, "nope.jar", false).expect_err("不存在的必须报");
assert!(matches!(missing, ModError::NotFound { .. }), "{missing}");
let dir = set_enabled(&root, "adir", false).expect_err("目录必须报");
assert!(matches!(dir, ModError::NotARegularFile { .. }), "{dir}");
assert!(root.join("mods/adir").is_dir(), "目录被改名了");
}
/// 没有 `mods/` 时启停报「目录不可用」(不替用户建目录——建目录是装 mod 的事)。
#[test]
fn toggling_requires_an_existing_mods_dir() {
let root = TempRoot::under("mods-nodir", "run");
let error = set_enabled(&root, "x.jar", false).expect_err("没有 mods 目录");
assert!(matches!(error, ModError::ModsDirUnusable { .. }), "{error}");
assert!(!root.join("mods").exists(), "启停不该顺手建目录");
// `ensure_dir` 才是建目录那条路(装 mod 用)。
ensure_dir(&root).expect("建 mods");
assert!(root.join("mods").is_dir());
}
/// 建目录链接:Windows 上可能没特权,返回是否建成(用例据此决定跳不跳)。
fn link_dir(target: &Path, link: &Path) -> bool {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link).is_ok()
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_dir(target, link).is_ok()
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
false
}
}
+166
View File
@@ -0,0 +1,166 @@
//! 启停一个 mod:**在 `mods/` 里改名**(`.disabled` 后缀就是禁用位)。
//!
//! # 为什么是改名而不是写配置文件
//!
//! 游戏本身不读任何"禁用列表"——它只加载 `mods/*.jar`。`.disabled` 后缀让文件**不再匹配**
//! 加载器的 glob,于是"禁用"是**文件系统状态**:换启动器、换机器、手动 `git checkout` 之后
//! 依然成立。这也是 Modrinth 官方导出表达禁用位的唯一方式(见
//! [`crate::addon::pack::manifest::PackFile::disabled`])。
//!
//! # 五条守卫(都不猜、都不覆盖)
//!
//! | 守卫 | 理由 |
//! |---|---|
//! | 只收**裸文件名**(过七条路径规则且不含 `/`) | 启停不接受相对路径,否则 UI 能指挥落点 |
//! | `mods/` 是符号链接就拒 | 改名会落到实例目录之外 |
//! | 目标必须是**普通文件**(非目录 / 非链接) | 改一个目录或链接不是"启停一个 mod" |
//! | 目标名已存在就拒([`ModError::Collision`]) | 覆盖掉的是**另一个 mod**,用户没同意 |
//! | rename 不做跨盘兜底(不 copy+delete) | copy 半截失败会留下两个文件,语义比报错更差 |
//!
//! # 幂等
//!
//! 「已经启用还要启用」不是错误:返回 `renamed = false` 的成功结果(UI 的开关重复点不会
//! 变成红字)。这一条由 [`super::suffix::renamed_for`] 的 `None` 表达。
use std::path::{Path, PathBuf};
use super::error::ModError;
use super::list::mods_dir;
use super::suffix::{is_disabled_path, renamed_for};
/// 一次启停的结果(`from == to` 且 `renamed == false` ⇒ 本来就是那个状态)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModToggle {
/// 原文件名的磁盘名(含后缀)。
pub from: String,
/// 改名后的磁盘名(没改名时与 `from` 相同)。
pub to: String,
/// 这一步之后的启用状态。
pub enabled: bool,
/// 是否真的在磁盘上改了名。
pub renamed: bool,
}
/// 把 `mods/` 下的某个文件切到 `enabled` 状态(改名)。
///
/// # Errors
///
/// 见模块文档的五条守卫:[`ModError::UnsafeName`] / [`ModError::ModsDirUnusable`] /
/// [`ModError::NotFound`] / [`ModError::NotARegularFile`] / [`ModError::Collision`] /
/// [`ModError::Rename`]。
pub fn set_enabled(run_dir: &Path, file: &str, enabled: bool) -> Result<ModToggle, ModError> {
let name = bare_name(file)?;
let dir = mods_dir(run_dir);
usable_dir(&dir)?;
let from_path = dir.join(&name);
let meta = std::fs::symlink_metadata(&from_path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
ModError::NotFound {
file: name.clone(),
dir: dir.clone(),
}
} else {
ModError::NotARegularFile {
file: name.clone(),
detail: error.to_string(),
}
}
})?;
if meta.file_type().is_symlink() || !meta.is_file() {
return Err(ModError::NotARegularFile {
file: name.clone(),
detail: "不是普通文件(符号链接 / 目录 / 设备都不动)".to_string(),
});
}
let Some(target) = renamed_for(&name, enabled) else {
// 已经是目标状态:幂等返回,不产生多余 rename(Windows 上同大小写改名还可能失败)。
return Ok(ModToggle {
enabled: !is_disabled_path(&name),
from: name.clone(),
to: name,
renamed: false,
});
};
let to_path = dir.join(&target);
if std::fs::symlink_metadata(&to_path).is_ok() {
return Err(ModError::Collision {
file: name,
target,
dir,
});
}
std::fs::rename(&from_path, &to_path).map_err(|error| ModError::Rename {
file: name.clone(),
target: target.clone(),
detail: error.to_string(),
})?;
Ok(ModToggle {
from: name,
to: target,
enabled,
renamed: true,
})
}
/// 文件名必须是**裸文件名**:先过整合包侧的七条路径规则,再要求没有 `/`。
fn bare_name(file: &str) -> Result<String, ModError> {
let trimmed = file.trim();
let normalized = crate::addon::pack::path::normalize_file(trimmed).map_err(|error| {
ModError::UnsafeName {
file: file.to_string(),
detail: error.to_string(),
}
})?;
if normalized.contains('/') || normalized != trimmed {
return Err(ModError::UnsafeName {
file: file.to_string(),
detail: "只接受 mods/ 下的裸文件名(不含路径分隔符、不做归一)".to_string(),
});
}
Ok(normalized)
}
/// `mods/` 必须存在、是目录、且**不是符号链接**。
fn usable_dir(dir: &Path) -> Result<(), ModError> {
let meta = std::fs::symlink_metadata(dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: if error.kind() == std::io::ErrorKind::NotFound {
"不存在(这个实例还没有 mods 目录)".to_string()
} else {
error.to_string()
},
})?;
if meta.file_type().is_symlink() {
return Err(ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: "是符号链接(不跟随:改名会落到实例目录之外)".to_string(),
});
}
if !meta.is_dir() {
return Err(ModError::ModsDirUnusable {
dir: dir.to_path_buf(),
detail: "不是目录".to_string(),
});
}
Ok(())
}
/// 建 `mods/` 目录(**只在装 mod 时用**;列目录/启停不建)。
///
/// # Errors
///
/// 建不出来 / 建出来是符号链接以外的怪形状时给 [`ModError::ModsDirUnusable`]。
pub fn ensure_dir(run_dir: &Path) -> Result<PathBuf, ModError> {
let dir = mods_dir(run_dir);
std::fs::create_dir_all(&dir).map_err(|error| ModError::ModsDirUnusable {
dir: dir.clone(),
detail: error.to_string(),
})?;
usable_dir(&dir)?;
Ok(dir)
}