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
//! 项目根发现(设计文档 5.7)。
//!
//! # 两遍走,不是一遍
//!
//! [`find_project_root`] 回答"**在哪执行**" —— 它必须给出唯一答案。
//! [`collect_config_paths`] 回答"**按什么规则执行**" —— 它天然是个叠加层。
//!
//! 把这两件事混在一起走一遍,就会出现「同一份根配置在两种 cwd 下行为不同」——
//! 那违反"同一目录、同一配置、结果必然一致"。所以它们的**停止条件相同,
//! 但结果是两种东西**。
//!
//! ```text
//! ~/repo/.git
//! ~/repo/.pmpx.toml            [plugin] rust = "cargo"
//! ~/repo/crates/core/.pmpx.toml [plugin] node = "pnpm"
//! cwd = ~/repo/crates/core/src/
//!
//! find_project_root      → ~/repo/crates/core   (第一层命中 Cargo.toml)
//! collect_config_paths   → [core/.pmpx.toml, repo/.pmpx.toml]  (两份都读得到)
//! ```
//!
//! 上一版的错误就在这儿:它只读项目根那一份,于是 `repo/.pmpx.toml` 里
//! "这个 workspace 的 rust 用 cargo" 这句在 `crates/core/src/` 下**看不见**。

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

use crate::config::DiscoveryConfig;

/// 走出来的目录列表:**从起点到最远**(由近及远)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Walk {
    /// 依次检查的目录,`[0]` 是起点。
    pub dirs: Vec<PathBuf>,
    /// 为什么停下来的。`pmpx info` 会把它显示出来 ——
    /// "为什么没找到项目"最常见的原因就是撞上了其中某一个。
    pub stopped: StopReason,
}

/// 上溯停止的原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
    /// 走到文件系统根了。
    FilesystemRoot,
    /// 撞到了 `$HOME` —— 再往上不是用户的项目。
    Home,
    /// 撞到了 `.git`。
    GitRoot,
    /// 到了 `max_depth` 上限。
    MaxDepth,
    /// 调用方明确不要上溯(`--no-walk-up` 或 `[discovery] walk_up = false`)。
    WalkUpDisabled,
}

impl StopReason {
    /// 人类可读的解释,给 `pmpx info` 用。
    pub fn describe(self, max_depth: usize) -> String {
        match self {
            StopReason::FilesystemRoot => "到达文件系统根".into(),
            StopReason::Home => "到达 $HOME".into(),
            StopReason::GitRoot => "到达 .git".into(),
            StopReason::MaxDepth => format!("到达 max_depth 上限({max_depth} 层)"),
            StopReason::WalkUpDisabled => "--no-walk-up / [discovery] walk_up = false".into(),
        }
    }
}

/// 从 `start` 向上走,收集要检查的目录。
///
/// # `$HOME` 是检查完再停,不是不检查
///
/// "到达 `$HOME` → 停"这条规则的字面读法是:`$HOME` 本身仍然是一个候选。
/// 这么定有两个具体的好处,而且都不会越界(我们从不走到 `$HOME` 之上):
///
/// - `~/Cargo.toml` 这种"家目录就是个 cargo workspace"是真的存在;
/// - `~/.pmpx.toml` 能生效 —— 一份"我在家目录下都按这套规则走"的兜底配置。
///
/// 坏处是:如果 `~/Cargo.toml` 存在,`~/scratch/` 里敲 pmpx 会认到 `~`。
/// 但那**是对的** —— 那个目录确实在 cargo workspace 里。`pmpx info` 会显示根路径,
/// 用户看得见。
pub fn walk(start: &Path, cfg: &DiscoveryConfig) -> Walk {
    let start = normalize(start);
    let mut dirs = vec![start.clone()];

    if !cfg.walk_up {
        return Walk {
            dirs,
            stopped: StopReason::WalkUpDisabled,
        };
    }

    let home = directories::UserDirs::new().map(|d| normalize(d.home_dir()));

    let mut current = start;

    // 每条 break 路径都直接产出一个原因 —— 比先赋个占位值再覆盖更不容易写错
    // (写成 `let mut stopped = ...` 的话,那个初值其实永远读不到)。
    let stopped = loop {
        if dirs.len() >= cfg.max_depth {
            break StopReason::MaxDepth;
        }

        // ⚠️ 顺序即语义:`.git` 与 `$HOME` 都是**检查完当前目录之后**才停。
        if cfg.stop_at_git && current.join(".git").exists() {
            break StopReason::GitRoot;
        }
        if Some(&current) == home.as_ref() {
            break StopReason::Home;
        }

        match current.parent() {
            // `parent() == Some(self)` 就是文件系统根(`/` 或 `C:\`)
            Some(parent) if parent != current => {
                current = parent.to_path_buf();
                dirs.push(current.clone());
            }
            _ => break StopReason::FilesystemRoot,
        }
    };

    Walk { dirs, stopped }
}

