mirror of
https://github.com/imjasonh/testscript-rs
synced 2026-07-08 17:16:38 +00:00
329 lines
11 KiB
Rust
329 lines
11 KiB
Rust
//! Built-in command implementations
|
|
|
|
use crate::error::{Error, Result};
|
|
use crate::run::environment::TestEnvironment;
|
|
use regex::Regex;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
impl TestEnvironment {
|
|
/// Check if a file or directory exists
|
|
pub fn file_exists(&self, path: &str) -> bool {
|
|
let full_path = if path == "stdout" || path == "stderr" {
|
|
// Special case: check if last command had output
|
|
return self.last_output.is_some();
|
|
} else {
|
|
self.work_dir.join(path)
|
|
};
|
|
full_path.exists()
|
|
}
|
|
|
|
/// Create directories
|
|
pub fn create_directories(&self, paths: &[String]) -> Result<()> {
|
|
for path in paths {
|
|
let full_path = self.work_dir.join(path);
|
|
fs::create_dir_all(&full_path)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Copy files or directories
|
|
pub fn copy_files(&mut self, args: &[String]) -> Result<()> {
|
|
if args.len() < 2 {
|
|
return Err(Error::command_error("cp", "At least 2 arguments required"));
|
|
}
|
|
|
|
let dest = &args[args.len() - 1];
|
|
let sources = &args[..args.len() - 1];
|
|
let dest_path = self.work_dir.join(dest);
|
|
|
|
for source in sources {
|
|
let source_content = if source == "stdout" {
|
|
if let Some(ref output) = self.last_output {
|
|
output.stdout.clone()
|
|
} else {
|
|
return Err(Error::command_error("cp", "No stdout available"));
|
|
}
|
|
} else if source == "stderr" {
|
|
if let Some(ref output) = self.last_output {
|
|
output.stderr.clone()
|
|
} else {
|
|
return Err(Error::command_error("cp", "No stderr available"));
|
|
}
|
|
} else {
|
|
let source_path = self.work_dir.join(source);
|
|
fs::read(&source_path).map_err(|e| {
|
|
Error::command_error("cp", format!("Cannot read '{}': {}", source, e))
|
|
})?
|
|
};
|
|
|
|
// If dest exists and is a directory, copy into it
|
|
let final_dest = if dest_path.is_dir() {
|
|
let filename = if source == "stdout" || source == "stderr" {
|
|
source
|
|
} else {
|
|
Path::new(source)
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or(source)
|
|
};
|
|
dest_path.join(filename)
|
|
} else {
|
|
dest_path.clone()
|
|
};
|
|
|
|
fs::write(&final_dest, source_content)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove files or directories
|
|
pub fn remove_files(&self, paths: &[String]) -> Result<()> {
|
|
for path in paths {
|
|
let full_path = self.work_dir.join(path);
|
|
if full_path.is_dir() {
|
|
fs::remove_dir_all(&full_path)?;
|
|
} else if full_path.exists() {
|
|
fs::remove_file(&full_path)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Move/rename files or directories
|
|
pub fn move_file(&self, source: &str, dest: &str) -> Result<()> {
|
|
let source_path = self.work_dir.join(source);
|
|
let dest_path = self.work_dir.join(dest);
|
|
|
|
if !source_path.exists() {
|
|
return Err(Error::command_error(
|
|
"mv",
|
|
format!("Source '{}' does not exist", source),
|
|
));
|
|
}
|
|
|
|
fs::rename(&source_path, &dest_path).map_err(|e| {
|
|
Error::command_error(
|
|
"mv",
|
|
format!("Cannot move '{}' to '{}': {}", source, dest, e),
|
|
)
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Compare files with environment variable substitution in the second file
|
|
pub fn compare_files_with_env(&self, file1: &str, file2: &str) -> Result<()> {
|
|
let path1 = self.work_dir.join(file1);
|
|
let path2 = self.work_dir.join(file2);
|
|
|
|
let contents1 = fs::read_to_string(&path1).map_err(|e| {
|
|
Error::command_error("cmpenv", format!("Cannot read '{}': {}", file1, e))
|
|
})?;
|
|
|
|
let contents2_raw = fs::read_to_string(&path2).map_err(|e| {
|
|
Error::command_error("cmpenv", format!("Cannot read '{}': {}", file2, e))
|
|
})?;
|
|
|
|
// Substitute environment variables in file2
|
|
let contents2 = self.substitute_env_vars(&contents2_raw);
|
|
|
|
if contents1.trim() != contents2.trim() {
|
|
return Err(Error::FileCompare {
|
|
message: format!(
|
|
"Files differ after environment substitution:\n'{}' contains:\n{}\n\n'{}' (after substitution) contains:\n{}",
|
|
file1, contents1, file2, contents2
|
|
),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Change file permissions (chmod)
|
|
pub fn change_permissions(&self, mode: &str, file: &str) -> Result<()> {
|
|
let file_path = self.work_dir.join(file);
|
|
|
|
// Parse the octal mode (e.g., "444", "755")
|
|
let mode_int = u32::from_str_radix(mode, 8)
|
|
.map_err(|_| Error::command_error("chmod", format!("Invalid mode: {}", mode)))?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(&file_path)
|
|
.map_err(|e| {
|
|
Error::command_error(
|
|
"chmod",
|
|
format!("Cannot get metadata for '{}': {}", file, e),
|
|
)
|
|
})?
|
|
.permissions();
|
|
perms.set_mode(mode_int);
|
|
fs::set_permissions(&file_path, perms).map_err(|e| {
|
|
Error::command_error(
|
|
"chmod",
|
|
format!("Cannot set permissions for '{}': {}", file, e),
|
|
)
|
|
})?;
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
// On Windows, we can only set read-only status
|
|
let mut perms = fs::metadata(&file_path)
|
|
.map_err(|e| {
|
|
Error::command_error(
|
|
"chmod",
|
|
format!("Cannot get metadata for '{}': {}", file, e),
|
|
)
|
|
})?
|
|
.permissions();
|
|
|
|
// If mode doesn't include write permissions for owner (e.g., 444), make it read-only
|
|
let readonly = (mode_int & 0o200) == 0;
|
|
perms.set_readonly(readonly);
|
|
|
|
fs::set_permissions(&file_path, perms).map_err(|e| {
|
|
Error::command_error(
|
|
"chmod",
|
|
format!("Cannot set permissions for '{}': {}", file, e),
|
|
)
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Unquote a file by removing leading ">" characters from each line
|
|
pub fn unquote_file(&self, file: &str) -> Result<()> {
|
|
let file_path = self.work_dir.join(file);
|
|
|
|
let contents = fs::read_to_string(&file_path).map_err(|e| {
|
|
Error::command_error("unquote", format!("Cannot read '{}': {}", file, e))
|
|
})?;
|
|
|
|
let unquoted_contents: String = contents
|
|
.lines()
|
|
.map(|line| line.strip_prefix('>').unwrap_or(line))
|
|
.collect::<Vec<&str>>()
|
|
.join("\n");
|
|
|
|
fs::write(&file_path, unquoted_contents).map_err(|e| {
|
|
Error::command_error("unquote", format!("Cannot write '{}': {}", file, e))
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Search for a pattern in files (like grep)
|
|
pub fn grep_files(&mut self, pattern: &str, files: &[String]) -> Result<()> {
|
|
// Enable Unicode mode for proper Unicode character matching in grep patterns
|
|
let pattern_with_flags = if pattern.starts_with("(?") {
|
|
// Pattern already has flags, don't add more
|
|
pattern.to_string()
|
|
} else {
|
|
format!("(?u){}", pattern)
|
|
};
|
|
let regex = Regex::new(&pattern_with_flags)
|
|
.map_err(|e| Error::command_error("grep", format!("Invalid regex: {}", e)))?;
|
|
|
|
let mut _found_matches = false;
|
|
let mut output = String::new();
|
|
|
|
for file in files {
|
|
let file_path = self.work_dir.join(file);
|
|
let contents = fs::read_to_string(&file_path).map_err(|e| {
|
|
Error::command_error("grep", format!("Cannot read '{}': {}", file, e))
|
|
})?;
|
|
|
|
for (line_num, line) in contents.lines().enumerate() {
|
|
if regex.is_match(line) {
|
|
output.push_str(&format!("{}:{}: {}\n", file, line_num + 1, line));
|
|
_found_matches = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set the output as if it came from an exec command
|
|
// Create a fake successful command output
|
|
let fake_output = std::process::Command::new("echo")
|
|
.arg("")
|
|
.output()
|
|
.unwrap_or_else(|_| std::process::Output {
|
|
status: std::process::ExitStatus::default(),
|
|
stdout: Vec::new(),
|
|
stderr: Vec::new(),
|
|
});
|
|
|
|
self.last_output = Some(std::process::Output {
|
|
status: fake_output.status,
|
|
stdout: output.trim_end().as_bytes().to_vec(),
|
|
stderr: Vec::new(),
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if a file is read-only
|
|
pub fn is_readonly(&self, file: &str) -> bool {
|
|
let file_path = self.work_dir.join(file);
|
|
|
|
if let Ok(metadata) = fs::metadata(&file_path) {
|
|
metadata.permissions().readonly()
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Create a symbolic link
|
|
pub fn create_symlink(&self, target: &str, link_name: &str) -> Result<()> {
|
|
let link_path = self.work_dir.join(link_name);
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
// For Unix systems, preserve the target path as-is to support relative links
|
|
std::os::unix::fs::symlink(target, &link_path).map_err(|e| {
|
|
Error::command_error(
|
|
"symlink",
|
|
format!(
|
|
"Cannot create symlink '{}' -> '{}': {}",
|
|
link_name, target, e
|
|
),
|
|
)
|
|
})?;
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
// On Windows, try to create a symlink but fall back gracefully
|
|
use std::os::windows::fs::symlink_file;
|
|
|
|
// For Windows, we need to resolve the path to check if it's a file
|
|
let target_path = self.work_dir.join(target);
|
|
if target_path.is_file() {
|
|
symlink_file(&target_path, &link_path).map_err(|e| {
|
|
Error::command_error(
|
|
"symlink",
|
|
format!("Cannot create symlink '{}' -> '{}': {} (Note: Windows symlinks may require administrator privileges)", link_name, target, e),
|
|
)
|
|
})?;
|
|
} else {
|
|
return Err(Error::command_error(
|
|
"symlink",
|
|
"Symlinks on Windows are only supported for files, not directories",
|
|
));
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
{
|
|
return Err(Error::command_error(
|
|
"symlink",
|
|
"Symlinks are not supported on this platform",
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|