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`, `.fsproj`, `.sln`, and `.slnx`.
6
7use std::borrow::Cow;
8use std::ffi::{OsStr, OsString};
9use std::path::{Component, Path, PathBuf};
10
11#[cfg(unix)]
12use std::ffi::CStr;
13#[cfg(unix)]
14use std::os::unix::ffi::OsStrExt;
15
16use crate::process::find_process_rule_by_names;
17use crate::text::file_extension;
18use crate::{ProjectInput, StackKind};
19
20const PROJECT_MARKERS: &[&str] = &[
21    "package.json",
22    "Cargo.toml",
23    "go.mod",
24    "go.work",
25    "pyproject.toml",
26    "requirements.txt",
27    "setup.py",
28    "Pipfile",
29    "pom.xml",
30    "build.gradle",
31    "build.gradle.kts",
32    "settings.gradle",
33    "settings.gradle.kts",
34    "composer.json",
35    "Gemfile",
36    "mix.exs",
37    "deno.json",
38    "deno.jsonc",
39    "bun.lockb",
40    "bun.lock",
41];
42
43const PROJECT_MARKER_EXTENSIONS: &[&str] = &["csproj", "fsproj", "sln", "slnx"];
44
45/// Maximum number of directories tested during one upward project walk.
46///
47/// The starting directory counts as the first. This cap is a safety net for
48/// unusual paths and very deep directory trees. The value is intentionally high
49/// enough for common monorepos while bounding worst-case filesystem work.
50pub const MAX_WALK_DEPTH: usize = 64;
51
52/// Resolve a project root from process-like path inputs without caching.
53///
54/// The fallback order is:
55///
56/// 1. Walk upward from the working directory set by [`ProjectInput::cwd`].
57/// 2. Walk upward from the parent directory of the executable set by
58///    [`ProjectInput::exe`], unless the executable is a known runtime or tool
59///    such as `node`, `python`, or `cargo`. A known runtime inside a Python
60///    virtual environment (a directory with `pyvenv.cfg` one or two levels
61///    above the executable, as in `app/.venv/bin/python` or
62///    `app\.venv\Scripts\python.exe`) walks from the directory holding the
63///    environment instead.
64/// 3. Walk upward from the parent directory of each absolute path in the
65///    arguments set by [`ProjectInput::cmd`].
66///
67/// The first accepted marker hit wins. Relative command-line paths are ignored
68/// because they are ambiguous without a reliable process working directory.
69/// `home` is the same optional ceiling as in [`find_project_root`]. A root
70/// found from the executable path is rejected when it lies inside a dot
71/// directory directly under `home` (`~/.nvm`, `~/.cargo`, `~/.local`): those
72/// hold installed toolchains and packages, not the process's project.
73///
74/// Use [`StackDetector::detect_project_root`](crate::StackDetector::detect_project_root)
75/// for repeated lookups; it applies the same order with caching and its own
76/// configured home ceiling.
77///
78/// # Examples
79///
80/// ```
81/// use what_stack::{ProjectInput, resolve_project_root};
82///
83/// assert_eq!(resolve_project_root(ProjectInput::new(), None), None);
84/// ```
85#[must_use]
86pub fn resolve_project_root(input: ProjectInput<'_>, home: Option<&Path>) -> Option<PathBuf> {
87    project_root_candidates(input).find_map(|(start, from_exe)| {
88        find_project_root(start, home).filter(|root| accepts_root(root, from_exe, home))
89    })
90}
91
92/// Starting directories for a project walk, in fallback order, each with
93/// whether it came from the executable path.
94pub fn project_root_candidates(
95    input: ProjectInput<'_>,
96) -> impl Iterator<Item = (&Path, bool)> + '_ {
97    input
98        .cwd
99        .into_iter()
100        .map(|cwd| (cwd, false))
101        .chain(exe_walk_start(input.exe).map(|start| (start, true)))
102        .chain(absolute_cmd_parents(input.cmd).map(|start| (start, false)))
103}
104
105/// The executable parent to walk from, or `None` when the executable is a
106/// known runtime or tool. An installed `node` or `python` says nothing about
107/// the project it runs, and walking up from its directory finds the install
108/// tree instead (`~/.nvm/versions/node/v20/bin/node`).
109///
110/// A known runtime inside a Python virtual environment is the exception: the
111/// environment usually belongs to the project around it (`app/.venv/bin/python`
112/// or `app\.venv\Scripts\python.exe`), so the walk starts at the environment
113/// directory itself. A separate `.venv` holds no project markers, so the walk
114/// reaches `app`; an environment created in place (`python -m venv .`) is the
115/// project directory and is found directly. Environments under a home dot directory
116/// (`~/.virtualenvs`, `~/.pyenv`) still yield no root, because
117/// [`accepts_root`] rejects roots found from the executable there.
118fn exe_walk_start(exe: Option<&Path>) -> Option<&Path> {
119    let exe = exe?;
120    let known_host = exe
121        .file_name()
122        .and_then(OsStr::to_str)
123        .and_then(|name| find_process_rule_by_names(name, None))
124        .is_some_and(|(_, label, _)| matches!(label.kind(), StackKind::Runtime | StackKind::Tool));
125
126    if known_host {
127        virtual_env_dir(exe)
128    } else {
129        exe.parent()
130    }
131}
132
133/// The Python virtual environment holding `exe`: its parent (`Scripts` on
134/// Windows, `bin` elsewhere) or grandparent when that directory contains
135/// `pyvenv.cfg`, which `venv`, `virtualenv`, and `uv` all write. Conda
136/// environments have no `pyvenv.cfg` and are not matched.
137fn virtual_env_dir(exe: &Path) -> Option<&Path> {
138    exe.ancestors()
139        .skip(1)
140        .take(2)
141        .find(|dir| dir.join("pyvenv.cfg").is_file())
142}
143
144/// Whether a root found from a walk start may be used. Roots found from the
145/// executable path must not lie inside a dot directory directly under `home`.
146pub fn accepts_root(root: &Path, from_exe: bool, home: Option<&Path>) -> bool {
147    !from_exe || home.is_none_or(|home| !is_inside_home_dot_dir(root, home))
148}
149
150/// Whether `path` is at or below `home/.name` for some dot directory `.name`.
151fn is_inside_home_dot_dir(path: &Path, home: &Path) -> bool {
152    path_starts_with(path, home)
153        && path
154            .components()
155            .nth(home.components().count())
156            .is_some_and(|component| {
157                matches!(component, Component::Normal(name) if name.as_encoded_bytes().starts_with(b"."))
158            })
159}
160
161/// Walk upward from `start` looking for project marker files.
162///
163/// `home` is an optional ceiling. When the walk reaches `home`, it stops before
164/// testing that directory for markers. This avoids accidental matches from
165/// marker files stored directly in a user's home directory. On Windows the
166/// ceiling comparison ignores ASCII case, matching the file system.
167///
168/// At most [`MAX_WALK_DEPTH`] directories are tested, starting with `start`.
169/// A relative `start` is walked lexically and ends at the current directory,
170/// so `src` tests `src` and then `.`, and a hit there is returned as `.`.
171///
172/// # Examples
173///
174/// ```
175/// use std::path::Path;
176/// use what_stack::find_project_root;
177///
178/// assert_eq!(find_project_root(Path::new("."), Some(Path::new("."))), None);
179/// ```
180#[must_use]
181pub fn find_project_root(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
182    Walk::new(start, home)
183        .find(|dir| has_marker(dir))
184        .map(Path::to_path_buf)
185}
186
187/// Return the display name for a project root path.
188///
189/// The returned label is derived only from the final path component. It does
190/// not read package manifests or normalize workspace names.
191///
192/// # Examples
193///
194/// ```
195/// use std::path::Path;
196/// use what_stack::project_name;
197///
198/// assert_eq!(project_name(Path::new("/workspace/api")).as_deref(), Some("api"));
199/// ```
200#[must_use]
201pub fn project_name(root: &Path) -> Option<Cow<'_, str>> {
202    root.file_name().map(OsStr::to_string_lossy)
203}
204
205/// Upward walk over the directories tested for project markers, nearest
206/// first.
207///
208/// The walk stops before `home` and after [`MAX_WALK_DEPTH`] directories. It
209/// is lexical, so the parent of a relative single-name start such as `src` is
210/// the empty path; that stands for the current directory and is tested as `.`.
211///
212/// After the walk returns `None`, [`hit_depth_cap`](Self::hit_depth_cap) tells
213/// whether it ended because of the depth cap while untested ancestors
214/// remained, rather than at the file system root or the home ceiling.
215#[derive(Debug)]
216pub struct Walk<'a> {
217    ancestors: Option<std::path::Ancestors<'a>>,
218    home: Option<&'a Path>,
219    empty_means_current: bool,
220    remaining: usize,
221    hit_depth_cap: bool,
222}
223
224impl<'a> Walk<'a> {
225    pub fn new(start: &'a Path, home: Option<&'a Path>) -> Self {
226        Self {
227            ancestors: Some(start.ancestors()),
228            home,
229            empty_means_current: matches!(start.components().next(), Some(Component::Normal(_))),
230            remaining: MAX_WALK_DEPTH,
231            hit_depth_cap: false,
232        }
233    }
234
235    /// Whether the walk stopped at [`MAX_WALK_DEPTH`] with ancestors left to
236    /// test. A walk that stopped there is not a complete answer for the
237    /// directories it visited: a walk starting at one of them could reach
238    /// further up.
239    pub const fn hit_depth_cap(&self) -> bool {
240        self.hit_depth_cap
241    }
242
243    fn next_ancestor(&mut self) -> Option<&'a Path> {
244        let ancestors = self.ancestors.as_mut()?;
245        let dir = ancestors.next()?;
246
247        if !dir.as_os_str().is_empty() {
248            Some(dir)
249        } else if self.empty_means_current {
250            Some(Path::new("."))
251        } else {
252            None
253        }
254    }
255}
256
257impl<'a> Iterator for Walk<'a> {
258    type Item = &'a Path;
259
260    fn next(&mut self) -> Option<&'a Path> {
261        let dir = self.next_ancestor();
262        let stop = match dir {
263            None => true,
264            Some(dir) if self.home.is_some_and(|home| paths_equal(dir, home)) => true,
265            Some(_) if self.remaining == 0 => {
266                self.hit_depth_cap = true;
267                true
268            }
269            Some(_) => false,
270        };
271
272        if stop {
273            self.ancestors = None;
274            return None;
275        }
276
277        self.remaining -= 1;
278        dir
279    }
280}
281
282fn absolute_cmd_parents(cmd: &[OsString]) -> impl Iterator<Item = &Path> + '_ {
283    cmd.iter().filter_map(|arg| {
284        let path = Path::new(arg.as_os_str());
285        path.is_absolute().then(|| path.parent()).flatten()
286    })
287}
288
289pub fn has_marker(dir: &Path) -> bool {
290    let Ok(entries) = std::fs::read_dir(dir) else {
291        return false;
292    };
293
294    entries.filter_map(Result::ok).any(|entry| {
295        let file_name = entry.file_name();
296        let Some(name) = file_name.to_str() else {
297            return false;
298        };
299
300        PROJECT_MARKERS.contains(&name)
301            || file_extension(name)
302                .is_some_and(|extension| PROJECT_MARKER_EXTENSIONS.contains(&extension))
303    })
304}
305
306/// Compare two paths component by component.
307///
308/// Windows file systems are case-insensitive, so components are compared
309/// ignoring ASCII case there. Other platforms compare exactly.
310pub fn paths_equal(left: &Path, right: &Path) -> bool {
311    #[cfg(windows)]
312    {
313        let mut left = left.components();
314        let mut right = right.components();
315        loop {
316            match (left.next(), right.next()) {
317                (None, None) => return true,
318                (Some(a), Some(b)) if components_equal(a, b) => {}
319                _ => return false,
320            }
321        }
322    }
323    #[cfg(not(windows))]
324    {
325        left == right
326    }
327}
328
329/// Return whether `path` lies at or below `prefix`, comparing whole components
330/// with the same case rules as [`paths_equal`].
331pub fn path_starts_with(path: &Path, prefix: &Path) -> bool {
332    #[cfg(windows)]
333    {
334        let mut path = path.components();
335        prefix.components().all(|expected| {
336            path.next()
337                .is_some_and(|actual| components_equal(actual, expected))
338        })
339    }
340    #[cfg(not(windows))]
341    {
342        path.starts_with(prefix)
343    }
344}
345
346#[cfg(windows)]
347fn components_equal(left: std::path::Component<'_>, right: std::path::Component<'_>) -> bool {
348    left.as_os_str().eq_ignore_ascii_case(right.as_os_str())
349}
350
351/// Return the current user's home directory, when it can be determined.
352///
353/// On Unix, this prefers passwd-database lookup for the invoking user. During
354/// `sudo` sessions it uses `SUDO_UID` to find the original user's home, falling
355/// back to `SUDO_HOME` and then `HOME` when passwd lookup is unavailable. On
356/// Windows, it reads `USERPROFILE`. On other targets (for example `wasm32`), it
357/// returns `None`.
358///
359/// [`StackDetector::new`](crate::StackDetector::new) calls this once to set its
360/// home ceiling. Callers of [`find_project_root`] or [`resolve_project_root`]
361/// should call it once and reuse the result.
362#[must_use]
363pub fn home_dir() -> Option<PathBuf> {
364    #[cfg(unix)]
365    {
366        select_home_dir(
367            preferred_home_uid().and_then(home_dir_from_uid),
368            sudo_home_dir(),
369            std::env::var_os("HOME").map(PathBuf::from),
370        )
371    }
372    #[cfg(windows)]
373    {
374        std::env::var_os("USERPROFILE").map(PathBuf::from)
375    }
376    #[cfg(not(any(unix, windows)))]
377    {
378        None
379    }
380}
381
382#[cfg(unix)]
383fn select_home_dir(
384    passwd_home: Option<PathBuf>,
385    sudo_home: Option<PathBuf>,
386    env_home: Option<PathBuf>,
387) -> Option<PathBuf> {
388    passwd_home.or(sudo_home).or(env_home)
389}
390
391#[cfg(unix)]
392fn sudo_home_dir() -> Option<PathBuf> {
393    (current_effective_uid() == 0)
394        .then(|| std::env::var_os("SUDO_HOME"))
395        .flatten()
396        .filter(|home| !home.is_empty())
397        .map(PathBuf::from)
398}
399
400#[cfg(unix)]
401fn preferred_home_uid() -> Option<libc::uid_t> {
402    preferred_home_uid_from_env(
403        std::env::var_os("SUDO_UID").as_deref(),
404        current_effective_uid(),
405    )
406}
407
408#[cfg(unix)]
409fn preferred_home_uid_from_env(
410    sudo_uid: Option<&OsStr>,
411    current_euid: libc::uid_t,
412) -> Option<libc::uid_t> {
413    if current_euid == 0 {
414        sudo_uid
415            .and_then(OsStr::to_str)
416            .and_then(|value| value.parse::<libc::uid_t>().ok())
417            .or(Some(current_euid))
418    } else {
419        Some(current_euid)
420    }
421}
422
423#[cfg(unix)]
424fn current_effective_uid() -> libc::uid_t {
425    // Safety: `geteuid` has no preconditions and only returns the caller's euid.
426    unsafe { libc::geteuid() }
427}
428
429#[cfg(unix)]
430fn home_dir_from_uid(uid: libc::uid_t) -> Option<PathBuf> {
431    let mut buffer = vec![0_u8; passwd_buffer_len()];
432    let mut passwd = std::mem::MaybeUninit::<libc::passwd>::zeroed();
433    let mut result = std::ptr::null_mut();
434    // Safety: all pointers reference valid stack/heap storage for this call,
435    // and `buffer` remains alive until `passwd.pw_dir` has been copied.
436    let status = unsafe {
437        libc::getpwuid_r(
438            uid,
439            passwd.as_mut_ptr(),
440            buffer.as_mut_ptr().cast(),
441            buffer.len(),
442            &raw mut result,
443        )
444    };
445
446    if status != 0 || result.is_null() {
447        return None;
448    }
449
450    // Safety: `getpwuid_r` succeeded and initialized `passwd`.
451    let passwd = unsafe { passwd.assume_init() };
452    if passwd.pw_dir.is_null() {
453        return None;
454    }
455
456    // Safety: successful passwd records expose a nul-terminated directory path.
457    let home = unsafe { CStr::from_ptr(passwd.pw_dir) };
458    Some(Path::new(OsStr::from_bytes(home.to_bytes())).to_path_buf())
459}
460
461#[cfg(unix)]
462fn passwd_buffer_len() -> usize {
463    const DEFAULT_PASSWD_BUFFER_LEN: usize = 1024;
464
465    // Safety: `sysconf` has no preconditions for this constant.
466    match unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) } {
467        size if size > 0 => usize::try_from(size).unwrap_or(DEFAULT_PASSWD_BUFFER_LEN),
468        _ => DEFAULT_PASSWD_BUFFER_LEN,
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn path_helpers_match_exact_paths_and_whole_component_prefixes() {
478        let root = Path::new("/workspace/app");
479        assert!(paths_equal(root, Path::new("/workspace/app")));
480        assert!(!paths_equal(root, Path::new("/workspace/app/src")));
481        assert!(!paths_equal(root, Path::new("/workspace")));
482        assert!(path_starts_with(Path::new("/workspace/app/bin/x"), root));
483        assert!(path_starts_with(root, root));
484        assert!(!path_starts_with(Path::new("/workspace/application"), root));
485        assert!(!path_starts_with(Path::new("/workspace"), root));
486    }
487
488    #[cfg(windows)]
489    #[test]
490    fn path_helpers_ignore_ascii_case_on_windows() {
491        let root = Path::new(r"C:\Users\Dev\App");
492        assert!(paths_equal(root, Path::new(r"c:\users\dev\app")));
493        assert!(paths_equal(root, Path::new("c:/USERS/dev/app")));
494        assert!(path_starts_with(
495            Path::new(r"c:\USERS\dev\app\bin\x.exe"),
496            root
497        ));
498        assert!(!path_starts_with(Path::new(r"c:\users\dev\apps"), root));
499    }
500
501    #[cfg(not(windows))]
502    #[test]
503    fn path_helpers_are_case_sensitive_off_windows() {
504        let root = Path::new("/home/dev/App");
505        assert!(!paths_equal(root, Path::new("/home/dev/app")));
506        assert!(!path_starts_with(Path::new("/home/dev/app/bin"), root));
507    }
508
509    #[cfg(unix)]
510    #[test]
511    fn preferred_home_uid_from_env_prefers_sudo_uid_for_root_sessions() {
512        assert_eq!(
513            preferred_home_uid_from_env(Some(OsStr::new("1000")), 0),
514            Some(1000),
515            "sudo sessions should prefer the invoking user's uid"
516        );
517    }
518
519    #[cfg(unix)]
520    #[test]
521    fn preferred_home_uid_from_env_ignores_sudo_uid_for_non_root_sessions() {
522        assert_eq!(
523            preferred_home_uid_from_env(Some(OsStr::new("1000")), 2000),
524            Some(2000),
525            "non-root sessions should keep the current effective uid"
526        );
527    }
528
529    #[cfg(unix)]
530    #[test]
531    fn preferred_home_uid_from_env_falls_back_when_sudo_uid_is_invalid() {
532        assert_eq!(
533            preferred_home_uid_from_env(Some(OsStr::new("not-a-uid")), 0),
534            Some(0),
535            "invalid sudo metadata should not break home-directory lookup"
536        );
537    }
538
539    #[cfg(unix)]
540    #[test]
541    fn select_home_dir_prefers_passwd_lookup_over_sudo_home() {
542        let passwd_home = Some(PathBuf::from("/home/invoking-user"));
543        let sudo_home = Some(PathBuf::from("/root"));
544        let env_home = Some(PathBuf::from("/tmp/fallback"));
545
546        assert_eq!(
547            select_home_dir(passwd_home.clone(), sudo_home, env_home),
548            passwd_home,
549            "passwd-database resolution should win over environment-derived sudo home"
550        );
551    }
552
553    #[cfg(unix)]
554    #[test]
555    fn select_home_dir_falls_back_to_sudo_home_before_home_env() {
556        let sudo_home = Some(PathBuf::from("/home/invoking-user"));
557        let env_home = Some(PathBuf::from("/root"));
558
559        assert_eq!(
560            select_home_dir(None, sudo_home.clone(), env_home),
561            sudo_home,
562            "sudo home should remain the fallback when passwd lookup is unavailable"
563        );
564    }
565
566    fn walk(start: &str) -> Vec<&Path> {
567        Walk::new(Path::new(start), None).collect()
568    }
569
570    #[test]
571    fn relative_walks_end_at_the_current_directory() {
572        assert_eq!(walk("src"), [Path::new("src"), Path::new(".")]);
573        assert_eq!(
574            walk("src/bin"),
575            [Path::new("src/bin"), Path::new("src"), Path::new(".")]
576        );
577        assert_eq!(walk("."), [Path::new(".")]);
578        assert_eq!(walk("./src"), [Path::new("./src"), Path::new(".")]);
579        assert_eq!(walk("../x"), [Path::new("../x"), Path::new("..")]);
580        assert_eq!(walk(""), Vec::<&Path>::new());
581    }
582
583    #[test]
584    fn relative_start_finds_a_marker_in_the_current_directory() {
585        // Tests run with the package root, which holds Cargo.toml, as the
586        // working directory.
587        assert_eq!(
588            find_project_root(Path::new("src"), None).as_deref(),
589            Some(Path::new("."))
590        );
591    }
592
593    #[test]
594    fn walk_reports_whether_the_depth_cap_ended_it() {
595        let deep: PathBuf = (0..=MAX_WALK_DEPTH)
596            .map(|index| format!("d{index}"))
597            .collect();
598        let mut capped = Walk::new(&deep, None);
599        assert_eq!(capped.by_ref().count(), MAX_WALK_DEPTH);
600        assert!(capped.hit_depth_cap());
601        assert_eq!(capped.next(), None, "a finished walk stays finished");
602
603        let exact: PathBuf = (1..MAX_WALK_DEPTH)
604            .map(|index| format!("d{index}"))
605            .collect();
606        let mut complete = Walk::new(&exact, None);
607        assert_eq!(complete.by_ref().count(), MAX_WALK_DEPTH, "the last is `.`");
608        assert!(!complete.hit_depth_cap());
609
610        let home = Path::new("/home/dev");
611        let mut stopped = Walk::new(Path::new("/home/dev/app/src"), Some(home));
612        assert_eq!(stopped.by_ref().count(), 2);
613        assert!(!stopped.hit_depth_cap());
614    }
615}