/// 找出项目根。
///
/// `is_root` 由调用方给:它通常长这样 ——
/// "这个目录里有 `.pmpx.toml`,或者有任一已装插件声明的 detect 文件"。
/// 把这条判断做成参数是为了让本模块**不认识插件**,也就不需要为它造桩。
pub fn find_project_root(
    start: &Path,
    cfg: &DiscoveryConfig,
    is_root: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
    walk(start, cfg).dirs.into_iter().find(|d| is_root(d))
}

/// 收集所有 `.pmpx.toml`,**从近到远**。
///
/// 停止条件与 [`find_project_root`] 完全一致 —— 这是 5.7 的要点:
/// 两遍走,但走到同一个地方。
///
/// 返回**从近到远**的顺序,因为 [`crate::config::MergedProjectConfig`] 的合并规则是
/// "近者优先",那个顺序就是它的输入口径。
pub fn collect_config_paths(start: &Path, cfg: &DiscoveryConfig) -> Vec<PathBuf> {
    walk(start, cfg)
        .dirs
        .into_iter()
        .map(|d| d.join(".pmpx.toml"))
        .filter(|p| p.is_file())
        .collect()
}

/// 把一个目录规整成可以直接比较的形式。
///
/// Windows 上 `C:\Users\me` 与 `C:\Users\me\` 是同一个目录,但 `PathBuf` 不相等 ——
/// 而 `$HOME` 比较用的正是相等。同理 `.` 与 `..` 也要消掉。
fn normalize(p: &Path) -> PathBuf {
    let absolute = if p.is_absolute() {
        p.to_path_buf()
    } else {
        std::env::current_dir()
            .map(|cwd| cwd.join(p))
            .unwrap_or_else(|_| p.to_path_buf())
    };

    // `canonicalize` 会解析符号链接,那在某些场景下反而错(比如 /tmp → /private/tmp
    // 会让"用户看到的路径"和"我们报出来的路径"对不上)。所以只做词法消解。
    lexical_normalize(&absolute)
}

