251 lines
7.4 KiB
Rust

//! Platform abstraction layer for cross-platform compatibility.
//!
//! This module provides unified interfaces for platform-specific operations,
//! making it easy to add support for new platforms by modifying only this file.
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Supported platform types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Windows,
Unix,
}
impl Platform {
/// Detect the current platform.
pub fn current() -> Self {
if cfg!(target_os = "windows") {
Platform::Windows
} else {
Platform::Unix
}
}
/// Check if running on Windows.
pub fn is_windows() -> bool {
cfg!(target_os = "windows")
}
}
/// Shell information for command execution.
#[derive(Debug, Clone)]
pub struct ShellInfo {
/// Tool name exposed to LLM.
pub name: &'static str,
/// Shell executable name.
pub executable: &'static str,
/// Arguments to pass before the command.
pub args: &'static [&'static str],
}
impl ShellInfo {
/// Get the default shell for the current platform.
pub fn default() -> Self {
Self::for_platform(Platform::current())
}
/// Get shell info for a specific platform.
pub fn for_platform(platform: Platform) -> Self {
match platform {
Platform::Windows => ShellInfo {
name: "shell",
executable: "powershell",
args: &["-Command"],
},
Platform::Unix => ShellInfo {
name: "bash",
executable: "bash",
args: &["-c"],
},
}
}
/// Alternative shells available on the platform.
pub fn available_shells(platform: Platform) -> Vec<ShellInfo> {
match platform {
Platform::Windows => vec![
ShellInfo {
name: "shell",
executable: "powershell",
args: &["-Command"],
},
ShellInfo {
name: "shell",
executable: "cmd",
args: &["/C"],
},
],
Platform::Unix => vec![
ShellInfo {
name: "bash",
executable: "bash",
args: &["-c"],
},
// Future: could add zsh, fish, sh
// ShellInfo { name: "zsh", executable: "zsh", args: &["-c"] },
],
}
}
}
/// Dangerous command patterns for safety guards.
pub fn dangerous_command_patterns() -> Vec<String> {
vec![
// Unix dangerous commands
r"\brm\s+-[rf]{1,2}\b".to_string(),
r"\bchmod\s+-[Rr]".to_string(),
r"\bchown\s+-[Rr]".to_string(),
// Windows dangerous commands
r"\bdel\s+/[fq]\b".to_string(),
r"\brmdir\s+/s\b".to_string(),
r"\bformat\s+".to_string(),
// PowerShell dangerous commands
r"\bRemove-Item\s+.*-Recurse".to_string(),
r"\bRemove-Item\s+.*-Force".to_string(),
// Fork bomb (cross-platform)
r":\(\)\s*\{.*\};\s*:".to_string(),
]
}
/// Get the user's home directory.
///
/// Supports environment variable overrides for testing:
/// - `HOME` (Unix-style, works on all platforms for testing)
/// - `USERPROFILE` (Windows-specific)
pub fn home_dir() -> Option<PathBuf> {
// Test scenario: support HOME variable override on all platforms
env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| {
// Windows: support USERPROFILE
env::var_os("USERPROFILE").map(PathBuf::from)
})
.or_else(|| dirs::home_dir())
}
/// Atomically rename a file, handling platform differences.
///
/// On Windows, `fs::rename` fails if the destination exists, so we need to
/// remove it first. On Unix, rename is atomic and replaces the destination.
pub fn atomic_rename(src: &Path, dst: &Path) -> io::Result<()> {
if Platform::is_windows() && dst.exists() {
fs::remove_file(dst)?;
}
fs::rename(src, dst)
}
/// Convert a filesystem path to a file:// URI.
///
/// Handles platform-specific path formats:
/// - Unix: `/path/to/file` -> `file:///path/to/file`
/// - Windows: `C:\path\to\file` -> `file:///C:/path/to/file`
pub fn path_to_uri(path: &Path) -> String {
let path_str = path.display().to_string();
if Platform::is_windows() {
// Windows paths use backslashes which must be converted to forward slashes
let normalized = path_str.replace('\\', "/");
format!("file:///{}", normalized)
} else {
format!("file://{}", path_str)
}
}
/// XML escape utility.
pub fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_platform_detect() {
let platform = Platform::current();
if cfg!(target_os = "windows") {
assert_eq!(platform, Platform::Windows);
} else {
assert_eq!(platform, Platform::Unix);
}
}
#[test]
fn test_shell_info_default() {
let shell = ShellInfo::default();
if cfg!(target_os = "windows") {
assert_eq!(shell.executable, "powershell");
assert_eq!(shell.args, &["-Command"]);
} else {
assert_eq!(shell.executable, "bash");
assert_eq!(shell.args, &["-c"]);
}
}
#[test]
fn test_shell_info_for_platform() {
let win_shell = ShellInfo::for_platform(Platform::Windows);
assert_eq!(win_shell.executable, "powershell");
let unix_shell = ShellInfo::for_platform(Platform::Unix);
assert_eq!(unix_shell.executable, "bash");
}
#[test]
fn test_path_to_uri() {
let temp_dir = tempfile::tempdir().unwrap();
let test_path = temp_dir.path().join("test.txt");
let uri = path_to_uri(&test_path);
assert!(uri.starts_with("file://"));
assert!(uri.contains("test.txt"));
assert!(!uri.contains('\\')); // No backslashes
}
#[test]
fn test_path_to_uri_windows_format() {
if cfg!(target_os = "windows") {
let win_path = PathBuf::from("C:\\Users\\test\\file.txt");
let uri = path_to_uri(&win_path);
assert!(uri.starts_with("file:///C:/"));
assert_eq!(uri, "file:///C:/Users/test/file.txt");
}
}
#[test]
fn test_atomic_rename() {
let temp_dir = tempfile::tempdir().unwrap();
let src = temp_dir.path().join("source.txt");
let dst = temp_dir.path().join("dest.txt");
fs::write(&src, "content").unwrap();
fs::write(&dst, "old content").unwrap();
atomic_rename(&src, &dst).unwrap();
assert!(!src.exists());
assert!(dst.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "content");
}
#[test]
fn test_dangerous_patterns() {
let patterns = dangerous_command_patterns();
assert!(!patterns.is_empty());
// Should contain patterns for both platforms
assert!(patterns.iter().any(|p| p.contains("rm")));
assert!(patterns.iter().any(|p| p.contains("del")));
}
#[test]
fn test_xml_escape() {
assert_eq!(xml_escape("a & b"), "a &amp; b");
assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
}
}