use std::fs::OpenOptions;
use std::io::Write;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
static LOG_MUTEX: Mutex<()> = Mutex::new(());
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellKind {
Pwsh,
WindowsPowerShell,
Cmd,
Sh,
Bash,
Custom { binary: String, flag: String },
}
impl ShellKind {
pub fn binary(&self) -> &str {
match self {
#[cfg(windows)]
ShellKind::Pwsh => "pwsh.exe",
#[cfg(not(windows))]
ShellKind::Pwsh => "pwsh",
#[cfg(windows)]
ShellKind::WindowsPowerShell => "powershell.exe",
#[cfg(not(windows))]
ShellKind::WindowsPowerShell => "powershell",
#[cfg(windows)]
ShellKind::Cmd => "cmd.exe",
#[cfg(not(windows))]
ShellKind::Cmd => "cmd",
ShellKind::Sh => "sh",
ShellKind::Bash => "bash",
ShellKind::Custom { binary, .. } => binary,
}
}
pub fn command_flag(&self) -> &str {
match self {
ShellKind::Pwsh | ShellKind::WindowsPowerShell => "-NoProfile",
ShellKind::Cmd => "/C",
ShellKind::Sh | ShellKind::Bash => "-c",
ShellKind::Custom { flag, .. } => flag,
}
}
pub fn needs_command_flag(&self) -> bool {
matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell)
}
#[cfg(test)]
pub fn is_powershell(&self) -> bool {
matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell)
}
}
#[derive(Debug, Clone)]
pub struct ShellDispatcher {
kind: ShellKind,
}
#[allow(dead_code)]
impl ShellDispatcher {
pub fn detect() -> Self {
let kind = Self::detect_shell();
Self::log_startup(&kind);
ShellDispatcher { kind }
}
pub fn log_exec(command: &str) {
if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
let _ = Self::append_log_static(&path, command);
}
}
fn log_startup(kind: &ShellKind) {
let _lock = LOG_MUTEX.lock();
if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
let init_line = format!(
"--- ShellDispatcher log started pid={} ---\n",
std::process::id()
);
let _ = Self::append_log(&path, &init_line);
let detect_line = format!("[{}] detect: {kind:?}\n", now_iso());
let _ = Self::append_log(&path, &detect_line);
}
}
fn append_log(path: &str, line: &str) -> std::io::Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(Path::new(path))?;
file.write_all(line.as_bytes())?;
file.flush()
}
fn append_log_static(path: &str, command: &str) -> std::io::Result<()> {
let kind = global_dispatcher().kind();
let _lock = LOG_MUTEX.lock();
let line = format!("[{}] exec via {kind:?}: {command}\n", now_iso());
Self::append_log(path, &line)
}
pub fn kind(&self) -> &ShellKind {
&self.kind
}
pub fn build_command(&self, shell_command: &str) -> Command {
let mut cmd = Command::new(self.kind.binary());
if self.kind.needs_command_flag() {
cmd.arg(self.kind.command_flag());
cmd.arg("-Command");
cmd.arg(shell_command);
} else if matches!(self.kind, ShellKind::Cmd) {
cmd.arg(self.kind.command_flag());
#[cfg(windows)]
{
cmd.raw_arg(shell_command);
}
#[cfg(not(windows))]
{
cmd.arg(shell_command);
}
} else {
cmd.arg(self.kind.command_flag());
cmd.arg(shell_command);
}
cmd
}
pub fn build_command_parts(&self, shell_command: &str) -> (String, Vec<String>) {
let program = self.kind.binary().to_string();
let args = if self.kind.needs_command_flag() {
vec![
self.kind.command_flag().to_string(),
"-Command".to_string(),
shell_command.to_string(),
]
} else {
vec![
self.kind.command_flag().to_string(),
shell_command.to_string(),
]
};
(program, args)
}
#[cfg(test)]
pub fn build_direct(&self, program: &str, args: &[String]) -> Command {
let mut cmd = Command::new(program);
cmd.args(args);
cmd
}
pub fn run_foreground(
&self,
shell_command: &str,
cwd: &std::path::Path,
) -> Result<String, anyhow::Error> {
use anyhow::Context;
{
let _lock = LOG_MUTEX.lock();
if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") {
let kind = self.kind();
let line = format!("[{}] exec via {kind:?}: {shell_command}\n", now_iso());
let _ = Self::append_log(&path, &line);
}
}
let raw_mode_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
if raw_mode_was_enabled {
let _ = crossterm::terminal::disable_raw_mode();
}
struct FgRawModeGuard {
restore: bool,
}
impl Drop for FgRawModeGuard {
fn drop(&mut self) {
if self.restore {
let _ = crossterm::terminal::enable_raw_mode();
}
}
}
let _guard = FgRawModeGuard {
restore: raw_mode_was_enabled,
};
let mut cmd = self.build_command(shell_command);
cmd.current_dir(cwd);
let output = cmd
.output()
.with_context(|| format!("failed to execute shell command: {shell_command}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"shell command failed (status={}): {}",
output.status,
stderr.trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(stdout)
}
fn detect_shell() -> ShellKind {
#[cfg(windows)]
{
if let Ok(shell) = std::env::var("SHELL") {
let lower = shell.to_lowercase();
if lower.contains("bash") {
return ShellKind::Bash;
}
if lower.contains("pwsh") {
return ShellKind::Pwsh;
}
if lower.contains("powershell") {
return ShellKind::WindowsPowerShell;
}
}
if Self::find_exe("pwsh.exe") {
return ShellKind::Pwsh;
}
if Self::find_exe("powershell.exe") {
return ShellKind::WindowsPowerShell;
}
ShellKind::Cmd
}
#[cfg(not(windows))]
{
if let Ok(shell) = std::env::var("SHELL") {
let lower = shell.to_lowercase();
if lower.contains("bash") {
return ShellKind::Bash;
}
if lower.contains("pwsh") {
return ShellKind::Pwsh;
}
if lower.contains("powershell") {
return ShellKind::WindowsPowerShell;
}
return ShellKind::Custom {
binary: shell,
flag: "-c".to_string(),
};
}
ShellKind::Sh
}
}
#[cfg(windows)]
fn find_exe(name: &str) -> bool {
if Self::binary_on_path(name) {
return true;
}
let known_dirs: &[&str] = &[
r"C:\Program Files\PowerShell\7",
r"C:\Windows\System32\WindowsPowerShell\v1.0",
];
known_dirs
.iter()
.any(|dir| std::path::Path::new(dir).join(name).is_file())
}
#[cfg(windows)]
fn binary_on_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|path| {
std::env::split_paths(&path).any(|dir| {
let candidate = dir.join(name);
candidate.is_file()
})
})
.unwrap_or(false)
}
}
fn now_iso() -> String {
chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3f")
.to_string()
}
pub fn global_dispatcher() -> &'static ShellDispatcher {
use std::sync::LazyLock;
static DISPATCHER: LazyLock<ShellDispatcher> = LazyLock::new(ShellDispatcher::detect);
&DISPATCHER
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_kind_binary_names() {
#[cfg(windows)]
{
assert_eq!(ShellKind::Pwsh.binary(), "pwsh.exe");
assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell.exe");
assert_eq!(ShellKind::Cmd.binary(), "cmd.exe");
}
#[cfg(not(windows))]
{
assert_eq!(ShellKind::Pwsh.binary(), "pwsh");
assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell");
assert_eq!(ShellKind::Cmd.binary(), "cmd");
}
assert_eq!(ShellKind::Sh.binary(), "sh");
assert_eq!(ShellKind::Bash.binary(), "bash");
}
#[test]
fn detect_returns_some_shell() {
let dispatcher = global_dispatcher();
let _kind = dispatcher.kind();
}
#[test]
fn powershell_build_command_includes_no_profile_and_command_flags() {
let dispatcher = ShellDispatcher {
kind: ShellKind::Pwsh,
};
let cmd = dispatcher.build_command("echo hello");
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
assert!(args.contains(&"-NoProfile"));
assert!(args.contains(&"-Command"));
assert!(args.contains(&"echo hello"));
}
#[test]
fn cmd_build_command_uses_c_flag() {
let dispatcher = ShellDispatcher {
kind: ShellKind::Cmd,
};
let cmd = dispatcher.build_command("echo hello");
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
assert!(args.contains(&"/C"));
assert!(args.contains(&"echo hello"));
}
#[test]
fn sh_build_command_uses_dash_c() {
let dispatcher = ShellDispatcher {
kind: ShellKind::Sh,
};
let cmd = dispatcher.build_command("echo hello");
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
assert!(args.contains(&"-c"));
assert!(args.contains(&"echo hello"));
}
#[cfg(test)]
#[test]
fn build_direct_preserves_args() {
let dispatcher = ShellDispatcher {
kind: ShellKind::Cmd,
};
let args = vec!["-m".to_string(), "commit message".to_string()];
let cmd = dispatcher.build_direct("git", &args);
let cmd_args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(cmd_args, vec!["-m", "commit message"]);
}
#[cfg(test)]
#[test]
fn powershell_flags_are_correct() {
assert!(ShellKind::Pwsh.needs_command_flag());
assert!(ShellKind::WindowsPowerShell.needs_command_flag());
assert!(!ShellKind::Cmd.needs_command_flag());
assert!(!ShellKind::Sh.needs_command_flag());
assert!(!ShellKind::Bash.needs_command_flag());
}
#[cfg(test)]
#[test]
fn is_powershell_detects_both_variants() {
assert!(ShellKind::Pwsh.is_powershell());
assert!(ShellKind::WindowsPowerShell.is_powershell());
assert!(!ShellKind::Cmd.is_powershell());
assert!(!ShellKind::Sh.is_powershell());
assert!(!ShellKind::Bash.is_powershell());
}
#[cfg(test)]
#[test]
fn build_command_quotes_spaces_for_cmd() {
let dispatcher = ShellDispatcher {
kind: ShellKind::Cmd,
};
let cmd = dispatcher.build_command("git commit -m \"msg with spaces\"");
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args.len(), 2);
assert_eq!(args[0], "/C");
assert!(args[1].contains("msg with spaces"));
assert!(args[1].starts_with("git "));
}
#[cfg(test)]
#[test]
fn build_command_quotes_spaces_for_pwsh() {
let dispatcher = ShellDispatcher {
kind: ShellKind::Pwsh,
};
let cmd = dispatcher.build_command("git commit -m \"msg with spaces\"");
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
assert_eq!(args.len(), 3);
assert_eq!(args[0], "-NoProfile");
assert_eq!(args[1], "-Command");
assert!(args[2].contains("msg with spaces"));
}
#[cfg(test)]
#[test]
fn build_direct_handles_empty_args() {
let dispatcher = ShellDispatcher {
kind: ShellKind::Sh,
};
let cmd = dispatcher.build_direct("echo", &[]);
let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
assert!(args.is_empty());
}
#[cfg(windows)]
#[test]
fn find_exe_finds_cmd_on_path() {
assert!(ShellDispatcher::find_exe("cmd.exe"));
}
#[cfg(windows)]
#[test]
fn find_exe_rejects_nonexistent_binary() {
assert!(!ShellDispatcher::find_exe("nonexistent_xyz_12345.exe"));
}
#[cfg(windows)]
#[test]
fn find_exe_falls_back_to_known_dirs() {
let ps_path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe";
if std::path::Path::new(ps_path).is_file() {
assert!(ShellDispatcher::find_exe("powershell.exe"));
} else {
eprintln!("Skipping: {ps_path} not present on this system");
}
}
#[test]
fn custom_shell_uses_provided_binary_and_flag() {
let kind = ShellKind::Custom {
binary: "/bin/zsh".to_string(),
flag: "-c".to_string(),
};
assert_eq!(kind.binary(), "/bin/zsh");
assert_eq!(kind.command_flag(), "-c");
}
}