Skip to main content

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(&current) == 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;