pmpx 0.0.0

One command surface for a project's package managers. Detects the project type, routes the verb, and spawns the real tool (cargo, pnpm, ...).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
//! 配置:全局一份,项目里可叠多层。
//!
//! # 两种配置,两种性质(设计文档 7.1 / 7.2)
//!
//! | | 全局 | 项目 |
//! | --- | --- | --- |
//! | 位置 | `<pmpx-config-dir>/config.toml`(7.4 的路径表) | 每个项目里的 `.pmpx.toml` |
//! | 份数 | 唯一一份 | **可有多份**,从 cwd 向上逐层收集 |
//! | 进 git | ❌ 个人偏好 | ✅ 应当提交 |
//! | 谁写 | `pmpx config set` | `pmpx plugin set/unset` |
//!
//! # 为什么项目配置要"收集"而不是"找一份"
//!
//! 项目根是"在哪执行"的唯一答案(必须唯一),配置是"按什么规则执行"的叠加层
//! (天然可分层)。所以 5.7 把它们拆成**两遍独立的 walk**。
//! 混在一起就会出现「同一份根配置在两种 cwd 下行为不同」,
//! 那违反"同一目录、同一配置、结果必然一致"。
//!
//! 合并规则:**近者优先**。实现上是"从最远的开始铺,越近的越后写" ——
//! 后者自然覆盖前者。

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// 路径(7.4)
// ---------------------------------------------------------------------------

/// 覆盖全局配置目录的环境变量。
///
/// 给**测试与多环境**用:没有它,pmpx 的 e2e 测试只能去读写用户真实的
/// `~/.config/pmpx`,那是不可接受的。与 `CARGO_HOME` / `RUSTUP_HOME` 同一类东西。
pub const ENV_CONFIG_DIR: &str = "PMPX_CONFIG_DIR";

/// 覆盖插件数据目录的环境变量。理由同上。
pub const ENV_DATA_DIR: &str = "PMPX_DATA_DIR";

/// 全局配置文件路径。
///
/// **不硬编码 `~/.config`** —— macOS 是 `~/Library/Application Support/pmpx/`,
/// Windows 是 `%APPDATA%\pmpx\`。`directories` 知道正确的答案。
///
/// 可以用 [`ENV_CONFIG_DIR`] 整体覆盖(那是个**目录**,不是文件)。
pub fn global_config_path() -> Result<PathBuf> {
    if let Some(dir) = std::env::var_os(ENV_CONFIG_DIR) {
        return Ok(PathBuf::from(dir).join("config.toml"));
    }
    let dirs = directories::ProjectDirs::from("", "", "pmpx")
        .context("拿不到配置目录(既没有 HOME 也没有 APPDATA?)")?;
    Ok(dirs.config_dir().join("config.toml"))
}

/// 插件数据目录:`~/.pmpx`。
///
/// ⚠️ **三个平台都是这一个位置**,不是 `directories` 的 data_dir。
/// 这跟全局配置不一样,是刻意的:插件目录要出现在用户手边
/// (`ls ~/.pmpx/plugins`),而 XDG / AppData 那套位置在三个平台上都不一样。
///
/// 这个值**显式交给 `crate-plugin-kit`**(`KitConfig::with_data_dir`),
/// 而不是让它用自己的默认值 —— 否则"pmpx 显示的位置"与"kit 实际用的位置"
/// 会成为两处独立实现,迟早对不上。
///
/// 可以用 [`ENV_DATA_DIR`] 覆盖。
pub fn default_data_dir() -> Result<PathBuf> {
    if let Some(dir) = std::env::var_os(ENV_DATA_DIR) {
        return Ok(PathBuf::from(dir));
    }
    let dirs =
        directories::UserDirs::new().context("拿不到用户目录(HOME / USERPROFILE 都没设?)")?;
    Ok(dirs.home_dir().join(".pmpx"))
}

/// 把开头的 `~` 展开成用户主目录。
///
/// 只在配置里用户手写的路径上用。不处理 `~user` 那种形式 —— 那在 Windows 上没有意义。
pub fn expand_tilde(raw: &str) -> PathBuf {
    let rest = raw
        .strip_prefix("~/")
        .or_else(|| raw.strip_prefix("~\\"))
        .or_else(|| if raw == "~" { Some("") } else { None });

    match rest {
        Some(rest) => match directories::UserDirs::new() {
            Some(dirs) => dirs.home_dir().join(rest),
            None => PathBuf::from(raw),
        },
        None => PathBuf::from(raw),
    }
}

