use async_trait::async_trait;
use chrono::{Datelike, Local, LocalResult, NaiveDate, TimeZone};
use std::path::Path;
use std::time::SystemTime;
use super::{Builtin, Context, resolve_path};
use crate::error::Result;
use crate::interpreter::ExecResult;
pub struct Mkdir;
#[async_trait]
impl Builtin for Mkdir {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: mkdir [OPTION]... DIRECTORY...\nCreate the DIRECTORY(ies), if they do not already exist.\n\n -p\t\tno error if existing, make parent directories as needed\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("mkdir (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.is_empty() {
return Ok(ExecResult::err("mkdir: missing operand\n".to_string(), 1));
}
let recursive = ctx.args.iter().any(|a| a == "-p");
let dirs: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect();
if dirs.is_empty() {
return Ok(ExecResult::err("mkdir: missing operand\n".to_string(), 1));
}
for dir in dirs {
let path = resolve_path(ctx.cwd, dir);
if ctx.fs.exists(&path).await.unwrap_or(false) {
if let Ok(meta) = ctx.fs.stat(&path).await
&& meta.file_type.is_dir()
{
if !recursive {
return Ok(ExecResult::err(
format!("mkdir: cannot create directory '{}': File exists\n", dir),
1,
));
}
continue;
}
return Ok(ExecResult::err(
format!("mkdir: cannot create directory '{}': File exists\n", dir),
1,
));
}
if let Err(e) = ctx.fs.mkdir(&path, recursive).await {
return Ok(ExecResult::err(
format!("mkdir: cannot create directory '{}': {}\n", dir, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Rm;
#[async_trait]
impl Builtin for Rm {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: rm [OPTION]... [FILE]...\nRemove (unlink) the FILE(s).\n\n -f\t\tignore nonexistent files and arguments, never prompt\n -r, -R\tremove directories and their contents recursively\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("rm (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.is_empty() {
return Ok(ExecResult::err("rm: missing operand\n".to_string(), 1));
}
let recursive = ctx.args.iter().any(|a| {
a == "-r"
|| a == "-R"
|| a == "-rf"
|| a == "-fr"
|| a.contains('r') && a.starts_with('-')
});
let force = ctx.args.iter().any(|a| {
a == "-f" || a == "-rf" || a == "-fr" || a.contains('f') && a.starts_with('-')
});
let files: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect();
if files.is_empty() {
return Ok(ExecResult::err("rm: missing operand\n".to_string(), 1));
}
for file in files {
let path = resolve_path(ctx.cwd, file);
let exists = ctx.fs.exists(&path).await.unwrap_or(false);
if !exists {
if !force {
return Ok(ExecResult::err(
format!("rm: cannot remove '{}': No such file or directory\n", file),
1,
));
}
continue;
}
let metadata = ctx.fs.stat(&path).await;
if let Ok(meta) = metadata
&& meta.file_type.is_dir()
&& !recursive
{
return Ok(ExecResult::err(
format!("rm: cannot remove '{}': Is a directory\n", file),
1,
));
}
if let Err(e) = ctx.fs.remove(&path, recursive).await
&& !force
{
return Ok(ExecResult::err(
format!("rm: cannot remove '{}': {}\n", file, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Cp;
#[async_trait]
impl Builtin for Cp {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: cp [OPTION]... SOURCE... DEST\nCopy SOURCE to DEST, or multiple SOURCE(s) to DIRECTORY.\n\n -r, -R\tcopy directories recursively\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("cp (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.len() < 2 {
return Ok(ExecResult::err("cp: missing file operand\n".to_string(), 1));
}
let _recursive = ctx.args.iter().any(|a| a == "-r" || a == "-R");
let files: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect();
if files.len() < 2 {
return Ok(ExecResult::err(
"cp: missing destination file operand\n".to_string(),
1,
));
}
let dest = files
.last()
.expect("files.last() valid: guarded by files.len() < 2 check above");
let sources = &files[..files.len() - 1];
let dest_path = resolve_path(ctx.cwd, dest);
let dest_is_dir = if let Ok(meta) = ctx.fs.stat(&dest_path).await {
meta.file_type.is_dir()
} else {
false
};
if sources.len() > 1 && !dest_is_dir {
return Ok(ExecResult::err(
format!("cp: target '{}' is not a directory\n", dest),
1,
));
}
for source in sources {
let src_path = resolve_path(ctx.cwd, source);
let final_dest = if dest_is_dir {
let filename = Path::new(source)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| source.to_string());
dest_path.join(&filename)
} else {
dest_path.clone()
};
if let Err(e) = ctx.fs.copy(&src_path, &final_dest).await {
return Ok(ExecResult::err(
format!("cp: cannot copy '{}': {}\n", source, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Mv;
#[async_trait]
impl Builtin for Mv {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: mv [OPTION]... SOURCE... DEST\nRename SOURCE to DEST, or move SOURCE(s) to DIRECTORY.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("mv (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.len() < 2 {
return Ok(ExecResult::err("mv: missing file operand\n".to_string(), 1));
}
let files: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect();
if files.len() < 2 {
return Ok(ExecResult::err(
"mv: missing destination file operand\n".to_string(),
1,
));
}
let dest = files
.last()
.expect("files.last() valid: guarded by files.len() < 2 check above");
let sources = &files[..files.len() - 1];
let dest_path = resolve_path(ctx.cwd, dest);
let dest_is_dir = if let Ok(meta) = ctx.fs.stat(&dest_path).await {
meta.file_type.is_dir()
} else {
false
};
if sources.len() > 1 && !dest_is_dir {
return Ok(ExecResult::err(
format!("mv: target '{}' is not a directory\n", dest),
1,
));
}
for source in sources {
let src_path = resolve_path(ctx.cwd, source);
let final_dest = if dest_is_dir {
let filename = Path::new(source)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| source.to_string());
dest_path.join(&filename)
} else {
dest_path.clone()
};
if let Err(e) = ctx.fs.rename(&src_path, &final_dest).await {
return Ok(ExecResult::err(
format!("mv: cannot move '{}': {}\n", source, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Touch;
fn parse_touch_timestamp(raw: &str) -> std::result::Result<SystemTime, String> {
let (main, seconds) = match raw.split_once('.') {
Some((main, seconds)) => {
if seconds.len() != 2 || !seconds.chars().all(|ch| ch.is_ascii_digit()) {
return Err(format!("touch: invalid date format '{}'\n", raw));
}
let seconds = seconds
.parse::<u32>()
.map_err(|_| format!("touch: invalid date format '{}'\n", raw))?;
(main, seconds)
}
None => (raw, 0),
};
if !main.chars().all(|ch| ch.is_ascii_digit()) {
return Err(format!("touch: invalid date format '{}'\n", raw));
}
let year = match main.len() {
8 => Local::now().year(),
10 => {
let yy = main[0..2]
.parse::<i32>()
.map_err(|_| format!("touch: invalid date format '{}'\n", raw))?;
if yy >= 69 { 1900 + yy } else { 2000 + yy }
}
12 => main[0..4]
.parse::<i32>()
.map_err(|_| format!("touch: invalid date format '{}'\n", raw))?,
_ => return Err(format!("touch: invalid date format '{}'\n", raw)),
};
let offset = main.len() - 8;
let month = main[offset..offset + 2]
.parse::<u32>()
.map_err(|_| format!("touch: invalid date format '{}'\n", raw))?;
let day = main[offset + 2..offset + 4]
.parse::<u32>()
.map_err(|_| format!("touch: invalid date format '{}'\n", raw))?;
let hour = main[offset + 4..offset + 6]
.parse::<u32>()
.map_err(|_| format!("touch: invalid date format '{}'\n", raw))?;
let minute = main[offset + 6..offset + 8]
.parse::<u32>()
.map_err(|_| format!("touch: invalid date format '{}'\n", raw))?;
let naive = NaiveDate::from_ymd_opt(year, month, day)
.and_then(|date| date.and_hms_opt(hour, minute, seconds))
.ok_or_else(|| format!("touch: invalid date format '{}'\n", raw))?;
let local = match Local.from_local_datetime(&naive) {
LocalResult::Single(dt) => dt,
LocalResult::Ambiguous(dt, _) => dt,
LocalResult::None => return Err(format!("touch: invalid date format '{}'\n", raw)),
};
Ok(local.into())
}
#[async_trait]
impl Builtin for Touch {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: touch [OPTION]... FILE...\nUpdate the access and modification times of each FILE to the current time.\nA FILE argument that does not exist is created empty.\n\n -t STAMP\tuse [[CC]YY]MMDDhhmm[.ss] instead of current time\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("touch (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.is_empty() {
return Ok(ExecResult::err(
"touch: missing file operand\n".to_string(),
1,
));
}
let mut files = Vec::new();
let mut target_time = SystemTime::now();
let mut i = 0;
while i < ctx.args.len() {
let arg = &ctx.args[i];
if arg == "-t" {
i += 1;
if i >= ctx.args.len() {
return Ok(ExecResult::err(
"touch: option requires an argument -- 't'\n".to_string(),
1,
));
}
match parse_touch_timestamp(&ctx.args[i]) {
Ok(parsed) => target_time = parsed,
Err(err) => return Ok(ExecResult::err(err, 1)),
}
} else if let Some(stamp) = arg.strip_prefix("-t")
&& !stamp.is_empty()
{
match parse_touch_timestamp(stamp) {
Ok(parsed) => target_time = parsed,
Err(err) => return Ok(ExecResult::err(err, 1)),
}
} else if arg.starts_with('-') {
return Ok(ExecResult::err(
format!("touch: invalid option -- '{}'\n", arg),
1,
));
} else {
files.push(arg);
}
i += 1;
}
if files.is_empty() {
return Ok(ExecResult::err(
"touch: missing file operand\n".to_string(),
1,
));
}
for file in files {
let path = resolve_path(ctx.cwd, file);
if !ctx.fs.exists(&path).await.unwrap_or(false)
&& let Err(e) = ctx.fs.write_file(&path, &[]).await
{
return Ok(ExecResult::err(
format!("touch: cannot touch '{}': {}\n", file, e),
1,
));
}
if let Err(e) = ctx.fs.set_modified_time(&path, target_time).await {
return Ok(ExecResult::err(
format!("touch: cannot touch '{}': {}\n", file, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Chmod;
fn apply_symbolic_mode(mode_str: &str, current_mode: u32) -> Option<u32> {
let mut mode = current_mode;
for clause in mode_str.split(',') {
let clause = clause.trim();
if clause.is_empty() {
return None;
}
let mut chars = clause.chars().peekable();
let mut who_u = false;
let mut who_g = false;
let mut who_o = false;
let mut has_who = false;
while let Some(&c) = chars.peek() {
match c {
'u' => {
who_u = true;
has_who = true;
chars.next();
}
'g' => {
who_g = true;
has_who = true;
chars.next();
}
'o' => {
who_o = true;
has_who = true;
chars.next();
}
'a' => {
who_u = true;
who_g = true;
who_o = true;
has_who = true;
chars.next();
}
_ => break,
}
}
if !has_who {
who_u = true;
who_g = true;
who_o = true;
}
let op = chars.next()?;
if op != '+' && op != '-' && op != '=' {
return None;
}
let mut perm_bits: u32 = 0;
for c in chars {
match c {
'r' => perm_bits |= 0o4,
'w' => perm_bits |= 0o2,
'x' => perm_bits |= 0o1,
'X' => {
if current_mode & 0o111 != 0 {
perm_bits |= 0o1;
}
}
's' | 't' => {} _ => return None,
}
}
let mut mask: u32 = 0;
let mut bits: u32 = 0;
if who_u {
mask |= 0o700;
bits |= perm_bits << 6;
}
if who_g {
mask |= 0o070;
bits |= perm_bits << 3;
}
if who_o {
mask |= 0o007;
bits |= perm_bits;
}
match op {
'+' => mode |= bits,
'-' => mode &= !bits,
'=' => mode = (mode & !mask) | bits,
_ => unreachable!(),
}
}
Some(mode)
}
#[async_trait]
impl Builtin for Chmod {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: chmod [OPTION]... MODE[,MODE]... FILE...\nChange the mode of each FILE to MODE.\nMODE can be octal (e.g., 755) or symbolic (e.g., u+x, a+r, go-w).\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("chmod (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.len() < 2 {
return Ok(ExecResult::err("chmod: missing operand\n".to_string(), 1));
}
let mode_str = &ctx.args[0];
let files = &ctx.args[1..];
let is_octal = u32::from_str_radix(mode_str, 8).is_ok();
for file in files.iter().filter(|a| !a.starts_with('-')) {
let path = resolve_path(ctx.cwd, file);
if !ctx.fs.exists(&path).await.unwrap_or(false) {
return Ok(ExecResult::err(
format!(
"chmod: cannot access '{}': No such file or directory\n",
file
),
1,
));
}
let mode = if is_octal {
u32::from_str_radix(mode_str, 8)
.expect("from_str_radix valid: is_octal confirmed by is_ok() check above")
} else {
let current_mode = match ctx.fs.stat(&path).await {
Ok(meta) => meta.mode,
Err(_) => 0o644, };
match apply_symbolic_mode(mode_str, current_mode) {
Some(m) => m,
None => {
return Ok(ExecResult::err(
format!("chmod: invalid mode: '{}'\n", mode_str),
1,
));
}
}
};
if let Err(e) = ctx.fs.chmod(&path, mode).await {
return Ok(ExecResult::err(
format!("chmod: changing permissions of '{}': {}\n", file, e),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Ln;
#[async_trait]
impl Builtin for Ln {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: ln [OPTION]... TARGET LINK_NAME\nCreate a link to TARGET with the name LINK_NAME.\n\n -s\t\tmake symbolic links instead of hard links\n -f\t\tremove existing destination files\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("ln (bashkit) 0.1"),
) {
return Ok(r);
}
let mut force = false;
let mut files: Vec<&str> = Vec::new();
for arg in ctx.args.iter() {
if arg.starts_with('-') && arg.len() > 1 {
for c in arg[1..].chars() {
match c {
's' => {} 'f' => force = true,
_ => {
return Ok(ExecResult::err(
format!("ln: invalid option -- '{}'\n", c),
1,
));
}
}
}
} else {
files.push(arg);
}
}
if files.len() < 2 {
return Ok(ExecResult::err("ln: missing file operand\n".to_string(), 1));
}
let target = files[0];
let link_name = files[1];
let link_path = resolve_path(ctx.cwd, link_name);
if ctx.fs.exists(&link_path).await.unwrap_or(false) {
if force {
let _ = ctx.fs.remove(&link_path, false).await;
} else {
return Ok(ExecResult::err(
format!(
"ln: failed to create symbolic link '{}': File exists\n",
link_name
),
1,
));
}
}
let target_path = Path::new(target);
if let Err(e) = ctx.fs.symlink(target_path, &link_path).await {
return Ok(ExecResult::err(
format!(
"ln: failed to create symbolic link '{}': {}\n",
link_name, e
),
1,
));
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Chown;
#[async_trait]
impl Builtin for Chown {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: chown [OPTION]... OWNER[:GROUP] FILE...\nChange file owner and group.\n\n -R, --recursive\toperate on files and directories recursively\n --help\t\tdisplay this help and exit\n --version\t\toutput version information and exit\n",
Some("chown (bashkit) 0.1"),
) {
return Ok(r);
}
let mut recursive = false;
let mut positional: Vec<&str> = Vec::new();
for arg in ctx.args {
match arg.as_str() {
"-R" | "--recursive" => recursive = true,
_ if arg.starts_with('-') => {} _ => positional.push(arg),
}
}
let _ = recursive;
if positional.len() < 2 {
return Ok(ExecResult::err("chown: missing operand\n".to_string(), 1));
}
let _owner = positional[0]; for file in &positional[1..] {
let path = resolve_path(ctx.cwd, file);
if !ctx.fs.exists(&path).await.unwrap_or(false) {
return Ok(ExecResult::err(
format!(
"chown: cannot access '{}': No such file or directory\n",
file
),
1,
));
}
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Kill;
#[async_trait]
impl Builtin for Kill {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: kill [-s SIGNAL | -SIGNAL] PID...\nSend a signal to a process.\n\n -s SIGNAL\tspecify the signal to send\n -l, -L\tlist signal names\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("kill (bashkit) 0.1"),
) {
return Ok(r);
}
let mut pids: Vec<&str> = Vec::new();
for arg in ctx.args {
if arg == "-l" || arg == "-L" {
return Ok(ExecResult::ok(
"HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM\n"
.to_string(),
));
}
if arg.starts_with('-') {
continue; }
pids.push(arg);
}
if pids.is_empty() {
return Ok(ExecResult::err(
"kill: usage: kill [-s sigspec | -n signum | -sigspec] pid | jobspec ...\n"
.to_string(),
2,
));
}
Ok(ExecResult::ok(String::new()))
}
}
pub struct Mktemp;
const MKTEMP_MAX_ATTEMPTS: usize = 64;
fn mktemp_suffix_for_attempt(attempt: usize) -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let mut hasher = RandomState::new().build_hasher();
hasher.write_usize(attempt);
let random = hasher.finish();
format!("{:010x}", random % 0xFF_FFFF_FFFF)
}
fn mktemp_name(template: Option<&str>, suffix: &str) -> String {
if let Some(tmpl) = template {
if tmpl.contains("XXXXXX") {
tmpl.replacen("XXXXXX", &suffix[..6], 1)
} else {
format!("{}.{}", tmpl, &suffix[..6])
}
} else {
format!("tmp.{}", &suffix[..10])
}
}
#[async_trait]
impl Builtin for Mktemp {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: mktemp [-d] [-p DIR] [-t] [TEMPLATE]\nCreate a temporary file or directory, safely, and print its name.\n\n -d\t\tcreate a directory, not a file\n -p DIR\tuse DIR as a prefix (default: /tmp)\n -t\t\tinterpret TEMPLATE relative to a temporary directory\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("mktemp (bashkit) 0.1"),
) {
return Ok(r);
}
let mut create_dir = false;
let mut prefix_dir = "/tmp".to_string();
let mut template: Option<String> = None;
let mut use_tmpdir = false;
let mut i = 0;
while i < ctx.args.len() {
match ctx.args[i].as_str() {
"-d" => create_dir = true,
"-p" => {
i += 1;
if i < ctx.args.len() {
prefix_dir = ctx.args[i].clone();
}
}
"-t" => use_tmpdir = true,
arg if !arg.starts_with('-') => {
template = Some(arg.to_string());
}
_ => {} }
i += 1;
}
for attempt in 0..MKTEMP_MAX_ATTEMPTS {
let suffix = mktemp_suffix_for_attempt(attempt);
let name = mktemp_name(template.as_deref(), &suffix);
let path = if use_tmpdir || template.is_none() || !name.contains('/') {
format!("{}/{}", prefix_dir, name)
} else {
let p = resolve_path(ctx.cwd, &name);
p.to_string_lossy().to_string()
};
let full_path = std::path::PathBuf::from(&path);
if let Some(parent) = full_path.parent()
&& !ctx.fs.exists(parent).await.unwrap_or(false)
{
let _ = ctx.fs.mkdir(parent, true).await;
}
if ctx.fs.exists(&full_path).await.unwrap_or(false) {
continue;
}
if create_dir {
match ctx.fs.mkdir(&full_path, false).await {
Ok(_) => return Ok(ExecResult::ok(format!("{}\n", path))),
Err(_) if ctx.fs.exists(&full_path).await.unwrap_or(false) => continue,
Err(e) => {
return Ok(ExecResult::err(
format!("mktemp: failed to create directory '{}': {}\n", path, e),
1,
));
}
}
} else {
match ctx.fs.write_file(&full_path, &[]).await {
Ok(_) => return Ok(ExecResult::ok(format!("{}\n", path))),
Err(_) if ctx.fs.exists(&full_path).await.unwrap_or(false) => continue,
Err(e) => {
return Ok(ExecResult::err(
format!("mktemp: failed to create file '{}': {}\n", path, e),
1,
));
}
}
}
}
Ok(ExecResult::err(
"mktemp: failed to create unique temporary path after 64 attempts\n".to_string(),
1,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Datelike, Local, Timelike};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::fs::{FileSystem, InMemoryFs};
async fn create_test_ctx() -> (Arc<InMemoryFs>, PathBuf, HashMap<String, String>) {
let fs = Arc::new(InMemoryFs::new());
let cwd = PathBuf::from("/home/user");
let variables = HashMap::new();
fs.mkdir(&cwd, true).await.unwrap();
(fs, cwd, variables)
}
#[tokio::test]
async fn test_mkdir_simple() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["testdir".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Mkdir.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(fs.exists(&cwd.join("testdir")).await.unwrap());
}
#[tokio::test]
async fn test_mkdir_recursive() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-p".to_string(), "a/b/c".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Mkdir.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(fs.exists(&cwd.join("a/b/c")).await.unwrap());
}
#[tokio::test]
async fn test_touch_create() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["newfile.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Touch.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(fs.exists(&cwd.join("newfile.txt")).await.unwrap());
}
#[tokio::test]
async fn test_touch_t_sets_existing_file_mtime() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let file = cwd.join("existing.txt");
fs.write_file(&file, b"content").await.unwrap();
let args = vec![
"-t".to_string(),
"202604061200.00".to_string(),
"existing.txt".to_string(),
];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Touch.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
let metadata = fs.stat(&file).await.unwrap();
let modified: DateTime<Local> = metadata.modified.into();
assert_eq!(modified.year(), 2026);
assert_eq!(modified.month(), 4);
assert_eq!(modified.day(), 6);
assert_eq!(modified.hour(), 12);
assert_eq!(modified.minute(), 0);
assert_eq!(modified.second(), 0);
}
#[tokio::test]
async fn test_touch_t_rejects_invalid_timestamp() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec![
"-t".to_string(),
"not-a-timestamp".to_string(),
"existing.txt".to_string(),
];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Touch.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("invalid date format"));
}
#[tokio::test]
async fn test_rm_file() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("testfile.txt"), b"content")
.await
.unwrap();
let args = vec!["testfile.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Rm.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(!fs.exists(&cwd.join("testfile.txt")).await.unwrap());
}
#[tokio::test]
async fn test_rm_force_nonexistent() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-f".to_string(), "nonexistent".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Rm.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0); }
#[tokio::test]
async fn test_cp_file() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("source.txt"), b"content")
.await
.unwrap();
let args = vec!["source.txt".to_string(), "dest.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Cp.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(fs.exists(&cwd.join("dest.txt")).await.unwrap());
let content = fs.read_file(&cwd.join("dest.txt")).await.unwrap();
assert_eq!(content, b"content");
}
#[tokio::test]
async fn test_mv_file() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("source.txt"), b"content")
.await
.unwrap();
let args = vec!["source.txt".to_string(), "dest.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Mv.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(!fs.exists(&cwd.join("source.txt")).await.unwrap());
assert!(fs.exists(&cwd.join("dest.txt")).await.unwrap());
}
#[tokio::test]
async fn test_chmod_octal() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("script.sh"), b"#!/bin/bash")
.await
.unwrap();
let args = vec!["755".to_string(), "script.sh".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Chmod.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
let meta = fs.stat(&cwd.join("script.sh")).await.unwrap();
assert_eq!(meta.mode, 0o755);
}
#[test]
fn test_mktemp_name_template_replaces_xxxxxx() {
let name = mktemp_name(Some("/tmp/myapp.XXXXXX"), "abcdef1234");
assert_eq!(name, "/tmp/myapp.abcdef");
}
#[test]
fn test_mktemp_name_template_without_xxxxxx_appends_suffix() {
let name = mktemp_name(Some("/tmp/myapp"), "abcdef1234");
assert_eq!(name, "/tmp/myapp.abcdef");
}
}