pmpx_engine/discovery/mod.rs
1//! Project root discovery.
2//!
3//! One walk up answers two questions: [`Walk::project_root`] answers "where do I run" (only the
4//! nearest match), and [`Walk::config_paths`] answers "which rules apply" (every `.pmpx.toml` along
5//! the way). The stop conditions are identical; the results are two different things, which is why
6//! the caller walks once and asks [`Walk`] both.
7//!
8//! ```text
9//! ~/repo/.git
10//! ~/repo/.pmpx.toml [plugin] rust = "cargo"
11//! ~/repo/crates/core/.pmpx.toml [plugin] node = "pnpm"
12//! cwd = ~/repo/crates/core/src/
13//!
14//! Walk::project_root → ~/repo/crates/core
15//! Walk::config_paths → [core/.pmpx.toml, repo/.pmpx.toml] (both are read)
16//! ```
17//!
18//! This file is the walk itself; why it stopped is [`StopReason`].
19
20use std::path::{Path, PathBuf};
21
22use pmpx_project::DiscoveryConfig;
23
24mod stop;
25
26pub use stop::dirs;
27pub use stop::StopReason;
28
29/// The directories walked: **from the start outward** (near to far).
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Walk {
32 /// Directories checked in order, `[0]` is the start.
33 pub dirs: Vec<PathBuf>,
34 /// Why the walk stopped. `pmpx info` displays it —
35 /// "why was no project found" is most often answered by having hit one of these.
36 pub stopped: StopReason,
37}
38
39/// Walk up from `start`, collecting the directories to check.
40///
41/// `max_depth` is **how many directories may be checked at most (including the start)**, not
42/// "how many levels to walk up".
43///
44/// Both `$HOME` and `.git` stop the walk **after the current directory has been checked**: `$HOME`
45/// itself is still a candidate, so `~/Cargo.toml` or `~/.pmpx.toml` can take effect. We never walk
46/// above `$HOME`.
47pub fn walk(start: &Path, cfg: &DiscoveryConfig) -> Walk {
48 let start = normalize(start);
49 let mut dirs = vec![start.clone()];
50
51 if !cfg.walk_up {
52 return Walk {
53 dirs,
54 stopped: StopReason::WalkUpDisabled,
55 };
56 }
57
58 let home = directories::UserDirs::new().map(|d| normalize(d.home_dir()));
59
60 let mut current = start;
61
62 let stopped = loop {
63 if dirs.len() >= cfg.max_depth {
64 break StopReason::MaxDepth;
65 }
66
67 // Order is semantics: both `.git` and `$HOME` stop only after the current directory has
68 // been checked.
69 if cfg.stop_at_git && current.join(".git").exists() {
70 break StopReason::GitRoot;
71 }
72 if Some(¤t) == home.as_ref() {
73 break StopReason::Home;
74 }
75
76 match current.parent() {
77 // `parent() == Some(self)` means the filesystem root (`/` or `C:\`)
78 Some(parent) if parent != current => {
79 current = parent.to_path_buf();
80 dirs.push(current.clone());
81 }
82 _ => break StopReason::FilesystemRoot,
83 }
84 };
85
86 Walk { dirs, stopped }
87}
88
89/// The two questions one walk up can answer.
90///
91/// Both read the same candidate list, so a caller that needs both walks once and asks twice; the
92/// stop conditions cannot drift apart because there is only one traversal.
93impl Walk {
94 /// The project root: the nearest walked directory `is_root` accepts.
95 ///
96 /// `is_root` comes from the caller, usually "this directory has a `.pmpx.toml`, or has one of
97 /// the detect files declared by an installed plugin". It is a parameter so that this module
98 /// does not need to know about plugins.
99 pub fn project_root(&self, is_root: impl Fn(&Path) -> bool) -> Option<PathBuf> {
100 self.dirs.iter().find(|d| is_root(d)).cloned()
101 }
102
103 /// Collect every `.pmpx.toml` on this walk, **near to far**.
104 ///
105 /// The stop conditions match [`Walk::project_root`]; this order is the "nearest wins" merge rule
106 /// of [`pmpx_project::MergedProjectConfig`].
107 pub fn config_paths(&self) -> Vec<PathBuf> {
108 self.dirs
109 .iter()
110 .map(|d| d.join(".pmpx.toml"))
111 .filter(|p| p.is_file())
112 .collect()
113 }
114}
115
116/// Normalize a directory into a directly comparable form.
117///
118/// Make a path absolute and lexically normal: no `.`, no `..`, no trailing separator.
119///
120/// The one place the engine decides how a path it reports is spelled, so the walk, the start
121/// directory and the context handed to a plugin all agree. See the note below on why this is not
122/// `canonicalize`.
123///
124/// On Windows `C:\Users\me` and `C:\Users\me\` are the same directory but not equal as `PathBuf`s,
125/// and the `$HOME` check is exactly that equality; `.` and `..` must be removed too.
126pub(crate) fn normalize(p: &Path) -> PathBuf {
127 let absolute = if p.is_absolute() {
128 p.to_path_buf()
129 } else {
130 std::env::current_dir()
131 .map(|cwd| cwd.join(p))
132 .unwrap_or_else(|_| p.to_path_buf())
133 };
134
135 // Not `canonicalize`: it resolves symlinks (/tmp → /private/tmp), which would make the reported
136 // path differ from what the user sees. Lexical resolution only.
137 lexical_normalize(&absolute)
138}
139
140/// Remove `..` and `.` purely lexically, without touching the filesystem.
141fn lexical_normalize(p: &Path) -> PathBuf {
142 use std::path::Component;
143
144 let mut out = PathBuf::new();
145 for comp in p.components() {
146 match comp {
147 Component::CurDir => {}
148 Component::ParentDir => {
149 // Pop one level; keep it if already at the root
150 if !out.pop() {
151 out.push("..");
152 }
153 }
154 other => out.push(other.as_os_str()),
155 }
156 }
157 out
158}
159
160#[cfg(test)]
161mod tests;