Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions crates/bashkit/src/builtins/clear.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! clear builtin command - clear terminal screen

use async_trait::async_trait;

use super::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;

/// The clear builtin command.
///
/// Outputs ANSI escape sequences to clear the terminal screen.
/// In virtual/non-interactive mode, outputs the escape codes as-is.
pub struct Clear;

#[async_trait]
impl Builtin for Clear {
async fn execute(&self, _ctx: Context<'_>) -> Result<ExecResult> {
// ESC[2J clears the screen, ESC[H moves cursor to top-left
Ok(ExecResult::ok("\x1b[2J\x1b[H".to_string()))
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::fs::InMemoryFs;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

#[tokio::test]
async fn test_clear_outputs_ansi() {
let args: Vec<String> = Vec::new();
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn crate::fs::FileSystem>;
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,
};
let result = Clear.execute(ctx).await.expect("clear failed");
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("\x1b[2J"));
assert!(result.stdout.contains("\x1b[H"));
}
}
257 changes: 257 additions & 0 deletions crates/bashkit/src/builtins/envsubst.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
//! envsubst builtin command - substitute environment variables in text

use async_trait::async_trait;

use super::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;

/// The envsubst builtin command.
///
/// Usage: envsubst [SHELL-FORMAT] < input
///
/// Substitutes `$VAR` and `${VAR}` references with environment variable values.
///
/// Options:
/// -v List variables found in input
/// SHELL-FORMAT Only substitute listed variables (e.g. '$HOST $PORT')
pub struct Envsubst;

#[async_trait]
impl Builtin for Envsubst {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let mut list_vars = false;
let mut restrict_vars: Option<Vec<String>> = None;

for arg in ctx.args {
match arg.as_str() {
"-v" | "--variables" => list_vars = true,
s if s.starts_with('$') => {
// SHELL-FORMAT: list of vars to substitute
let vars: Vec<String> = s
.split_whitespace()
.map(|v| {
v.trim_start_matches('$')
.trim_matches(|c| c == '{' || c == '}')
})
.filter(|v| !v.is_empty())
.map(|v| v.to_string())
.collect();
restrict_vars = Some(vars);
}
_ => {}
}
}

let input = ctx.stdin.unwrap_or("");

if list_vars {
// List variables found in input
let vars = find_variables(input);
let mut output = String::new();
for var in vars {
output.push_str(&var);
output.push('\n');
}
return Ok(ExecResult::ok(output));
}

let output = substitute(input, ctx.env, ctx.variables, restrict_vars.as_deref());
Ok(ExecResult::ok(output))
}
}

fn find_variables(input: &str) -> Vec<String> {
let mut vars = Vec::new();
let chars: Vec<char> = input.chars().collect();
let mut i = 0;

while i < chars.len() {
if chars[i] == '$' {
i += 1;
if i < chars.len() && chars[i] == '{' {
// ${VAR}
i += 1;
let start = i;
while i < chars.len() && chars[i] != '}' {
i += 1;
}
let name: String = chars[start..i].iter().collect();
if !name.is_empty() && !vars.contains(&name) {
vars.push(name);
}
if i < chars.len() {
i += 1; // skip }
}
} else {
// $VAR
let start = i;
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
let name: String = chars[start..i].iter().collect();
if !name.is_empty() && !vars.contains(&name) {
vars.push(name);
}
}
} else {
i += 1;
}
}

vars
}

fn substitute(
input: &str,
env: &std::collections::HashMap<String, String>,
variables: &std::collections::HashMap<String, String>,
restrict: Option<&[String]>,
) -> String {
let mut output = String::new();
let chars: Vec<char> = input.chars().collect();
let mut i = 0;

while i < chars.len() {
if chars[i] == '$' {
i += 1;
if i < chars.len() && chars[i] == '{' {
// ${VAR}
i += 1;
let start = i;
while i < chars.len() && chars[i] != '}' {
i += 1;
}
let name: String = chars[start..i].iter().collect();
if i < chars.len() {
i += 1; // skip }
}
if should_substitute(&name, restrict) {
if let Some(val) = env.get(&name).or_else(|| variables.get(&name)) {
output.push_str(val);
}
// If not found, substitute with empty string
} else {
output.push_str("${");
output.push_str(&name);
output.push('}');
}
} else {
// $VAR
let start = i;
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
let name: String = chars[start..i].iter().collect();
if should_substitute(&name, restrict) {
if let Some(val) = env.get(&name).or_else(|| variables.get(&name)) {
output.push_str(val);
}
} else {
output.push('$');
output.push_str(&name);
}
}
} else {
output.push(chars[i]);
i += 1;
}
}

output
}

fn should_substitute(name: &str, restrict: Option<&[String]>) -> bool {
match restrict {
Some(allowed) => allowed.iter().any(|v| v == name),
None => true,
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::fs::InMemoryFs;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

async fn run_envsubst(
args: &[&str],
stdin: Option<&str>,
env: HashMap<String, String>,
) -> ExecResult {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn crate::fs::FileSystem>;
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
};
Envsubst.execute(ctx).await.expect("envsubst failed")
}

#[tokio::test]
async fn test_basic_substitution() {
let mut env = HashMap::new();
env.insert("HOST".to_string(), "localhost".to_string());
env.insert("PORT".to_string(), "8080".to_string());
let result = run_envsubst(&[], Some("server=$HOST:$PORT"), env).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "server=localhost:8080");
}

#[tokio::test]
async fn test_braced_substitution() {
let mut env = HashMap::new();
env.insert("NAME".to_string(), "world".to_string());
let result = run_envsubst(&[], Some("hello ${NAME}!"), env).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "hello world!");
}

#[tokio::test]
async fn test_missing_var_becomes_empty() {
let env = HashMap::new();
let result = run_envsubst(&[], Some("value=$MISSING"), env).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "value=");
}

#[tokio::test]
async fn test_list_variables() {
let env = HashMap::new();
let result = run_envsubst(&["-v"], Some("$HOST and ${PORT} and $DB"), env).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("HOST"));
assert!(result.stdout.contains("PORT"));
assert!(result.stdout.contains("DB"));
}

#[tokio::test]
async fn test_restrict_variables() {
let mut env = HashMap::new();
env.insert("HOST".to_string(), "localhost".to_string());
env.insert("PORT".to_string(), "8080".to_string());
let result = run_envsubst(&["$HOST"], Some("$HOST:$PORT"), env).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "localhost:$PORT");
}

#[tokio::test]
async fn test_no_vars_passthrough() {
let env = HashMap::new();
let result = run_envsubst(&[], Some("no variables here"), env).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "no variables here");
}
}
Loading
Loading