1use 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
41pub const MAX_WALK_DEPTH: usize = 64;
47
48#[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
78pub 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#[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#[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
188pub 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
211pub 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#[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 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 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 let passwd = unsafe { passwd.assume_init() };
334 if passwd.pw_dir.is_null() {
335 return None;
336 }
337
338 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 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}