//! System-shell gateway. //! //! The interactive prompt doubles as a gateway between the user and their //! system shell: every input that is not an am command is executed by the //! selected shell (pwsh, powershell, cmd, bash, zsh, fish, sh, ...). //! //! The default shell is resolved at startup from, in order: the //! `settings.default_shell` configuration entry, the `SHELL` environment //! variable, `COMSPEC` (Windows), then the first shell found on PATH among //! the platform's preferred shells. `shell ` switches at runtime. use crate::config::Config; use anyhow::{anyhow, Result}; use std::ffi::OsString; use std::path::Path; use std::process::Command as ProcessCommand; /// One supported system shell and how to invoke it with a command line. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ShellSpec { /// Canonical short name ("pwsh", "cmd", "bash", ...). pub name: &'static str, /// Human-readable title ("PowerShell 7", "Command Prompt", ...). pub title: &'static str, /// Executable to spawn. pub program: &'static str, /// Arguments placed before the user's command line. pub args: &'static [&'static str], /// Executable probed on PATH to decide whether the shell is available. pub probe: &'static str, } /// Every shell the gateway understands, in display order. pub static KNOWN_SHELLS: &[ShellSpec] = &[ ShellSpec { name: "pwsh", title: "PowerShell 7", program: "pwsh", args: &["-NoLogo", "-NoProfile", "-Command"], probe: "pwsh", }, ShellSpec { name: "powershell", title: "Windows PowerShell", program: "powershell", args: &["-NoLogo", "-NoProfile", "-Command"], probe: "powershell", }, ShellSpec { name: "cmd", title: "Command Prompt", program: "cmd", args: &["/C"], probe: "cmd", }, ShellSpec { name: "bash", title: "Bash", program: "bash", args: &["-c"], probe: "bash", }, ShellSpec { name: "zsh", title: "Zsh", program: "zsh", args: &["-c"], probe: "zsh", }, ShellSpec { name: "fish", title: "Fish", program: "fish", args: &["-c"], probe: "fish", }, ShellSpec { name: "sh", title: "POSIX sh", program: "sh", args: &["-c"], probe: "sh", }, ShellSpec { name: "nu", title: "Nushell", program: "nu", args: &["-c"], probe: "nu", }, ShellSpec { name: "elvish", title: "Elvish", program: "elvish", args: &["-c"], probe: "elvish", }, ]; /// Resolve a shell by canonical name or common alias, case-insensitively /// ("pwsh" | "powershell7" | "ps" | "cmd.exe" | "nushell" | ...). pub fn find(name: &str) -> Option<&'static ShellSpec> { let mut n = name.trim().to_ascii_lowercase(); if let Some(stripped) = n.strip_suffix(".exe") { n = stripped.to_string(); } KNOWN_SHELLS.iter().find(|s| { s.name == n || match s.name { "pwsh" => n == "powershell7" || n == "ps7", "powershell" => n == "ps", "cmd" => n == "dos" || n == "command" || n == "command-prompt", "sh" => n == "posix" || n == "dash", "nu" => n == "nushell", _ => false, } }) } /// Whether a shell executable can be found (or, for cmd on Windows, whether /// COMSPEC is set). pub fn probe_available(spec: &ShellSpec) -> bool { which::which(spec.probe).is_ok() || (cfg!(windows) && spec.name == "cmd" && std::env::var_os("COMSPEC").is_some()) } /// Resolve the user's default shell from the configuration, environment /// variables, then the shells found on PATH. Always returns a shell. pub fn resolve_default( configured: Option<&str>, shell_env: Option, comspec: Option, ) -> &'static ShellSpec { if let Some(s) = configured.and_then(find) { return s; } if let Some(s) = shell_env.and_then(|v| from_env_path(&v)) { return s; } if let Some(s) = comspec.and_then(|v| from_env_path(&v)) { return s; } let preferred: &[&str] = if cfg!(windows) { &["pwsh", "powershell", "cmd"] } else { &["bash", "zsh", "fish", "sh"] }; for name in preferred { if let Some(s) = find(name) { if probe_available(s) { return s; } } } // Last resort: the platform's built-in shell. find(if cfg!(windows) { "cmd" } else { "sh" }).expect("built-in shell must exist") } /// Extract a shell name from an environment value such as SHELL or COMSPEC. fn from_env_path(value: &OsString) -> Option<&'static ShellSpec> { let text = value.to_string_lossy(); let name = Path::new(text.as_ref()) .file_name() .map(|f| f.to_string_lossy().to_string()) .unwrap_or_default(); find(&name) } /// The gateway's runtime state: which shell executes system commands now, /// which one was detected as the user's default, and which are installed. #[derive(Debug, Clone)] pub struct ShellSession { /// Shell executing system commands right now. pub current: &'static ShellSpec, /// The shell detected as the user's default at startup. pub default: &'static ShellSpec, /// Shells found on this machine. pub available: Vec<&'static ShellSpec>, } impl ShellSession { /// Build a session: detect the default shell and probe availability. pub fn start(config: &Config) -> Self { let default = resolve_default( config.settings.default_shell.as_deref(), std::env::var_os("SHELL"), std::env::var_os("COMSPEC"), ); let available = KNOWN_SHELLS .iter() .filter(|s| probe_available(s)) .collect(); Self { current: default, default, available, } } /// Switch the active shell. Fails for unknown or unavailable shells. pub fn switch(&mut self, name: &str) -> Result<&'static ShellSpec> { let spec = find(name).ok_or_else(|| anyhow!("unknown shell '{name}'"))?; if !self.available.iter().any(|s| s.name == spec.name) { return Err(anyhow!( "shell '{}' is not available on this machine (available: {})", spec.name, self.available .iter() .map(|s| s.name) .collect::>() .join(", ") )); } self.current = spec; Ok(spec) } /// Whether the active shell is the user's default shell. pub fn is_default(&self) -> bool { self.current.name == self.default.name } /// Execute one system command line in the current shell with inherited /// stdio, so interactive programs keep working. Returns the exit code. pub fn run(&self, line: &str) -> Result { let status = ProcessCommand::new(self.current.program) .args(self.current.args) .arg(line) .status() .map_err(|e| anyhow!("cannot run {}: {e}", self.current.program))?; Ok(status.code().unwrap_or(1)) } } #[cfg(test)] mod tests { use super::*; #[test] fn find_resolves_aliases() { assert_eq!(find("pwsh").unwrap().name, "pwsh"); assert_eq!(find("PowerShell7").unwrap().name, "pwsh"); assert_eq!(find("ps").unwrap().name, "powershell"); assert_eq!(find("cmd.exe").unwrap().name, "cmd"); assert_eq!(find("CMD").unwrap().name, "cmd"); assert_eq!(find("nushell").unwrap().name, "nu"); assert_eq!(find("dash").unwrap().name, "sh"); assert!(find("blorp").is_none()); } #[test] fn resolve_default_prefers_configured_shell() { let s = resolve_default(Some("zsh"), None, None); assert_eq!(s.name, "zsh"); } #[test] fn resolve_default_uses_environment_values() { // SHELL wins over COMSPEC when the configured value is absent. let s = resolve_default( None, Some(OsString::from("/usr/bin/bash")), Some(OsString::from("C:/Windows/system32/cmd.exe")), ); assert_eq!(s.name, "bash"); // COMSPEC alone is honored. let s = resolve_default( None, None, Some(OsString::from("C:/Windows/system32/cmd.exe")), ); assert_eq!(s.name, "cmd"); // Unrecognized values fall through to a probe — result is still a // known shell. let s = resolve_default(None, Some(OsString::from("/usr/bin/weird")), None); assert!(KNOWN_SHELLS.iter().any(|k| k.name == s.name)); } #[test] fn switch_validates_availability() { let sh = find("sh").unwrap(); let bash = find("bash").unwrap(); let mut session = ShellSession { current: sh, default: sh, available: vec![sh, bash], }; assert_eq!(session.switch("bash").unwrap().name, "bash"); assert_eq!(session.current.name, "bash"); assert!(!session.is_default()); let err = session.switch("zsh").unwrap_err(); assert!(err.to_string().contains("not available"), "{err}"); let err = session.switch("blorp").unwrap_err(); assert!(err.to_string().contains("unknown shell"), "{err}"); } }