Skip to main content

what_stack/
project.rs

1//! Project-root detection.
2//!
3//! Project detection walks upward from process paths and looks for marker
4//! files such as `package.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, and
5//! project-file extensions such as `.csproj` and `.fsproj`.
6
7use std::borrow::Cow;
8use std::ffi::{OsStr, OsString};
9use std::path::{Path, PathBuf};
10
11#[cfg(unix)]
12use std::ffi::CStr;
13#[cfg(unix)]
14use std::os::unix::ffi::OsStrExt;
15
16use crate::ProjectInput;
17
18const PROJECT_MARKERS: &[&str] = &[
19    "package.json",
20    "Cargo.toml",
21    "go.mod",
22    "go.work",
23    "pyproject.toml",
24    "requirements.txt",
25    "setup.py",
26    "Pipfile",
27    "pom.xml",
28    "build.gradle",
29    "build.gradle.kts",
30    "composer.json",
31    "Gemfile",
32    "mix.exs",
33    "deno.json",
34    "deno.jsonc",
35    "bun.lockb",
36    "bun.lock",
37];
38
39const PROJECT_MARKER_EXTENSIONS: &[&str] = &["csproj", "fsproj"];
40
41/// Maximum number of directories tested during one upward project walk.
42///
43/// The starting directory counts as the first. This cap is a safety net for
44/// unusual paths and very deep directory trees. The value is intentionally high
45/// enough for common monorepos while bounding worst-case filesystem work.
46pub const MAX_WALK_DEPTH: usize = 64;
47
48/// Resolve a project root from process-like path inputs without caching.
49///
50/// The fallback order is:
51///
52/// 1. Walk upward from the working directory set by [`ProjectInput::cwd`].
53/// 2. Walk upward from the parent directory of the executable set by
54///    [`ProjectInput::exe`].
55/// 3. Walk upward from the parent directory of each absolute path in the
56///    arguments set by [`ProjectInput::cmd`].
57///
58/// The first marker hit wins. Relative command-line paths are ignored because
59/// they are ambiguous without a reliable process working directory. `home` is
60/// the same optional ceiling as in [`find_project_root`].
61///
62/// Use [`StackDetector::detect_project_root`](crate::StackDetector::detect_project_root)
63/// for repeated lookups; it applies the same order with caching and its own
64/// configured home ceiling.
65///
66/// # Examples
67///
68/// ```
69/// use what_stack::{ProjectInput, resolve_project_root};
70///
71/// assert_eq!(resolve_project_root(ProjectInput::new(), None), None);
72/// ```
73#[must_use]
74pub fn resolve_project_root(input: ProjectInput<'_>, home: Option<&Path>) -> Option<PathBuf> {
75    project_root_candidates(input).find_map(|start| find_project_root(start, home))
76}
77
78/// Starting directories for a project walk, in fallback order.
79pub fn project_root_candidates(input: ProjectInput<'_>) -> impl Iterator<Item = &Path> + '_ {
80    input
81        .cwd
82        .into_iter()
83        .chain(input.exe.and_then(Path::parent))
84        .chain(absolute_cmd_parents(input.cmd))
85}
86
87/// Walk upward from `start` looking for project marker files.
88///
89/// `home` is an optional ceiling. When the walk reaches `home`, it stops before
90/// testing that directory for markers. This avoids accidental matches from
91/// marker files stored directly in a user's home directory. On Windows the
92/// ceiling comparison ignores ASCII case, matching the file system.
93///
94/// At most [`MAX_WALK_DEPTH`] directories are tested, starting with `start`.
95///
96/// # Examples
97///
98/// ```
99/// use std::path::Path;
100/// use what_stack::find_project_root;
101///
102/// assert_eq!(find_project_root(Path::new("."), Some(Path::new("."))), None);
103/// ```
104#[must_use]
105pub fn find_project_root(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
106    walk_ancestors(start, home).find(|dir| has_marker(dir))
107}
108
109/// Return the display name for a project root path.
110///
111/// The returned label is derived only from the final path component. It does
112/// not read package manifests or normalize workspace names.
113///
114/// # Examples
115///
116/// ```
117/// use std::path::Path;
118/// use what_stack::project_name;
119///
120/// assert_eq!(project_name(Path::new("/workspace/api")).as_deref(), Some("api"));
121/// ```
122#[must_use]
123pub fn project_name(root: &Path) -> Option<Cow<'_, str>> {
124    root.file_name().map(OsStr::to_string_lossy)
125}
126
127pub fn walk_ancestors<'a>(
128    start: &'a Path,
129    home: Option<&'a Path>,
130) -> impl Iterator<Item = PathBuf> + 'a {
131    let mut current = Some(start.to_path_buf());
132    let mut depth = 0;
133
134    std::iter::from_fn(move || {
135        let dir = current.as_ref()?.clone();
136
137        if depth >= MAX_WALK_DEPTH {
138            current = None;
139            return None;
140        }
141
142        if let Some(home_dir) = home
143            && paths_equal(&dir, home_dir)
144        {
145            current = None;
146            return None;
147        }
148
149        depth += 1;
150
151        let mut next = dir.clone();
152        if next.pop() && next != dir {
153            current = Some(next);
154        } else {
155            current = None;
156        }
157
158        Some(dir)
159    })
160}
161
162fn absolute_cmd_parents(cmd: &[OsString]) -> impl Iterator<Item = &Path> + '_ {
163    cmd.iter().filter_map(|arg| {
164        let path = Path::new(arg.as_os_str());
165        path.is_absolute().then(|| path.parent()).flatten()
166    })
167}
168
169pub fn has_marker(dir: &Path) -> bool {
170    let Ok(entries) = std::fs::read_dir(dir) else {
171        return false;
172    };
173
174    entries.filter_map(Result::ok).any(|entry| {
175        let file_name = entry.file_name();
176        let Some(name) = file_name.to_str() else {
177            return false;
178        };
179
180        PROJECT_MARKERS.contains(&name)
181            || Path::new(name)
182                .extension()
183                .and_then(OsStr::to_str)
184                .is_some_and(|extension| PROJECT_MARKER_EXTENSIONS.contains(&extension))
185    })
186}
187
188/// Compare two paths component by component.
189///
190/// Windows file systems are case-insensitive, so components are compared
191/// ignoring ASCII case there. Other platforms compare exactly.
192pub fn paths_equal(left: &Path, right: &Path) -> bool {
193    #[cfg(windows)]
194    {
195        let mut left = left.components();
196        let mut right = right.components();
197        loop {
198            match (left.next(), right.next()) {
199                (None, None) => return true,
200                (Some(a), Some(b)) if components_equal(a, b) => {}
201                _ => return false,
202            }
203        }
204    }
205    #[cfg(not(windows))]
206    {
207        left == right
208    }
209}
210
211/// Return whether `path` lies at or below `prefix`, comparing whole components
212/// with the same case rules as [`paths_equal`].
213pub fn path_starts_with(path: &Path, prefix: &Path) -> bool {
214    #[cfg(windows)]
215    {
216        let mut path = path.components();
217        prefix.components().all(|expected| {
218            path.next()
219                .is_some_and(|actual| components_equal(actual, expected))
220        })
221    }
222    #[cfg(not(windows))]
223    {
224        path.starts_with(prefix)
225    }
226}
227
228#[cfg(windows)]
229fn components_equal(left: std::path::Component<'_>, right: std::path::Component<'_>) -> bool {
230    left.as_os_str().eq_ignore_ascii_case(right.as_os_str())
231}
232
233/// Return the current user's home directory, when it can be determined.
234///
235/// On Unix, this prefers passwd-database lookup for the invoking user. During
236/// `sudo` sessions it uses `SUDO_UID` to find the original user's home, falling
237/// back to `SUDO_HOME` and then `HOME` when passwd lookup is unavailable. On
238/// Windows, it reads `USERPROFILE`. On other targets (for example `wasm32`), it
239/// returns `None`.
240///
241/// [`StackDetector::new`](crate::StackDetector::new) calls this once to set its
242/// home ceiling. Callers of [`find_project_root`] or [`resolve_project_root`]
243/// should call it once and reuse the result.
244#[must_use]
245pub fn home_dir() -> Option<PathBuf> {
246    #[cfg(unix)]
247    {
248        select_home_dir(
249            preferred_home_uid().and_then(home_dir_from_uid),
250            sudo_home_dir(),
251            std::env::var_os("HOME").map(PathBuf::from),
252        )
253    }
254    #[cfg(windows)]
255    {
256        std::env::var_os("USERPROFILE").map(PathBuf::from)
257    }
258    #[cfg(not(any(unix, windows)))]
259    {
260        None
261    }
262}
263
264#[cfg(unix)]
265fn select_home_dir(
266    passwd_home: Option<PathBuf>,
267    sudo_home: Option<PathBuf>,
268    env_home: Option<PathBuf>,
269) -> Option<PathBuf> {
270    passwd_home.or(sudo_home).or(env_home)
271}
272
273#[cfg(unix)]
274fn sudo_home_dir() -> Option<PathBuf> {
275    (current_effective_uid() == 0)
276        .then(|| std::env::var_os("SUDO_HOME"))
277        .flatten()
278        .filter(|home| !home.is_empty())
279        .map(PathBuf::from)
280}
281
282#[cfg(unix)]
283fn preferred_home_uid() -> Option<libc::uid_t> {
284    preferred_home_uid_from_env(
285        std::env::var_os("SUDO_UID").as_deref(),
286        current_effective_uid(),
287    )
288}
289
290#[cfg(unix)]
291fn preferred_home_uid_from_env(
292    sudo_uid: Option<&OsStr>,
293    current_euid: libc::uid_t,
294) -> Option<libc::uid_t> {
295    if current_euid == 0 {
296        sudo_uid
297            .and_then(OsStr::to_str)
298            .and_then(|value| value.parse::<libc::uid_t>().ok())
299            .or(Some(current_euid))
300    } else {
301        Some(current_euid)
302    }
303}
304
305#[cfg(unix)]
306fn current_effective_uid() -> libc::uid_t {
307    // Safety: `geteuid` has no preconditions and only returns the caller's euid.
308    unsafe { libc::geteuid() }
309}
310
311#[cfg(unix)]
312fn home_dir_from_uid(uid: libc::uid_t) -> Option<PathBuf> {
313    let mut buffer = vec![0_u8; passwd_buffer_len()];
314    let mut passwd = std::mem::MaybeUninit::<libc::passwd>::zeroed();
315    let mut result = std::ptr::null_mut();
316    // Safety: all pointers reference valid stack/heap storage for this call,
317    // and `buffer` remains alive until `passwd.pw_dir` has been copied.
318    let status = unsafe {
319        libc::getpwuid_r(
320            uid,
321            passwd.as_mut_ptr(),
322            buffer.as_mut_ptr().cast(),
323            buffer.len(),
324            &raw mut result,
325        )
326    };
327
328    if status != 0 || result.is_null() {
329        return None;
330    }
331
332    // Safety: `getpwuid_r` succeeded and initialized `passwd`.
333    let passwd = unsafe { passwd.assume_init() };
334    if passwd.pw_dir.is_null() {
335        return None;
336    }
337
338    // Safety: successful passwd records expose a nul-terminated directory path.
339    let home = unsafe { CStr::from_ptr(passwd.pw_dir) };
340    Some(Path::new(OsStr::from_bytes(home.to_bytes())).to_path_buf())
341}
342
343#[cfg(unix)]
344fn passwd_buffer_len() -> usize {
345    const DEFAULT_PASSWD_BUFFER_LEN: usize = 1024;
346
347    // Safety: `sysconf` has no preconditions for this constant.
348    match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } {
349        size if size > 0 => usize::try_from(size).unwrap_or(DEFAULT_PASSWD_BUFFER_LEN),
350        _ => DEFAULT_PASSWD_BUFFER_LEN,
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn path_helpers_match_exact_paths_and_whole_component_prefixes() {
360        let root = Path::new("/workspace/app");
361        assert!(paths_equal(root, Path::new("/workspace/app")));
362        assert!(!paths_equal(root, Path::new("/workspace/app/src")));
363        assert!(!paths_equal(root, Path::new("/workspace")));
364        assert!(path_starts_with(Path::new("/workspace/app/bin/x"), root));
365        assert!(path_starts_with(root, root));
366        assert!(!path_starts_with(Path::new("/workspace/application"), root));
367        assert!(!path_starts_with(Path::new("/workspace"), root));
368    }
369
370    #[cfg(windows)]
371    #[test]
372    fn path_helpers_ignore_ascii_case_on_windows() {
373        let root = Path::new(r"C:\Users\Dev\App");
374        assert!(paths_equal(root, Path::new(r"c:\users\dev\app")));
375        assert!(paths_equal(root, Path::new("c:/USERS/dev/app")));
376        assert!(path_starts_with(
377            Path::new(r"c:\USERS\dev\app\bin\x.exe"),
378            root
379        ));
380        assert!(!path_starts_with(Path::new(r"c:\users\dev\apps"), root));
381    }
382
383    #[cfg(not(windows))]
384    #[test]
385    fn path_helpers_are_case_sensitive_off_windows() {
386        let root = Path::new("/home/dev/App");
387        assert!(!paths_equal(root, Path::new("/home/dev/app")));
388        assert!(!path_starts_with(Path::new("/home/dev/app/bin"), root));
389    }
390
391    #[cfg(unix)]
392    #[test]
393    fn preferred_home_uid_from_env_prefers_sudo_uid_for_root_sessions() {
394        assert_eq!(
395            preferred_home_uid_from_env(Some(OsStr::new("1000")), 0),
396            Some(1000),
397            "sudo sessions should prefer the invoking user's uid"
398        );
399    }
400
401    #[cfg(unix)]
402    #[test]
403    fn preferred_home_uid_from_env_ignores_sudo_uid_for_non_root_sessions() {
404        assert_eq!(
405            preferred_home_uid_from_env(Some(OsStr::new("1000")), 2000),
406            Some(2000),
407            "non-root sessions should keep the current effective uid"
408        );
409    }
410
411    #[cfg(unix)]
412    #[test]
413    fn preferred_home_uid_from_env_falls_back_when_sudo_uid_is_invalid() {
414        assert_eq!(
415            preferred_home_uid_from_env(Some(OsStr::new("not-a-uid")), 0),
416            Some(0),
417            "invalid sudo metadata should not break home-directory lookup"
418        );
419    }
420
421    #[cfg(unix)]
422    #[test]
423    fn select_home_dir_prefers_passwd_lookup_over_sudo_home() {
424        let passwd_home = Some(PathBuf::from("/home/invoking-user"));
425        let sudo_home = Some(PathBuf::from("/root"));
426        let env_home = Some(PathBuf::from("/tmp/fallback"));
427
428        assert_eq!(
429            select_home_dir(passwd_home.clone(), sudo_home, env_home),
430            passwd_home,
431            "passwd-database resolution should win over environment-derived sudo home"
432        );
433    }
434
435    #[cfg(unix)]
436    #[test]
437    fn select_home_dir_falls_back_to_sudo_home_before_home_env() {
438        let sudo_home = Some(PathBuf::from("/home/invoking-user"));
439        let env_home = Some(PathBuf::from("/root"));
440
441        assert_eq!(
442            select_home_dir(None, sudo_home.clone(), env_home),
443            sudo_home,
444            "sudo home should remain the fallback when passwd lookup is unavailable"
445        );
446    }
447}