/// 纯词法地把 `..` 与 `.` 消掉,不碰文件系统。
fn lexical_normalize(p: &Path) -> PathBuf {
    use std::path::Component;

    let mut out = PathBuf::new();
    for comp in p.components() {
        match comp {
            Component::CurDir => {}
            Component::ParentDir => {
                // 消掉一层;消不掉(已经在根)就保留 —— 保留比假装成功好
                if !out.pop() {
                    out.push("..");
                }
            }
            other => out.push(other.as_os_str()),
        }
    }
    out
}

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

    fn cfg() -> DiscoveryConfig {
        DiscoveryConfig::default()
    }

    fn cfg_at_most(n: usize) -> DiscoveryConfig {
        DiscoveryConfig {
            max_depth: n,
            ..DiscoveryConfig::default()
        }
    }

    /// 造一棵目录树。传进来的每一项都是相对 `<tmp>` 的目录,会被建出来。
    fn tree(dirs: &[&str]) -> tempfile::TempDir {
        let tmp = tempfile::tempdir().unwrap();
        for d in dirs {
            std::fs::create_dir_all(tmp.path().join(d)).unwrap();
        }
        tmp
    }

    fn touch(path: &Path) {
        if let Some(p) = path.parent() {
            std::fs::create_dir_all(p).unwrap();
        }
        std::fs::write(path, "").unwrap();
    }

    #[test]
    fn starts_with_the_start_directory() {
        let tmp = tree(&["a/b/c"]);
        let w = walk(&tmp.path().join("a/b/c"), &cfg());

        assert_eq!(w.dirs[0], normalize(&tmp.path().join("a/b/c")));
    }

    #[test]
    fn walks_up_in_order_from_near_to_far() {
        let tmp = tree(&["a/b/c"]);
        let w = walk(&tmp.path().join("a/b/c"), &cfg());

        // 前三个必须是 c / b / a —— 由近及远
        assert!(w.dirs[0].ends_with("c"), "{:?}", w.dirs[0]);
        assert!(w.dirs[1].ends_with("b"), "{:?}", w.dirs[1]);
        assert!(w.dirs[2].ends_with("a"), "{:?}", w.dirs[2]);

        // 逐层上溯,一层都不能跳
        for pair in w.dirs.windows(2) {
            assert_eq!(
                pair[1].as_path(),
                pair[0].parent().unwrap(),
                "从 {} 应当走到它的父目录",
                pair[0].display()
            );
        }

        // 停下来必须有个原因,且不超过上限。
        //
        // ⚠️ 具体是哪一个原因取决于**测试跑在哪** —— 临时目录可能深到撞上 max_depth,
        // 也可能先撞上 $HOME。断言"是其中一个"而不是写死一个,否则这个测试会在
        // 别人的机器上莫名其妙地红。
        assert!(w.dirs.len() <= cfg().max_depth);
        assert!(
            matches!(
                w.stopped,
                StopReason::MaxDepth
                    | StopReason::FilesystemRoot
                    | StopReason::Home
                    | StopReason::GitRoot
            ),
            "意外停在 {:?}",
            w.stopped
        );
    }

    /// 上限足够大时,一定会走到 `$HOME` 或文件系统根 —— 不会无限上溯。
    #[test]
    fn a_generous_max_depth_still_terminates() {
        let tmp = tree(&["a/b/c"]);
        let mut c = cfg();
        c.max_depth = 4096;

        let w = walk(&tmp.path().join("a/b/c"), &c);
        assert!(
            matches!(w.stopped, StopReason::Home | StopReason::FilesystemRoot),
            "意外停在 {:?}",
            w.stopped
        );
        assert!(w.dirs.len() < 4096, "不该真的走满上限");
    }

    /// `walk_up = false`(或 `--no-walk-up`):**只有起点这一个候选**。
    #[test]
    fn walk_up_disabled_returns_only_the_start() {
        let tmp = tree(&["a/b/c"]);
        let start = tmp.path().join("a/b/c");
        let mut c = cfg();
        c.walk_up = false;

        let w = walk(&start, &c);
        assert_eq!(w.dirs, vec![normalize(&start)]);
        assert_eq!(w.stopped, StopReason::WalkUpDisabled);
    }

    #[test]
    fn max_depth_limits_how_many_directories_are_checked() {
        let tmp = tree(&["a/b/c/d/e"]);
        let w = walk(&tmp.path().join("a/b/c/d/e"), &cfg_at_most(3));

        assert_eq!(w.dirs.len(), 3);
        assert_eq!(w.stopped, StopReason::MaxDepth);
        assert_eq!(w.stopped.describe(3), "到达 max_depth 上限(3 层)");
    }

    /// `.git` 之后不再往上 —— 仓库根再往上不属于本项目。
    #[test]
    fn stops_after_a_directory_containing_git() {
        let tmp = tree(&["repo/web/src"]);
        std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap();

        let w = walk(&tmp.path().join("repo/web/src"), &cfg());

        assert_eq!(w.stopped, StopReason::GitRoot);
        // repo 是最后一个候选,**它本身要被检查**
        assert!(w.dirs.last().unwrap().ends_with("repo"));
        // 没有走到 tmp 之上
        let above_tmp = normalize(tmp.path());
        let above_tmp = above_tmp.parent().unwrap();
        assert!(!w.dirs.iter().any(|d| d.as_path() == above_tmp));
    }

    #[test]
    fn stop_at_git_can_be_turned_off() {
        let tmp = tree(&["repo/web/src"]);
        std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap();

        let mut c = cfg();
        c.stop_at_git = false;
        let w = walk(&tmp.path().join("repo/web/src"), &c);

        // 越过了 repo,继续往上
        assert!(w.dirs.len() > 3);
        assert_ne!(w.stopped, StopReason::GitRoot);
    }

    /// `.git` 是个**文件**(worktree / submodule)时同样算数。
    #[test]
    fn git_file_also_stops_the_walk() {
        let tmp = tree(&["repo/web/src"]);
        touch(&tmp.path().join("repo/.git"));

        let w = walk(&tmp.path().join("repo/web/src"), &cfg());
        assert_eq!(w.stopped, StopReason::GitRoot);
    }

    #[test]
    fn find_project_root_picks_the_nearest_hit() {
        let tmp = tree(&["repo/web/src"]);
        touch(&tmp.path().join("repo/Cargo.toml"));
        touch(&tmp.path().join("repo/web/package.json"));

        // 从 web/src 出发:先命中 web 的 package.json,不会跑到 repo
        let root = find_project_root(&tmp.path().join("repo/web/src"), &cfg(), |d| {
            d.join("Cargo.toml").exists() || d.join("package.json").exists()
        })
        .unwrap();

        assert!(root.ends_with("web"), "根应当是 web,实际 {root:?}");
    }

    #[test]
    fn find_project_root_returns_none_when_nothing_matches() {
        let tmp = tree(&["repo/some/dir"]);
        std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap();

        // 有 .git 但没有清单文件 → 找不到(5.7 的第三行)
        let root = find_project_root(&tmp.path().join("repo/some/dir"), &cfg(), |d| {
            d.join("Cargo.toml").exists()
        });
        assert!(root.is_none());
    }

    #[test]
    fn the_start_directory_itself_can_be_the_root() {
        let tmp = tree(&["proj"]);
        touch(&tmp.path().join("proj/Cargo.toml"));

        let root = find_project_root(&tmp.path().join("proj"), &cfg(), |d| {
            d.join("Cargo.toml").exists()
        })
        .unwrap();
        assert_eq!(root, normalize(&tmp.path().join("proj")));
    }

    /// 5.7 的核心场景:**配置能看到项目根之外的那一层**。
    #[test]
    fn config_collection_reaches_above_the_project_root() {
        let tmp = tree(&["repo/crates/core/src"]);
        std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap();
        touch(&tmp.path().join("repo/.pmpx.toml"));
        touch(&tmp.path().join("repo/crates/core/.pmpx.toml"));
        touch(&tmp.path().join("repo/crates/core/Cargo.toml"));

        let start = tmp.path().join("repo/crates/core/src");

        // 项目根:core(最近命中清单的那一层)
        let root = find_project_root(&start, &cfg(), |d| d.join("Cargo.toml").exists()).unwrap();
        assert!(root.ends_with("core"));

        // 配置:core 与 repo **两份都要看得到**,从近到远
        let cfgs = collect_config_paths(&start, &cfg());
        assert_eq!(cfgs.len(), 2, "{cfgs:?}");
        assert!(cfgs[0].ends_with("core/.pmpx.toml"));
        assert!(cfgs[1].ends_with("repo/.pmpx.toml"));

        // 这正是旧设计做不到的那件事
        assert!(
            cfgs.iter().any(|p| p.ends_with("repo/.pmpx.toml")),
            "项目根之上那一层的配置必须可见"
        );
    }

    #[test]
    fn config_collection_skips_directories_without_a_config() {
        let tmp = tree(&["repo/a/b/c"]);
        touch(&tmp.path().join("repo/.pmpx.toml"));

        let cfgs = collect_config_paths(&tmp.path().join("repo/a/b/c"), &cfg());
        assert_eq!(cfgs.len(), 1);
        assert!(cfgs[0].ends_with("repo/.pmpx.toml"));
    }

    #[test]
    fn walking_from_a_relative_path_works() {
        // 相对路径要先接到 cwd 上,否则 `..` 会被消错
        let w = walk(Path::new("."), &cfg());
        assert!(w.dirs[0].is_absolute(), "{:?}", w.dirs[0]);
        assert_eq!(w.dirs[0], normalize(&std::env::current_dir().unwrap()));
    }

    #[test]
    fn lexical_normalize_removes_dots_without_touching_the_fs() {
        assert_eq!(
            lexical_normalize(Path::new("/a/b/../c")),
            PathBuf::from("/a/c")
        );
        assert_eq!(
            lexical_normalize(Path::new("/a/./b")),
            PathBuf::from("/a/b")
        );
        assert_eq!(
            lexical_normalize(Path::new("/a/b/../..")),
            PathBuf::from("/")
        );
    }

    #[test]
    fn every_stop_reason_has_a_description() {
        for (reason, max) in [
            (StopReason::FilesystemRoot, 8),
            (StopReason::Home, 8),
            (StopReason::GitRoot, 8),
            (StopReason::MaxDepth, 8),
            (StopReason::WalkUpDisabled, 8),
        ] {
            assert!(!reason.describe(max).is_empty());
        }
    }
}