zeroclaw/src/runtime/native.rs

343 lines
9.3 KiB
Rust

use super::traits::RuntimeAdapter;
use std::path::{Path, PathBuf};
/// Native runtime — full access, runs on Mac/Linux/Docker/Raspberry Pi
pub struct NativeRuntime {
shell: Option<ShellProgram>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ShellProgram {
kind: ShellKind,
program: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShellKind {
Sh,
Bash,
Pwsh,
PowerShell,
Cmd,
}
impl ShellKind {
fn as_str(self) -> &'static str {
match self {
ShellKind::Sh => "sh",
ShellKind::Bash => "bash",
ShellKind::Pwsh => "pwsh",
ShellKind::PowerShell => "powershell",
ShellKind::Cmd => "cmd",
}
}
}
impl ShellProgram {
fn add_shell_args(&self, process: &mut tokio::process::Command, command: &str) {
match self.kind {
ShellKind::Sh | ShellKind::Bash => {
process.arg("-c").arg(command);
}
ShellKind::Pwsh | ShellKind::PowerShell => {
process
.arg("-NoLogo")
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-Command")
.arg(command);
}
ShellKind::Cmd => {
process.arg("/C").arg(command);
}
}
}
}
fn detect_native_shell() -> Option<ShellProgram> {
#[cfg(target_os = "windows")]
{
let comspec = std::env::var_os("COMSPEC").map(PathBuf::from);
detect_native_shell_with(true, |name| which::which(name).ok(), comspec)
}
#[cfg(not(target_os = "windows"))]
{
detect_native_shell_with(false, |name| which::which(name).ok(), None)
}
}
fn detect_native_shell_with<F>(
is_windows: bool,
mut resolve: F,
comspec: Option<PathBuf>,
) -> Option<ShellProgram>
where
F: FnMut(&str) -> Option<PathBuf>,
{
if is_windows {
for (name, kind) in [
("bash", ShellKind::Bash),
("sh", ShellKind::Sh),
("pwsh", ShellKind::Pwsh),
("powershell", ShellKind::PowerShell),
("cmd", ShellKind::Cmd),
("cmd.exe", ShellKind::Cmd),
] {
if let Some(program) = resolve(name) {
return Some(ShellProgram { kind, program });
}
}
if let Some(program) = comspec {
return Some(ShellProgram {
kind: ShellKind::Cmd,
program,
});
}
return None;
}
for (name, kind) in [("sh", ShellKind::Sh), ("bash", ShellKind::Bash)] {
if let Some(program) = resolve(name) {
return Some(ShellProgram { kind, program });
}
}
None
}
fn missing_shell_error() -> &'static str {
#[cfg(target_os = "windows")]
{
"Native runtime could not find a usable shell (tried: bash, sh, pwsh, powershell, cmd). \
Install Git Bash or PowerShell and ensure it is available on PATH."
}
#[cfg(not(target_os = "windows"))]
{
"Native runtime could not find a usable shell (tried: sh, bash). \
Install a POSIX shell and ensure it is available on PATH."
}
}
impl NativeRuntime {
pub fn new() -> Self {
Self {
shell: detect_native_shell(),
}
}
pub(crate) fn selected_shell_kind(&self) -> Option<&'static str> {
self.shell.as_ref().map(|shell| shell.kind.as_str())
}
pub(crate) fn selected_shell_program(&self) -> Option<&Path> {
self.shell.as_ref().map(|shell| shell.program.as_path())
}
#[cfg(test)]
fn new_for_test(shell: Option<ShellProgram>) -> Self {
Self { shell }
}
}
impl RuntimeAdapter for NativeRuntime {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> &str {
"native"
}
fn has_shell_access(&self) -> bool {
self.shell.is_some()
}
fn has_filesystem_access(&self) -> bool {
true
}
fn storage_path(&self) -> PathBuf {
directories::UserDirs::new().map_or_else(
|| PathBuf::from(".zeroclaw"),
|u| u.home_dir().join(".zeroclaw"),
)
}
fn supports_long_running(&self) -> bool {
true
}
fn build_shell_command(
&self,
command: &str,
workspace_dir: &Path,
) -> anyhow::Result<tokio::process::Command> {
let shell = self
.shell
.as_ref()
.ok_or_else(|| anyhow::anyhow!(missing_shell_error()))?;
let mut process = tokio::process::Command::new(&shell.program);
shell.add_shell_args(&mut process, command);
process.current_dir(workspace_dir);
Ok(process)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn native_name() {
assert_eq!(NativeRuntime::new().name(), "native");
}
#[test]
fn native_has_shell_access() {
assert_eq!(
NativeRuntime::new().has_shell_access(),
detect_native_shell().is_some()
);
}
#[test]
fn native_has_filesystem_access() {
assert!(NativeRuntime::new().has_filesystem_access());
}
#[test]
fn native_supports_long_running() {
assert!(NativeRuntime::new().supports_long_running());
}
#[test]
fn native_memory_budget_unlimited() {
assert_eq!(NativeRuntime::new().memory_budget(), 0);
}
#[test]
fn native_storage_path_contains_zeroclaw() {
let path = NativeRuntime::new().storage_path();
assert!(path.to_string_lossy().contains("zeroclaw"));
}
#[test]
fn detect_shell_windows_prefers_git_bash() {
let mut map = HashMap::new();
map.insert("bash", r"C:\Program Files\Git\bin\bash.exe");
map.insert(
"powershell",
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
);
map.insert("cmd", r"C:\Windows\System32\cmd.exe");
let shell = detect_native_shell_with(
true,
|name| map.get(name).map(PathBuf::from),
Some(PathBuf::from(r"C:\Windows\System32\cmd.exe")),
)
.expect("windows shell should be detected");
assert_eq!(shell.kind, ShellKind::Bash);
}
#[test]
fn detect_shell_windows_falls_back_to_powershell_then_cmd() {
let mut map = HashMap::new();
map.insert(
"powershell",
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
);
let shell = detect_native_shell_with(
true,
|name| map.get(name).map(PathBuf::from),
Some(PathBuf::from(r"C:\Windows\System32\cmd.exe")),
)
.expect("windows shell should be detected");
assert_eq!(shell.kind, ShellKind::PowerShell);
let cmd_shell = detect_native_shell_with(
true,
|_name| None,
Some(PathBuf::from(r"C:\Windows\System32\cmd.exe")),
)
.expect("cmd fallback should be detected");
assert_eq!(cmd_shell.kind, ShellKind::Cmd);
}
#[test]
fn detect_shell_unix_prefers_sh() {
let mut map = HashMap::new();
map.insert("sh", "/bin/sh");
map.insert("bash", "/usr/bin/bash");
let shell = detect_native_shell_with(false, |name| map.get(name).map(PathBuf::from), None)
.expect("unix shell should be detected");
assert_eq!(shell.kind, ShellKind::Sh);
}
#[test]
fn native_without_shell_disables_shell_access() {
let runtime = NativeRuntime::new_for_test(None);
assert!(!runtime.has_shell_access());
let err = runtime
.build_shell_command("echo hello", Path::new("."))
.expect_err("build should fail without available shell")
.to_string();
assert!(err.contains("could not find a usable shell"));
}
#[test]
fn native_builds_powershell_command() {
let runtime = NativeRuntime::new_for_test(Some(ShellProgram {
kind: ShellKind::PowerShell,
program: PathBuf::from("powershell"),
}));
let command = runtime
.build_shell_command("Get-Location", Path::new("."))
.expect("powershell command should build");
let debug = format!("{command:?}");
assert!(debug.contains("powershell"));
assert!(debug.contains("-NoProfile"));
assert!(debug.contains("-Command"));
assert!(debug.contains("Get-Location"));
}
#[test]
fn native_builds_cmd_command() {
let runtime = NativeRuntime::new_for_test(Some(ShellProgram {
kind: ShellKind::Cmd,
program: PathBuf::from("cmd"),
}));
let command = runtime
.build_shell_command("echo hello", Path::new("."))
.expect("cmd command should build");
let debug = format!("{command:?}");
assert!(debug.contains("cmd"));
assert!(debug.contains("/C"));
assert!(debug.contains("echo hello"));
}
#[test]
fn native_builds_shell_command() {
let runtime = NativeRuntime::new();
if !runtime.has_shell_access() {
return;
}
let cwd = std::env::temp_dir();
let command = runtime.build_shell_command("echo hello", &cwd).unwrap();
let debug = format!("{command:?}");
assert!(debug.contains("echo hello"));
}
}