// ---------------------------------------------------------------------------
// 全局配置(7.1)
// ---------------------------------------------------------------------------

/// `<pmpx-config-dir>/config.toml`。
///
/// 读一次、在启动时读完 —— 它是个小 TOML,而且 `[discovery]` 影响**所有**命令,
/// 没有"按需读"的空间。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GlobalConfig {
    /// 插件相关的次序表。
    pub plugin: GlobalPluginConfig,
    /// 项目根发现的行为。
    pub discovery: DiscoveryConfig,
    /// 插件库的位置与安装偏好。
    pub plugin_store: PluginStoreConfig,

    /// 不认识的键**原样保留**。
    ///
    /// `pmpx config set` 是读-改-写,如果这里丢了未知键,用户手写的注释性配置
    /// 就会被静默吃掉。保留它比"严格 schema"更重要。
    #[serde(flatten)]
    pub extra: toml::Table,
}

/// 跨生态与生态内的次序表。**这是"混合项目默认走 Node"的唯一来源。**
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GlobalPluginConfig {
    /// 生态之间的次序:**跨生态同分时**裁决。越靠前越优先。
    ///
    /// 未列出的位于所有列出的之后,按名字典序。这与"不认识的生态也能被裁决"
    /// 是一致的 —— `Family` 是开放类型(决策 44)。
    pub family_priority: Vec<String>,

    /// 同一生态内插件的次序:**同分时**裁决。
    pub priority: Vec<String>,
}

impl Default for GlobalPluginConfig {
    fn default() -> Self {
        // 默认值刻意放在这里而不是散在文档里 —— 它们**就是**默认行为本身。
        Self {
            // 混合项目(Rust + 前端)默认走 Node:这是 rust 不在首位的唯一原因,
            // 而不是某处写了一句 `if mixed { node }`。想改就调这个数组。
            family_priority: vec![
                "node".into(),
                "rust".into(),
                "python".into(),
                "go".into(),
                "jvm".into(),
                "dotnet".into(),
                "php".into(),
                "ruby".into(),
            ],
            // 只有一个 package.json 时(四个 Node 后端都 10 分)默认选 pnpm。
            priority: vec![
                "pnpm".into(),
                "npm".into(),
                "yarn".into(),
                "bun".into(),
                "cargo".into(),
            ],
        }
    }
}

/// `[discovery]`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DiscoveryConfig {
    /// 要不要向上找项目根。`false` = 只看起点目录。
    pub walk_up: bool,
    /// 最多上溯几层。
    pub max_depth: usize,
    /// 遇到 `.git` 就停(仓库根再往上不属于本项目)。
    pub stop_at_git: bool,
}

impl Default for DiscoveryConfig {
    fn default() -> Self {
        Self {
            walk_up: true,
            max_depth: 8,
            stop_at_git: true,
        }
    }
}

/// `[plugin_store]`
///
/// > 原设计这里是 `search_paths = [...]`(多个目录)。实际只保留了**一个** `data_dir`,
/// > 因为 `crate-plugin-kit` 的 `KitConfig` 只有一个数据目录 —— 列多个而只用第一个,
/// > 比不支持更糟。见 README 的偏差记录。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PluginStoreConfig {
    /// 覆盖插件目录。留空 = [`default_data_dir`]。
    pub data_dir: Option<PathBuf>,
    /// 有 prebuilt 就下载,失败自动回落 build-host。
    pub prefer_prebuilt: Option<bool>,
}

impl PluginStoreConfig {
    /// 生效的数据目录。
    pub fn effective_data_dir(&self) -> Result<PathBuf> {
        match &self.data_dir {
            Some(p) => Ok(expand_tilde(&p.to_string_lossy())),
            None => default_data_dir(),
        }
    }

    /// 生效的 prebuilt 偏好。
    pub fn effective_prefer_prebuilt(&self) -> bool {
        self.prefer_prebuilt.unwrap_or(true)
    }
}

impl GlobalConfig {
    /// 读全局配置。**文件不存在 = 全默认,不是错误。**
    pub fn load() -> Result<Self> {
        Self::load_from(&global_config_path()?)
    }

