use async_trait::async_trait;
use super::{Builtin, Context, ExecutionPlan, SubCommand, resolve_path};
use crate::error::Result;
use crate::interpreter::ExecResult;
pub struct Xargs;
struct XargsOptions {
replace_str: Option<String>,
max_args: Option<usize>,
delimiter: Option<char>,
command: Vec<String>,
}
#[allow(clippy::result_large_err)]
fn parse_xargs_args(args: &[String]) -> std::result::Result<XargsOptions, ExecResult> {
let mut replace_str: Option<String> = None;
let mut max_args: Option<usize> = None;
let mut delimiter: Option<char> = None;
let mut command: Vec<String> = Vec::new();
let mut p = super::arg_parser::ArgParser::new(args);
while !p.is_done() {
if let Some(val) = p
.flag_value("-I", "xargs")
.map_err(|e| ExecResult::err(format!("{e}\n"), 1))?
{
replace_str = Some(val.to_string());
max_args = Some(1); } else if let Some(val) = p
.flag_value("-n", "xargs")
.map_err(|e| ExecResult::err(format!("{e}\n"), 1))?
{
match val.parse::<usize>() {
Ok(n) if n > 0 => max_args = Some(n),
_ => {
return Err(ExecResult::err(
format!("xargs: invalid number: '{}'\n", val),
1,
));
}
}
} else if let Some(val) = p
.flag_value("-d", "xargs")
.map_err(|e| ExecResult::err(format!("{e}\n"), 1))?
{
delimiter = val.chars().next();
} else if p.flag("-0") {
delimiter = Some('\0');
} else if p.is_flag() && p.current() != Some("-") {
let Some(s) = p.current() else {
p.advance();
continue;
};
return Err(ExecResult::err(
format!("xargs: invalid option -- '{}'\n", &s[1..]),
1,
));
} else {
command.extend(p.rest().iter().cloned());
break;
}
}
if command.is_empty() {
command.push("echo".to_string());
}
Ok(XargsOptions {
replace_str,
max_args,
delimiter,
command,
})
}
fn build_xargs_commands(opts: &XargsOptions, input: &str) -> Vec<SubCommand> {
if input.is_empty() {
return Vec::new();
}
let items: Vec<&str> = if let Some(delim) = opts.delimiter {
input.split(delim).filter(|s| !s.is_empty()).collect()
} else {
input.split_whitespace().collect()
};
if items.is_empty() {
return Vec::new();
}
let chunk_size = opts.max_args.unwrap_or(items.len());
let chunks: Vec<Vec<&str>> = items.chunks(chunk_size).map(|c| c.to_vec()).collect();
chunks
.into_iter()
.map(|chunk| {
let cmd_args: Vec<String> = if let Some(ref repl) = opts.replace_str {
let item = chunk.first().unwrap_or(&"");
opts.command
.iter()
.map(|arg| arg.replace(repl, item))
.collect()
} else {
let mut full = opts.command.clone();
full.extend(chunk.iter().map(|s| s.to_string()));
full
};
let name = cmd_args[0].clone();
let args = cmd_args[1..].to_vec();
SubCommand {
name,
args,
stdin: None,
}
})
.collect()
}
#[async_trait]
impl Builtin for Xargs {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let opts = match parse_xargs_args(ctx.args) {
Ok(opts) => opts,
Err(e) => return Ok(e),
};
let input = ctx.stdin.unwrap_or("");
if input.is_empty() {
return Ok(ExecResult::ok(String::new()));
}
let commands = build_xargs_commands(&opts, input);
if commands.is_empty() {
return Ok(ExecResult::ok(String::new()));
}
let mut output = String::new();
for cmd in &commands {
output.push_str(&cmd.name);
for arg in &cmd.args {
output.push(' ');
output.push_str(arg);
}
output.push('\n');
}
Ok(ExecResult::ok(output))
}
async fn execution_plan(&self, ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
let opts = match parse_xargs_args(ctx.args) {
Ok(opts) => opts,
Err(_) => return Ok(None), };
let input = ctx.stdin.unwrap_or("");
if input.is_empty() {
return Ok(None); }
let commands = build_xargs_commands(&opts, input);
if commands.is_empty() {
return Ok(None);
}
Ok(Some(ExecutionPlan::Batch { commands }))
}
}
pub struct Tee;
#[async_trait]
impl Builtin for Tee {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut append = false;
let mut files: Vec<String> = Vec::new();
for arg in ctx.args {
if arg == "-a" || arg == "--append" {
append = true;
} else if arg.starts_with('-') && arg != "-" {
return Ok(ExecResult::err(
format!("tee: invalid option -- '{}'\n", &arg[1..]),
1,
));
} else {
files.push(arg.clone());
}
}
let input = ctx.stdin.unwrap_or("");
for file in &files {
let path = resolve_path(ctx.cwd, file);
if append {
ctx.fs.append_file(&path, input.as_bytes()).await?;
} else {
ctx.fs.write_file(&path, input.as_bytes()).await?;
}
}
Ok(ExecResult::ok(input.to_string()))
}
}
pub struct Watch;
#[async_trait]
impl Builtin for Watch {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut _interval: f64 = 2.0;
let mut command_start: Option<usize> = None;
let mut i = 0;
while i < ctx.args.len() {
let arg = &ctx.args[i];
if arg == "-n" {
i += 1;
if i >= ctx.args.len() {
return Ok(ExecResult::err(
"watch: option requires an argument -- 'n'\n".to_string(),
1,
));
}
match ctx.args[i].parse::<f64>() {
Ok(n) if n > 0.0 => _interval = n,
_ => {
return Ok(ExecResult::err(
format!("watch: invalid interval '{}'\n", ctx.args[i]),
1,
));
}
}
} else if arg.starts_with('-') && arg != "-" {
} else {
command_start = Some(i);
break;
}
i += 1;
}
let start = match command_start {
Some(s) => s,
None => {
return Ok(ExecResult::err(
"watch: no command specified\n".to_string(),
1,
));
}
};
let command: Vec<_> = ctx.args[start..].iter().collect();
let output = format!(
"Every {:.1}s: {}\n\n(watch: continuous execution not supported in virtual mode)\n",
_interval,
command
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" ")
);
Ok(ExecResult::ok(output))
}
}
#[cfg(test)]
mod tests {
use super::*;
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_xargs_basic() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args: Vec<String> = vec![];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("foo bar baz"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Xargs.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("echo foo bar baz"));
}
#[tokio::test]
async fn test_xargs_with_command() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["rm".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("file1 file2"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Xargs.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("rm file1 file2"));
}
#[tokio::test]
async fn test_xargs_n_option() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-n".to_string(), "1".to_string(), "echo".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("a b c"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Xargs.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
let lines: Vec<_> = result.stdout.lines().collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("echo a"));
assert!(lines[1].contains("echo b"));
assert!(lines[2].contains("echo c"));
}
#[tokio::test]
async fn test_xargs_i_option() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec![
"-I".to_string(),
"{}".to_string(),
"cp".to_string(),
"{}".to_string(),
"{}.bak".to_string(),
];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("file1\nfile2"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Xargs.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("cp file1 file1.bak"));
assert!(result.stdout.contains("cp file2 file2.bak"));
}
#[tokio::test]
async fn test_xargs_d_option() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-d".to_string(), ":".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("a:b:c"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Xargs.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("echo a b c"));
}
#[tokio::test]
async fn test_xargs_empty_input() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args: Vec<String> = vec![];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some(""),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Xargs.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.is_empty());
}
#[tokio::test]
async fn test_xargs_invalid_option() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-z".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("test"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Xargs.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("invalid option"));
}
#[tokio::test]
async fn test_xargs_plan_basic() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["rm".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("file1 file2"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let plan = Xargs.execution_plan(&ctx).await.unwrap();
match plan {
Some(ExecutionPlan::Batch { commands }) => {
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].name, "rm");
assert_eq!(commands[0].args, vec!["file1", "file2"]);
}
_ => panic!("expected Batch plan"),
}
}
#[tokio::test]
async fn test_xargs_plan_n_option() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-n".to_string(), "1".to_string(), "echo".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("a b c"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let plan = Xargs.execution_plan(&ctx).await.unwrap();
match plan {
Some(ExecutionPlan::Batch { commands }) => {
assert_eq!(commands.len(), 3);
assert_eq!(commands[0].name, "echo");
assert_eq!(commands[0].args, vec!["a"]);
assert_eq!(commands[1].args, vec!["b"]);
assert_eq!(commands[2].args, vec!["c"]);
}
_ => panic!("expected Batch plan"),
}
}
#[tokio::test]
async fn test_tee_basic() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["output.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("Hello, world!"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Tee.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "Hello, world!");
let content = fs.read_file(&cwd.join("output.txt")).await.unwrap();
assert_eq!(content, b"Hello, world!");
}
#[tokio::test]
async fn test_tee_multiple_files() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["file1.txt".to_string(), "file2.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("content"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Tee.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "content");
let content1 = fs.read_file(&cwd.join("file1.txt")).await.unwrap();
let content2 = fs.read_file(&cwd.join("file2.txt")).await.unwrap();
assert_eq!(content1, b"content");
assert_eq!(content2, b"content");
}
#[tokio::test]
async fn test_tee_append() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
fs.write_file(&cwd.join("output.txt"), b"initial\n")
.await
.unwrap();
let args = vec!["-a".to_string(), "output.txt".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("appended"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Tee.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
let content = fs.read_file(&cwd.join("output.txt")).await.unwrap();
assert_eq!(content, b"initial\nappended");
}
#[tokio::test]
async fn test_tee_no_files() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args: Vec<String> = vec![];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("pass through"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Tee.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "pass through");
}
#[tokio::test]
async fn test_tee_invalid_option() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-z".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs.clone(),
stdin: Some("test"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Tee.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("invalid option"));
}
#[tokio::test]
async fn test_watch_basic() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["ls".to_string(), "-l".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 = Watch.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("ls -l"));
assert!(result.stdout.contains("Every 2.0s"));
}
#[tokio::test]
async fn test_watch_n_option() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-n".to_string(), "5".to_string(), "date".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 = Watch.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("Every 5.0s"));
assert!(result.stdout.contains("date"));
}
#[tokio::test]
async fn test_watch_no_command() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args: Vec<String> = vec![];
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 = Watch.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("no command specified"));
}
#[tokio::test]
async fn test_watch_invalid_interval() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-n".to_string(), "abc".to_string(), "ls".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 = Watch.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("invalid interval"));
}
}