use std::path::PathBuf;
pub fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub fn resolve_shell(choice: &str) -> String {
if let Some(s) = std::env::var_os("BOHAY_SHELL") {
if !s.is_empty() {
return s.to_string_lossy().into_owned();
}
}
match choice {
"" | "default" => platform_default_shell(),
"powershell" => find_on_path("pwsh.exe")
.or_else(|| find_on_path("pwsh"))
.or_else(|| find_on_path("powershell.exe"))
.unwrap_or_else(platform_default_shell),
"cmd" => std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()),
other => other.to_string(),
}
}
#[cfg(windows)]
fn platform_default_shell() -> String {
find_on_path("pwsh.exe")
.or_else(|| find_on_path("powershell.exe"))
.unwrap_or_else(|| std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()))
}
#[cfg(not(windows))]
fn platform_default_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
pub fn shell_run_then_interactive(shell: &str, cmd: &str) -> Option<Vec<String>> {
if shell.contains('\'') {
return None; }
let base = std::path::Path::new(shell)
.file_name()?
.to_str()?
.to_ascii_lowercase();
match base.strip_suffix(".exe").unwrap_or(&base) {
"sh" | "bash" | "zsh" | "dash" | "ksh" | "fish" => Some(vec![
shell.to_string(),
"-c".to_string(),
format!("{cmd}; exec '{shell}'"),
]),
"pwsh" | "powershell" => Some(vec![
shell.to_string(),
"-NoExit".to_string(),
"-Command".to_string(),
cmd.to_string(),
]),
_ => None,
}
}
fn find_on_path(exe: &str) -> Option<String> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(exe))
.find(|full| full.is_file())
.map(|full| full.to_string_lossy().into_owned())
}
#[cfg(windows)]
pub fn shell_choices() -> &'static [(&'static str, &'static str)] {
&[
("default", "Default"),
("powershell", "PowerShell"),
("cmd", "Command Prompt"),
]
}
#[cfg(windows)]
pub fn shell_label(choice: &str) -> &str {
shell_choices()
.iter()
.find(|(k, _)| *k == choice)
.map(|(_, label)| *label)
.unwrap_or(choice)
}
#[cfg(target_os = "macos")]
pub fn process_cwd(pid: u32) -> Option<PathBuf> {
use std::mem;
unsafe {
let mut info: libc::proc_vnodepathinfo = mem::zeroed();
let size = mem::size_of::<libc::proc_vnodepathinfo>() as libc::c_int;
let n = libc::proc_pidinfo(
pid as libc::c_int,
libc::PROC_PIDVNODEPATHINFO,
0,
&mut info as *mut _ as *mut libc::c_void,
size,
);
if n < size {
return None;
}
let raw = std::slice::from_raw_parts(
info.pvi_cdir.vip_path.as_ptr() as *const u8,
mem::size_of_val(&info.pvi_cdir.vip_path),
);
let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
if end == 0 {
return None;
}
Some(PathBuf::from(
String::from_utf8_lossy(&raw[..end]).into_owned(),
))
}
}
#[cfg(target_os = "linux")]
pub fn process_cwd(pid: u32) -> Option<PathBuf> {
std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub fn process_cwd(_pid: u32) -> Option<PathBuf> {
None
}
#[derive(Clone, Debug, PartialEq)]
pub struct ProcInfo {
pub pid: u32,
pub depth: u16,
pub command: String,
}
#[cfg(unix)]
pub fn process_tree(root: u32) -> Vec<ProcInfo> {
use std::collections::HashMap;
let out = match std::process::Command::new("ps")
.args(["-Ao", "pid=,ppid=,args="])
.output()
{
Ok(o) if o.status.success() => o.stdout,
_ => return Vec::new(),
};
let text = String::from_utf8_lossy(&out);
let mut cmd: HashMap<u32, String> = HashMap::new();
let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
for line in text.lines() {
let mut it = line.split_whitespace();
let (Some(pid), Some(ppid)) = (it.next(), it.next()) else {
continue;
};
let (Ok(pid), Ok(ppid)) = (pid.parse::<u32>(), ppid.parse::<u32>()) else {
continue;
};
let rest = line
.splitn(3, |c: char| c.is_whitespace())
.nth(2)
.unwrap_or("")
.trim_start();
if rest.is_empty() {
continue;
}
cmd.insert(pid, rest.to_string());
children.entry(ppid).or_default().push(pid);
}
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut stack = vec![(root, 0u16)];
while let Some((pid, depth)) = stack.pop() {
if !seen.insert(pid) || out.len() >= 64 {
continue;
}
if let Some(c) = cmd.get(&pid) {
out.push(ProcInfo {
pid,
depth,
command: c.clone(),
});
}
if let Some(kids) = children.get(&pid) {
for &k in kids.iter().rev() {
stack.push((k, depth.saturating_add(1)));
}
}
}
out
}
#[cfg(not(unix))]
pub fn process_tree(_root: u32) -> Vec<ProcInfo> {
Vec::new()
}
#[must_use]
pub fn high_res_timer() -> TimerGuard {
#[cfg(windows)]
unsafe {
timeBeginPeriod(1);
}
TimerGuard
}
pub struct TimerGuard;
impl Drop for TimerGuard {
fn drop(&mut self) {
#[cfg(windows)]
unsafe {
timeEndPeriod(1);
}
}
}
#[cfg(windows)]
#[link(name = "winmm")]
extern "system" {
fn timeBeginPeriod(u_period: u32) -> u32;
fn timeEndPeriod(u_period: u32) -> u32;
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
#[test]
fn process_tree_finds_this_process_and_its_children() {
let me = std::process::id();
let tree = super::process_tree(me);
assert!(!tree.is_empty(), "the root process itself is listed");
let root = &tree[0];
assert_eq!(root.pid, me);
assert_eq!(root.depth, 0);
assert!(!root.command.is_empty(), "the command line is captured");
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let tree = super::process_tree(me);
let found = tree
.iter()
.find(|p| p.pid == child.id())
.expect("the child is in the tree");
assert!(found.depth >= 1, "the child nests under us");
assert!(
found.command.contains("sleep") && found.command.contains("30"),
"full argv, not truncated: {:?}",
found.command
);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn run_then_interactive_covers_shell_families() {
let argv = super::shell_run_then_interactive("/bin/zsh", "claude --resume 'abc'").unwrap();
assert_eq!(argv[0], "/bin/zsh");
assert_eq!(argv[1], "-c");
assert_eq!(argv[2], "claude --resume 'abc'; exec '/bin/zsh'");
assert!(super::shell_run_then_interactive("/usr/bin/fish", "x").is_some());
let ps = super::shell_run_then_interactive("pwsh.exe", "codex resume 'a'").unwrap();
assert_eq!(ps[1], "-NoExit");
assert_eq!(ps[3], "codex resume 'a'");
assert!(super::shell_run_then_interactive("cmd.exe", "x").is_none());
assert!(super::shell_run_then_interactive("/opt/o'dd/zsh", "x").is_none());
}
#[cfg(unix)]
#[test]
fn shell_override_is_honored() {
std::env::set_var("BOHAY_SHELL", "/bin/sh");
assert_eq!(super::resolve_shell("default"), "/bin/sh");
assert_eq!(super::resolve_shell("zsh"), "/bin/sh");
std::env::remove_var("BOHAY_SHELL");
}
#[cfg(windows)]
#[test]
fn shell_choices_have_labels() {
for (keyword, label) in super::shell_choices() {
assert!(!label.is_empty());
assert_eq!(super::shell_label(keyword), *label);
}
assert_eq!(super::shell_label("nu"), "nu");
}
}