1use 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
45pub const MAX_WALK_DEPTH: usize = 64;
51
52#[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
92pub 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
105fn 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
133fn 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
144pub 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
150fn 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#[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#[must_use]
201pub fn project_name(root: &Path) -> Option<Cow<'_, str>> {
202 root.file_name().map(OsStr::to_string_lossy)
203}
204
205#[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 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
306pub 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
329pub 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#[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 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 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 let passwd = unsafe { passwd.assume_init() };
452 if passwd.pw_dir.is_null() {
453 return None;
454 }
455
456 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 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 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}