use async_trait::async_trait;
use super::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;
pub struct Env;
#[async_trait]
impl Builtin for Env {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut ignore_env = false;
let mut env_vars: Vec<(String, String)> = Vec::new();
let mut command_start = 0;
for (i, arg) in ctx.args.iter().enumerate() {
if arg == "-i" || arg == "--ignore-environment" {
ignore_env = true;
} else if arg == "-u" {
return Ok(ExecResult::err(
"env: -u option not supported\n".to_string(),
1,
));
} else if let Some((name, value)) = arg.split_once('=') {
env_vars.push((name.to_string(), value.to_string()));
} else {
command_start = i;
break;
}
}
if command_start == 0 || command_start == ctx.args.len() {
let mut output = String::new();
if !ignore_env {
let mut pairs: Vec<_> = ctx.env.iter().collect();
pairs.sort_by_key(|(k, _)| *k);
for (key, value) in pairs {
output.push_str(&format!("{}={}\n", key, value));
}
}
for (key, value) in env_vars {
output.push_str(&format!("{}={}\n", key, value));
}
return Ok(ExecResult::ok(output));
}
Ok(ExecResult::err(
"env: executing commands not supported in virtual mode\n".to_string(),
126,
))
}
}
pub struct Printenv;
#[async_trait]
impl Builtin for Printenv {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if ctx.args.is_empty() {
let mut output = String::new();
let mut pairs: Vec<_> = ctx.env.iter().collect();
pairs.sort_by_key(|(k, _)| *k);
for (key, value) in pairs {
output.push_str(&format!("{}={}\n", key, value));
}
return Ok(ExecResult::ok(output));
}
let mut output = String::new();
let mut exit_code = 0;
for var_name in ctx.args {
if let Some(value) = ctx.env.get(var_name.as_str()) {
output.push_str(value);
output.push('\n');
} else {
exit_code = 1;
}
}
Ok(ExecResult {
stdout: output,
stderr: String::new(),
exit_code,
control_flow: crate::interpreter::ControlFlow::None,
..Default::default()
})
}
}
pub struct History;
impl History {
fn parse_duration_to_secs(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num_str, multiplier) = if let Some(stripped) = s.strip_suffix('d') {
(stripped, 86400)
} else if let Some(stripped) = s.strip_suffix('h') {
(stripped, 3600)
} else if let Some(stripped) = s.strip_suffix('m') {
(stripped, 60)
} else if let Some(stripped) = s.strip_suffix('s') {
(stripped, 1)
} else {
(s, 1)
};
let num: i64 = num_str.parse().ok()?;
Some(num * multiplier)
}
}
#[async_trait]
impl Builtin for History {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let Some(shell) = ctx.shell.as_ref() else {
return Ok(ExecResult::ok(String::new()));
};
let mut clear = false;
let mut count: Option<usize> = None;
let mut grep_pattern: Option<String> = None;
let mut cwd_filter: Option<String> = None;
let mut failed_only = false;
let mut since_secs: Option<i64> = None;
let mut i = 0;
while i < ctx.args.len() {
let arg = &ctx.args[i];
match arg.as_str() {
"-c" => clear = true,
"--grep" => {
i += 1;
if i < ctx.args.len() {
grep_pattern = Some(ctx.args[i].clone());
} else {
return Ok(ExecResult::err(
"history: --grep requires an argument\n".to_string(),
1,
));
}
}
"--cwd" => {
i += 1;
if i < ctx.args.len() {
cwd_filter = Some(ctx.args[i].clone());
} else {
return Ok(ExecResult::err(
"history: --cwd requires an argument\n".to_string(),
1,
));
}
}
"--failed" => failed_only = true,
"--since" => {
i += 1;
if i < ctx.args.len() {
match Self::parse_duration_to_secs(&ctx.args[i]) {
Some(secs) => since_secs = Some(secs),
None => {
return Ok(ExecResult::err(
format!(
"history: invalid duration '{}' (use e.g. 2d, 1h, 30m, 60s)\n",
ctx.args[i]
),
1,
));
}
}
} else {
return Ok(ExecResult::err(
"history: --since requires an argument\n".to_string(),
1,
));
}
}
_ => {
if let Some(opt) = arg.strip_prefix("--") {
return Ok(ExecResult::err(
format!("history: unrecognized option '--{}'\n", opt),
1,
));
} else if let Some(opt) = arg.strip_prefix('-') {
if opt != "c" {
return Ok(ExecResult::err(
format!("history: invalid option -- '{}'\n", opt),
1,
));
}
} else if let Ok(n) = arg.parse::<usize>() {
count = Some(n);
}
}
}
i += 1;
}
if clear {
let mut result = ExecResult::ok(String::new());
result
.side_effects
.push(super::BuiltinSideEffect::ClearHistory);
return Ok(result);
}
let history = shell.history_entries();
let now = chrono::Utc::now().timestamp();
let filtered: Vec<(usize, &crate::interpreter::HistoryEntry)> = history
.iter()
.enumerate()
.filter(|(_, entry)| {
if let Some(ref pat) = grep_pattern
&& !entry.command.contains(pat.as_str())
{
return false;
}
if let Some(ref cwd) = cwd_filter
&& !entry.cwd.starts_with(cwd.as_str())
{
return false;
}
if failed_only && entry.exit_code == 0 {
return false;
}
if let Some(secs) = since_secs
&& now - entry.timestamp > secs
{
return false;
}
true
})
.collect();
let entries: &[(usize, &crate::interpreter::HistoryEntry)] = if let Some(n) = count {
let start = filtered.len().saturating_sub(n);
&filtered[start..]
} else {
&filtered
};
let mut output = String::new();
for (idx, entry) in entries {
use std::fmt::Write;
let _ = writeln!(output, " {} {}", idx + 1, entry.command);
}
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_env_print_all() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let mut env = HashMap::new();
env.insert("HOME".to_string(), "/home/user".to_string());
env.insert("PATH".to_string(), "/bin:/usr/bin".to_string());
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,
shell: None,
};
let result = Env.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("HOME=/home/user"));
assert!(result.stdout.contains("PATH=/bin:/usr/bin"));
}
#[tokio::test]
async fn test_env_ignore_environment() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let mut env = HashMap::new();
env.insert("HOME".to_string(), "/home/user".to_string());
let args = vec!["-i".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,
shell: None,
};
let result = Env.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(!result.stdout.contains("HOME"));
}
#[tokio::test]
async fn test_env_add_vars() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["FOO=bar".to_string(), "BAZ=qux".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,
shell: None,
};
let result = Env.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("FOO=bar"));
assert!(result.stdout.contains("BAZ=qux"));
}
#[tokio::test]
async fn test_env_command_not_supported() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec![
"FOO=bar".to_string(),
"echo".to_string(),
"hello".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,
shell: None,
};
let result = Env.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 126);
assert!(result.stderr.contains("not supported"));
}
#[tokio::test]
async fn test_printenv_all() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let mut env = HashMap::new();
env.insert("HOME".to_string(), "/home/user".to_string());
env.insert("PATH".to_string(), "/bin".to_string());
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,
shell: None,
};
let result = Printenv.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("HOME=/home/user"));
assert!(result.stdout.contains("PATH=/bin"));
}
#[tokio::test]
async fn test_printenv_single_var() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let mut env = HashMap::new();
env.insert("HOME".to_string(), "/home/user".to_string());
let args = vec!["HOME".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,
shell: None,
};
let result = Printenv.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "/home/user");
}
#[tokio::test]
async fn test_printenv_multiple_vars() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let mut env = HashMap::new();
env.insert("HOME".to_string(), "/home/user".to_string());
env.insert("PATH".to_string(), "/bin".to_string());
let args = vec!["HOME".to_string(), "PATH".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,
shell: None,
};
let result = Printenv.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("/home/user"));
assert!(result.stdout.contains("/bin"));
}
#[tokio::test]
async fn test_printenv_missing_var() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["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,
shell: None,
};
let result = Printenv.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stdout.is_empty());
}
#[tokio::test]
async fn test_printenv_mixed() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let mut env = HashMap::new();
env.insert("HOME".to_string(), "/home/user".to_string());
let args = vec!["HOME".to_string(), "MISSING".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,
shell: None,
};
let result = Printenv.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1); assert!(result.stdout.contains("/home/user"));
}
#[tokio::test]
async fn test_history_empty() {
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,
shell: None,
};
let result = History.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_history_clear() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["-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,
shell: None,
};
let result = History.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_history_count() {
let (fs, mut cwd, mut variables) = create_test_ctx().await;
let env = HashMap::new();
let args = vec!["10".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,
shell: None,
};
let result = History.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
}
#[tokio::test]
async fn test_history_no_shell_state() {
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: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = History.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0);
}
}