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, } #[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 { #[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( is_windows: bool, mut resolve: F, comspec: Option, ) -> Option where F: FnMut(&str) -> Option, { 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) -> 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 { 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")); } }