    /// 从指定路径读。测试用。
    pub fn load_from(path: &Path) -> Result<Self> {
        match std::fs::read_to_string(path) {
            Ok(text) => toml::from_str(&text)
                .with_context(|| format!("解析全局配置失败:{}", path.display())),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
            Err(e) => Err(e).with_context(|| format!("读全局配置失败:{}", path.display())),
        }
    }
}

// ---------------------------------------------------------------------------
// 项目配置(7.2)
// ---------------------------------------------------------------------------

/// 一份 `.pmpx.toml`。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
    /// `[plugin] <family> = "<name>"`
    ///
    /// 键是 **family 名字**,不是插件名 —— 而且它是 `BTreeMap<String, _>` 而不是
    /// 一个带固定字段的结构体,因为 `Family` 是**开放类型**(决策 44):
    /// 第三方插件带来新生态时,`.pmpx.toml` 不需要等 pmpx 发版就能写。
    pub plugin: BTreeMap<String, String>,

    /// `[scripts] name = "run something"`
    ///
    /// ⚠️ **v0.1 还没定义它的语义**(设计文档只列了这个段,没说它做什么)。
    /// 现在它被**解析并原样保留**,理由只有一个:`plugin set/unset` 是读-改-写,
    /// 不保留的话用户手写的这段会被静默吃掉。
    pub scripts: BTreeMap<String, String>,

    /// 不认识的键原样保留,理由同 [`GlobalConfig::extra`]。
    #[serde(flatten)]
    pub extra: toml::Table,
}

impl ProjectConfig {
    /// 读一份。文件不存在返回 `None`(调用方据此知道"这里没有配置")。
    pub fn load_from(path: &Path) -> Result<Option<Self>> {
        match std::fs::read_to_string(path) {
            Ok(text) => {
                let cfg: Self = toml::from_str(&text)
                    .with_context(|| format!("解析项目配置失败:{}", path.display()))?;
                Ok(Some(cfg))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e).with_context(|| format!("读项目配置失败:{}", path.display())),
        }
    }

    /// 把 `other` 叠在 `self` 上,`other` 赢(它就是"更近的那一层")。
    pub fn overlay(&mut self, other: ProjectConfig) {
        // ⚠️ 顺序很重要:先 extend 未知键,再让已知键覆盖,
        // 否则 `other` 里一个同名的未知键会把已知键顶掉。
        for (k, v) in other.extra {
            self.extra.insert(k, v);
        }
        self.plugin.extend(other.plugin);
        self.scripts.extend(other.scripts);
    }
}

/// 从近到远收集到的所有 `.pmpx.toml` 合并结果。
#[derive(Debug, Clone, Default)]
pub struct MergedProjectConfig {
    /// 生效的 `[plugin]` 固化项:family → 插件名。
    pub plugin: BTreeMap<String, String>,
    /// 生效的 `[scripts]`。
    ///
    /// 本结构里暂时没有读取方,但它不能删:合并结果就是"配置最终长什么样"的答案,
    /// 少了这一段,之后任何读取方都再也看不到用户手写的脚本 —— 理由同
    /// [`ProjectConfig::scripts`]。
    #[allow(dead_code)]
    pub scripts: BTreeMap<String, String>,
    /// 实际读到的文件,**从近到远**。
    ///
    /// 留着是为了 `pmpx info` 能说清"这些值是从哪几份配置来的" ——
    /// 分层配置最容易让人困惑的就是"到底哪一层生效了"。
    pub sources: Vec<PathBuf>,
}

impl MergedProjectConfig {
    /// 按"近者优先"合并。
    ///
    /// `paths` 必须**从近到远**排列(调用方是逐层上溯收集的)。
    pub fn from_paths_near_to_far(paths: &[PathBuf]) -> Result<Self> {
        let mut merged = ProjectConfig::default();
        let mut found = Vec::new();

        // 从最远的开始铺,越近的越后写 —— 后者自然覆盖前者。
        for path in paths.iter().rev() {
            if let Some(cfg) = ProjectConfig::load_from(path)? {
                merged.overlay(cfg);
                found.push(path.clone());
            }
        }

        // `found` 此时是从远到近,翻过来变成从近到远,与入参口径一致。
        found.reverse();

        Ok(Self {
            plugin: merged.plugin,
            scripts: merged.scripts,
            sources: found,
        })
    }

    /// 某个生态被固化成了哪个插件。
    pub fn pinned_plugin(&self, family: &str) -> Option<&str> {
        self.plugin.get(family).map(String::as_str)
    }

