use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellKind {
Bourne,
PowerShell,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Shell {
pub path: PathBuf,
pub kind: ShellKind,
}
impl Shell {
fn new(path: PathBuf) -> Self {
Self {
kind: kind_for(&path),
path,
}
}
pub fn invocation(&self) -> String {
match self.kind {
ShellKind::Bourne => format!("{} -c", self.path.display()),
ShellKind::PowerShell => format!("{} -NoProfile -Command", self.path.display()),
}
}
pub fn apply(&self, cmd: &mut tokio::process::Command, command: &str) {
match self.kind {
ShellKind::Bourne => {
cmd.arg("-c").arg(command);
}
ShellKind::PowerShell => {
cmd.arg("-NoProfile").arg("-Command").arg(command);
}
}
}
}
fn kind_for(path: &Path) -> ShellKind {
let name = path
.as_os_str()
.to_string_lossy()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_lowercase();
let stem = name.strip_suffix(".exe").unwrap_or(&name);
if stem == "pwsh" || stem.contains("powershell") {
ShellKind::PowerShell
} else {
ShellKind::Bourne
}
}
pub struct Resolution {
pub shell: Shell,
pub warning: Option<String>,
}
pub fn resolve(configured: Option<&str>) -> Resolution {
let configured = configured.filter(|p| !p.trim().is_empty());
if let Some(path) = configured {
if let Some(shell) = locate(path) {
return Resolution {
shell,
warning: None,
};
}
let default = default_shell();
return Resolution {
warning: Some(format!(
"configured shell '{path}' was not found; falling back to the default shell ({}). \
Fix `shell.path` in config.kdl or remove it to use the default.",
default.path.display()
)),
shell: default,
};
}
Resolution {
shell: default_shell(),
warning: None,
}
}
fn locate(path: &str) -> Option<Shell> {
let candidate = Path::new(path);
if candidate.components().count() > 1 && is_executable_file(candidate) {
return Some(Shell::new(candidate.to_path_buf()));
}
find_in_path(path).map(Shell::new)
}
fn find_in_path(name: &str) -> Option<PathBuf> {
let dirs = std::env::var_os("PATH")?;
find_in_dirs(name, std::env::split_paths(&dirs))
}
fn find_in_dirs<I>(name: &str, dirs: I) -> Option<PathBuf>
where
I: IntoIterator<Item = PathBuf>,
{
dirs.into_iter()
.map(|dir| dir.join(name))
.find(|candidate| is_executable_file(candidate))
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
#[cfg(windows)]
fn is_executable_file(path: &Path) -> bool {
std::fs::metadata(path)
.map(|meta| meta.is_file())
.unwrap_or(false)
}
#[cfg(unix)]
fn default_shell() -> Shell {
#[cfg(target_os = "macos")]
const CANDIDATES: &[&str] = &["bash", "zsh", "sh"];
#[cfg(all(unix, not(target_os = "macos")))]
const CANDIDATES: &[&str] = &["bash", "sh"];
for name in CANDIDATES {
if let Some(path) = find_in_path(name) {
return Shell::new(path);
}
}
Shell::new(PathBuf::from("sh"))
}
#[cfg(windows)]
fn default_shell() -> Shell {
for root in git_bash_roots() {
let candidate = root.join("bin").join("bash.exe");
if is_executable_file(&candidate) {
return Shell::new(candidate);
}
}
for name in ["bash.exe", "pwsh.exe", "powershell.exe"] {
if let Some(path) = find_in_path(name) {
return Shell::new(path);
}
}
Shell::new(PathBuf::from("powershell.exe"))
}
#[cfg(windows)]
fn git_bash_roots() -> Vec<PathBuf> {
let mut roots = vec![
PathBuf::from(r"C:\Program Files\Git"),
PathBuf::from(r"C:\Program Files (x86)\Git"),
];
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
roots.push(PathBuf::from(local).join("Programs").join("Git"));
}
roots
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_detected_from_executable_name() {
assert_eq!(kind_for(Path::new("/usr/bin/bash")), ShellKind::Bourne);
assert_eq!(kind_for(Path::new("/bin/sh")), ShellKind::Bourne);
assert_eq!(kind_for(Path::new("/usr/bin/zsh")), ShellKind::Bourne);
assert_eq!(
kind_for(Path::new(r"C:\Program Files\PowerShell\7\pwsh.exe")),
ShellKind::PowerShell
);
assert_eq!(
kind_for(Path::new(
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
)),
ShellKind::PowerShell
);
}
#[test]
fn apply_uses_bourne_flags() {
let shell = Shell::new(PathBuf::from("/bin/sh"));
let mut cmd = tokio::process::Command::new(&shell.path);
shell.apply(&mut cmd, "echo hi");
assert_eq!(
cmd.as_std().get_args().collect::<Vec<_>>(),
["-c", "echo hi"]
);
}
#[test]
fn apply_uses_powershell_flags() {
let shell = Shell::new(PathBuf::from(r"C:\Windows\System32\pwsh.exe"));
let mut cmd = tokio::process::Command::new(&shell.path);
shell.apply(&mut cmd, "echo hi");
assert_eq!(
cmd.as_std().get_args().collect::<Vec<_>>(),
["-NoProfile", "-Command", "echo hi"]
);
}
#[test]
fn resolve_defaults_without_config() {
let resolution = resolve(None);
assert!(resolution.warning.is_none());
assert!(!resolution.shell.path.as_os_str().is_empty());
}
#[test]
fn resolve_blank_config_uses_default() {
for configured in ["", " "] {
let resolution = resolve(Some(configured));
assert!(resolution.warning.is_none(), "{configured}");
assert!(!resolution.shell.path.as_os_str().is_empty());
}
}
#[test]
fn resolve_missing_configured_path_warns_and_falls_back() {
let default = resolve(None).shell;
let resolution = resolve(Some("/definitely/not/a/real/shell"));
assert!(resolution.warning.is_some());
assert_eq!(resolution.shell, default);
let warning = resolution.warning.unwrap();
assert!(
warning.contains("/definitely/not/a/real/shell"),
"{warning}"
);
assert!(
warning.contains(default.path.to_string_lossy().as_ref()),
"{warning}"
);
}
#[cfg(unix)]
#[test]
fn resolve_absolute_path_wins() {
let resolution = resolve(Some("/bin/sh"));
assert!(resolution.warning.is_none());
assert_eq!(resolution.shell.path, PathBuf::from("/bin/sh"));
}
#[test]
fn find_in_dirs_locates_executable() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("shuvarie-fake-shell");
std::fs::write(&script, "").unwrap();
set_executable(&script);
let found = find_in_dirs("shuvarie-fake-shell", [dir.path().to_path_buf()]);
assert_eq!(found, Some(script));
assert_eq!(
find_in_dirs("shuvarie-missing-shell", [dir.path().to_path_buf()]),
None
);
}
#[cfg(unix)]
fn set_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
#[cfg(windows)]
fn set_executable(_path: &Path) {}
}