297 lines
9.4 KiB
Rust
297 lines
9.4 KiB
Rust
//! 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 <name>` 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<OsString>,
|
|
comspec: Option<OsString>,
|
|
) -> &'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::<Vec<_>>()
|
|
.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<i32> {
|
|
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}");
|
|
}
|
|
}
|