    /// 被 pin 过的所有 family。
    pub fn pinned_families(&self) -> Vec<&str> {
        self.plugin.keys().map(String::as_str).collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
        let p = dir.join(name);
        std::fs::write(&p, body).unwrap();
        p
    }

    #[test]
    fn missing_global_config_is_all_defaults() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg = GlobalConfig::load_from(&tmp.path().join("nope.toml")).unwrap();

        assert_eq!(cfg.plugin.family_priority[0], "node");
        assert_eq!(cfg.plugin.priority[0], "pnpm");
        assert!(cfg.discovery.walk_up);
        assert_eq!(cfg.discovery.max_depth, 8);
        assert!(cfg.discovery.stop_at_git);
        assert!(cfg.plugin_store.effective_prefer_prebuilt());
    }

    #[test]
    fn unknown_keys_in_the_global_config_are_kept() {
        let tmp = tempfile::tempdir().unwrap();
        let path = write(
            tmp.path(),
            "config.toml",
            r#"
[plugin]
family_priority = ["rust", "node"]

# 用户自己加的,pmpx 不认识
[my_own_thing]
keep = "me"
"#,
        );

        let cfg = GlobalConfig::load_from(&path).unwrap();
        assert_eq!(cfg.plugin.family_priority, vec!["rust", "node"]);
        // 没写的字段落到默认值
        assert_eq!(cfg.plugin.priority[0], "pnpm");
        // 不认识的段必须活下来 —— `pmpx config set` 读-改-写时全靠它原样带回文件里
        assert!(cfg.extra.contains_key("my_own_thing"));
    }

    #[test]
    fn a_broken_global_config_is_an_error_not_a_silent_default() {
        let tmp = tempfile::tempdir().unwrap();
        let path = write(tmp.path(), "config.toml", "this is not toml = = =");

        let err = GlobalConfig::load_from(&path).unwrap_err();
        assert!(err.to_string().contains("解析全局配置失败"), "{err}");
    }

    #[test]
    fn project_config_missing_is_none_not_default() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(ProjectConfig::load_from(&tmp.path().join("nope.toml"))
            .unwrap()
            .is_none());
    }

    #[test]
    fn near_config_wins_over_far_one() {
        let tmp = tempfile::tempdir().unwrap();
        let far = write(
            tmp.path(),
            "far.toml",
            "[plugin]\nnode = \"npm\"\nrust = \"cargo\"\n",
        );
        let near = write(tmp.path(), "near.toml", "[plugin]\nnode = \"pnpm\"\n");

        // 入参口径是"从近到远"
        let merged =
            MergedProjectConfig::from_paths_near_to_far(&[near.clone(), far.clone()]).unwrap();

        assert_eq!(merged.pinned_plugin("node"), Some("pnpm"), "近的赢");
        assert_eq!(merged.pinned_plugin("rust"), Some("cargo"), "远的没被顶掉");
        assert_eq!(merged.pinned_plugin("python"), None);
    }

    #[test]
    fn overlaying_unknown_keys_does_not_clobber_known_ones() {
        let mut base = ProjectConfig::default();
        base.plugin.insert("node".into(), "npm".into());

        let mut other = ProjectConfig::default();
        other.plugin.insert("node".into(), "pnpm".into());
        base.overlay(other);

        assert_eq!(base.plugin.get("node").map(String::as_str), Some("pnpm"));
    }

    #[test]
    fn scripts_merge_across_layers() {
        let tmp = tempfile::tempdir().unwrap();
        let far = write(
            tmp.path(),
            "far.toml",
            "[scripts]\nfmt = \"run format\"\nlint = \"run lint\"\n",
        );
        let near = write(tmp.path(), "near.toml", "[scripts]\nfmt = \"run f\"\n");

        let merged = MergedProjectConfig::from_paths_near_to_far(&[near, far]).unwrap();
        assert_eq!(merged.scripts.get("fmt").map(String::as_str), Some("run f"));
        assert_eq!(
            merged.scripts.get("lint").map(String::as_str),
            Some("run lint")
        );
    }

    #[test]
    fn sources_are_recorded_near_to_far() {
        let tmp = tempfile::tempdir().unwrap();
        let far = write(tmp.path(), "far.toml", "[plugin]\nrust = \"cargo\"\n");
        let near = write(tmp.path(), "near.toml", "[plugin]\nnode = \"pnpm\"\n");

        let merged =
            MergedProjectConfig::from_paths_near_to_far(&[near.clone(), far.clone()]).unwrap();

        assert_eq!(merged.sources, vec![near, far], "从近到远");
    }

    #[test]
    fn a_config_file_that_vanished_mid_walk_is_skipped() {
        let tmp = tempfile::tempdir().unwrap();
        let real = write(tmp.path(), "real.toml", "[plugin]\nrust = \"cargo\"\n");
        let ghost = tmp.path().join("ghost.toml");

        let merged = MergedProjectConfig::from_paths_near_to_far(&[ghost, real]).unwrap();
        assert_eq!(merged.pinned_plugin("rust"), Some("cargo"));
        assert_eq!(merged.sources.len(), 1);
    }

    #[test]
    fn expand_tilde_expands_to_home() {
        let home = directories::UserDirs::new()
            .unwrap()
            .home_dir()
            .to_path_buf();

        assert_eq!(expand_tilde("~/x/y"), home.join("x").join("y"));
        assert_eq!(expand_tilde("~"), home);
        // 不处理 `~user`,也不该把普通路径改坏
        assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
        assert_eq!(
            expand_tilde("relative/path"),
            PathBuf::from("relative/path")
        );
    }

    #[test]
    fn pinned_families_lists_every_pin() {
        let tmp = tempfile::tempdir().unwrap();
        let a = write(
            tmp.path(),
            "a.toml",
            "[plugin]\nnode = \"pnpm\"\nrust = \"cargo\"\n",
        );

        let merged = MergedProjectConfig::from_paths_near_to_far(&[a]).unwrap();
        let mut fams = merged.pinned_families();
        fams.sort_unstable();
        assert_eq!(fams, vec!["node", "rust"]);
    }

    // ---- 环境变量覆盖(给测试与多环境用)--------------------------------

    /// 两个覆盖变量必须能真的生效 —— 否则 e2e 测试就只好去动用户真实的
    /// `~/.config/pmpx` 与 `~/.pmpx`,那是不可接受的。
    ///
    /// ⚠️ 这个测试改的是**进程级**环境变量。它把原值存下来、结束时还原,
    /// 但严格说与并发跑的其它测试仍有竞争 —— 所以它只碰那两个**只有本模块读**、
    /// 且名字带 `PMPX_` 前缀的变量,并把整段逻辑放在同一个测试里,不去动 PATH。
    #[test]
    fn env_overrides_take_effect() {
        let tmp = tempfile::tempdir().unwrap();
        let cfg_dir = tmp.path().join("cfg");
        let data_dir = tmp.path().join("data");

        let old_cfg = std::env::var_os(ENV_CONFIG_DIR);
        let old_data = std::env::var_os(ENV_DATA_DIR);

        // SAFETY: 见上面关于作用域的说明。
        unsafe {
            std::env::set_var(ENV_CONFIG_DIR, &cfg_dir);
            std::env::set_var(ENV_DATA_DIR, &data_dir);
        }

        let got_cfg = global_config_path().unwrap();
        let got_data = default_data_dir().unwrap();

        // 还原要在断言之前 —— 断言失败也不能把环境弄脏
        unsafe {
            match old_cfg {
                Some(v) => std::env::set_var(ENV_CONFIG_DIR, v),
                None => std::env::remove_var(ENV_CONFIG_DIR),
            }
            match old_data {
                Some(v) => std::env::set_var(ENV_DATA_DIR, v),
                None => std::env::remove_var(ENV_DATA_DIR),
            }
        }

        assert_eq!(got_cfg, cfg_dir.join("config.toml"));
        assert_eq!(got_data, data_dir);
    }

    /// 没有覆盖变量时,走的是平台正确的位置。
    #[test]
    fn without_overrides_the_platform_paths_are_used() {
        // 只有在调用方没设覆盖时才检查 —— 免得因为别处设了而误报
        if std::env::var_os(ENV_CONFIG_DIR).is_none() {
            let p = global_config_path().unwrap();
            assert!(p.ends_with("config.toml"), "{p:?}");
            assert!(
                p.to_string_lossy().contains("pmpx"),
                "路径里应当有 pmpx:{p:?}"
            );
        }
        if std::env::var_os(ENV_DATA_DIR).is_none() {
            let p = default_data_dir().unwrap();
            assert_eq!(p.file_name().unwrap().to_string_lossy(), ".pmpx");
        }
    }
}