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:
1 parent
71144dfca8
commit
a0f61d8bb5
40 files changed
+2762
-91
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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`):进报告,告诉用户"这个名字我们是认得的"。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user