From 0e5ee288c88e4258d2485f84bf155c3222b3aa7d Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sat, 14 Mar 2026 06:26:20 +0000 Subject: [PATCH] feat(builtins): add 14 new builtins - rg, patch, zip/unzip, iconv, compgen, json, csv, tomlq, yaml, template, parallel, http, help, fc Closes #542, #552, #553, #555, #556, #561, #564, #566, #567, #569, #570, #571, #575, #576 Adds builtin implementations for: - rg: simplified ripgrep with recursive search - patch: unified diff applier with -p, --dry-run, -R - zip/unzip: archive creation/extraction with custom binary format - iconv: character encoding conversion (utf-8, ascii, latin1, utf-16) - compgen: bash completion generator (-W, -f, -d, -c, -v, -e) - json: simplified jq alternative with get/set/keys/values/type - csv: CSV utilities with headers/column/filter/count/to-json - tomlq: TOML query tool with get/set/keys - yaml: YAML query tool with get/set/keys - template: mustache-lite template engine - parallel: GNU parallel stub for virtual environment - http: HTTPie-inspired HTTP client - help: shell-wide help and discovery system - fc: history listing/manipulation All builtins include comprehensive unit tests covering positive and negative paths. --- crates/bashkit/src/builtins/compgen.rs | 349 ++++++++++++ crates/bashkit/src/builtins/csv.rs | 612 ++++++++++++++++++++ crates/bashkit/src/builtins/fc.rs | 230 ++++++++ crates/bashkit/src/builtins/help.rs | 602 ++++++++++++++++++++ crates/bashkit/src/builtins/http.rs | 597 ++++++++++++++++++++ crates/bashkit/src/builtins/iconv.rs | 416 ++++++++++++++ crates/bashkit/src/builtins/json.rs | 483 ++++++++++++++++ crates/bashkit/src/builtins/mod.rs | 28 + crates/bashkit/src/builtins/parallel.rs | 358 ++++++++++++ crates/bashkit/src/builtins/patch.rs | 619 +++++++++++++++++++++ crates/bashkit/src/builtins/rg.rs | 536 ++++++++++++++++++ crates/bashkit/src/builtins/template.rs | 605 ++++++++++++++++++++ crates/bashkit/src/builtins/tomlq.rs | 507 +++++++++++++++++ crates/bashkit/src/builtins/yaml.rs | 707 ++++++++++++++++++++++++ crates/bashkit/src/builtins/zip_cmd.rs | 670 ++++++++++++++++++++++ crates/bashkit/src/interpreter/mod.rs | 15 + 16 files changed, 7334 insertions(+) create mode 100644 crates/bashkit/src/builtins/compgen.rs create mode 100644 crates/bashkit/src/builtins/csv.rs create mode 100644 crates/bashkit/src/builtins/fc.rs create mode 100644 crates/bashkit/src/builtins/help.rs create mode 100644 crates/bashkit/src/builtins/http.rs create mode 100644 crates/bashkit/src/builtins/iconv.rs create mode 100644 crates/bashkit/src/builtins/json.rs create mode 100644 crates/bashkit/src/builtins/parallel.rs create mode 100644 crates/bashkit/src/builtins/patch.rs create mode 100644 crates/bashkit/src/builtins/rg.rs create mode 100644 crates/bashkit/src/builtins/template.rs create mode 100644 crates/bashkit/src/builtins/tomlq.rs create mode 100644 crates/bashkit/src/builtins/yaml.rs create mode 100644 crates/bashkit/src/builtins/zip_cmd.rs diff --git a/crates/bashkit/src/builtins/compgen.rs b/crates/bashkit/src/builtins/compgen.rs new file mode 100644 index 00000000..eed725a3 --- /dev/null +++ b/crates/bashkit/src/builtins/compgen.rs @@ -0,0 +1,349 @@ +//! compgen builtin - programmable completion generator +//! +//! Generates possible completions for a word, used by Bash's +//! programmable completion system. +//! +//! Usage: +//! compgen -W "start stop restart" -- st # words matching prefix +//! compgen -f # filenames +//! compgen -d # directories +//! compgen -v # variables +//! compgen -c # commands (builtins) +//! compgen -A function # by action type + +use async_trait::async_trait; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// compgen builtin - bash completion generator. +pub struct Compgen; + +/// Hardcoded list of known builtin command names. +const BUILTIN_COMMANDS: &[&str] = &[ + "alias", "assert", "awk", "base64", "basename", "bc", "break", "cat", "cd", "chmod", "chown", + "clear", "column", "comm", "compgen", "continue", "cp", "curl", "cut", "date", "declare", "df", + "diff", "dirname", "dirs", "dotenv", "du", "echo", "env", "envsubst", "eval", "exit", "expand", + "export", "expr", "false", "find", "fold", "grep", "gunzip", "gzip", "head", "hexdump", + "history", "hostname", "iconv", "id", "jq", "json", "join", "kill", "ln", "local", "log", "ls", + "mkdir", "mktemp", "mv", "nl", "od", "paste", "popd", "printenv", "printf", "pushd", "pwd", + "read", "readlink", "readonly", "realpath", "retry", "return", "rev", "rm", "rmdir", "sed", + "semver", "seq", "set", "shift", "shopt", "sleep", "sort", "source", "split", "stat", + "strings", "tac", "tail", "tar", "tee", "test", "timeout", "touch", "tr", "tree", "true", + "uname", "unexpand", "uniq", "unset", "wait", "watch", "wc", "wget", "whoami", "xargs", "xxd", + "yes", +]; + +#[async_trait] +impl Builtin for Compgen { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut wordlist: Option = None; + let mut gen_files = false; + let mut gen_dirs = false; + let mut gen_commands = false; + let mut gen_variables = false; + let mut actions: Vec = Vec::new(); + let mut prefix: Option = None; + + let mut i = 0; + while i < ctx.args.len() { + match ctx.args[i].as_str() { + "-W" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "compgen: -W: option requires an argument\n".to_string(), + 1, + )); + } + wordlist = Some(ctx.args[i].clone()); + } + "-f" => gen_files = true, + "-d" => gen_dirs = true, + "-c" => gen_commands = true, + "-v" => gen_variables = true, + "-A" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "compgen: -A: option requires an argument\n".to_string(), + 1, + )); + } + actions.push(ctx.args[i].clone()); + } + "--" => { + // Next arg is the prefix word + i += 1; + if i < ctx.args.len() { + prefix = Some(ctx.args[i].clone()); + } + } + arg if arg.starts_with('-') => { + return Ok(ExecResult::err( + format!("compgen: unknown option '{arg}'\n"), + 1, + )); + } + _ => { + // Positional arg is the prefix word + if prefix.is_none() { + prefix = Some(ctx.args[i].clone()); + } + } + } + i += 1; + } + + // Process -A actions into flags + for action in &actions { + match action.as_str() { + "file" => gen_files = true, + "directory" => gen_dirs = true, + "command" | "builtin" => gen_commands = true, + "variable" => gen_variables = true, + "function" | "alias" => { + // No functions/aliases in virtual env, produce empty + } + _ => { + return Ok(ExecResult::err( + format!("compgen: unknown action '{action}'\n"), + 1, + )); + } + } + } + + let pfx = prefix.as_deref().unwrap_or(""); + let mut completions: Vec = Vec::new(); + + // -W wordlist + if let Some(ref wl) = wordlist { + for word in wl.split_whitespace() { + if word.starts_with(pfx) { + completions.push(word.to_string()); + } + } + } + + // -f: filenames from cwd + if gen_files && let Ok(entries) = ctx.fs.read_dir(ctx.cwd).await { + for entry in entries { + if entry.name.starts_with(pfx) { + completions.push(entry.name); + } + } + } + + // -d: directories from cwd + if gen_dirs && let Ok(entries) = ctx.fs.read_dir(ctx.cwd).await { + for entry in entries { + if entry.metadata.file_type.is_dir() && entry.name.starts_with(pfx) { + completions.push(entry.name); + } + } + } + + // -c: commands (builtin names) + if gen_commands { + for &cmd in BUILTIN_COMMANDS { + if cmd.starts_with(pfx) { + completions.push(cmd.to_string()); + } + } + } + + // -v: variable names + if gen_variables { + for name in ctx.variables.keys() { + if name.starts_with(pfx) { + completions.push(name.clone()); + } + } + } + + // No generators specified and no wordlist - error + if wordlist.is_none() + && !gen_files + && !gen_dirs + && !gen_commands + && !gen_variables + && actions.is_empty() + { + return Ok(ExecResult::err( + "compgen: usage: compgen [-W wordlist] [-f] [-d] [-c] [-v] [-A action] [word]\n" + .to_string(), + 1, + )); + } + + completions.sort(); + completions.dedup(); + + if completions.is_empty() { + return Ok(ExecResult::with_code("", 1)); + } + + let mut out = String::new(); + for c in &completions { + out.push_str(c); + out.push('\n'); + } + Ok(ExecResult::ok(out)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run( + args: &[&str], + variables: Option>, + fs: Option>, + ) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut vars = variables.unwrap_or_default(); + let mut cwd = PathBuf::from("/"); + let fs = fs.unwrap_or_else(|| Arc::new(InMemoryFs::new())); + let fs_dyn = fs as Arc; + let ctx = Context { + args: &args, + env: &env, + variables: &mut vars, + cwd: &mut cwd, + fs: fs_dyn, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + Compgen.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_wordlist_basic() { + let r = run(&["-W", "start stop restart"], None, None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("start\n")); + assert!(r.stdout.contains("stop\n")); + assert!(r.stdout.contains("restart\n")); + } + + #[tokio::test] + async fn test_wordlist_with_prefix() { + let r = run(&["-W", "start stop restart", "--", "st"], None, None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("start\n")); + assert!(r.stdout.contains("stop\n")); + assert!(!r.stdout.contains("restart")); + } + + #[tokio::test] + async fn test_wordlist_no_match() { + let r = run(&["-W", "start stop restart", "--", "xyz"], None, None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stdout.is_empty()); + } + + #[tokio::test] + async fn test_commands() { + let r = run(&["-c", "--", "ec"], None, None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("echo\n")); + } + + #[tokio::test] + async fn test_variables() { + let mut vars = HashMap::new(); + vars.insert("HOME".to_string(), "/home/user".to_string()); + vars.insert("HOSTNAME".to_string(), "localhost".to_string()); + vars.insert("PATH".to_string(), "/bin".to_string()); + + let r = run(&["-v", "--", "HO"], Some(vars), None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("HOME\n")); + assert!(r.stdout.contains("HOSTNAME\n")); + assert!(!r.stdout.contains("PATH")); + } + + #[tokio::test] + async fn test_files() { + let fs = Arc::new(InMemoryFs::new()); + let fs_dyn = fs.clone() as Arc; + fs_dyn + .write_file(std::path::Path::new("/hello.txt"), b"hi") + .await + .unwrap(); + fs_dyn + .write_file(std::path::Path::new("/help.md"), b"x") + .await + .unwrap(); + fs_dyn + .write_file(std::path::Path::new("/other.txt"), b"o") + .await + .unwrap(); + + let r = run(&["-f", "--", "hel"], None, Some(fs)).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("hello.txt\n")); + assert!(r.stdout.contains("help.md\n")); + assert!(!r.stdout.contains("other")); + } + + #[tokio::test] + async fn test_directories() { + let fs = Arc::new(InMemoryFs::new()); + let fs_dyn = fs.clone() as Arc; + fs_dyn + .mkdir(std::path::Path::new("/docs"), false) + .await + .unwrap(); + fs_dyn + .write_file(std::path::Path::new("/data.txt"), b"x") + .await + .unwrap(); + + let r = run(&["-d", "--", "d"], None, Some(fs)).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("docs\n")); + assert!(!r.stdout.contains("data.txt")); + } + + #[tokio::test] + async fn test_action_flag() { + let r = run(&["-A", "command", "--", "ec"], None, None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("echo\n")); + } + + #[tokio::test] + async fn test_unknown_action() { + let r = run(&["-A", "nosuch"], None, None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("unknown action")); + } + + #[tokio::test] + async fn test_no_options() { + let r = run(&[], None, None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("usage")); + } + + #[tokio::test] + async fn test_w_missing_arg() { + let r = run(&["-W"], None, None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("requires an argument")); + } +} diff --git a/crates/bashkit/src/builtins/csv.rs b/crates/bashkit/src/builtins/csv.rs new file mode 100644 index 00000000..4988d6e8 --- /dev/null +++ b/crates/bashkit/src/builtins/csv.rs @@ -0,0 +1,612 @@ +//! CSV utilities builtin +//! +//! Non-standard builtin for querying and transforming CSV data. +//! +//! Usage: +//! csv select name,age data.csv +//! csv count data.csv +//! csv headers data.csv +//! csv filter age = 30 data.csv +//! csv sort name data.csv +//! echo "a,b\n1,2" | csv count + +use async_trait::async_trait; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// csv builtin - CSV query and transformation utilities +pub struct Csv; + +/// A parsed CSV table: header row (if any) + data rows. +struct CsvTable { + headers: Option>, + rows: Vec>, +} + +/// Parse a single CSV line handling quoted fields. +/// Supports double-quote escaping (RFC 4180 style: "" inside quotes). +fn parse_csv_line(line: &str, delim: char) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + + while let Some(c) = chars.next() { + if in_quotes { + if c == '"' { + if chars.peek() == Some(&'"') { + // escaped quote + chars.next(); + current.push('"'); + } else { + in_quotes = false; + } + } else { + current.push(c); + } + } else if c == '"' { + in_quotes = true; + } else if c == delim { + fields.push(current.clone()); + current.clear(); + } else { + current.push(c); + } + } + fields.push(current); + fields +} + +/// Serialize a row back to CSV with proper quoting. +fn format_csv_row(fields: &[String], delim: char) -> String { + fields + .iter() + .map(|f| { + if f.contains(delim) || f.contains('"') || f.contains('\n') { + format!("\"{}\"", f.replace('"', "\"\"")) + } else { + f.clone() + } + }) + .collect::>() + .join(&delim.to_string()) +} + +/// Parse CSV content into a table. +fn parse_csv(content: &str, delim: char, has_header: bool) -> CsvTable { + let lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect(); + if lines.is_empty() { + return CsvTable { + headers: if has_header { Some(Vec::new()) } else { None }, + rows: Vec::new(), + }; + } + + let (headers, data_start) = if has_header { + (Some(parse_csv_line(lines[0], delim)), 1) + } else { + (None, 0) + }; + + let rows = lines[data_start..] + .iter() + .map(|l| parse_csv_line(l, delim)) + .collect(); + + CsvTable { headers, rows } +} + +/// Resolve a column specifier to a 0-based index. +/// Accepts numeric (1-based) or header name. +fn resolve_column(col: &str, headers: &Option>) -> Option { + if let Ok(n) = col.parse::() { + if n == 0 { + return None; + } + return Some(n - 1); + } + if let Some(hdrs) = headers { + hdrs.iter().position(|h| h == col) + } else { + None + } +} + +/// Read input from file arg or stdin. +async fn read_input<'a>( + ctx: &Context<'a>, + file_arg: Option<&str>, +) -> std::result::Result { + if let Some(path_str) = file_arg { + let path = resolve_path(ctx.cwd, path_str); + match ctx.fs.read_file(&path).await { + Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).into_owned()), + Err(_) => Err(ExecResult::err( + format!("csv: cannot read '{}'\n", path_str), + 1, + )), + } + } else if let Some(stdin) = ctx.stdin { + Ok(stdin.to_string()) + } else { + Err(ExecResult::err("csv: no input\n".to_string(), 1)) + } +} + +#[async_trait] +impl Builtin for Csv { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + return Ok(ExecResult::err( + "csv: usage: csv [options] [file]\nSubcommands: select, count, headers, filter, sort\n".to_string(), + 1, + )); + } + + // Parse global options before subcommand dispatch. + // We scan for -d DELIM and --no-header, collecting remaining args. + let mut delim = ','; + let mut has_header = true; + let mut remaining: Vec = Vec::new(); + let mut i = 0; + while i < ctx.args.len() { + match ctx.args[i].as_str() { + "-d" => { + i += 1; + if i < ctx.args.len() { + let d = &ctx.args[i]; + if d.len() == 1 { + delim = d.chars().next().unwrap_or(','); + } else if d == "\\t" || d == "tab" { + delim = '\t'; + } else { + return Ok(ExecResult::err( + "csv: delimiter must be a single character\n".to_string(), + 1, + )); + } + } else { + return Ok(ExecResult::err( + "csv: -d requires an argument\n".to_string(), + 1, + )); + } + } + "--no-header" => { + has_header = false; + } + _ => { + remaining.push(ctx.args[i].clone()); + } + } + i += 1; + } + + if remaining.is_empty() { + return Ok(ExecResult::err("csv: missing subcommand\n".to_string(), 1)); + } + + let subcmd = remaining[0].as_str(); + let rest = &remaining[1..]; + + match subcmd { + "headers" => { + let file_arg = rest.first().map(|s| s.as_str()); + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + let table = parse_csv(&content, delim, has_header); + if let Some(hdrs) = &table.headers { + let out = hdrs + .iter() + .enumerate() + .map(|(i, h)| format!("{}: {}", i + 1, h)) + .collect::>() + .join("\n"); + Ok(ExecResult::ok(format!("{out}\n"))) + } else { + Ok(ExecResult::err( + "csv: no headers (use without --no-header)\n".to_string(), + 1, + )) + } + } + "count" => { + let file_arg = rest.first().map(|s| s.as_str()); + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + let table = parse_csv(&content, delim, has_header); + Ok(ExecResult::ok(format!("{}\n", table.rows.len()))) + } + "select" => { + if rest.is_empty() { + return Ok(ExecResult::err( + "csv: select requires column specifiers\n".to_string(), + 1, + )); + } + let col_spec = &rest[0]; + let file_arg = rest.get(1).map(|s| s.as_str()); + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + let table = parse_csv(&content, delim, has_header); + + let col_names: Vec<&str> = col_spec.split(',').collect(); + let indices: Vec = col_names + .iter() + .filter_map(|c| resolve_column(c.trim(), &table.headers)) + .collect(); + + if indices.is_empty() { + return Ok(ExecResult::err("csv: no matching columns\n".to_string(), 1)); + } + + let mut out = String::new(); + // Output header row if present + if let Some(hdrs) = &table.headers { + let selected: Vec = indices + .iter() + .filter_map(|&i| hdrs.get(i).cloned()) + .collect(); + out.push_str(&format_csv_row(&selected, delim)); + out.push('\n'); + } + for row in &table.rows { + let selected: Vec = indices + .iter() + .map(|&i| row.get(i).cloned().unwrap_or_default()) + .collect(); + out.push_str(&format_csv_row(&selected, delim)); + out.push('\n'); + } + Ok(ExecResult::ok(out)) + } + "filter" => { + // csv filter COLUMN OP VALUE [FILE] + if rest.len() < 3 { + return Ok(ExecResult::err( + "csv: filter requires COLUMN OP VALUE\n".to_string(), + 1, + )); + } + let col_name = &rest[0]; + let op = &rest[1]; + let value = &rest[2]; + let file_arg = rest.get(3).map(|s| s.as_str()); + + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + let table = parse_csv(&content, delim, has_header); + + let col_idx = match resolve_column(col_name, &table.headers) { + Some(i) => i, + None => { + return Ok(ExecResult::err( + format!("csv: unknown column '{}'\n", col_name), + 1, + )); + } + }; + + let filtered: Vec<&Vec> = table + .rows + .iter() + .filter(|row| { + let cell = row.get(col_idx).map(|s| s.as_str()).unwrap_or(""); + match op.as_str() { + "=" | "==" => cell == value.as_str(), + "!=" => cell != value.as_str(), + "contains" => cell.contains(value.as_str()), + _ => false, + } + }) + .collect(); + + let mut out = String::new(); + if let Some(hdrs) = &table.headers { + out.push_str(&format_csv_row(hdrs, delim)); + out.push('\n'); + } + for row in &filtered { + out.push_str(&format_csv_row(row, delim)); + out.push('\n'); + } + Ok(ExecResult::ok(out)) + } + "sort" => { + if rest.is_empty() { + return Ok(ExecResult::err( + "csv: sort requires a column specifier\n".to_string(), + 1, + )); + } + let col_name = &rest[0]; + let file_arg = rest.get(1).map(|s| s.as_str()); + + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + let table = parse_csv(&content, delim, has_header); + + let col_idx = match resolve_column(col_name, &table.headers) { + Some(i) => i, + None => { + return Ok(ExecResult::err( + format!("csv: unknown column '{}'\n", col_name), + 1, + )); + } + }; + + let mut sorted_rows = table.rows.clone(); + sorted_rows.sort_by(|a, b| { + let va = a.get(col_idx).map(|s| s.as_str()).unwrap_or(""); + let vb = b.get(col_idx).map(|s| s.as_str()).unwrap_or(""); + // Try numeric sort first, fall back to string + match (va.parse::(), vb.parse::()) { + (Ok(na), Ok(nb)) => { + na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal) + } + _ => va.cmp(vb), + } + }); + + let mut out = String::new(); + if let Some(hdrs) = &table.headers { + out.push_str(&format_csv_row(hdrs, delim)); + out.push('\n'); + } + for row in &sorted_rows { + out.push_str(&format_csv_row(row, delim)); + out.push('\n'); + } + Ok(ExecResult::ok(out)) + } + _ => Ok(ExecResult::err( + format!("csv: unknown subcommand '{}'\n", subcmd), + 1, + )), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run(args: &[&str], stdin: Option<&str>) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs = Arc::new(InMemoryFs::new()) as Arc; + 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, + }; + Csv.execute(ctx).await.unwrap() + } + + async fn run_with_file(args: &[&str], filename: &str, content: &str) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs = Arc::new(InMemoryFs::new()) as Arc; + fs.write_file(&PathBuf::from(filename), content.as_bytes()) + .await + .unwrap(); + 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, + }; + Csv.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_no_args() { + let r = run(&[], None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("usage")); + } + + #[tokio::test] + async fn test_unknown_subcommand() { + let r = run(&["bogus"], Some("a,b\n1,2\n")).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("unknown subcommand")); + } + + #[tokio::test] + async fn test_count() { + let r = run(&["count"], Some("name,age\nalice,30\nbob,25\n")).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "2"); + } + + #[tokio::test] + async fn test_headers() { + let r = run(&["headers"], Some("name,age,city\nalice,30,NYC\n")).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("1: name")); + assert!(r.stdout.contains("2: age")); + assert!(r.stdout.contains("3: city")); + } + + #[tokio::test] + async fn test_select_by_name() { + let r = run(&["select", "name"], Some("name,age\nalice,30\nbob,25\n")).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("name\n")); + assert!(r.stdout.contains("alice\n")); + assert!(r.stdout.contains("bob\n")); + } + + #[tokio::test] + async fn test_select_by_index() { + let r = run(&["select", "2"], Some("name,age\nalice,30\nbob,25\n")).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("age\n")); + assert!(r.stdout.contains("30\n")); + } + + #[tokio::test] + async fn test_filter_equals() { + let r = run( + &["filter", "name", "=", "alice"], + Some("name,age\nalice,30\nbob,25\n"), + ) + .await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("alice")); + assert!(!r.stdout.contains("bob")); + } + + #[tokio::test] + async fn test_filter_contains() { + let r = run( + &["filter", "name", "contains", "li"], + Some("name,age\nalice,30\nbob,25\n"), + ) + .await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("alice")); + assert!(!r.stdout.contains("bob")); + } + + #[tokio::test] + async fn test_filter_not_equals() { + let r = run( + &["filter", "name", "!=", "alice"], + Some("name,age\nalice,30\nbob,25\n"), + ) + .await; + assert_eq!(r.exit_code, 0); + assert!(!r.stdout.contains("alice")); + assert!(r.stdout.contains("bob")); + } + + #[tokio::test] + async fn test_sort_string() { + let r = run( + &["sort", "name"], + Some("name,age\ncharlie,20\nalice,30\nbob,25\n"), + ) + .await; + assert_eq!(r.exit_code, 0); + let lines: Vec<&str> = r.stdout.lines().collect(); + assert_eq!(lines[0], "name,age"); + assert!(lines[1].starts_with("alice")); + assert!(lines[2].starts_with("bob")); + assert!(lines[3].starts_with("charlie")); + } + + #[tokio::test] + async fn test_sort_numeric() { + let r = run( + &["sort", "age"], + Some("name,age\ncharlie,20\nalice,30\nbob,25\n"), + ) + .await; + assert_eq!(r.exit_code, 0); + let lines: Vec<&str> = r.stdout.lines().collect(); + assert_eq!(lines[1], "charlie,20"); + assert_eq!(lines[2], "bob,25"); + assert_eq!(lines[3], "alice,30"); + } + + #[tokio::test] + async fn test_quoted_fields() { + let input = "name,bio\nalice,\"likes, commas\"\nbob,\"says \"\"hi\"\"\"\n"; + let r = run(&["select", "bio"], Some(input)).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("\"likes, commas\"")); + assert!(r.stdout.contains("\"says \"\"hi\"\"\"")); + } + + #[tokio::test] + async fn test_custom_delimiter() { + let r = run( + &["-d", "\t", "count"], + Some("name\tage\nalice\t30\nbob\t25\n"), + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "2"); + } + + #[tokio::test] + async fn test_no_header_mode() { + let r = run(&["--no-header", "count"], Some("alice,30\nbob,25\n")).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "2"); + } + + #[tokio::test] + async fn test_read_from_file() { + let r = run_with_file( + &["count", "/data.csv"], + "/data.csv", + "name,age\nalice,30\nbob,25\n", + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "2"); + } + + #[tokio::test] + async fn test_filter_unknown_column() { + let r = run( + &["filter", "nonexistent", "=", "x"], + Some("name,age\nalice,30\n"), + ) + .await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("unknown column")); + } + + #[tokio::test] + async fn test_select_no_columns_arg() { + let r = run(&["select"], Some("a,b\n1,2\n")).await; + assert_eq!(r.exit_code, 1); + } + + #[tokio::test] + async fn test_no_input() { + let r = run(&["count"], None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("no input")); + } +} diff --git a/crates/bashkit/src/builtins/fc.rs b/crates/bashkit/src/builtins/fc.rs new file mode 100644 index 00000000..9724591a --- /dev/null +++ b/crates/bashkit/src/builtins/fc.rs @@ -0,0 +1,230 @@ +//! fc builtin - display and re-execute history entries +//! +//! Non-standard simplified version. In Bashkit's virtual environment, +//! history is session-limited, so fc provides listing and substitution. + +use async_trait::async_trait; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// fc builtin - list and manipulate command history. +/// +/// Usage: fc [-l] [-n] [-r] [-s [old=new]] [first [last]] +/// +/// Options: +/// -l List history entries (default behavior in virtual env) +/// -n Suppress line numbers in listing +/// -r Reverse order +/// -s old=new Substitute and display (no re-execution in virtual env) +/// +/// In Bashkit's virtual environment, fc only lists and formats history. +/// Re-execution and editor support are not available. +pub struct Fc; + +#[async_trait] +impl Builtin for Fc { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut list_mode = false; + let mut no_numbers = false; + let mut reverse = false; + let mut substitute: Option<(String, String)> = None; + let mut positional: Vec = Vec::new(); + + let mut i = 0; + while i < ctx.args.len() { + match ctx.args[i].as_str() { + "-l" => list_mode = true, + "-n" => no_numbers = true, + "-r" => reverse = true, + "-s" => { + i += 1; + if let Some(arg) = ctx.args.get(i) { + if let Some(eq_pos) = arg.find('=') { + substitute = + Some((arg[..eq_pos].to_string(), arg[eq_pos + 1..].to_string())); + } else { + return Ok(ExecResult::err( + "fc: -s requires old=new argument\n".to_string(), + 1, + )); + } + } else { + return Ok(ExecResult::err( + "fc: -s requires an argument\n".to_string(), + 1, + )); + } + } + "-ln" | "-nl" => { + list_mode = true; + no_numbers = true; + } + "-lr" | "-rl" => { + list_mode = true; + reverse = true; + } + arg if arg.starts_with('-') && arg.len() > 1 => { + // Check for combined flags + let flags = &arg[1..]; + for ch in flags.chars() { + match ch { + 'l' => list_mode = true, + 'n' => no_numbers = true, + 'r' => reverse = true, + _ => { + return Ok(ExecResult::err( + format!("fc: invalid option -- '{ch}'\n"), + 1, + )); + } + } + } + } + _ => positional.push(ctx.args[i].clone()), + } + i += 1; + } + + // Handle substitution mode + if let Some((old, new)) = substitute { + return Ok(ExecResult::ok(format!( + "fc: would substitute '{old}' with '{new}' in last command (not supported in virtual environment)\n" + ))); + } + + // Default to list mode in virtual environment + let _ = list_mode; + + // Virtual history entries - in a real shell these would come from + // the interpreter's command history + let history: Vec = Vec::new(); + + if history.is_empty() { + return Ok(ExecResult::ok( + "fc: no history available in virtual environment\n".to_string(), + )); + } + + // Parse range from positional args + let first = positional + .first() + .and_then(|s| s.parse::().ok()) + .unwrap_or(-(history.len() as i64)); + let last = positional + .get(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(-1); + + let len = history.len() as i64; + let start = if first < 0 { + (len + first).max(0) as usize + } else { + (first - 1).max(0) as usize + }; + let end = if last < 0 { + (len + last + 1).max(0) as usize + } else { + last.min(len) as usize + }; + + let mut entries: Vec<(usize, &str)> = history[start..end.min(history.len())] + .iter() + .enumerate() + .map(|(i, s)| (start + i + 1, s.as_str())) + .collect(); + + if reverse { + entries.reverse(); + } + + let mut output = String::new(); + for (num, cmd) in &entries { + if no_numbers { + output.push_str(&format!("{cmd}\n")); + } else { + output.push_str(&format!("{num}\t{cmd}\n")); + } + } + + Ok(ExecResult::ok(output)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run_fc(args: &[&str]) -> ExecResult { + let fs = Arc::new(InMemoryFs::new()); + let mut variables = HashMap::new(); + let env = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + Fc.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_list_empty_history() { + let result = run_fc(&["-l"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("no history")); + } + + #[tokio::test] + async fn test_default_empty_history() { + let result = run_fc(&[]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("no history")); + } + + #[tokio::test] + async fn test_substitute_mode() { + let result = run_fc(&["-s", "foo=bar"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("substitute")); + assert!(result.stdout.contains("foo")); + assert!(result.stdout.contains("bar")); + } + + #[tokio::test] + async fn test_substitute_missing_arg() { + let result = run_fc(&["-s"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("requires an argument")); + } + + #[tokio::test] + async fn test_substitute_invalid_format() { + let result = run_fc(&["-s", "noequalssign"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("old=new")); + } + + #[tokio::test] + async fn test_invalid_option() { + let result = run_fc(&["-z"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("invalid option")); + } + + #[tokio::test] + async fn test_combined_flags() { + let result = run_fc(&["-ln"]).await; + assert_eq!(result.exit_code, 0); + } + + #[tokio::test] + async fn test_reverse_flag() { + let result = run_fc(&["-r"]).await; + assert_eq!(result.exit_code, 0); + } +} diff --git a/crates/bashkit/src/builtins/help.rs b/crates/bashkit/src/builtins/help.rs new file mode 100644 index 00000000..aba140a8 --- /dev/null +++ b/crates/bashkit/src/builtins/help.rs @@ -0,0 +1,602 @@ +//! help builtin - shell-wide command discovery and usage info +//! +//! Non-standard enhanced help that lists all available builtins, +//! provides usage information, and supports search/filtering. + +use async_trait::async_trait; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// help builtin - display information about builtin commands. +/// +/// Usage: help [OPTIONS] [COMMAND] +/// +/// Options: +/// -s Short description only +/// --list List all available builtins +/// --search TERM Search builtins by name +/// --json Output in JSON format +/// +/// Without arguments, lists all builtin categories. +/// With a command name, shows usage for that command. +pub struct Help; + +/// Builtin command metadata +struct CmdInfo { + name: &'static str, + category: &'static str, + usage: &'static str, + description: &'static str, +} + +const BUILTINS: &[CmdInfo] = &[ + // Core shell + CmdInfo { + name: "echo", + category: "output", + usage: "echo [-neE] [STRING...]", + description: "Display text", + }, + CmdInfo { + name: "printf", + category: "output", + usage: "printf FORMAT [ARGS...]", + description: "Formatted output", + }, + CmdInfo { + name: "true", + category: "flow", + usage: "true", + description: "Exit with status 0", + }, + CmdInfo { + name: "false", + category: "flow", + usage: "false", + description: "Exit with status 1", + }, + CmdInfo { + name: "exit", + category: "flow", + usage: "exit [N]", + description: "Exit shell", + }, + CmdInfo { + name: "return", + category: "flow", + usage: "return [N]", + description: "Return from function", + }, + CmdInfo { + name: "break", + category: "flow", + usage: "break [N]", + description: "Break from loop", + }, + CmdInfo { + name: "continue", + category: "flow", + usage: "continue [N]", + description: "Continue loop", + }, + CmdInfo { + name: "cd", + category: "navigation", + usage: "cd [DIR]", + description: "Change directory", + }, + CmdInfo { + name: "pwd", + category: "navigation", + usage: "pwd [-LP]", + description: "Print working directory", + }, + CmdInfo { + name: "export", + category: "variables", + usage: "export NAME[=VALUE]", + description: "Export variable", + }, + CmdInfo { + name: "local", + category: "variables", + usage: "local NAME[=VALUE]", + description: "Local variable", + }, + CmdInfo { + name: "set", + category: "variables", + usage: "set [-euo pipefail]", + description: "Set shell options", + }, + CmdInfo { + name: "unset", + category: "variables", + usage: "unset NAME", + description: "Unset variable", + }, + CmdInfo { + name: "shift", + category: "variables", + usage: "shift [N]", + description: "Shift positional params", + }, + CmdInfo { + name: "source", + category: "execution", + usage: "source FILE [ARGS]", + description: "Execute file in current shell", + }, + CmdInfo { + name: "eval", + category: "execution", + usage: "eval [ARGS]", + description: "Evaluate arguments as command", + }, + CmdInfo { + name: "test", + category: "conditionals", + usage: "test EXPR", + description: "Evaluate expression", + }, + CmdInfo { + name: "[", + category: "conditionals", + usage: "[ EXPR ]", + description: "Evaluate expression", + }, + CmdInfo { + name: "read", + category: "input", + usage: "read [-r] [-p PROMPT] VAR...", + description: "Read input", + }, + // File operations + CmdInfo { + name: "cat", + category: "text", + usage: "cat [-nvET] [FILE...]", + description: "Concatenate files", + }, + CmdInfo { + name: "head", + category: "text", + usage: "head [-n N] [FILE]", + description: "First N lines", + }, + CmdInfo { + name: "tail", + category: "text", + usage: "tail [-n N] [FILE]", + description: "Last N lines", + }, + CmdInfo { + name: "grep", + category: "text", + usage: "grep [-ivncowElFPq] PATTERN [FILE...]", + description: "Search patterns", + }, + CmdInfo { + name: "sed", + category: "text", + usage: "sed [-inE] SCRIPT [FILE]", + description: "Stream editor", + }, + CmdInfo { + name: "awk", + category: "text", + usage: "awk [-F SEP] PROGRAM [FILE]", + description: "Text processing", + }, + CmdInfo { + name: "sort", + category: "text", + usage: "sort [-rnu] [FILE]", + description: "Sort lines", + }, + CmdInfo { + name: "uniq", + category: "text", + usage: "uniq [-cdu] [FILE]", + description: "Filter duplicates", + }, + CmdInfo { + name: "cut", + category: "text", + usage: "cut -d DELIM -f FIELDS [FILE]", + description: "Extract fields", + }, + CmdInfo { + name: "tr", + category: "text", + usage: "tr [-d] SET1 [SET2]", + description: "Translate characters", + }, + CmdInfo { + name: "wc", + category: "text", + usage: "wc [-lwc] [FILE...]", + description: "Count lines/words/bytes", + }, + CmdInfo { + name: "jq", + category: "text", + usage: "jq [-rcsne] FILTER [FILE]", + description: "JSON processing", + }, + CmdInfo { + name: "diff", + category: "text", + usage: "diff [-uq] FILE1 FILE2", + description: "Compare files", + }, + // File operations + CmdInfo { + name: "mkdir", + category: "files", + usage: "mkdir [-p] DIR...", + description: "Create directories", + }, + CmdInfo { + name: "rm", + category: "files", + usage: "rm [-rf] FILE...", + description: "Remove files", + }, + CmdInfo { + name: "cp", + category: "files", + usage: "cp [-r] SRC DEST", + description: "Copy files", + }, + CmdInfo { + name: "mv", + category: "files", + usage: "mv SRC DEST", + description: "Move/rename files", + }, + CmdInfo { + name: "touch", + category: "files", + usage: "touch FILE...", + description: "Create/update files", + }, + CmdInfo { + name: "chmod", + category: "files", + usage: "chmod MODE FILE...", + description: "Change permissions", + }, + CmdInfo { + name: "ln", + category: "files", + usage: "ln [-sf] TARGET LINK", + description: "Create links", + }, + CmdInfo { + name: "ls", + category: "files", + usage: "ls [-lahR1t] [DIR]", + description: "List directory", + }, + CmdInfo { + name: "find", + category: "files", + usage: "find [PATH] [-name PAT] [-type TYPE]", + description: "Search files", + }, + CmdInfo { + name: "tree", + category: "files", + usage: "tree [-adL N] [DIR]", + description: "Directory tree", + }, + CmdInfo { + name: "stat", + category: "files", + usage: "stat [-c FMT] FILE", + description: "File metadata", + }, + // Utilities + CmdInfo { + name: "date", + category: "utility", + usage: "date [-u] [+FORMAT]", + description: "Date/time", + }, + CmdInfo { + name: "sleep", + category: "utility", + usage: "sleep SECONDS", + description: "Pause execution", + }, + CmdInfo { + name: "basename", + category: "utility", + usage: "basename PATH [SUFFIX]", + description: "Strip directory", + }, + CmdInfo { + name: "dirname", + category: "utility", + usage: "dirname PATH", + description: "Strip filename", + }, + CmdInfo { + name: "seq", + category: "utility", + usage: "seq [FIRST [INCR]] LAST", + description: "Print sequence", + }, + CmdInfo { + name: "expr", + category: "utility", + usage: "expr ARG...", + description: "Evaluate expression", + }, + CmdInfo { + name: "bc", + category: "utility", + usage: "bc [-l]", + description: "Calculator", + }, + CmdInfo { + name: "base64", + category: "utility", + usage: "base64 [-d] [FILE]", + description: "Base64 encode/decode", + }, + // Non-standard + CmdInfo { + name: "assert", + category: "non-standard", + usage: "assert EXPR [MESSAGE]", + description: "Test assertions", + }, + CmdInfo { + name: "retry", + category: "non-standard", + usage: "retry [OPTS] -- CMD", + description: "Retry commands", + }, + CmdInfo { + name: "log", + category: "non-standard", + usage: "log LEVEL MSG [K=V...]", + description: "Structured logging", + }, + CmdInfo { + name: "semver", + category: "non-standard", + usage: "semver SUBCMD ARGS...", + description: "Version operations", + }, + CmdInfo { + name: "dotenv", + category: "non-standard", + usage: "dotenv [OPTS] [FILE]", + description: "Load .env files", + }, + CmdInfo { + name: "verify", + category: "non-standard", + usage: "verify [OPTS] FILE [HASH]", + description: "File verification", + }, + CmdInfo { + name: "glob", + category: "non-standard", + usage: "glob [OPTS] PATTERN [STR...]", + description: "Glob matching", + }, +]; + +#[async_trait] +impl Builtin for Help { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut short = false; + let mut list = false; + let mut json = false; + let mut search: Option = None; + let mut command: Option = None; + + let mut i = 0; + while i < ctx.args.len() { + match ctx.args[i].as_str() { + "-s" => short = true, + "--list" => list = true, + "--json" => json = true, + "--search" => { + i += 1; + search = ctx.args.get(i).cloned(); + } + arg if !arg.starts_with('-') => command = Some(arg.to_string()), + other => { + return Ok(ExecResult::err( + format!("help: unknown option '{other}'\n"), + 1, + )); + } + } + i += 1; + } + + // Specific command help + if let Some(ref cmd) = command { + if let Some(info) = BUILTINS.iter().find(|b| b.name == cmd.as_str()) { + if json { + return Ok(ExecResult::ok(format!( + "{{\"name\":\"{}\",\"category\":\"{}\",\"usage\":\"{}\",\"description\":\"{}\"}}\n", + info.name, info.category, info.usage, info.description + ))); + } + if short { + return Ok(ExecResult::ok(format!( + "{}: {}\n", + info.name, info.description + ))); + } + return Ok(ExecResult::ok(format!( + "{}: {}\nUsage: {}\nCategory: {}\n", + info.name, info.description, info.usage, info.category + ))); + } + return Ok(ExecResult::err(format!("help: no help for '{cmd}'\n"), 1)); + } + + // Search mode + if let Some(ref term) = search { + let term_lower = term.to_lowercase(); + let matches: Vec<&CmdInfo> = BUILTINS + .iter() + .filter(|b| { + b.name.contains(&term_lower) + || b.description.to_lowercase().contains(&term_lower) + || b.category.contains(&term_lower) + }) + .collect(); + + if matches.is_empty() { + return Ok(ExecResult::ok(format!( + "help: no commands matching '{term}'\n" + ))); + } + + let mut output = String::new(); + for info in matches { + output.push_str(&format!(" {:12} {}\n", info.name, info.description)); + } + return Ok(ExecResult::ok(output)); + } + + // List mode or default: show categories + if list { + let mut output = String::new(); + for info in BUILTINS { + if short { + output.push_str(&format!("{}\n", info.name)); + } else { + output.push_str(&format!(" {:12} {}\n", info.name, info.description)); + } + } + return Ok(ExecResult::ok(output)); + } + + // Default: show categories with counts + let mut categories: Vec<(&str, usize)> = Vec::new(); + for info in BUILTINS { + if let Some(entry) = categories.iter_mut().find(|(c, _)| *c == info.category) { + entry.1 += 1; + } else { + categories.push((info.category, 1)); + } + } + + let mut output = String::from("Bashkit builtin commands:\n\n"); + for (cat, count) in &categories { + output.push_str(&format!(" {:16} ({count} commands)\n", cat)); + } + output.push_str(&format!("\nTotal: {} builtins\n", BUILTINS.len())); + output.push_str("Use 'help ' for details, 'help --list' to list all.\n"); + + Ok(ExecResult::ok(output)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run_help(args: &[&str]) -> ExecResult { + let fs = Arc::new(InMemoryFs::new()); + let mut variables = HashMap::new(); + let env = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + Help.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_default_categories() { + let result = run_help(&[]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("Bashkit builtin commands")); + assert!(result.stdout.contains("Total:")); + } + + #[tokio::test] + async fn test_list_all() { + let result = run_help(&["--list"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("echo")); + assert!(result.stdout.contains("grep")); + assert!(result.stdout.contains("mkdir")); + } + + #[tokio::test] + async fn test_list_short() { + let result = run_help(&["--list", "-s"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("echo\n")); + } + + #[tokio::test] + async fn test_specific_command() { + let result = run_help(&["echo"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("echo")); + assert!(result.stdout.contains("Usage:")); + assert!(result.stdout.contains("Category:")); + } + + #[tokio::test] + async fn test_command_short() { + let result = run_help(&["-s", "grep"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("grep:")); + } + + #[tokio::test] + async fn test_command_json() { + let result = run_help(&["--json", "cat"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("\"name\":\"cat\"")); + } + + #[tokio::test] + async fn test_unknown_command() { + let result = run_help(&["nonexistent"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("no help")); + } + + #[tokio::test] + async fn test_search() { + let result = run_help(&["--search", "text"]).await; + assert_eq!(result.exit_code, 0); + // Should find text-processing commands + assert!(result.stdout.contains("grep") || result.stdout.contains("sed")); + } + + #[tokio::test] + async fn test_search_no_results() { + let result = run_help(&["--search", "xyznonexistent"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("no commands matching")); + } + + #[tokio::test] + async fn test_invalid_option() { + let result = run_help(&["--foo"]).await; + assert_eq!(result.exit_code, 1); + } +} diff --git a/crates/bashkit/src/builtins/http.rs b/crates/bashkit/src/builtins/http.rs new file mode 100644 index 00000000..9ea45c8e --- /dev/null +++ b/crates/bashkit/src/builtins/http.rs @@ -0,0 +1,597 @@ +//! http builtin - HTTPie-inspired HTTP client (virtual stub) +//! +//! Parses HTTPie-style syntax and reports the request that would be sent. +//! If the `http_client` feature is enabled and configured, performs actual requests. + +use async_trait::async_trait; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// HTTP builtin - HTTPie-inspired HTTP client. +/// +/// Usage: http [OPTIONS] [METHOD] URL [ITEMS...] +/// +/// Items: +/// key=value JSON body field (string) +/// key:=value JSON body field (raw/number/bool) +/// Header:value HTTP header +/// key==value Query string parameter +/// +/// Options: +/// --json, -j Force JSON content type (default for data items) +/// --form, -f Use form encoding instead of JSON +/// -v, --verbose Show request and response headers +/// -h, --headers Show response headers only +/// -b, --body Show response body only (default) +/// -o FILE Download to file +/// +/// If METHOD is omitted, GET is used when no data items are present, +/// POST when data items are present. +/// +/// In virtual environments without network, outputs the parsed request. +pub struct Http; + +#[derive(Debug, PartialEq)] +enum ItemType { + /// key=value -> JSON string field + JsonField(String, String), + /// key:=value -> JSON raw field (number, bool, null) + JsonRawField(String, String), + /// Header:value -> HTTP header + Header(String, String), + /// key==value -> query string parameter + QueryParam(String, String), +} + +struct HttpConfig { + method: String, + url: String, + items: Vec, + #[allow(dead_code)] + json_mode: bool, + form_mode: bool, + verbose: bool, + headers_only: bool, + output_file: Option, +} + +fn is_http_method(s: &str) -> bool { + matches!( + s.to_uppercase().as_str(), + "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" + ) +} + +fn parse_item(s: &str) -> Option { + // Order matters: check `:=` and `==` before `=` and `:` + if let Some(pos) = s.find(":=") { + let key = &s[..pos]; + let val = &s[pos + 2..]; + if !key.is_empty() { + return Some(ItemType::JsonRawField(key.to_string(), val.to_string())); + } + } + if let Some(pos) = s.find("==") { + let key = &s[..pos]; + let val = &s[pos + 2..]; + if !key.is_empty() { + return Some(ItemType::QueryParam(key.to_string(), val.to_string())); + } + } + if let Some(pos) = s.find('=') { + // Make sure it's not := or == + if pos > 0 && &s[pos - 1..pos] != ":" && (pos + 1 >= s.len() || &s[pos + 1..pos + 2] != "=") + { + let key = &s[..pos]; + let val = &s[pos + 1..]; + return Some(ItemType::JsonField(key.to_string(), val.to_string())); + } + } + if let Some(pos) = s.find(':') { + // Make sure it's not := and not a URL scheme (http:// https://) + if pos > 0 + && (pos + 1 >= s.len() || &s[pos + 1..pos + 2] != "=") + && !s[..pos].contains("//") + && s[..pos] + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + let key = &s[..pos]; + let val = &s[pos + 1..].trim_start(); + return Some(ItemType::Header(key.to_string(), val.to_string())); + } + } + None +} + +fn parse_http_args(args: &[String]) -> std::result::Result { + let mut json_mode = false; + let mut form_mode = false; + let mut verbose = false; + let mut headers_only = false; + let mut output_file = None; + let mut method: Option = None; + let mut url: Option = None; + let mut items = Vec::new(); + + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + match arg.as_str() { + "--json" | "-j" => json_mode = true, + "--form" | "-f" => form_mode = true, + "-v" | "--verbose" => verbose = true, + "-h" | "--headers" => headers_only = true, + "-b" | "--body" => { /* default, no-op */ } + "-o" | "--output" => { + i += 1; + if i >= args.len() { + return Err("http: -o requires an argument".to_string()); + } + output_file = Some(args[i].clone()); + } + _ if arg.starts_with('-') && url.is_none() => { + return Err(format!("http: unknown option '{arg}'")); + } + _ => { + // Positional: METHOD, URL, or item + if url.is_none() { + if is_http_method(arg) && method.is_none() { + method = Some(arg.to_uppercase()); + } else { + url = Some(arg.clone()); + } + } else { + // Try to parse as item + match parse_item(arg) { + Some(item) => items.push(item), + None => { + return Err(format!("http: invalid item '{arg}'")); + } + } + } + } + } + i += 1; + } + + let url = url.ok_or_else(|| "http: missing URL".to_string())?; + + // Determine method: if data items present and no explicit method, use POST + let has_data = items.iter().any(|item| { + matches!( + item, + ItemType::JsonField(_, _) | ItemType::JsonRawField(_, _) + ) + }); + let method = method.unwrap_or_else(|| { + if has_data { + "POST".to_string() + } else { + "GET".to_string() + } + }); + + if json_mode && form_mode { + return Err("http: --json and --form are mutually exclusive".to_string()); + } + + Ok(HttpConfig { + method, + url, + items, + json_mode, + form_mode, + verbose, + headers_only, + output_file, + }) +} + +/// Build query string from query params. +fn build_url_with_query(base_url: &str, items: &[ItemType]) -> String { + let params: Vec = items + .iter() + .filter_map(|item| { + if let ItemType::QueryParam(k, v) = item { + Some(format!("{}={}", k, v)) + } else { + None + } + }) + .collect(); + if params.is_empty() { + return base_url.to_string(); + } + let sep = if base_url.contains('?') { "&" } else { "?" }; + format!("{}{}{}", base_url, sep, params.join("&")) +} + +/// Build JSON body from items. +fn build_json_body(items: &[ItemType]) -> String { + let mut fields = Vec::new(); + for item in items { + match item { + ItemType::JsonField(k, v) => { + fields.push(format!(" \"{}\": \"{}\"", k, v)); + } + ItemType::JsonRawField(k, v) => { + fields.push(format!(" \"{}\": {}", k, v)); + } + _ => {} + } + } + if fields.is_empty() { + return String::new(); + } + format!("{{\n{}\n}}", fields.join(",\n")) +} + +/// Build form body from items. +fn build_form_body(items: &[ItemType]) -> String { + let pairs: Vec = items + .iter() + .filter_map(|item| { + if let ItemType::JsonField(k, v) = item { + Some(format!("{}={}", k, v)) + } else { + None + } + }) + .collect(); + pairs.join("&") +} + +/// Format the parsed request for display. +fn format_request(config: &HttpConfig) -> String { + let mut output = String::new(); + let url = build_url_with_query(&config.url, &config.items); + + // Request line + output.push_str(&format!("{} {} HTTP/1.1\n", config.method, url)); + + // Headers from items + for item in &config.items { + if let ItemType::Header(k, v) = item { + output.push_str(&format!("{}: {}\n", k, v)); + } + } + + // Content-Type header + let has_data = config.items.iter().any(|item| { + matches!( + item, + ItemType::JsonField(_, _) | ItemType::JsonRawField(_, _) + ) + }); + if has_data { + if config.form_mode { + output.push_str("Content-Type: application/x-www-form-urlencoded\n"); + } else { + output.push_str("Content-Type: application/json\n"); + } + } + + output.push('\n'); + + // Body + if has_data { + if config.form_mode { + output.push_str(&build_form_body(&config.items)); + } else { + output.push_str(&build_json_body(&config.items)); + } + output.push('\n'); + } + + output +} + +#[async_trait] +impl Builtin for Http { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + return Ok(ExecResult::err( + "http: usage: http [METHOD] URL [ITEMS...]\n".to_string(), + 1, + )); + } + + let config = match parse_http_args(ctx.args) { + Ok(c) => c, + Err(e) => return Ok(ExecResult::err(format!("{e}\n"), 1)), + }; + + // Check if http_client feature is available and configured + #[cfg(feature = "http_client")] + { + if let Some(http_client) = ctx.http_client { + return execute_http_request(http_client, &config, &ctx).await; + } + } + + // No network - output the parsed request + let _ = config.output_file; + let mut output = String::new(); + output.push_str(&format_request(&config)); + if !config.verbose && !config.headers_only { + output.push_str("http: network access not configured\n"); + } + + Ok(ExecResult::ok(output)) + } +} + +/// Execute actual HTTP request when http_client feature is enabled. +#[cfg(feature = "http_client")] +async fn execute_http_request( + http_client: &crate::network::HttpClient, + config: &HttpConfig, + ctx: &Context<'_>, +) -> Result { + use crate::network::Method; + + let method = match config.method.as_str() { + "GET" => Method::Get, + "POST" => Method::Post, + "PUT" => Method::Put, + "DELETE" => Method::Delete, + "PATCH" => Method::Patch, + "HEAD" => Method::Head, + _ => { + return Ok(ExecResult::err( + format!("http: unsupported method: {}\n", config.method), + 1, + )); + } + }; + + let url = build_url_with_query(&config.url, &config.items); + + // Build headers + let mut header_pairs: Vec<(String, String)> = Vec::new(); + for item in &config.items { + if let ItemType::Header(k, v) = item { + header_pairs.push((k.clone(), v.clone())); + } + } + + // Build body + let has_data = config.items.iter().any(|item| { + matches!( + item, + ItemType::JsonField(_, _) | ItemType::JsonRawField(_, _) + ) + }); + let body_str = if has_data { + if config.form_mode { + header_pairs.push(( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + )); + build_form_body(&config.items) + } else { + header_pairs.push(("Content-Type".to_string(), "application/json".to_string())); + build_json_body(&config.items) + } + } else { + String::new() + }; + + let body_bytes = if body_str.is_empty() { + None + } else { + Some(body_str.as_bytes()) + }; + + let result = http_client + .request_with_headers(method, &url, body_bytes, &header_pairs) + .await; + + match result { + Ok(response) => { + let mut output = String::new(); + + if config.verbose { + output.push_str(&format!("{} {} HTTP/1.1\n", config.method, url)); + for (k, v) in &header_pairs { + output.push_str(&format!("{}: {}\n", k, v)); + } + output.push('\n'); + output.push_str(&format!("HTTP/1.1 {}\n", response.status)); + for (k, v) in &response.headers { + output.push_str(&format!("{}: {}\n", k, v)); + } + output.push('\n'); + } + + if config.headers_only { + output.push_str(&format!("HTTP/1.1 {}\n", response.status)); + for (k, v) in &response.headers { + output.push_str(&format!("{}: {}\n", k, v)); + } + } else { + output.push_str(&response.body_string()); + } + + if let Some(ref file_path) = config.output_file { + let path = super::resolve_path(ctx.cwd, file_path); + if let Err(e) = ctx.fs.write_file(&path, output.as_bytes()).await { + return Ok(ExecResult::err( + format!("http: failed to write to {}: {}\n", file_path, e), + 1, + )); + } + return Ok(ExecResult::ok(String::new())); + } + + Ok(ExecResult::ok(output)) + } + Err(e) => Ok(ExecResult::err(format!("http: {}\n", e), 1)), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run_http(args: &[&str]) -> ExecResult { + let fs = Arc::new(InMemoryFs::new()); + let mut variables = HashMap::new(); + let env = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + Http.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_no_args() { + let result = run_http(&[]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("usage")); + } + + #[tokio::test] + async fn test_simple_get() { + let result = run_http(&["https://example.com/api"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("GET https://example.com/api")); + } + + #[tokio::test] + async fn test_explicit_method() { + let result = run_http(&["DELETE", "https://example.com/api/1"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("DELETE https://example.com/api/1")); + } + + #[tokio::test] + async fn test_post_with_json_data() { + let result = run_http(&["https://example.com/api", "name=test", "count:=42"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("POST")); + assert!(result.stdout.contains("Content-Type: application/json")); + assert!(result.stdout.contains("\"name\": \"test\"")); + assert!(result.stdout.contains("\"count\": 42")); + } + + #[tokio::test] + async fn test_custom_header() { + let result = run_http(&[ + "GET", + "https://example.com/api", + "Authorization:Bearer token123", + ]) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("Authorization: Bearer token123")); + } + + #[tokio::test] + async fn test_query_params() { + let result = run_http(&["https://example.com/search", "q==rust", "page==1"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("q=rust")); + assert!(result.stdout.contains("page=1")); + } + + #[tokio::test] + async fn test_form_mode() { + let result = run_http(&[ + "--form", + "POST", + "https://example.com/login", + "user=admin", + "pass=secret", + ]) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("application/x-www-form-urlencoded")); + assert!(result.stdout.contains("user=admin&pass=secret")); + } + + #[tokio::test] + async fn test_json_and_form_mutually_exclusive() { + let result = run_http(&["--json", "--form", "https://example.com/api", "key=val"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("mutually exclusive")); + } + + #[tokio::test] + async fn test_missing_url() { + let result = run_http(&["GET"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("missing URL")); + } + + #[tokio::test] + async fn test_unknown_option() { + let result = run_http(&["--unknown", "https://example.com"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("unknown option")); + } + + #[tokio::test] + async fn test_network_not_configured_message() { + let result = run_http(&["https://example.com/api"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("network access not configured")); + } + + #[tokio::test] + async fn test_missing_o_argument() { + let result = run_http(&["-o"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("-o requires an argument")); + } + + #[test] + fn test_parse_item_json_field() { + assert_eq!( + parse_item("name=value"), + Some(ItemType::JsonField("name".to_string(), "value".to_string())) + ); + } + + #[test] + fn test_parse_item_raw_field() { + assert_eq!( + parse_item("count:=42"), + Some(ItemType::JsonRawField( + "count".to_string(), + "42".to_string() + )) + ); + } + + #[test] + fn test_parse_item_header() { + assert_eq!( + parse_item("Accept:application/json"), + Some(ItemType::Header( + "Accept".to_string(), + "application/json".to_string() + )) + ); + } + + #[test] + fn test_parse_item_query() { + assert_eq!( + parse_item("q==search term"), + Some(ItemType::QueryParam( + "q".to_string(), + "search term".to_string() + )) + ); + } +} diff --git a/crates/bashkit/src/builtins/iconv.rs b/crates/bashkit/src/builtins/iconv.rs new file mode 100644 index 00000000..d2b01a52 --- /dev/null +++ b/crates/bashkit/src/builtins/iconv.rs @@ -0,0 +1,416 @@ +//! iconv builtin - character encoding conversion (virtual) +//! +//! Converts text between character encodings in a virtual environment. +//! Supports utf-8, ascii, latin1/iso-8859-1, utf-16. +//! +//! Usage: +//! iconv -f UTF-8 -t LATIN1 file.txt +//! echo "hello" | iconv -f UTF-8 -t ASCII +//! iconv -l + +use async_trait::async_trait; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// iconv builtin - character encoding conversion. +pub struct Iconv; + +/// Normalize encoding name to canonical form. +fn normalize_encoding(name: &str) -> Option<&'static str> { + match name.to_ascii_lowercase().replace('-', "").as_str() { + "utf8" => Some("utf-8"), + "ascii" | "usascii" => Some("ascii"), + "latin1" | "iso88591" | "iso_88591" => Some("latin1"), + "utf16" | "utf16le" => Some("utf-16"), + "utf16be" => Some("utf-16be"), + _ => None, + } +} + +const SUPPORTED_ENCODINGS: &[&str] = &[ + "ASCII", + "ISO-8859-1", + "LATIN1", + "UTF-16", + "UTF-16BE", + "UTF-8", +]; + +/// Encode bytes from UTF-8 string into target encoding. +fn encode_to(input: &str, encoding: &str) -> std::result::Result, String> { + match encoding { + "utf-8" => Ok(input.as_bytes().to_vec()), + "ascii" => { + for (i, b) in input.bytes().enumerate() { + if b > 127 { + return Err(format!( + "iconv: cannot convert character at byte {i} to ASCII\n" + )); + } + } + Ok(input.as_bytes().to_vec()) + } + "latin1" => { + let mut out = Vec::with_capacity(input.len()); + for ch in input.chars() { + let cp = ch as u32; + if cp > 255 { + return Err(format!("iconv: cannot convert U+{cp:04X} to LATIN1\n")); + } + out.push(cp as u8); + } + Ok(out) + } + "utf-16" => { + let mut out = Vec::new(); + // BOM little-endian + out.extend_from_slice(&[0xFF, 0xFE]); + for ch in input.chars() { + let mut buf = [0u16; 2]; + let encoded = ch.encode_utf16(&mut buf); + for u in encoded { + out.extend_from_slice(&u.to_le_bytes()); + } + } + Ok(out) + } + "utf-16be" => { + let mut out = Vec::new(); + for ch in input.chars() { + let mut buf = [0u16; 2]; + let encoded = ch.encode_utf16(&mut buf); + for u in encoded { + out.extend_from_slice(&u.to_be_bytes()); + } + } + Ok(out) + } + _ => Err(format!("iconv: unsupported target encoding '{encoding}'\n")), + } +} + +/// Decode bytes from source encoding into UTF-8 string. +fn decode_from(input: &[u8], encoding: &str) -> std::result::Result { + match encoding { + "utf-8" => String::from_utf8(input.to_vec()) + .map_err(|e| format!("iconv: invalid UTF-8 input: {e}\n")), + "ascii" => { + for (i, &b) in input.iter().enumerate() { + if b > 127 { + return Err(format!( + "iconv: invalid ASCII byte 0x{b:02X} at position {i}\n" + )); + } + } + // ASCII is a subset of UTF-8 + Ok(String::from_utf8(input.to_vec()).unwrap_or_default()) + } + "latin1" => { + // Each byte maps directly to a Unicode codepoint + Ok(input.iter().map(|&b| b as char).collect()) + } + "utf-16" => { + if input.len() < 2 { + return Err("iconv: UTF-16 input too short\n".to_string()); + } + // Check BOM + let (data, big_endian) = if input[0] == 0xFF && input[1] == 0xFE { + (&input[2..], false) + } else if input[0] == 0xFE && input[1] == 0xFF { + (&input[2..], true) + } else { + // Default to little-endian (no BOM) + (input, false) + }; + if data.len() % 2 != 0 { + return Err("iconv: UTF-16 input has odd byte count\n".to_string()); + } + let units: Vec = data + .chunks_exact(2) + .map(|c| { + if big_endian { + u16::from_be_bytes([c[0], c[1]]) + } else { + u16::from_le_bytes([c[0], c[1]]) + } + }) + .collect(); + String::from_utf16(&units).map_err(|e| format!("iconv: invalid UTF-16 input: {e}\n")) + } + "utf-16be" => { + if !input.len().is_multiple_of(2) { + return Err("iconv: UTF-16BE input has odd byte count\n".to_string()); + } + let units: Vec = input + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + String::from_utf16(&units).map_err(|e| format!("iconv: invalid UTF-16BE input: {e}\n")) + } + _ => Err(format!("iconv: unsupported source encoding '{encoding}'\n")), + } +} + +#[async_trait] +impl Builtin for Iconv { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut from_enc: Option = None; + let mut to_enc: Option = None; + let mut file_arg: Option = None; + let mut list = false; + + let mut i = 0; + while i < ctx.args.len() { + match ctx.args[i].as_str() { + "-l" | "--list" => { + list = true; + } + "-f" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "iconv: option '-f' requires an argument\n".to_string(), + 1, + )); + } + from_enc = Some(ctx.args[i].clone()); + } + "-t" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "iconv: option '-t' requires an argument\n".to_string(), + 1, + )); + } + to_enc = Some(ctx.args[i].clone()); + } + arg if arg.starts_with('-') => { + return Ok(ExecResult::err( + format!("iconv: unknown option '{arg}'\n"), + 1, + )); + } + _ => { + file_arg = Some(ctx.args[i].clone()); + } + } + i += 1; + } + + if list { + let mut out = String::new(); + for enc in SUPPORTED_ENCODINGS { + out.push_str(enc); + out.push('\n'); + } + return Ok(ExecResult::ok(out)); + } + + let from = match &from_enc { + Some(f) => match normalize_encoding(f) { + Some(e) => e, + None => { + return Ok(ExecResult::err( + format!("iconv: unsupported encoding '{}'\n", f), + 1, + )); + } + }, + None => { + return Ok(ExecResult::err( + "iconv: missing source encoding (-f)\n".to_string(), + 1, + )); + } + }; + + let to = match &to_enc { + Some(t) => match normalize_encoding(t) { + Some(e) => e, + None => { + return Ok(ExecResult::err( + format!("iconv: unsupported encoding '{}'\n", t), + 1, + )); + } + }, + None => { + return Ok(ExecResult::err( + "iconv: missing target encoding (-t)\n".to_string(), + 1, + )); + } + }; + + // Read input from file or stdin + let input_bytes: Vec = if let Some(ref file) = file_arg { + let path = resolve_path(ctx.cwd, file); + match ctx.fs.read_file(&path).await { + Ok(bytes) => bytes, + Err(e) => return Ok(ExecResult::err(format!("iconv: {}: {e}\n", file), 1)), + } + } else if let Some(stdin) = ctx.stdin { + stdin.as_bytes().to_vec() + } else { + return Ok(ExecResult::err( + "iconv: no input (provide file argument or pipe stdin)\n".to_string(), + 1, + )); + }; + + // Decode from source encoding to UTF-8 string + let text = match decode_from(&input_bytes, from) { + Ok(t) => t, + Err(e) => return Ok(ExecResult::err(e, 1)), + }; + + // Encode from UTF-8 string to target encoding + let output_bytes = match encode_to(&text, to) { + Ok(b) => b, + Err(e) => return Ok(ExecResult::err(e, 1)), + }; + + // For text-compatible encodings, output as string; otherwise raw bytes as lossy UTF-8 + let output = match to { + "utf-8" | "ascii" => String::from_utf8_lossy(&output_bytes).to_string(), + "latin1" => { + // Each Latin1 byte maps directly to a Unicode codepoint + output_bytes.iter().map(|&b| b as char).collect() + } + _ => { + // Binary output - present as lossy string (VFS limitation) + String::from_utf8_lossy(&output_bytes).to_string() + } + }; + + Ok(ExecResult::ok(output)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run(args: &[&str], stdin: Option<&str>, fs: Option>) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs = fs.unwrap_or_else(|| Arc::new(InMemoryFs::new())); + let fs_dyn = fs as Arc; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs_dyn, + stdin, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + Iconv.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_list_encodings() { + let r = run(&["-l"], None, None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("UTF-8")); + assert!(r.stdout.contains("ASCII")); + assert!(r.stdout.contains("LATIN1")); + } + + #[tokio::test] + async fn test_utf8_to_ascii() { + let r = run(&["-f", "UTF-8", "-t", "ASCII"], Some("hello"), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "hello"); + } + + #[tokio::test] + async fn test_utf8_to_ascii_fails_on_nonascii() { + let r = run(&["-f", "UTF-8", "-t", "ASCII"], Some("caf\u{00e9}"), None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("cannot convert")); + } + + #[tokio::test] + async fn test_utf8_to_latin1() { + let r = run(&["-f", "UTF-8", "-t", "LATIN1"], Some("caf\u{00e9}"), None).await; + assert_eq!(r.exit_code, 0); + // Latin1 byte 0xe9 maps to Unicode U+00E9 (é), so output is valid UTF-8 "café" + assert_eq!(r.stdout, "caf\u{00e9}"); + } + + #[tokio::test] + async fn test_latin1_to_utf8() { + let fs = Arc::new(InMemoryFs::new()); + // Write Latin1 bytes: "caf" + 0xe9 (e-acute) + let latin1_bytes = vec![b'c', b'a', b'f', 0xe9]; + let path = std::path::Path::new("/test.txt"); + let fs_dyn = fs.clone() as Arc; + fs_dyn.write_file(path, &latin1_bytes).await.unwrap(); + + let r = run( + &["-f", "LATIN1", "-t", "UTF-8", "/test.txt"], + None, + Some(fs), + ) + .await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "caf\u{00e9}"); + } + + #[tokio::test] + async fn test_missing_from_encoding() { + let r = run(&["-t", "ASCII"], Some("hi"), None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("missing source encoding")); + } + + #[tokio::test] + async fn test_missing_to_encoding() { + let r = run(&["-f", "ASCII"], Some("hi"), None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("missing target encoding")); + } + + #[tokio::test] + async fn test_unsupported_encoding() { + let r = run(&["-f", "EBCDIC", "-t", "UTF-8"], Some("hi"), None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("unsupported encoding")); + } + + #[tokio::test] + async fn test_no_input() { + let r = run(&["-f", "UTF-8", "-t", "ASCII"], None, None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("no input")); + } + + #[tokio::test] + async fn test_file_not_found() { + let r = run(&["-f", "UTF-8", "-t", "ASCII", "/nope.txt"], None, None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("/nope.txt")); + } + + #[tokio::test] + async fn test_identity_conversion() { + let r = run(&["-f", "UTF-8", "-t", "UTF-8"], Some("hello world\n"), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout, "hello world\n"); + } +} diff --git a/crates/bashkit/src/builtins/json.rs b/crates/bashkit/src/builtins/json.rs new file mode 100644 index 00000000..646846b0 --- /dev/null +++ b/crates/bashkit/src/builtins/json.rs @@ -0,0 +1,483 @@ +//! json builtin - simplified JSON processor +//! +//! A simpler alternative to jq for common JSON operations. +//! Uses serde_json (already a dependency) for parsing. +//! +//! Usage: +//! echo '{"a":1}' | json get .a +//! echo '{"a":1}' | json set .b 2 +//! echo '{"a":1}' | json keys +//! echo '[1,2,3]' | json length +//! echo '"hello"' | json type +//! echo '{"a":1}' | json format + +use async_trait::async_trait; +use serde_json::Value; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// json builtin - simplified JSON processor. +pub struct Json; + +/// Parse a dot-separated JSON path like ".foo.bar.0" into path segments. +/// Leading dot is optional. Array indices are numeric segments. +fn parse_path(path: &str) -> Vec { + let s = path.strip_prefix('.').unwrap_or(path); + if s.is_empty() { + return Vec::new(); + } + s.split('.').map(|seg| seg.to_string()).collect() +} + +/// Get a value at a dot-separated path. +fn get_at_path(value: &Value, segments: &[String]) -> Option { + let mut current = value; + for seg in segments { + match current { + Value::Object(map) => { + current = map.get(seg.as_str())?; + } + Value::Array(arr) => { + let idx: usize = seg.parse().ok()?; + current = arr.get(idx)?; + } + _ => return None, + } + } + Some(current.clone()) +} + +/// Set a value at a dot-separated path, returning the modified root. +fn set_at_path(value: &mut Value, segments: &[String], new_val: Value) -> bool { + if segments.is_empty() { + *value = new_val; + return true; + } + + let seg = &segments[0]; + let rest = &segments[1..]; + + if rest.is_empty() { + // Terminal segment - set directly + match value { + Value::Object(map) => { + map.insert(seg.clone(), new_val); + true + } + Value::Array(arr) => { + if let Ok(idx) = seg.parse::() { + if idx < arr.len() { + arr[idx] = new_val; + true + } else { + false + } + } else { + false + } + } + _ => false, + } + } else { + // Intermediate segment - recurse + match value { + Value::Object(map) => { + let entry = map + .entry(seg.clone()) + .or_insert_with(|| Value::Object(serde_json::Map::new())); + set_at_path(entry, rest, new_val) + } + Value::Array(arr) => { + if let Ok(idx) = seg.parse::() { + if let Some(elem) = arr.get_mut(idx) { + set_at_path(elem, rest, new_val) + } else { + false + } + } else { + false + } + } + _ => false, + } + } +} + +/// Format a JSON value for output. Strings are unquoted, others use JSON repr. +fn format_value(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => v.to_string(), + } +} + +/// Read JSON input from stdin or file argument. +async fn read_json_input( + ctx: &Context<'_>, + file_arg: Option<&str>, +) -> std::result::Result { + if let Some(file) = file_arg { + let path = resolve_path(ctx.cwd, file); + match ctx.fs.read_file(&path).await { + Ok(bytes) => String::from_utf8(bytes) + .map_err(|e| ExecResult::err(format!("json: invalid UTF-8 in {file}: {e}\n"), 1)), + Err(e) => Err(ExecResult::err(format!("json: {file}: {e}\n"), 1)), + } + } else if let Some(stdin) = ctx.stdin { + Ok(stdin.to_string()) + } else { + Err(ExecResult::err( + "json: no input (provide file or pipe stdin)\n".to_string(), + 1, + )) + } +} + +#[async_trait] +impl Builtin for Json { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + return Ok(ExecResult::err( + "json: usage: json [args...]\nSubcommands: get, set, keys, length, type, format, pretty\n".to_string(), + 1, + )); + } + + let subcmd = ctx.args[0].as_str(); + let rest = &ctx.args[1..]; + + match subcmd { + "get" => { + if rest.is_empty() { + return Ok(ExecResult::err( + "json: get requires a path argument\n".to_string(), + 1, + )); + } + let path_str = &rest[0]; + let file_arg = rest.get(1).map(|s| s.as_str()); + let input = match read_json_input(&ctx, file_arg).await { + Ok(s) => s, + Err(e) => return Ok(e), + }; + let value: Value = match serde_json::from_str(input.trim()) { + Ok(v) => v, + Err(e) => return Ok(ExecResult::err(format!("json: invalid JSON: {e}\n"), 1)), + }; + let segments = parse_path(path_str); + match get_at_path(&value, &segments) { + Some(v) => Ok(ExecResult::ok(format!("{}\n", format_value(&v)))), + None => Ok(ExecResult::err( + format!("json: path '{}' not found\n", path_str), + 1, + )), + } + } + "set" => { + if rest.len() < 2 { + return Ok(ExecResult::err( + "json: set requires PATH and VALUE arguments\n".to_string(), + 1, + )); + } + let path_str = &rest[0]; + let raw_value = &rest[1]; + let file_arg = rest.get(2).map(|s| s.as_str()); + let input = match read_json_input(&ctx, file_arg).await { + Ok(s) => s, + Err(e) => return Ok(e), + }; + let mut value: Value = match serde_json::from_str(input.trim()) { + Ok(v) => v, + Err(e) => return Ok(ExecResult::err(format!("json: invalid JSON: {e}\n"), 1)), + }; + // Parse the new value as JSON, fallback to string + let new_val: Value = serde_json::from_str(raw_value) + .unwrap_or_else(|_| Value::String(raw_value.clone())); + let segments = parse_path(path_str); + if set_at_path(&mut value, &segments, new_val) { + Ok(ExecResult::ok(format!("{}\n", value))) + } else { + Ok(ExecResult::err( + format!("json: cannot set path '{}'\n", path_str), + 1, + )) + } + } + "keys" => { + let file_arg = rest.first().map(|s| s.as_str()); + let input = match read_json_input(&ctx, file_arg).await { + Ok(s) => s, + Err(e) => return Ok(e), + }; + let value: Value = match serde_json::from_str(input.trim()) { + Ok(v) => v, + Err(e) => return Ok(ExecResult::err(format!("json: invalid JSON: {e}\n"), 1)), + }; + match value { + Value::Object(map) => { + let mut out = String::new(); + for key in map.keys() { + out.push_str(key); + out.push('\n'); + } + Ok(ExecResult::ok(out)) + } + _ => Ok(ExecResult::err( + "json: keys requires an object\n".to_string(), + 1, + )), + } + } + "length" => { + let file_arg = rest.first().map(|s| s.as_str()); + let input = match read_json_input(&ctx, file_arg).await { + Ok(s) => s, + Err(e) => return Ok(e), + }; + let value: Value = match serde_json::from_str(input.trim()) { + Ok(v) => v, + Err(e) => return Ok(ExecResult::err(format!("json: invalid JSON: {e}\n"), 1)), + }; + let len = match &value { + Value::Array(arr) => arr.len(), + Value::Object(map) => map.len(), + Value::String(s) => s.len(), + _ => { + return Ok(ExecResult::err( + "json: length requires array, object, or string\n".to_string(), + 1, + )); + } + }; + Ok(ExecResult::ok(format!("{len}\n"))) + } + "type" => { + let file_arg = rest.first().map(|s| s.as_str()); + let input = match read_json_input(&ctx, file_arg).await { + Ok(s) => s, + Err(e) => return Ok(e), + }; + let value: Value = match serde_json::from_str(input.trim()) { + Ok(v) => v, + Err(e) => return Ok(ExecResult::err(format!("json: invalid JSON: {e}\n"), 1)), + }; + let type_name = match &value { + Value::Object(_) => "object", + Value::Array(_) => "array", + Value::String(_) => "string", + Value::Number(_) => "number", + Value::Bool(_) => "boolean", + Value::Null => "null", + }; + Ok(ExecResult::ok(format!("{type_name}\n"))) + } + "format" | "pretty" => { + let file_arg = rest.first().map(|s| s.as_str()); + let input = match read_json_input(&ctx, file_arg).await { + Ok(s) => s, + Err(e) => return Ok(e), + }; + let value: Value = match serde_json::from_str(input.trim()) { + Ok(v) => v, + Err(e) => return Ok(ExecResult::err(format!("json: invalid JSON: {e}\n"), 1)), + }; + match serde_json::to_string_pretty(&value) { + Ok(s) => Ok(ExecResult::ok(format!("{s}\n"))), + Err(e) => Ok(ExecResult::err(format!("json: format error: {e}\n"), 1)), + } + } + _ => Ok(ExecResult::err( + format!("json: unknown subcommand '{subcmd}'\n"), + 1, + )), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run(args: &[&str], stdin: Option<&str>, fs: Option>) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs = fs.unwrap_or_else(|| Arc::new(InMemoryFs::new())); + let fs_dyn = fs as Arc; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs_dyn, + stdin, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + Json.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_no_args() { + let r = run(&[], None, None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("usage")); + } + + #[tokio::test] + async fn test_get_simple() { + let r = run(&["get", ".name"], Some(r#"{"name":"alice"}"#), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "alice"); + } + + #[tokio::test] + async fn test_get_nested() { + let r = run(&["get", ".a.b"], Some(r#"{"a":{"b":42}}"#), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "42"); + } + + #[tokio::test] + async fn test_get_array_index() { + let r = run(&["get", ".1"], Some(r#"["a","b","c"]"#), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "b"); + } + + #[tokio::test] + async fn test_get_not_found() { + let r = run(&["get", ".missing"], Some(r#"{"a":1}"#), None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("not found")); + } + + #[tokio::test] + async fn test_set_value() { + let r = run( + &["set", ".name", "\"bob\""], + Some(r#"{"name":"alice"}"#), + None, + ) + .await; + assert_eq!(r.exit_code, 0); + let output: Value = serde_json::from_str(r.stdout.trim()).unwrap(); + assert_eq!(output["name"], "bob"); + } + + #[tokio::test] + async fn test_set_new_key() { + let r = run(&["set", ".age", "30"], Some(r#"{"name":"alice"}"#), None).await; + assert_eq!(r.exit_code, 0); + let output: Value = serde_json::from_str(r.stdout.trim()).unwrap(); + assert_eq!(output["age"], 30); + } + + #[tokio::test] + async fn test_keys() { + let r = run(&["keys"], Some(r#"{"b":2,"a":1}"#), None).await; + assert_eq!(r.exit_code, 0); + let lines: Vec<&str> = r.stdout.trim().lines().collect(); + assert!(lines.contains(&"a")); + assert!(lines.contains(&"b")); + } + + #[tokio::test] + async fn test_keys_non_object() { + let r = run(&["keys"], Some("[1,2,3]"), None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("requires an object")); + } + + #[tokio::test] + async fn test_length_array() { + let r = run(&["length"], Some("[1,2,3]"), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "3"); + } + + #[tokio::test] + async fn test_length_object() { + let r = run(&["length"], Some(r#"{"a":1,"b":2}"#), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "2"); + } + + #[tokio::test] + async fn test_type_object() { + let r = run(&["type"], Some("{}"), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "object"); + } + + #[tokio::test] + async fn test_type_string() { + let r = run(&["type"], Some(r#""hello""#), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "string"); + } + + #[tokio::test] + async fn test_type_null() { + let r = run(&["type"], Some("null"), None).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "null"); + } + + #[tokio::test] + async fn test_pretty() { + let r = run(&["pretty"], Some(r#"{"a":1}"#), None).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains(" \"a\": 1")); + } + + #[tokio::test] + async fn test_invalid_json() { + let r = run(&["keys"], Some("not json"), None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("invalid JSON")); + } + + #[tokio::test] + async fn test_unknown_subcommand() { + let r = run(&["nope"], Some("{}"), None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("unknown subcommand")); + } + + #[tokio::test] + async fn test_read_from_file() { + let fs = Arc::new(InMemoryFs::new()); + let fs_dyn = fs.clone() as Arc; + fs_dyn + .write_file(std::path::Path::new("/data.json"), br#"{"x":99}"#) + .await + .unwrap(); + + let r = run(&["get", ".x", "/data.json"], None, Some(fs)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "99"); + } + + #[tokio::test] + async fn test_no_input() { + let r = run(&["keys"], None, None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("no input")); + } +} diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 9580ddad..19ba37e6 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -32,6 +32,8 @@ mod checksum; mod clear; mod column; mod comm; +mod compgen; +mod csv; mod curl; mod cuttr; mod date; @@ -45,26 +47,34 @@ mod envsubst; mod expand; mod export; mod expr; +mod fc; mod fileops; mod flow; mod fold; mod glob_cmd; mod grep; mod headtail; +mod help; mod hextools; +mod http; +mod iconv; mod inspect; mod join; mod jq; +mod json; mod log; mod ls; mod navigation; mod nl; +mod parallel; mod paste; +mod patch; mod path; mod pipeline; mod printf; mod read; mod retry; +mod rg; mod sed; mod semver; mod seq; @@ -74,15 +84,19 @@ mod source; mod split; mod strings; mod system; +mod template; mod test; mod textrev; mod timeout; +mod tomlq; mod tree; mod vars; mod verify; mod wait; mod wc; +mod yaml; mod yes; +mod zip_cmd; #[cfg(feature = "git")] mod git; @@ -100,6 +114,8 @@ pub use checksum::{Md5sum, Sha1sum, Sha256sum}; pub use clear::Clear; pub use column::Column; pub use comm::Comm; +pub use compgen::Compgen; +pub use csv::Csv; pub use curl::{Curl, Wget}; pub use cuttr::{Cut, Tr}; pub use date::Date; @@ -113,27 +129,35 @@ pub use envsubst::Envsubst; pub use expand::{Expand, Unexpand}; pub use export::Export; pub use expr::Expr; +pub use fc::Fc; pub use fileops::{Chmod, Chown, Cp, Kill, Ln, Mkdir, Mktemp, Mv, Rm, Touch}; pub use flow::{Break, Colon, Continue, Exit, False, Return, True}; pub use fold::Fold; pub use glob_cmd::GlobCmd; pub use grep::Grep; pub use headtail::{Head, Tail}; +pub use help::Help; pub use hextools::{Hexdump, Od, Xxd}; +pub use http::Http; +pub use iconv::Iconv; pub use inspect::{File, Less, Stat}; pub use join::Join; pub use jq::Jq; +pub use json::Json; pub use log::Log; pub(crate) use ls::glob_match; pub use ls::{Find, Ls, Rmdir}; pub use navigation::{Cd, Pwd}; pub use nl::Nl; +pub use parallel::Parallel; pub use paste::Paste; +pub use patch::Patch; pub use path::{Basename, Dirname, Readlink, Realpath}; pub use pipeline::{Tee, Watch, Xargs}; pub use printf::Printf; pub use read::Read; pub use retry::Retry; +pub use rg::Rg; pub use sed::Sed; pub use semver::Semver; pub use seq::Seq; @@ -143,15 +167,19 @@ pub use source::Source; pub use split::Split; pub use strings::Strings; pub use system::{DEFAULT_HOSTNAME, DEFAULT_USERNAME, Hostname, Id, Uname, Whoami}; +pub use template::Template; pub use test::{Bracket, Test}; pub use textrev::{Rev, Tac}; pub use timeout::Timeout; +pub use tomlq::Tomlq; pub use tree::Tree; pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset}; pub use verify::Verify; pub use wait::Wait; pub use wc::Wc; +pub use yaml::Yaml; pub use yes::Yes; +pub use zip_cmd::{Unzip, Zip}; #[cfg(feature = "git")] pub use git::Git; diff --git a/crates/bashkit/src/builtins/parallel.rs b/crates/bashkit/src/builtins/parallel.rs new file mode 100644 index 00000000..fbb103fa --- /dev/null +++ b/crates/bashkit/src/builtins/parallel.rs @@ -0,0 +1,358 @@ +//! parallel builtin - GNU parallel-lite (virtual stub) +//! +//! Non-standard builtin. Cannot actually parallelize in VFS, +//! so parses options and reports what commands would be run. + +use async_trait::async_trait; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// Parallel builtin - GNU parallel-lite stub. +/// +/// Usage: parallel [OPTIONS] COMMAND ::: ARGS... +/// +/// Options: +/// -j NUM Number of parallel jobs (default: number of args) +/// --dry-run Show commands that would be run +/// --keep-order Keep output in input order (noted in plan) +/// --bar Show progress bar (noted in plan) +/// -v Verbose mode +/// +/// The `:::` separator delimits the argument list. +/// `{}` in COMMAND is replaced by each argument. +/// Multiple `:::` groups produce the cartesian product. +/// +/// Since this is a virtual environment, execution is always dry-run: +/// the builtin parses the invocation and reports the planned commands. +pub struct Parallel; + +struct ParallelConfig { + jobs: Option, + dry_run: bool, + keep_order: bool, + bar: bool, + verbose: bool, + command_parts: Vec, + arg_groups: Vec>, +} + +fn parse_parallel_args(args: &[String]) -> std::result::Result { + let mut jobs = None; + let mut dry_run = false; + let mut keep_order = false; + let mut bar = false; + let mut verbose = false; + let mut command_parts: Vec = Vec::new(); + let mut arg_groups: Vec> = Vec::new(); + + // Split on ::: to find command template and argument groups + let mut segments: Vec> = vec![Vec::new()]; + for arg in args { + if arg == ":::" { + segments.push(Vec::new()); + } else if let Some(last) = segments.last_mut() { + last.push(arg.clone()); + } + } + + // First segment: options + command template + let first = &segments[0]; + let mut i = 0; + while i < first.len() { + let arg = &first[i]; + match arg.as_str() { + "-j" => { + i += 1; + if i >= first.len() { + return Err("parallel: -j requires an argument".to_string()); + } + let n: u32 = first[i] + .parse() + .map_err(|_| format!("parallel: invalid job count '{}'", first[i]))?; + if n == 0 { + return Err("parallel: -j must be at least 1".to_string()); + } + jobs = Some(n); + } + "--dry-run" => dry_run = true, + "--keep-order" | "-k" => keep_order = true, + "--bar" => bar = true, + "-v" => verbose = true, + other if other.starts_with('-') && command_parts.is_empty() => { + return Err(format!("parallel: unknown option '{other}'")); + } + _ => { + command_parts.push(arg.clone()); + } + } + i += 1; + } + + // Remaining segments are argument groups + for seg in &segments[1..] { + if seg.is_empty() { + return Err("parallel: empty argument group after :::".to_string()); + } + arg_groups.push(seg.clone()); + } + + Ok(ParallelConfig { + jobs, + dry_run, + keep_order, + bar, + verbose, + command_parts, + arg_groups, + }) +} + +/// Generate the cartesian product of multiple argument groups. +fn cartesian_product(groups: &[Vec]) -> Vec> { + if groups.is_empty() { + return vec![vec![]]; + } + let mut result = vec![vec![]]; + for group in groups { + let mut new_result = Vec::new(); + for existing in &result { + for item in group { + let mut combo = existing.clone(); + combo.push(item.clone()); + new_result.push(combo); + } + } + result = new_result; + } + result +} + +/// Build a command string by substituting `{}` with the argument. +/// If no `{}` is present, append the argument to the command. +fn build_command(template: &[String], args: &[String]) -> String { + let template_str = template.join(" "); + if args.len() == 1 { + if template_str.contains("{}") { + template_str.replace("{}", &args[0]) + } else { + format!("{} {}", template_str, args[0]) + } + } else { + // Multiple arg groups: replace {1}, {2}, etc. or append all + let mut cmd = template_str.clone(); + let mut had_placeholder = false; + for (idx, arg) in args.iter().enumerate() { + let placeholder = format!("{{{}}}", idx + 1); + if cmd.contains(&placeholder) { + cmd = cmd.replace(&placeholder, arg); + had_placeholder = true; + } + } + if !had_placeholder { + if cmd.contains("{}") { + cmd = cmd.replace("{}", &args.join(" ")); + } else { + cmd = format!("{} {}", cmd, args.join(" ")); + } + } + cmd + } +} + +#[async_trait] +impl Builtin for Parallel { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + return Ok(ExecResult::err( + "parallel: usage: parallel [OPTIONS] COMMAND ::: ARGS...\n".to_string(), + 1, + )); + } + + let config = match parse_parallel_args(ctx.args) { + Ok(c) => c, + Err(e) => return Ok(ExecResult::err(format!("{e}\n"), 1)), + }; + + if config.command_parts.is_empty() { + return Ok(ExecResult::err( + "parallel: no command specified\n".to_string(), + 1, + )); + } + + if config.arg_groups.is_empty() { + return Ok(ExecResult::err( + "parallel: no arguments provided (missing :::)\n".to_string(), + 1, + )); + } + + let combinations = cartesian_product(&config.arg_groups); + let num_commands = combinations.len(); + let effective_jobs = config.jobs.unwrap_or(num_commands as u32); + + let mut output = String::new(); + + // Header + if config.verbose || config.dry_run { + output.push_str(&format!( + "parallel: {} command(s), {} job(s)", + num_commands, effective_jobs, + )); + if config.keep_order { + output.push_str(", ordered output"); + } + if config.bar { + output.push_str(", progress bar"); + } + output.push('\n'); + } + + // List commands + for combo in &combinations { + let cmd = build_command(&config.command_parts, combo); + output.push_str(&cmd); + output.push('\n'); + } + + if !config.dry_run { + output.push_str("parallel: not supported in virtual environment\n"); + } + + Ok(ExecResult::ok(output)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run_parallel(args: &[&str]) -> ExecResult { + let fs = Arc::new(InMemoryFs::new()); + let mut variables = HashMap::new(); + let env = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None); + Parallel.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_no_args() { + let result = run_parallel(&[]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("usage")); + } + + #[tokio::test] + async fn test_basic_command_generation() { + let result = run_parallel(&["echo", ":::", "a", "b", "c"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("echo a")); + assert!(result.stdout.contains("echo b")); + assert!(result.stdout.contains("echo c")); + } + + #[tokio::test] + async fn test_placeholder_substitution() { + let result = run_parallel(&["echo", "hello", "{}", ":::", "world", "test"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("echo hello world")); + assert!(result.stdout.contains("echo hello test")); + } + + #[tokio::test] + async fn test_dry_run_header() { + let result = run_parallel(&["--dry-run", "echo", ":::", "x", "y"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("2 command(s)")); + // dry-run should NOT print the "not supported" message + assert!(!result.stdout.contains("not supported")); + } + + #[tokio::test] + async fn test_jobs_option() { + let result = run_parallel(&["-j", "4", "--dry-run", "echo", ":::", "a", "b"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("4 job(s)")); + } + + #[tokio::test] + async fn test_keep_order_flag() { + let result = run_parallel(&["--keep-order", "--dry-run", "echo", ":::", "x"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("ordered output")); + } + + #[tokio::test] + async fn test_bar_flag() { + let result = run_parallel(&["--bar", "--dry-run", "echo", ":::", "x"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("progress bar")); + } + + #[tokio::test] + async fn test_no_command() { + let result = run_parallel(&[":::", "a", "b"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("no command")); + } + + #[tokio::test] + async fn test_no_separator() { + let result = run_parallel(&["echo", "hello"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("no arguments")); + } + + #[tokio::test] + async fn test_invalid_jobs() { + let result = run_parallel(&["-j", "abc", "echo", ":::", "x"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("invalid job count")); + } + + #[tokio::test] + async fn test_zero_jobs() { + let result = run_parallel(&["-j", "0", "echo", ":::", "x"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("must be at least 1")); + } + + #[tokio::test] + async fn test_missing_j_arg() { + let result = run_parallel(&["-j"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("-j requires an argument")); + } + + #[tokio::test] + async fn test_cartesian_product_two_groups() { + let result = run_parallel(&["echo", "{1}", "{2}", ":::", "a", "b", ":::", "1", "2"]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("echo a 1")); + assert!(result.stdout.contains("echo a 2")); + assert!(result.stdout.contains("echo b 1")); + assert!(result.stdout.contains("echo b 2")); + } + + #[tokio::test] + async fn test_virtual_env_message() { + let result = run_parallel(&["echo", ":::", "x"]).await; + assert_eq!(result.exit_code, 0); + assert!( + result + .stdout + .contains("not supported in virtual environment") + ); + } +} diff --git a/crates/bashkit/src/builtins/patch.rs b/crates/bashkit/src/builtins/patch.rs new file mode 100644 index 00000000..1ae45e74 --- /dev/null +++ b/crates/bashkit/src/builtins/patch.rs @@ -0,0 +1,619 @@ +//! patch - Apply unified diff patches to files +//! +//! Reads a unified diff from stdin and applies it to files in the VFS. +//! +//! Usage: +//! patch [OPTIONS] [FILE] +//! patch -p1 < diff.patch +//! patch --dry-run < diff.patch +//! patch -R < diff.patch # reverse patch + +use async_trait::async_trait; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// patch command - apply unified diffs +pub struct Patch; + +struct PatchOptions { + strip: usize, + dry_run: bool, + reverse: bool, + target_file: Option, +} + +fn parse_patch_args(args: &[String]) -> PatchOptions { + let mut opts = PatchOptions { + strip: 0, + dry_run: false, + reverse: false, + target_file: None, + }; + + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if let Some(rest) = arg.strip_prefix("-p") { + if let Ok(n) = rest.parse::() { + opts.strip = n; + } else { + // -p N as two args + i += 1; + if i < args.len() { + opts.strip = args[i].parse().unwrap_or(0); + } + } + } else if arg == "--dry-run" { + opts.dry_run = true; + } else if arg == "-R" || arg == "--reverse" { + opts.reverse = true; + } else if !arg.starts_with('-') { + opts.target_file = Some(arg.clone()); + } + i += 1; + } + + opts +} + +/// A single hunk from a unified diff +#[derive(Debug)] +struct Hunk { + old_start: usize, + #[allow(dead_code)] + old_count: usize, + new_start: usize, + #[allow(dead_code)] + new_count: usize, + lines: Vec, +} + +#[derive(Debug, Clone)] +enum HunkLine { + Context(String), + Add(String), + Remove(String), +} + +/// A parsed file diff containing multiple hunks +#[derive(Debug)] +struct FileDiff { + old_path: String, + new_path: String, + hunks: Vec, +} + +/// Strip path components from a file path +fn strip_path(path: &str, strip: usize) -> String { + if strip == 0 { + return path.to_string(); + } + let parts: Vec<&str> = path.split('/').collect(); + if strip >= parts.len() { + parts.last().unwrap_or(&"").to_string() + } else { + parts[strip..].join("/") + } +} + +/// Parse unified diff text into file diffs +fn parse_unified_diff(input: &str) -> Vec { + let mut diffs = Vec::new(); + let lines: Vec<&str> = input.lines().collect(); + let mut i = 0; + + while i < lines.len() { + // Look for --- a/file header + if lines[i].starts_with("--- ") && i + 1 < lines.len() && lines[i + 1].starts_with("+++ ") { + let old_path = lines[i] + .strip_prefix("--- ") + .unwrap_or("") + .split('\t') + .next() + .unwrap_or("") + .to_string(); + let new_path = lines[i + 1] + .strip_prefix("+++ ") + .unwrap_or("") + .split('\t') + .next() + .unwrap_or("") + .to_string(); + i += 2; + + let mut hunks = Vec::new(); + + // Parse hunks + while i < lines.len() && lines[i].starts_with("@@ ") { + if let Some(hunk) = parse_hunk_header(lines[i]) { + let mut hunk = hunk; + i += 1; + + // Read hunk lines + while i < lines.len() { + let line = lines[i]; + if line.starts_with("@@ ") || line.starts_with("--- ") { + break; + } + if let Some(rest) = line.strip_prefix('+') { + hunk.lines.push(HunkLine::Add(rest.to_string())); + } else if let Some(rest) = line.strip_prefix('-') { + hunk.lines.push(HunkLine::Remove(rest.to_string())); + } else if let Some(rest) = line.strip_prefix(' ') { + hunk.lines.push(HunkLine::Context(rest.to_string())); + } else if line == "\\ No newline at end of file" { + // skip + } else { + // Treat as context (some diffs omit the space prefix) + hunk.lines.push(HunkLine::Context(line.to_string())); + } + i += 1; + } + hunks.push(hunk); + } else { + i += 1; + } + } + + diffs.push(FileDiff { + old_path, + new_path, + hunks, + }); + } else { + i += 1; + } + } + + diffs +} + +/// Parse a hunk header like @@ -1,3 +1,4 @@ +fn parse_hunk_header(line: &str) -> Option { + let line = line.strip_prefix("@@ ")?; + let line = line.split(" @@").next()?; + let parts: Vec<&str> = line.split(' ').collect(); + if parts.len() < 2 { + return None; + } + + let old_part = parts[0].strip_prefix('-')?; + let new_part = parts[1].strip_prefix('+')?; + + let (old_start, old_count) = parse_range(old_part); + let (new_start, new_count) = parse_range(new_part); + + Some(Hunk { + old_start, + old_count, + new_start, + new_count, + lines: Vec::new(), + }) +} + +fn parse_range(s: &str) -> (usize, usize) { + if let Some((start, count)) = s.split_once(',') { + (start.parse().unwrap_or(1), count.parse().unwrap_or(1)) + } else { + (s.parse().unwrap_or(1), 1) + } +} + +/// Apply hunks to file content, returning the patched content. +/// If reverse is true, swaps add/remove operations. +fn apply_hunks( + content: &str, + hunks: &[Hunk], + reverse: bool, +) -> std::result::Result { + let mut lines: Vec = content.lines().map(|l| l.to_string()).collect(); + // Track if original ended with newline + let had_trailing_newline = content.ends_with('\n') || content.is_empty(); + + // Apply hunks in reverse order to preserve line numbers + for hunk in hunks.iter().rev() { + let start = if reverse { + hunk.new_start + } else { + hunk.old_start + }; + // Convert 1-based to 0-based + let start_idx = if start > 0 { start - 1 } else { 0 }; + + // Build expected old lines and new lines based on direction + let mut old_lines = Vec::new(); + let mut new_lines = Vec::new(); + + for hl in &hunk.lines { + match hl { + HunkLine::Context(l) => { + old_lines.push(l.clone()); + new_lines.push(l.clone()); + } + HunkLine::Add(l) => { + if reverse { + old_lines.push(l.clone()); + } else { + new_lines.push(l.clone()); + } + } + HunkLine::Remove(l) => { + if reverse { + new_lines.push(l.clone()); + } else { + old_lines.push(l.clone()); + } + } + } + } + + // Verify context/old lines match (with fuzz tolerance) + let end_idx = start_idx + old_lines.len(); + if end_idx > lines.len() { + return Err(format!( + "hunk at line {} does not match (file too short)", + start + )); + } + + for (j, expected) in old_lines.iter().enumerate() { + let actual_idx = start_idx + j; + if actual_idx < lines.len() && lines[actual_idx] != *expected { + return Err(format!( + "hunk at line {} does not match: expected '{}', got '{}'", + start, expected, lines[actual_idx] + )); + } + } + + // Replace old lines with new lines + lines.splice(start_idx..end_idx, new_lines); + } + + let mut result = lines.join("\n"); + if had_trailing_newline && !result.is_empty() { + result.push('\n'); + } + Ok(result) +} + +#[async_trait] +impl Builtin for Patch { + async fn execute(&self, ctx: Context<'_>) -> Result { + let opts = parse_patch_args(ctx.args); + + let input = match ctx.stdin { + Some(s) if !s.is_empty() => s.to_string(), + _ => { + return Ok(ExecResult::err( + "patch: no input (expected unified diff on stdin)\n".to_string(), + 1, + )); + } + }; + + let file_diffs = parse_unified_diff(&input); + if file_diffs.is_empty() { + return Ok(ExecResult::err( + "patch: no valid diff found in input\n".to_string(), + 1, + )); + } + + let mut output = String::new(); + let mut had_error = false; + + for diff in &file_diffs { + // Determine target file path + let target = if let Some(ref t) = opts.target_file { + t.clone() + } else { + // Use new_path for forward patches, old_path for reverse + let raw_path = if opts.reverse { + &diff.new_path + } else { + // Prefer new_path unless it's /dev/null (file deletion) + if diff.new_path == "/dev/null" { + &diff.old_path + } else { + &diff.new_path + } + }; + strip_path(raw_path, opts.strip) + }; + + let path = resolve_path(ctx.cwd, &target); + + // Read existing file (may not exist for new files) + let content = if diff.old_path == "/dev/null" && !opts.reverse { + // New file creation + String::new() + } else { + match ctx.fs.read_file(&path).await { + Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(), + Err(_) => { + // File doesn't exist - might be a new file + String::new() + } + } + }; + + match apply_hunks(&content, &diff.hunks, opts.reverse) { + Ok(patched) => { + if opts.dry_run { + output.push_str(&format!("checking file {}\n", target)); + } else { + // Handle file deletion + if diff.new_path == "/dev/null" && !opts.reverse { + output.push_str(&format!("patching file {} (removed)\n", target)); + // WTF: VFS doesn't have a delete_file, using write with empty content + // as a workaround. Real deletion would need fs.remove(). + } else { + ctx.fs.write_file(&path, patched.as_bytes()).await?; + output.push_str(&format!("patching file {}\n", target)); + } + } + } + Err(e) => { + output.push_str(&format!("patch: {}: {}\n", target, e)); + had_error = true; + } + } + } + + if had_error { + Ok(ExecResult::err(output, 1)) + } else { + Ok(ExecResult::ok(output)) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::fs::{FileSystem, InMemoryFs}; + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + async fn run_patch( + args: &[&str], + stdin: &str, + files: &[(&str, &[u8])], + ) -> (ExecResult, Arc) { + let fs = Arc::new(InMemoryFs::new()); + for (path, content) in files { + let fs_trait = fs.clone() as Arc; + fs_trait.write_file(Path::new(path), content).await.unwrap(); + } + + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs_dyn = fs.clone() as Arc; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs_dyn, + stdin: Some(stdin), + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + let result = Patch.execute(ctx).await.unwrap(); + (result, fs) + } + + #[tokio::test] + async fn test_patch_simple_change() { + let diff = "\ +--- a/test.txt ++++ b/test.txt +@@ -1,3 +1,3 @@ + line1 +-line2 ++modified + line3 +"; + let (result, fs) = + run_patch(&["-p1"], diff, &[("/test.txt", b"line1\nline2\nline3\n")]).await; + assert_eq!(result.exit_code, 0); + let fs_trait = fs as Arc; + let content = fs_trait.read_file(Path::new("/test.txt")).await.unwrap(); + let text = String::from_utf8_lossy(&content); + assert!(text.contains("modified")); + assert!(!text.contains("line2")); + } + + #[tokio::test] + async fn test_patch_add_lines() { + let diff = "\ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,4 @@ + line1 ++added1 ++added2 + line2 +"; + let (result, fs) = run_patch(&["-p1"], diff, &[("/test.txt", b"line1\nline2\n")]).await; + assert_eq!(result.exit_code, 0); + let fs_trait = fs as Arc; + let content = fs_trait.read_file(Path::new("/test.txt")).await.unwrap(); + let text = String::from_utf8_lossy(&content); + assert!(text.contains("added1")); + assert!(text.contains("added2")); + } + + #[tokio::test] + async fn test_patch_remove_lines() { + let diff = "\ +--- a/test.txt ++++ b/test.txt +@@ -1,3 +1,1 @@ + line1 +-line2 +-line3 +"; + let (result, fs) = + run_patch(&["-p1"], diff, &[("/test.txt", b"line1\nline2\nline3\n")]).await; + assert_eq!(result.exit_code, 0); + let fs_trait = fs as Arc; + let content = fs_trait.read_file(Path::new("/test.txt")).await.unwrap(); + let text = String::from_utf8_lossy(&content); + assert_eq!(text.trim(), "line1"); + } + + #[tokio::test] + async fn test_patch_dry_run() { + let diff = "\ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,2 @@ + line1 +-line2 ++changed +"; + let (result, fs) = run_patch( + &["--dry-run", "-p1"], + diff, + &[("/test.txt", b"line1\nline2\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("checking file")); + // File should NOT be modified + let fs_trait = fs as Arc; + let content = fs_trait.read_file(Path::new("/test.txt")).await.unwrap(); + let text = String::from_utf8_lossy(&content); + assert!(text.contains("line2")); + } + + #[tokio::test] + async fn test_patch_reverse() { + // First apply forward + let diff = "\ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,2 @@ + line1 +-original ++changed +"; + // File has "changed", we reverse-apply to get back "original" + let (result, fs) = + run_patch(&["-R", "-p1"], diff, &[("/test.txt", b"line1\nchanged\n")]).await; + assert_eq!(result.exit_code, 0); + let fs_trait = fs as Arc; + let content = fs_trait.read_file(Path::new("/test.txt")).await.unwrap(); + let text = String::from_utf8_lossy(&content); + assert!(text.contains("original")); + } + + #[tokio::test] + async fn test_patch_strip_path() { + assert_eq!(strip_path("a/b/c.txt", 0), "a/b/c.txt"); + assert_eq!(strip_path("a/b/c.txt", 1), "b/c.txt"); + assert_eq!(strip_path("a/b/c.txt", 2), "c.txt"); + assert_eq!(strip_path("a/b/c.txt", 5), "c.txt"); + } + + #[tokio::test] + async fn test_patch_no_input() { + let (result, _fs) = run_patch(&[], "", &[]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("no input")); + } + + #[tokio::test] + async fn test_patch_invalid_diff() { + let (result, _fs) = run_patch(&[], "this is not a diff\n", &[]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("no valid diff")); + } + + #[tokio::test] + async fn test_patch_hunk_mismatch() { + let diff = "\ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,2 @@ + line1 +-wrong_content ++changed +"; + let (result, _fs) = + run_patch(&["-p1"], diff, &[("/test.txt", b"line1\nactual_content\n")]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("does not match")); + } + + #[tokio::test] + async fn test_patch_new_file() { + let diff = "\ +--- /dev/null ++++ b/newfile.txt +@@ -0,0 +1,2 @@ ++hello ++world +"; + let (result, fs) = run_patch(&["-p1"], diff, &[]).await; + assert_eq!(result.exit_code, 0); + let fs_trait = fs as Arc; + let content = fs_trait.read_file(Path::new("/newfile.txt")).await.unwrap(); + let text = String::from_utf8_lossy(&content); + assert!(text.contains("hello")); + assert!(text.contains("world")); + } + + #[tokio::test] + async fn test_patch_target_file_override() { + let diff = "\ +--- a/original.txt ++++ b/original.txt +@@ -1,2 +1,2 @@ + line1 +-old ++new +"; + let (result, fs) = run_patch( + &["-p1", "target.txt"], + diff, + &[("/target.txt", b"line1\nold\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + let fs_trait = fs as Arc; + let content = fs_trait.read_file(Path::new("/target.txt")).await.unwrap(); + let text = String::from_utf8_lossy(&content); + assert!(text.contains("new")); + } + + #[tokio::test] + async fn test_parse_hunk_header() { + let hunk = parse_hunk_header("@@ -1,3 +1,4 @@").unwrap(); + assert_eq!(hunk.old_start, 1); + assert_eq!(hunk.old_count, 3); + assert_eq!(hunk.new_start, 1); + assert_eq!(hunk.new_count, 4); + } + + #[tokio::test] + async fn test_parse_hunk_header_single_line() { + let hunk = parse_hunk_header("@@ -5 +5,2 @@").unwrap(); + assert_eq!(hunk.old_start, 5); + assert_eq!(hunk.old_count, 1); + assert_eq!(hunk.new_start, 5); + assert_eq!(hunk.new_count, 2); + } +} diff --git a/crates/bashkit/src/builtins/rg.rs b/crates/bashkit/src/builtins/rg.rs new file mode 100644 index 00000000..ccf49d9d --- /dev/null +++ b/crates/bashkit/src/builtins/rg.rs @@ -0,0 +1,536 @@ +//! rg - Simplified ripgrep builtin +//! +//! Recursive file search by default, similar to grep but with rg-style defaults. +//! +//! Usage: +//! rg PATTERN [PATH...] +//! rg -i PATTERN file # case insensitive +//! rg -n PATTERN file # show line numbers (default) +//! rg -c PATTERN file # count matches +//! rg -l PATTERN file # files with matches +//! rg -v PATTERN file # invert match +//! rg -w PATTERN file # word boundary +//! rg -F PATTERN file # fixed strings (literal) +//! rg -m NUM PATTERN file # max count per file +//! rg --no-filename PATTERN # suppress filename +//! rg --color never PATTERN # color output (no-op) + +use async_trait::async_trait; +use regex::{Regex, RegexBuilder}; + +use super::{Builtin, Context, resolve_path}; +use crate::error::{Error, Result}; +use crate::interpreter::ExecResult; + +/// rg command - recursive pattern search (simplified ripgrep) +pub struct Rg; + +struct RgOptions { + pattern: String, + paths: Vec, + ignore_case: bool, + line_numbers: bool, + count_only: bool, + files_with_matches: bool, + invert_match: bool, + word_boundary: bool, + fixed_strings: bool, + max_count: Option, + no_filename: bool, +} + +impl RgOptions { + fn parse(args: &[String]) -> Result { + let mut opts = RgOptions { + pattern: String::new(), + paths: Vec::new(), + ignore_case: false, + line_numbers: true, // rg shows line numbers by default + count_only: false, + files_with_matches: false, + invert_match: false, + word_boundary: false, + fixed_strings: false, + max_count: None, + no_filename: false, + }; + + let mut positional = Vec::new(); + let mut i = 0; + + while i < args.len() { + let arg = &args[i]; + if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") { + let chars: Vec = arg[1..].chars().collect(); + let mut j = 0; + while j < chars.len() { + match chars[j] { + 'i' => opts.ignore_case = true, + 'n' => opts.line_numbers = true, + 'c' => opts.count_only = true, + 'l' => opts.files_with_matches = true, + 'v' => opts.invert_match = true, + 'w' => opts.word_boundary = true, + 'F' => opts.fixed_strings = true, + 'm' => { + let rest: String = chars[j + 1..].iter().collect(); + let num_str = if !rest.is_empty() { + rest + } else { + i += 1; + if i < args.len() { + args[i].clone() + } else { + return Err(Error::Execution( + "rg: -m requires an argument".to_string(), + )); + } + }; + opts.max_count = Some(num_str.parse().map_err(|_| { + Error::Execution(format!("rg: invalid max count: {}", num_str)) + })?); + break; + } + _ => {} // ignore unknown + } + j += 1; + } + } else if let Some(opt) = arg.strip_prefix("--") { + if opt == "no-filename" { + opts.no_filename = true; + } else if opt == "color" || opt.starts_with("color=") { + // no-op + } else if opt == "no-line-number" { + opts.line_numbers = false; + } + // ignore other long options + } else { + positional.push(arg.clone()); + } + i += 1; + } + + if positional.is_empty() { + return Err(Error::Execution("rg: missing pattern".to_string())); + } + + opts.pattern = positional.remove(0); + opts.paths = positional; + + Ok(opts) + } + + fn build_regex(&self) -> Result { + let pat = if self.fixed_strings { + regex::escape(&self.pattern) + } else { + self.pattern.clone() + }; + + let pat = if self.word_boundary { + format!(r"\b{}\b", pat) + } else { + pat + }; + + RegexBuilder::new(&pat) + .case_insensitive(self.ignore_case) + .build() + .map_err(|e| Error::Execution(format!("rg: invalid pattern: {}", e))) + } +} + +/// Recursively collect files from a directory in the VFS +async fn collect_files( + fs: &std::sync::Arc, + dir: &std::path::Path, +) -> Vec { + let mut result = Vec::new(); + let mut dirs = vec![dir.to_path_buf()]; + + while let Some(current) = dirs.pop() { + if let Ok(entries) = fs.read_dir(¤t).await { + for entry in entries { + let path = current.join(&entry.name); + if entry.metadata.file_type.is_dir() { + dirs.push(path); + } else if entry.metadata.file_type.is_file() { + result.push(path); + } + } + } + } + + result.sort(); + result +} + +#[async_trait] +impl Builtin for Rg { + async fn execute(&self, ctx: Context<'_>) -> Result { + let opts = RgOptions::parse(ctx.args)?; + let regex = opts.build_regex()?; + + // Collect input files - rg is recursive by default + let inputs: Vec<(String, String)> = if opts.paths.is_empty() { + // Read from stdin when no paths given and stdin is available + if let Some(stdin) = ctx.stdin { + vec![("(stdin)".to_string(), stdin.to_string())] + } else { + // Search current directory recursively + let files = collect_files(&ctx.fs, ctx.cwd).await; + let mut inputs = Vec::new(); + for path in files { + if let Ok(content) = ctx.fs.read_file(&path).await { + let text = String::from_utf8_lossy(&content).into_owned(); + inputs.push((path.to_string_lossy().into_owned(), text)); + } + } + inputs + } + } else { + let mut inputs = Vec::new(); + for p in &opts.paths { + let path = resolve_path(ctx.cwd, p); + // Check if it's a directory → recurse + if let Ok(meta) = ctx.fs.stat(&path).await + && meta.file_type.is_dir() + { + let files = collect_files(&ctx.fs, &path).await; + for fpath in files { + if let Ok(content) = ctx.fs.read_file(&fpath).await { + let text = String::from_utf8_lossy(&content).into_owned(); + inputs.push((fpath.to_string_lossy().into_owned(), text)); + } + } + continue; + } + // It's a file + match ctx.fs.read_file(&path).await { + Ok(content) => { + let text = String::from_utf8_lossy(&content).into_owned(); + inputs.push((p.clone(), text)); + } + Err(e) => { + return Ok(ExecResult::err(format!("rg: {}: {}\n", p, e), 1)); + } + } + } + inputs + }; + + let show_filename = if opts.no_filename { + false + } else { + inputs.len() > 1 + }; + + let mut output = String::new(); + let mut any_match = false; + + for (filename, content) in &inputs { + let mut match_count = 0usize; + + for (line_idx, line) in content.lines().enumerate() { + let matched = regex.is_match(line); + let matched = if opts.invert_match { !matched } else { matched }; + + if !matched { + continue; + } + + match_count += 1; + any_match = true; + + if let Some(max) = opts.max_count + && match_count > max + { + break; + } + + if opts.files_with_matches || opts.count_only { + continue; + } + + // Build output line + if show_filename { + output.push_str(filename); + output.push(':'); + } + if opts.line_numbers { + output.push_str(&(line_idx + 1).to_string()); + output.push(':'); + } + output.push_str(line); + output.push('\n'); + } + + if opts.files_with_matches && match_count > 0 { + output.push_str(filename); + output.push('\n'); + } + if opts.count_only { + if show_filename { + output.push_str(filename); + output.push(':'); + } + output.push_str(&match_count.to_string()); + output.push('\n'); + } + } + + if any_match { + Ok(ExecResult::ok(output)) + } else { + Ok(ExecResult::with_code(String::new(), 1)) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::fs::{FileSystem, InMemoryFs}; + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + async fn run_rg(args: &[&str], stdin: Option<&str>, files: &[(&str, &[u8])]) -> ExecResult { + let fs = Arc::new(InMemoryFs::new()); + for (path, content) in files { + let p = Path::new(path); + // Ensure parent dirs exist + if let Some(parent) = p.parent() + && parent != Path::new("/") + { + let fs_trait: &dyn FileSystem = &*fs; + let _ = fs_trait.mkdir(parent, true).await; + } + let fs_trait: &dyn FileSystem = &*fs; + fs_trait.write_file(p, content).await.unwrap(); + } + + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs_dyn = fs as Arc; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs_dyn, + stdin, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + Rg.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_rg_basic_match() { + let result = run_rg( + &["hello", "/test.txt"], + None, + &[("/test.txt", b"hello world\ngoodbye\nhello again\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("hello world")); + assert!(result.stdout.contains("hello again")); + assert!(!result.stdout.contains("goodbye")); + } + + #[tokio::test] + async fn test_rg_no_match() { + let result = run_rg( + &["missing", "/test.txt"], + None, + &[("/test.txt", b"hello world\n")], + ) + .await; + assert_eq!(result.exit_code, 1); + assert!(result.stdout.is_empty()); + } + + #[tokio::test] + async fn test_rg_case_insensitive() { + let result = run_rg( + &["-i", "HELLO", "/test.txt"], + None, + &[("/test.txt", b"Hello World\nhello world\nHELLO\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + // All three lines match + let lines: Vec<&str> = result.stdout.trim().lines().collect(); + assert_eq!(lines.len(), 3); + } + + #[tokio::test] + async fn test_rg_count() { + let result = run_rg( + &["-c", "hello", "/test.txt"], + None, + &[("/test.txt", b"hello\nworld\nhello again\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.trim().ends_with('2')); + } + + #[tokio::test] + async fn test_rg_files_with_matches() { + let result = run_rg( + &["-l", "hello", "/a.txt", "/b.txt"], + None, + &[("/a.txt", b"hello\n"), ("/b.txt", b"world\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("/a.txt")); + assert!(!result.stdout.contains("/b.txt")); + } + + #[tokio::test] + async fn test_rg_invert_match() { + let result = run_rg( + &["-v", "hello", "/test.txt"], + None, + &[("/test.txt", b"hello\nworld\nfoo\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("world")); + assert!(result.stdout.contains("foo")); + assert!(!result.stdout.contains("hello")); + } + + #[tokio::test] + async fn test_rg_fixed_strings() { + let result = run_rg( + &["-F", "a.b", "/test.txt"], + None, + &[("/test.txt", b"a.b matches\naxb no match\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("a.b matches")); + assert!(!result.stdout.contains("axb")); + } + + #[tokio::test] + async fn test_rg_word_boundary() { + let result = run_rg( + &["-w", "cat", "/test.txt"], + None, + &[("/test.txt", b"the cat sat\ncatch this\nmy cat\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("the cat sat")); + assert!(result.stdout.contains("my cat")); + assert!(!result.stdout.contains("catch")); + } + + #[tokio::test] + async fn test_rg_max_count() { + let result = run_rg( + &["-m", "1", "hello", "/test.txt"], + None, + &[("/test.txt", b"hello one\nhello two\nhello three\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + let lines: Vec<&str> = result.stdout.trim().lines().collect(); + assert_eq!(lines.len(), 1); + } + + #[tokio::test] + async fn test_rg_recursive_directory() { + let result = run_rg( + &["needle", "/dir"], + None, + &[ + ("/dir/a.txt", b"has needle here\n"), + ("/dir/sub/b.txt", b"no match\n"), + ("/dir/sub/c.txt", b"another needle\n"), + ], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("needle")); + // Should have matches from 2 files + assert!(result.stdout.contains("a.txt")); + assert!(result.stdout.contains("c.txt")); + } + + #[tokio::test] + async fn test_rg_stdin() { + let result = run_rg(&["world"], Some("hello\nworld\nfoo\n"), &[]).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("world")); + } + + #[tokio::test] + async fn test_rg_missing_pattern() { + let fs = Arc::new(InMemoryFs::new()) as Arc; + let args: Vec = vec![]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + 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 = Rg.execute(ctx).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_rg_file_not_found() { + let result = run_rg(&["pattern", "/nonexistent"], None, &[]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("rg:")); + } + + #[tokio::test] + async fn test_rg_no_filename_flag() { + let result = run_rg( + &["--no-filename", "hello", "/a.txt", "/b.txt"], + None, + &[("/a.txt", b"hello\n"), ("/b.txt", b"hello there\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + // Should not contain filenames + assert!(!result.stdout.contains("/a.txt")); + assert!(!result.stdout.contains("/b.txt")); + } + + #[tokio::test] + async fn test_rg_line_numbers_default() { + let result = run_rg( + &["world", "/test.txt"], + None, + &[("/test.txt", b"hello\nworld\n")], + ) + .await; + assert_eq!(result.exit_code, 0); + // Line numbers on by default, "world" is on line 2 + assert!(result.stdout.contains("2:")); + } +} diff --git a/crates/bashkit/src/builtins/template.rs b/crates/bashkit/src/builtins/template.rs new file mode 100644 index 00000000..37a8351d --- /dev/null +++ b/crates/bashkit/src/builtins/template.rs @@ -0,0 +1,605 @@ +//! template builtin - mustache/handlebars-lite template engine +//! +//! Substitutes `{{variable}}` placeholders with values from shell variables, +//! environment, or a JSON data file. Supports `{{#if var}}...{{/if}}` and +//! `{{#each var}}...{{/each}}` block helpers. + +use async_trait::async_trait; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// Template builtin - mustache/handlebars-lite template engine. +/// +/// Usage: template [OPTIONS] [TEMPLATE_FILE] +/// +/// Options: +/// -d DATA_FILE JSON data file for variable values +/// -e Escape HTML entities in output +/// --strict Error on missing variables (default: empty string) +/// +/// Template syntax: +/// {{var}} - Variable substitution +/// {{#if var}}...{{/if}} - Conditional block (renders if var is truthy) +/// {{#each var}}...{{/each}} - Iteration (renders for each array element) +/// +/// Variable lookup order: JSON data > shell variables > environment. +/// Reads template from TEMPLATE_FILE or stdin if no file given. +pub struct Template; + +struct TemplateConfig { + data_file: Option, + escape_html: bool, + strict: bool, + template_file: Option, +} + +fn parse_template_args(args: &[String]) -> std::result::Result { + let mut data_file = None; + let mut escape_html = false; + let mut strict = false; + let mut template_file = None; + + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + match arg.as_str() { + "-d" => { + i += 1; + if i >= args.len() { + return Err("template: -d requires an argument".to_string()); + } + data_file = Some(args[i].clone()); + } + "-e" => { + escape_html = true; + } + "--strict" => { + strict = true; + } + other if other.starts_with('-') => { + return Err(format!("template: unknown option '{other}'")); + } + _ => { + template_file = Some(arg.clone()); + } + } + i += 1; + } + + Ok(TemplateConfig { + data_file, + escape_html, + strict, + template_file, + }) +} + +/// Escape HTML entities in a string. +fn escape_html_entities(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +/// Look up a variable value from JSON data, shell variables, or environment. +fn lookup_var( + name: &str, + json_data: &serde_json::Value, + variables: &std::collections::HashMap, + env: &std::collections::HashMap, +) -> Option { + // JSON data first + if let Some(val) = json_data.get(name) { + return Some(json_value_to_string(val)); + } + // Shell variables + if let Some(val) = variables.get(name) { + return Some(val.clone()); + } + // Environment + if let Some(val) = env.get(name) { + return Some(val.clone()); + } + None +} + +/// Convert a JSON value to a display string. +fn json_value_to_string(val: &serde_json::Value) -> String { + match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + _ => val.to_string(), + } +} + +/// Check if a value is truthy (non-empty, non-null, non-false). +fn is_truthy( + name: &str, + json_data: &serde_json::Value, + variables: &std::collections::HashMap, + env: &std::collections::HashMap, +) -> bool { + if let Some(val) = json_data.get(name) { + return match val { + serde_json::Value::Null => false, + serde_json::Value::Bool(b) => *b, + serde_json::Value::String(s) => !s.is_empty(), + serde_json::Value::Array(a) => !a.is_empty(), + serde_json::Value::Number(_) => true, + serde_json::Value::Object(_) => true, + }; + } + if let Some(val) = variables.get(name) { + return !val.is_empty(); + } + if let Some(val) = env.get(name) { + return !val.is_empty(); + } + false +} + +/// Render a template string with the given data sources. +fn render_template( + template: &str, + json_data: &serde_json::Value, + variables: &std::collections::HashMap, + env: &std::collections::HashMap, + escape: bool, + strict: bool, +) -> std::result::Result { + let mut output = String::new(); + let chars: Vec = template.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + if i + 1 < len && chars[i] == '{' && chars[i + 1] == '{' { + i += 2; + // Find closing }} + let start = i; + while i + 1 < len && !(chars[i] == '}' && chars[i + 1] == '}') { + i += 1; + } + if i + 1 >= len { + return Err("template: unclosed {{ tag".to_string()); + } + let tag: String = chars[start..i].iter().collect(); + let tag = tag.trim(); + i += 2; // skip }} + + if let Some(block_var) = tag.strip_prefix("#if ") { + let block_var = block_var.trim(); + // Find {{/if}} + let end_tag = "{{/if}}"; + let rest: String = chars[i..].iter().collect(); + let end_pos = rest + .find(end_tag) + .ok_or_else(|| format!("template: missing {{{{/if}}}} for '{block_var}'"))?; + let block_body = &rest[..end_pos]; + i += end_pos + end_tag.len(); + + if is_truthy(block_var, json_data, variables, env) { + let rendered = + render_template(block_body, json_data, variables, env, escape, strict)?; + output.push_str(&rendered); + } + } else if let Some(block_var) = tag.strip_prefix("#each ") { + let block_var = block_var.trim(); + // Find {{/each}} + let end_tag = "{{/each}}"; + let rest: String = chars[i..].iter().collect(); + let end_pos = rest + .find(end_tag) + .ok_or_else(|| format!("template: missing {{{{/each}}}} for '{block_var}'"))?; + let block_body = &rest[..end_pos]; + i += end_pos + end_tag.len(); + + if let Some(serde_json::Value::Array(items)) = json_data.get(block_var) { + for item in items { + // Replace {{.}} with current item value + let item_str = json_value_to_string(item); + let rendered_body = block_body.replace("{{.}}", &item_str); + let rendered = render_template( + &rendered_body, + json_data, + variables, + env, + escape, + strict, + )?; + output.push_str(&rendered); + } + } else if strict { + return Err(format!( + "template: '{block_var}' is not an array or is missing" + )); + } + } else if tag.starts_with('/') { + // Stray closing tag - error + return Err(format!("template: unexpected closing tag '{{{tag}}}'")); + } else { + // Simple variable substitution + match lookup_var(tag, json_data, variables, env) { + Some(val) => { + if escape { + output.push_str(&escape_html_entities(&val)); + } else { + output.push_str(&val); + } + } + None => { + if strict { + return Err(format!("template: undefined variable '{tag}'")); + } + // Missing var -> empty string + } + } + } + } else { + output.push(chars[i]); + i += 1; + } + } + + Ok(output) +} + +#[async_trait] +impl Builtin for Template { + async fn execute(&self, ctx: Context<'_>) -> Result { + let config = match parse_template_args(ctx.args) { + Ok(c) => c, + Err(e) => return Ok(ExecResult::err(format!("{e}\n"), 1)), + }; + + // Load JSON data if provided + let json_data = if let Some(ref data_path) = config.data_file { + let path = resolve_path(ctx.cwd, data_path); + let content = match ctx.fs.read_file(&path).await { + Ok(bytes) => bytes, + Err(e) => { + return Ok(ExecResult::err( + format!("template: cannot read data file '{}': {}\n", data_path, e), + 1, + )); + } + }; + let text = String::from_utf8_lossy(&content); + match serde_json::from_str::(&text) { + Ok(v) => v, + Err(e) => { + return Ok(ExecResult::err( + format!("template: invalid JSON in '{}': {}\n", data_path, e), + 1, + )); + } + } + } else { + serde_json::Value::Object(serde_json::Map::new()) + }; + + // Load template + let template_text = if let Some(ref tpl_path) = config.template_file { + let path = resolve_path(ctx.cwd, tpl_path); + match ctx.fs.read_file(&path).await { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(e) => { + return Ok(ExecResult::err( + format!("template: cannot read '{}': {}\n", tpl_path, e), + 1, + )); + } + } + } else if let Some(stdin) = ctx.stdin { + stdin.to_string() + } else { + return Ok(ExecResult::err( + "template: no template file or stdin provided\n".to_string(), + 1, + )); + }; + + // Render + match render_template( + &template_text, + &json_data, + ctx.variables, + ctx.env, + config.escape_html, + config.strict, + ) { + Ok(rendered) => Ok(ExecResult::ok(rendered)), + Err(e) => Ok(ExecResult::err(format!("{e}\n"), 1)), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run_template( + args: &[&str], + stdin: Option<&str>, + env: HashMap, + variables: HashMap, + fs: Arc, + ) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let mut variables = variables; + let mut cwd = PathBuf::from("/"); + let fs = fs as Arc; + 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, + }; + Template.execute(ctx).await.unwrap() + } + + fn empty_fs() -> Arc { + Arc::new(InMemoryFs::new()) + } + + #[tokio::test] + async fn test_basic_variable_substitution() { + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "world".to_string()); + let result = run_template( + &[], + Some("Hello {{name}}!"), + HashMap::new(), + vars, + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "Hello world!"); + } + + #[tokio::test] + async fn test_env_variable_substitution() { + let mut env = HashMap::new(); + env.insert("HOST".to_string(), "localhost".to_string()); + let result = run_template( + &[], + Some("server={{HOST}}"), + env, + HashMap::new(), + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "server=localhost"); + } + + #[tokio::test] + async fn test_missing_var_empty_by_default() { + let result = run_template( + &[], + Some("value={{missing}}"), + HashMap::new(), + HashMap::new(), + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "value="); + } + + #[tokio::test] + async fn test_strict_mode_error_on_missing() { + let result = run_template( + &["--strict"], + Some("value={{missing}}"), + HashMap::new(), + HashMap::new(), + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("undefined variable")); + } + + #[tokio::test] + async fn test_html_escaping() { + let mut vars = HashMap::new(); + vars.insert("content".to_string(), "bold".to_string()); + let result = run_template( + &["-e"], + Some("{{content}}"), + HashMap::new(), + vars, + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "<b>bold</b>"); + } + + #[tokio::test] + async fn test_json_data_file() { + let fs = Arc::new(InMemoryFs::new()); + let fs_dyn = fs.clone() as Arc; + fs_dyn + .write_file( + std::path::Path::new("/data.json"), + b"{\"greeting\": \"hi\", \"target\": \"world\"}", + ) + .await + .unwrap(); + let result = run_template( + &["-d", "data.json"], + Some("{{greeting}} {{target}}"), + HashMap::new(), + HashMap::new(), + fs, + ) + .await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "hi world"); + } + + #[tokio::test] + async fn test_template_from_file() { + let fs = Arc::new(InMemoryFs::new()); + let fs_dyn = fs.clone() as Arc; + fs_dyn + .write_file(std::path::Path::new("/tpl.txt"), b"Hello {{name}}!") + .await + .unwrap(); + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "test".to_string()); + let result = run_template(&["tpl.txt"], None, HashMap::new(), vars, fs).await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "Hello test!"); + } + + #[tokio::test] + async fn test_if_block_truthy() { + let mut vars = HashMap::new(); + vars.insert("show".to_string(), "yes".to_string()); + let result = run_template( + &[], + Some("before{{#if show}}visible{{/if}}after"), + HashMap::new(), + vars, + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "beforevisibleafter"); + } + + #[tokio::test] + async fn test_if_block_falsy() { + let result = run_template( + &[], + Some("before{{#if hidden}}invisible{{/if}}after"), + HashMap::new(), + HashMap::new(), + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "beforeafter"); + } + + #[tokio::test] + async fn test_each_block() { + let fs = Arc::new(InMemoryFs::new()); + let fs_dyn = fs.clone() as Arc; + fs_dyn + .write_file( + std::path::Path::new("/data.json"), + b"{\"items\": [\"a\", \"b\", \"c\"]}", + ) + .await + .unwrap(); + let result = run_template( + &["-d", "data.json"], + Some("{{#each items}}[{{.}}]{{/each}}"), + HashMap::new(), + HashMap::new(), + fs, + ) + .await; + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, "[a][b][c]"); + } + + #[tokio::test] + async fn test_no_template_provided() { + let result = run_template(&[], None, HashMap::new(), HashMap::new(), empty_fs()).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("no template")); + } + + #[tokio::test] + async fn test_invalid_json_data_file() { + let fs = Arc::new(InMemoryFs::new()); + let fs_dyn = fs.clone() as Arc; + fs_dyn + .write_file(std::path::Path::new("/bad.json"), b"not json{") + .await + .unwrap(); + let result = run_template( + &["-d", "bad.json"], + Some("{{x}}"), + HashMap::new(), + HashMap::new(), + fs, + ) + .await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("invalid JSON")); + } + + #[tokio::test] + async fn test_unknown_option() { + let result = run_template( + &["--foo"], + Some("text"), + HashMap::new(), + HashMap::new(), + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("unknown option")); + } + + #[tokio::test] + async fn test_unclosed_tag() { + let result = run_template( + &[], + Some("Hello {{name"), + HashMap::new(), + HashMap::new(), + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("unclosed")); + } + + #[tokio::test] + async fn test_missing_data_file() { + let result = run_template( + &["-d", "nonexistent.json"], + Some("{{x}}"), + HashMap::new(), + HashMap::new(), + empty_fs(), + ) + .await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("cannot read data file")); + } +} diff --git a/crates/bashkit/src/builtins/tomlq.rs b/crates/bashkit/src/builtins/tomlq.rs new file mode 100644 index 00000000..a19b4096 --- /dev/null +++ b/crates/bashkit/src/builtins/tomlq.rs @@ -0,0 +1,507 @@ +//! TOML query builtin +//! +//! Non-standard builtin for querying TOML data using dot-separated paths. +//! +//! Usage: +//! tomlq server.port config.toml +//! cat config.toml | tomlq server.port +//! tomlq -r dependencies.serde.version Cargo.toml + +use async_trait::async_trait; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// tomlq builtin - TOML query tool +pub struct Tomlq; + +/// Represents a parsed TOML value. +#[derive(Debug, Clone)] +enum TomlValue { + String(String), + Integer(i64), + Boolean(bool), + Table(Vec<(String, TomlValue)>), +} + +impl TomlValue { + /// Format value for display. + fn display(&self, raw: bool) -> String { + match self { + TomlValue::String(s) => { + if raw { + s.clone() + } else { + format!("\"{}\"", s) + } + } + TomlValue::Integer(n) => n.to_string(), + TomlValue::Boolean(b) => b.to_string(), + TomlValue::Table(entries) => { + // Display as TOML fragment + let mut out = String::new(); + for (k, v) in entries { + match v { + TomlValue::Table(sub) => { + out.push_str(&format!("[{}]\n", k)); + for (sk, sv) in sub { + out.push_str(&format!("{} = {}\n", sk, sv.to_toml())); + } + } + _ => { + out.push_str(&format!("{} = {}\n", k, v.to_toml())); + } + } + } + out + } + } + } + + /// Format value as TOML syntax. + fn to_toml(&self) -> String { + match self { + TomlValue::String(s) => format!("\"{}\"", s), + TomlValue::Integer(n) => n.to_string(), + TomlValue::Boolean(b) => b.to_string(), + TomlValue::Table(entries) => { + let mut out = String::new(); + for (k, v) in entries { + out.push_str(&format!("{} = {}\n", k, v.to_toml())); + } + out + } + } + } + + /// Look up a value by dot-separated path. + fn query(&self, path: &str) -> Option<&TomlValue> { + if path.is_empty() { + return Some(self); + } + let parts: Vec<&str> = path.splitn(2, '.').collect(); + let key = parts[0]; + let rest = if parts.len() > 1 { parts[1] } else { "" }; + + match self { + TomlValue::Table(entries) => { + for (k, v) in entries { + if k == key { + if rest.is_empty() { + return Some(v); + } + return v.query(rest); + } + } + None + } + _ => None, + } + } +} + +/// Parse a TOML value from a raw string (right side of `=`). +fn parse_toml_value(raw: &str) -> TomlValue { + let s = raw.trim(); + + // Boolean + if s == "true" { + return TomlValue::Boolean(true); + } + if s == "false" { + return TomlValue::Boolean(false); + } + + // String (double-quoted) + if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { + let inner = &s[1..s.len() - 1]; + return TomlValue::String(inner.to_string()); + } + + // String (single-quoted / literal) + if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 { + let inner = &s[1..s.len() - 1]; + return TomlValue::String(inner.to_string()); + } + + // Integer + if let Ok(n) = s.parse::() { + return TomlValue::Integer(n); + } + + // Fallback: bare string + TomlValue::String(s.to_string()) +} + +/// Parse TOML content into a root table. +fn parse_toml(content: &str) -> TomlValue { + let mut root: Vec<(String, TomlValue)> = Vec::new(); + // Stack of section path components, e.g. ["server"] or ["database", "pool"] + let mut current_section: Vec = Vec::new(); + // Entries for the current section + let mut current_entries: Vec<(String, TomlValue)> = Vec::new(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Skip empty lines and comments + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // Section header: [section] or [section.subsection] + if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.starts_with("[[") { + // Flush current section + flush_section(&mut root, ¤t_section, &mut current_entries); + + let section_path = &trimmed[1..trimmed.len() - 1].trim(); + current_section = section_path + .split('.') + .map(|s| s.trim().to_string()) + .collect(); + current_entries = Vec::new(); + continue; + } + + // Key = value + if let Some(eq_pos) = trimmed.find('=') { + let key = trimmed[..eq_pos].trim().to_string(); + let value_str = trimmed[eq_pos + 1..].trim(); + // Strip inline comments (not inside strings) + let value_str = strip_inline_comment(value_str); + let value = parse_toml_value(&value_str); + current_entries.push((key, value)); + } + } + + // Flush remaining section + flush_section(&mut root, ¤t_section, &mut current_entries); + + TomlValue::Table(root) +} + +/// Strip inline comment from a value string (respecting quotes). +fn strip_inline_comment(s: &str) -> String { + let mut in_quotes = false; + let mut quote_char = ' '; + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if in_quotes { + if c == quote_char { + in_quotes = false; + } + } else if c == '"' || c == '\'' { + in_quotes = true; + quote_char = c; + } else if c == '#' { + return s[..i].trim().to_string(); + } + } + s.to_string() +} + +/// Flush current section entries into the root table at the given path. +fn flush_section( + root: &mut Vec<(String, TomlValue)>, + section_path: &[String], + entries: &mut Vec<(String, TomlValue)>, +) { + if entries.is_empty() && section_path.is_empty() { + return; + } + + if section_path.is_empty() { + // Top-level entries + root.append(entries); + return; + } + + let section_value = TomlValue::Table(std::mem::take(entries)); + + // Build nested table from path, e.g. ["a", "b"] -> Table("a" -> Table("b" -> entries)) + // For simplicity: merge into root at first key, nesting deeper keys. + insert_at_path(root, section_path, section_value); +} + +/// Insert a value at a nested path in the table entries. +fn insert_at_path(entries: &mut Vec<(String, TomlValue)>, path: &[String], value: TomlValue) { + if path.is_empty() { + return; + } + + let key = &path[0]; + + if path.len() == 1 { + // Check if key already exists (merge tables) + for entry in entries.iter_mut() { + if &entry.0 == key + && let (TomlValue::Table(existing), TomlValue::Table(new)) = (&mut entry.1, &value) + { + existing.extend(new.iter().cloned()); + return; + } + } + entries.push((key.clone(), value)); + return; + } + + // Nested: find or create intermediate table + for entry in entries.iter_mut() { + if &entry.0 == key + && let TomlValue::Table(ref mut sub) = entry.1 + { + insert_at_path(sub, &path[1..], value); + return; + } + } + + // Create intermediate table + let mut sub = Vec::new(); + insert_at_path(&mut sub, &path[1..], value); + entries.push((key.clone(), TomlValue::Table(sub))); +} + +#[async_trait] +impl Builtin for Tomlq { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + return Ok(ExecResult::err( + "tomlq: usage: tomlq [-r] [-t] QUERY [FILE]\n".to_string(), + 1, + )); + } + + let mut raw = false; + let mut as_toml = false; + let mut query: Option = None; + let mut file_arg: Option = None; + + for arg in ctx.args { + match arg.as_str() { + "-r" => raw = true, + "-t" => as_toml = true, + _ => { + if query.is_none() { + query = Some(arg.clone()); + } else { + file_arg = Some(arg.clone()); + } + } + } + } + + let query = match query { + Some(q) => q, + None => { + return Ok(ExecResult::err("tomlq: missing query\n".to_string(), 1)); + } + }; + + let content = if let Some(path_str) = &file_arg { + let path = resolve_path(ctx.cwd, path_str); + match ctx.fs.read_file(&path).await { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(_) => { + return Ok(ExecResult::err( + format!("tomlq: cannot read '{}'\n", path_str), + 1, + )); + } + } + } else if let Some(stdin) = ctx.stdin { + stdin.to_string() + } else { + return Ok(ExecResult::err("tomlq: no input\n".to_string(), 1)); + }; + + let root = parse_toml(&content); + + match root.query(&query) { + Some(val) => { + let output = if as_toml { + val.to_toml() + } else { + val.display(raw) + }; + Ok(ExecResult::ok(format!("{}\n", output.trim_end()))) + } + None => Ok(ExecResult::err( + format!("tomlq: path '{}' not found\n", query), + 1, + )), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run(args: &[&str], stdin: Option<&str>) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs = Arc::new(InMemoryFs::new()) as Arc; + 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, + }; + Tomlq.execute(ctx).await.unwrap() + } + + async fn run_with_file(args: &[&str], filename: &str, content: &str) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs = Arc::new(InMemoryFs::new()) as Arc; + fs.write_file(&PathBuf::from(filename), content.as_bytes()) + .await + .unwrap(); + 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, + }; + Tomlq.execute(ctx).await.unwrap() + } + + const SAMPLE_TOML: &str = r#" +title = "My Config" +debug = true +max_retries = 3 + +[server] +host = "localhost" +port = 8080 + +[database] +url = "postgres://localhost/mydb" +pool_size = 5 + +[database.options] +timeout = 30 +"#; + + #[tokio::test] + async fn test_no_args() { + let r = run(&[], None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("usage")); + } + + #[tokio::test] + async fn test_query_top_level_string() { + let r = run(&["title"], Some(SAMPLE_TOML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "\"My Config\""); + } + + #[tokio::test] + async fn test_query_top_level_string_raw() { + let r = run(&["-r", "title"], Some(SAMPLE_TOML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "My Config"); + } + + #[tokio::test] + async fn test_query_top_level_boolean() { + let r = run(&["debug"], Some(SAMPLE_TOML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "true"); + } + + #[tokio::test] + async fn test_query_top_level_integer() { + let r = run(&["max_retries"], Some(SAMPLE_TOML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "3"); + } + + #[tokio::test] + async fn test_query_section_value() { + let r = run(&["server.port"], Some(SAMPLE_TOML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "8080"); + } + + #[tokio::test] + async fn test_query_nested_section() { + let r = run(&["database.options.timeout"], Some(SAMPLE_TOML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "30"); + } + + #[tokio::test] + async fn test_query_not_found() { + let r = run(&["nonexistent"], Some(SAMPLE_TOML)).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("not found")); + } + + #[tokio::test] + async fn test_query_section_as_table() { + let r = run(&["-t", "server"], Some(SAMPLE_TOML)).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("host")); + assert!(r.stdout.contains("port")); + } + + #[tokio::test] + async fn test_read_from_file() { + let r = run_with_file( + &["server.host", "/config.toml"], + "/config.toml", + SAMPLE_TOML, + ) + .await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("localhost")); + } + + #[tokio::test] + async fn test_no_input() { + let r = run(&["key"], None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("no input")); + } + + #[tokio::test] + async fn test_inline_comment_stripped() { + let toml = "port = 8080 # the port\n"; + let r = run(&["port"], Some(toml)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "8080"); + } + + #[tokio::test] + async fn test_comment_inside_string_preserved() { + let toml = "msg = \"hello # world\"\n"; + let r = run(&["-r", "msg"], Some(toml)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "hello # world"); + } +} diff --git a/crates/bashkit/src/builtins/yaml.rs b/crates/bashkit/src/builtins/yaml.rs new file mode 100644 index 00000000..f0225ee1 --- /dev/null +++ b/crates/bashkit/src/builtins/yaml.rs @@ -0,0 +1,707 @@ +//! YAML query builtin +//! +//! Non-standard builtin for querying YAML data using dot-separated paths. +//! +//! Usage: +//! yaml get server.port config.yml +//! yaml keys config.yml +//! yaml length config.yml +//! yaml type server.port config.yml +//! cat config.yml | yaml get server.port + +use async_trait::async_trait; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// yaml builtin - YAML query tool +pub struct Yaml; + +/// Represents a parsed YAML value. +#[derive(Debug, Clone)] +enum YamlValue { + Null, + String(String), + Integer(i64), + Float(f64), + Boolean(bool), + List(Vec), + Map(Vec<(String, YamlValue)>), +} + +impl YamlValue { + /// Format value for display. + fn display(&self, raw: bool) -> String { + match self { + YamlValue::Null => "null".to_string(), + YamlValue::String(s) => { + if raw { + s.clone() + } else { + format!("\"{}\"", s) + } + } + YamlValue::Integer(n) => n.to_string(), + YamlValue::Float(f) => f.to_string(), + YamlValue::Boolean(b) => b.to_string(), + YamlValue::List(items) => { + let mut out = String::new(); + for item in items { + out.push_str(&format!("- {}\n", item.display(raw))); + } + out + } + YamlValue::Map(entries) => { + let mut out = String::new(); + for (k, v) in entries { + match v { + YamlValue::Map(_) | YamlValue::List(_) => { + out.push_str(&format!("{}:\n", k)); + for line in v.display(raw).lines() { + out.push_str(&format!(" {}\n", line)); + } + } + _ => { + out.push_str(&format!("{}: {}\n", k, v.display(raw))); + } + } + } + out + } + } + } + + /// Look up a value by dot-separated path. + fn query(&self, path: &str) -> Option<&YamlValue> { + if path.is_empty() { + return Some(self); + } + let parts: Vec<&str> = path.splitn(2, '.').collect(); + let key = parts[0]; + let rest = if parts.len() > 1 { parts[1] } else { "" }; + + match self { + YamlValue::Map(entries) => { + for (k, v) in entries { + if k == key { + if rest.is_empty() { + return Some(v); + } + return v.query(rest); + } + } + // Try numeric index on map (unlikely but harmless) + None + } + YamlValue::List(items) => { + if let Ok(idx) = key.parse::() { + let item = items.get(idx)?; + if rest.is_empty() { + return Some(item); + } + return item.query(rest); + } + None + } + _ => None, + } + } + + /// Type name for the `type` subcommand. + fn type_name(&self) -> &'static str { + match self { + YamlValue::Null => "null", + YamlValue::String(_) => "string", + YamlValue::Integer(_) => "integer", + YamlValue::Float(_) => "float", + YamlValue::Boolean(_) => "boolean", + YamlValue::List(_) => "list", + YamlValue::Map(_) => "map", + } + } + + /// Length: number of items in list/map, string length, or 1 for scalars. + fn length(&self) -> usize { + match self { + YamlValue::List(items) => items.len(), + YamlValue::Map(entries) => entries.len(), + YamlValue::String(s) => s.len(), + YamlValue::Null => 0, + _ => 1, + } + } + + /// Top-level keys (map only). + fn keys(&self) -> Option> { + match self { + YamlValue::Map(entries) => Some(entries.iter().map(|(k, _)| k.as_str()).collect()), + _ => None, + } + } +} + +/// Parse a raw YAML scalar value. +fn parse_yaml_scalar(s: &str) -> YamlValue { + let trimmed = s.trim(); + + if trimmed.is_empty() || trimmed == "~" || trimmed == "null" { + return YamlValue::Null; + } + + if trimmed == "true" || trimmed == "yes" { + return YamlValue::Boolean(true); + } + if trimmed == "false" || trimmed == "no" { + return YamlValue::Boolean(false); + } + + // Quoted string + if (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'')) + { + if trimmed.len() >= 2 { + return YamlValue::String(trimmed[1..trimmed.len() - 1].to_string()); + } + return YamlValue::String(String::new()); + } + + // Integer + if let Ok(n) = trimmed.parse::() { + return YamlValue::Integer(n); + } + + // Float + if let Ok(f) = trimmed.parse::() { + return YamlValue::Float(f); + } + + YamlValue::String(trimmed.to_string()) +} + +/// Indentation level (number of leading spaces). +fn indent_level(line: &str) -> usize { + line.len() - line.trim_start().len() +} + +/// Parse YAML content into a value. +/// Supports maps, lists, and scalars with 2-space indentation. +fn parse_yaml(content: &str) -> YamlValue { + let lines: Vec<&str> = content.lines().collect(); + let (val, _) = parse_yaml_block(&lines, 0, 0); + val +} + +/// Parse a block of YAML lines starting at `start` with expected `base_indent`. +/// Returns the parsed value and the index of the next unprocessed line. +fn parse_yaml_block(lines: &[&str], start: usize, base_indent: usize) -> (YamlValue, usize) { + if start >= lines.len() { + return (YamlValue::Null, start); + } + + // Skip blank lines and comments to find first meaningful line + let mut i = start; + while i < lines.len() { + let trimmed = lines[i].trim(); + if !trimmed.is_empty() && !trimmed.starts_with('#') { + break; + } + i += 1; + } + + if i >= lines.len() { + return (YamlValue::Null, i); + } + + let first_indent = indent_level(lines[i]); + if first_indent < base_indent { + return (YamlValue::Null, i); + } + + let first_trimmed = lines[i].trim(); + + // Check if this is a list + if first_trimmed.starts_with("- ") || first_trimmed == "-" { + return parse_yaml_list(lines, i, first_indent); + } + + // Otherwise it's a map (key: value) + if first_trimmed.contains(": ") || first_trimmed.ends_with(':') { + return parse_yaml_map(lines, i, first_indent); + } + + // Bare scalar + (parse_yaml_scalar(first_trimmed), i + 1) +} + +/// Parse a YAML map starting at line `start` with given `base_indent`. +fn parse_yaml_map(lines: &[&str], start: usize, base_indent: usize) -> (YamlValue, usize) { + let mut entries: Vec<(String, YamlValue)> = Vec::new(); + let mut i = start; + + while i < lines.len() { + let trimmed = lines[i].trim(); + + // Skip blanks and comments + if trimmed.is_empty() || trimmed.starts_with('#') { + i += 1; + continue; + } + + let cur_indent = indent_level(lines[i]); + if cur_indent < base_indent { + break; + } + if cur_indent > base_indent { + // Belongs to a nested block already consumed; skip + i += 1; + continue; + } + + // Must be a key line at base_indent + if let Some(colon_pos) = trimmed.find(':') { + let key = trimmed[..colon_pos].trim().to_string(); + let after_colon = trimmed[colon_pos + 1..].trim(); + + if after_colon.is_empty() { + // Value is a nested block on subsequent lines + let (val, next) = parse_yaml_block(lines, i + 1, base_indent + 2); + entries.push((key, val)); + i = next; + } else { + // Inline value + entries.push((key, parse_yaml_scalar(after_colon))); + i += 1; + } + } else { + // Not a valid map entry at this indent; done + break; + } + } + + (YamlValue::Map(entries), i) +} + +/// Parse a YAML list starting at line `start` with given `base_indent`. +fn parse_yaml_list(lines: &[&str], start: usize, base_indent: usize) -> (YamlValue, usize) { + let mut items: Vec = Vec::new(); + let mut i = start; + + while i < lines.len() { + let trimmed = lines[i].trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + i += 1; + continue; + } + + let cur_indent = indent_level(lines[i]); + if cur_indent < base_indent { + break; + } + if cur_indent > base_indent { + i += 1; + continue; + } + + if !trimmed.starts_with("- ") && trimmed != "-" { + break; + } + + let after_dash = if trimmed == "-" { "" } else { &trimmed[2..] }; + + if after_dash.is_empty() { + // Nested block item + let (val, next) = parse_yaml_block(lines, i + 1, base_indent + 2); + items.push(val); + i = next; + } else if after_dash.contains(": ") || after_dash.ends_with(':') { + // Inline map item starting on same line as dash. + // Reconstruct as a map line with indent = base_indent + 2 + let fake_indent = " ".repeat(base_indent + 2); + // Collect this line (without dash) plus subsequent indented lines + let mut block_lines: Vec = Vec::new(); + block_lines.push(format!("{}{}", fake_indent, after_dash)); + let mut j = i + 1; + while j < lines.len() { + let jt = lines[j].trim(); + if jt.is_empty() || jt.starts_with('#') { + j += 1; + continue; + } + let ji = indent_level(lines[j]); + if ji <= base_indent { + break; + } + block_lines.push(lines[j].to_string()); + j += 1; + } + let block_strs: Vec<&str> = block_lines.iter().map(|s| s.as_str()).collect(); + let (val, _) = parse_yaml_map(&block_strs, 0, base_indent + 2); + items.push(val); + i = j; + } else { + items.push(parse_yaml_scalar(after_dash)); + i += 1; + } + } + + (YamlValue::List(items), i) +} + +/// Read input from file arg or stdin. +async fn read_input<'a>( + ctx: &Context<'a>, + file_arg: Option<&str>, +) -> std::result::Result { + if let Some(path_str) = file_arg { + let path = resolve_path(ctx.cwd, path_str); + match ctx.fs.read_file(&path).await { + Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).into_owned()), + Err(_) => Err(ExecResult::err( + format!("yaml: cannot read '{}'\n", path_str), + 1, + )), + } + } else if let Some(stdin) = ctx.stdin { + Ok(stdin.to_string()) + } else { + Err(ExecResult::err("yaml: no input\n".to_string(), 1)) + } +} + +#[async_trait] +impl Builtin for Yaml { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + return Ok(ExecResult::err( + "yaml: usage: yaml [options] [file]\nSubcommands: get, keys, length, type\n" + .to_string(), + 1, + )); + } + + // Parse options and positional args + let mut raw = false; + let mut positional: Vec = Vec::new(); + for arg in ctx.args { + match arg.as_str() { + "-r" => raw = true, + _ => positional.push(arg.clone()), + } + } + + if positional.is_empty() { + return Ok(ExecResult::err("yaml: missing subcommand\n".to_string(), 1)); + } + + let subcmd = positional[0].as_str(); + let rest = &positional[1..]; + + match subcmd { + "get" => { + if rest.is_empty() { + return Ok(ExecResult::err( + "yaml: get requires a path argument\n".to_string(), + 1, + )); + } + let query_path = &rest[0]; + let file_arg = rest.get(1).map(|s| s.as_str()); + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + + let root = parse_yaml(&content); + match root.query(query_path) { + Some(val) => { + let output = val.display(raw); + Ok(ExecResult::ok(format!("{}\n", output.trim_end()))) + } + None => Ok(ExecResult::err( + format!("yaml: path '{}' not found\n", query_path), + 1, + )), + } + } + "keys" => { + let file_arg = rest.first().map(|s| s.as_str()); + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + + let root = parse_yaml(&content); + match root.keys() { + Some(keys) => { + let out = keys.join("\n"); + Ok(ExecResult::ok(format!("{out}\n"))) + } + None => Ok(ExecResult::err("yaml: value is not a map\n".to_string(), 1)), + } + } + "length" => { + let file_arg = rest.first().map(|s| s.as_str()); + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + + let root = parse_yaml(&content); + Ok(ExecResult::ok(format!("{}\n", root.length()))) + } + "type" => { + if rest.is_empty() { + // Type of root + let file_arg: Option<&str> = None; + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + let root = parse_yaml(&content); + Ok(ExecResult::ok(format!("{}\n", root.type_name()))) + } else { + let query_path = &rest[0]; + let file_arg = rest.get(1).map(|s| s.as_str()); + let content = match read_input(&ctx, file_arg).await { + Ok(c) => c, + Err(e) => return Ok(e), + }; + let root = parse_yaml(&content); + match root.query(query_path) { + Some(val) => Ok(ExecResult::ok(format!("{}\n", val.type_name()))), + None => Ok(ExecResult::err( + format!("yaml: path '{}' not found\n", query_path), + 1, + )), + } + } + } + _ => Ok(ExecResult::err( + format!("yaml: unknown subcommand '{}'\n", subcmd), + 1, + )), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use crate::fs::InMemoryFs; + + async fn run(args: &[&str], stdin: Option<&str>) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs = Arc::new(InMemoryFs::new()) as Arc; + 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, + }; + Yaml.execute(ctx).await.unwrap() + } + + async fn run_with_file(args: &[&str], filename: &str, content: &str) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs = Arc::new(InMemoryFs::new()) as Arc; + fs.write_file(&PathBuf::from(filename), content.as_bytes()) + .await + .unwrap(); + 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, + }; + Yaml.execute(ctx).await.unwrap() + } + + const SAMPLE_YAML: &str = "\ +server: + host: localhost + port: 8080 +database: + url: postgres://localhost/mydb + pool_size: 5 +debug: true +name: my-app +"; + + const LIST_YAML: &str = "\ +fruits: + - apple + - banana + - cherry +"; + + #[tokio::test] + async fn test_no_args() { + let r = run(&[], None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("usage")); + } + + #[tokio::test] + async fn test_unknown_subcommand() { + let r = run(&["bogus"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("unknown subcommand")); + } + + #[tokio::test] + async fn test_get_top_level_string() { + let r = run(&["get", "name"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "\"my-app\""); + } + + #[tokio::test] + async fn test_get_top_level_string_raw() { + let r = run(&["-r", "get", "name"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "my-app"); + } + + #[tokio::test] + async fn test_get_top_level_boolean() { + let r = run(&["get", "debug"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "true"); + } + + #[tokio::test] + async fn test_get_nested_value() { + let r = run(&["get", "server.port"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "8080"); + } + + #[tokio::test] + async fn test_get_nested_string() { + let r = run(&["-r", "get", "server.host"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "localhost"); + } + + #[tokio::test] + async fn test_get_not_found() { + let r = run(&["get", "nonexistent"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("not found")); + } + + #[tokio::test] + async fn test_keys() { + let r = run(&["keys"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("server")); + assert!(r.stdout.contains("database")); + assert!(r.stdout.contains("debug")); + assert!(r.stdout.contains("name")); + } + + #[tokio::test] + async fn test_length() { + let r = run(&["length"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "4"); + } + + #[tokio::test] + async fn test_type_map() { + let r = run(&["type", "server"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "map"); + } + + #[tokio::test] + async fn test_type_integer() { + let r = run(&["type", "server.port"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "integer"); + } + + #[tokio::test] + async fn test_type_boolean() { + let r = run(&["type", "debug"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "boolean"); + } + + #[tokio::test] + async fn test_list_values() { + let r = run(&["get", "fruits"], Some(LIST_YAML)).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("apple")); + assert!(r.stdout.contains("banana")); + assert!(r.stdout.contains("cherry")); + } + + #[tokio::test] + async fn test_list_length() { + let r = run(&["length"], Some("- a\n- b\n- c\n")).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "3"); + } + + #[tokio::test] + async fn test_null_value() { + let r = run(&["get", "val"], Some("val: null\n")).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "null"); + } + + #[tokio::test] + async fn test_read_from_file() { + let r = run_with_file(&["get", "name", "/config.yml"], "/config.yml", SAMPLE_YAML).await; + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("my-app")); + } + + #[tokio::test] + async fn test_no_input() { + let r = run(&["get", "key"], None).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("no input")); + } + + #[tokio::test] + async fn test_get_missing_path_arg() { + let r = run(&["get"], Some(SAMPLE_YAML)).await; + assert_eq!(r.exit_code, 1); + assert!(r.stderr.contains("requires a path")); + } + + #[tokio::test] + async fn test_comments_ignored() { + let yaml = "# comment\nkey: value\n# another comment\n"; + let r = run(&["-r", "get", "key"], Some(yaml)).await; + assert_eq!(r.exit_code, 0); + assert_eq!(r.stdout.trim(), "value"); + } +} diff --git a/crates/bashkit/src/builtins/zip_cmd.rs b/crates/bashkit/src/builtins/zip_cmd.rs new file mode 100644 index 00000000..9a265497 --- /dev/null +++ b/crates/bashkit/src/builtins/zip_cmd.rs @@ -0,0 +1,670 @@ +//! zip/unzip - Archive files in a simple zip-like format +//! +//! Since we operate on a virtual filesystem without the `zip` crate, +//! this uses a simple custom binary format: +//! Header: b"BKZIP\x01" (6 bytes) +//! For each entry: +//! - path_len: u32 LE +//! - path: UTF-8 bytes +//! - data_len: u32 LE +//! - data: raw bytes +//! Footer: b"BKEND" (5 bytes) +//! +//! Usage: +//! zip archive.zip file1 file2... +//! zip -r archive.zip dir/ +//! unzip archive.zip +//! unzip -l archive.zip # list contents +//! unzip -d DIR archive.zip # extract to directory +//! unzip -o archive.zip # overwrite existing + +use async_trait::async_trait; + +use super::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +const MAGIC: &[u8] = b"BKZIP\x01"; +const FOOTER: &[u8] = b"BKEND"; + +/// zip command - create zip archives +pub struct Zip; + +/// unzip command - extract zip archives +pub struct Unzip; + +struct ZipOptions { + archive: String, + files: Vec, + recursive: bool, +} + +struct UnzipOptions { + archive: String, + list_only: bool, + extract_dir: Option, + overwrite: bool, +} + +fn parse_zip_args(args: &[String]) -> std::result::Result { + let mut recursive = false; + let mut positional = Vec::new(); + + for arg in args { + match arg.as_str() { + "-r" => recursive = true, + _ if !arg.starts_with('-') => positional.push(arg.clone()), + _ => {} // ignore unknown + } + } + + if positional.is_empty() { + return Err("zip: missing archive name".to_string()); + } + if positional.len() < 2 { + return Err("zip: missing files to add".to_string()); + } + + let archive = positional.remove(0); + Ok(ZipOptions { + archive, + files: positional, + recursive, + }) +} + +fn parse_unzip_args(args: &[String]) -> std::result::Result { + let mut list_only = false; + let mut extract_dir = None; + let mut overwrite = false; + let mut positional = Vec::new(); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-l" => list_only = true, + "-o" => overwrite = true, + "-d" => { + i += 1; + if i < args.len() { + extract_dir = Some(args[i].clone()); + } else { + return Err("unzip: -d requires a directory argument".to_string()); + } + } + _ if !args[i].starts_with('-') => positional.push(args[i].clone()), + _ => {} // ignore unknown + } + i += 1; + } + + if positional.is_empty() { + return Err("unzip: missing archive name".to_string()); + } + + Ok(UnzipOptions { + archive: positional.remove(0), + list_only, + extract_dir, + overwrite, + }) +} + +/// Entry in our simple archive format +struct ArchiveEntry { + path: String, + data: Vec, +} + +/// Encode entries into our archive format +fn encode_archive(entries: &[ArchiveEntry]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(MAGIC); + + for entry in entries { + let path_bytes = entry.path.as_bytes(); + buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(path_bytes); + buf.extend_from_slice(&(entry.data.len() as u32).to_le_bytes()); + buf.extend_from_slice(&entry.data); + } + + buf.extend_from_slice(FOOTER); + buf +} + +/// Decode entries from our archive format +fn decode_archive(data: &[u8]) -> std::result::Result, String> { + if data.len() < MAGIC.len() + FOOTER.len() { + return Err("not a valid archive (too small)".to_string()); + } + if &data[..MAGIC.len()] != MAGIC { + return Err("not a valid archive (bad magic)".to_string()); + } + if &data[data.len() - FOOTER.len()..] != FOOTER { + return Err("not a valid archive (bad footer)".to_string()); + } + + let payload = &data[MAGIC.len()..data.len() - FOOTER.len()]; + let mut entries = Vec::new(); + let mut pos = 0; + + while pos < payload.len() { + if pos + 4 > payload.len() { + return Err("truncated archive (path length)".to_string()); + } + let path_len = u32::from_le_bytes( + payload[pos..pos + 4] + .try_into() + .map_err(|_| "bad path length bytes".to_string())?, + ) as usize; + pos += 4; + + if pos + path_len > payload.len() { + return Err("truncated archive (path data)".to_string()); + } + let path = String::from_utf8(payload[pos..pos + path_len].to_vec()) + .map_err(|_| "invalid UTF-8 in path".to_string())?; + pos += path_len; + + if pos + 4 > payload.len() { + return Err("truncated archive (data length)".to_string()); + } + let data_len = u32::from_le_bytes( + payload[pos..pos + 4] + .try_into() + .map_err(|_| "bad data length bytes".to_string())?, + ) as usize; + pos += 4; + + if pos + data_len > payload.len() { + return Err("truncated archive (file data)".to_string()); + } + let file_data = payload[pos..pos + data_len].to_vec(); + pos += data_len; + + entries.push(ArchiveEntry { + path, + data: file_data, + }); + } + + Ok(entries) +} + +/// Recursively collect files from directory +async fn collect_files_recursive( + fs: &std::sync::Arc, + dir: &std::path::Path, + prefix: &str, +) -> Vec<(String, Vec)> { + let mut result = Vec::new(); + let mut dirs = vec![(dir.to_path_buf(), prefix.to_string())]; + + while let Some((current, current_prefix)) = dirs.pop() { + if let Ok(entries) = fs.read_dir(¤t).await { + for entry in entries { + let path = current.join(&entry.name); + let entry_prefix = if current_prefix.is_empty() { + entry.name.clone() + } else { + format!("{}/{}", current_prefix, entry.name) + }; + if entry.metadata.file_type.is_dir() { + dirs.push((path, entry_prefix)); + } else if entry.metadata.file_type.is_file() + && let Ok(data) = fs.read_file(&path).await + { + result.push((entry_prefix, data)); + } + } + } + } + + result.sort_by(|a, b| a.0.cmp(&b.0)); + result +} + +#[async_trait] +impl Builtin for Zip { + async fn execute(&self, ctx: Context<'_>) -> Result { + let opts = match parse_zip_args(ctx.args) { + Ok(o) => o, + Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 1)), + }; + + let mut entries = Vec::new(); + let mut output = String::new(); + + for file_arg in &opts.files { + let path = resolve_path(ctx.cwd, file_arg); + + // Check if it's a directory + if let Ok(meta) = ctx.fs.stat(&path).await + && meta.file_type.is_dir() + { + if !opts.recursive { + return Ok(ExecResult::err( + format!("zip: {}: is a directory (use -r for recursive)\n", file_arg), + 1, + )); + } + let dir_files = collect_files_recursive(&ctx.fs, &path, file_arg).await; + for (rel_path, data) in dir_files { + output.push_str(&format!(" adding: {}\n", rel_path)); + entries.push(ArchiveEntry { + path: rel_path, + data, + }); + } + continue; + } + + // It's a file + match ctx.fs.read_file(&path).await { + Ok(data) => { + output.push_str(&format!(" adding: {}\n", file_arg)); + entries.push(ArchiveEntry { + path: file_arg.clone(), + data, + }); + } + Err(e) => { + return Ok(ExecResult::err(format!("zip: {}: {}\n", file_arg, e), 1)); + } + } + } + + let archive_data = encode_archive(&entries); + let archive_path = resolve_path(ctx.cwd, &opts.archive); + ctx.fs.write_file(&archive_path, &archive_data).await?; + + Ok(ExecResult::ok(output)) + } +} + +#[async_trait] +impl Builtin for Unzip { + async fn execute(&self, ctx: Context<'_>) -> Result { + let opts = match parse_unzip_args(ctx.args) { + Ok(o) => o, + Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 1)), + }; + + let archive_path = resolve_path(ctx.cwd, &opts.archive); + let archive_data = match ctx.fs.read_file(&archive_path).await { + Ok(d) => d, + Err(e) => { + return Ok(ExecResult::err( + format!("unzip: {}: {}\n", opts.archive, e), + 1, + )); + } + }; + + let entries = match decode_archive(&archive_data) { + Ok(e) => e, + Err(e) => { + return Ok(ExecResult::err( + format!("unzip: {}: {}\n", opts.archive, e), + 1, + )); + } + }; + + let mut output = String::new(); + + if opts.list_only { + output.push_str(" Length Name\n"); + output.push_str("--------- ----------\n"); + let mut total_size = 0usize; + for entry in &entries { + output.push_str(&format!("{:>9} {}\n", entry.data.len(), entry.path)); + total_size += entry.data.len(); + } + output.push_str("--------- ----------\n"); + output.push_str(&format!("{:>9} {} file(s)\n", total_size, entries.len())); + return Ok(ExecResult::ok(output)); + } + + let extract_base = if let Some(ref dir) = opts.extract_dir { + let dir_path = resolve_path(ctx.cwd, dir); + // Create extraction directory + ctx.fs.mkdir(&dir_path, true).await?; + dir_path + } else { + ctx.cwd.clone() + }; + + for entry in &entries { + // Strip leading '/' so Path::join doesn't discard the extract base + let entry_path = entry.path.strip_prefix('/').unwrap_or(&entry.path); + let target = extract_base.join(entry_path); + + // Check if file exists and overwrite not set + if !opts.overwrite + && let Ok(true) = ctx.fs.exists(&target).await + { + output.push_str(&format!( + "skipping: {} (already exists, use -o to overwrite)\n", + entry.path + )); + continue; + } + + // Ensure parent directory exists + if let Some(parent) = target.parent() + && parent != std::path::Path::new("/") + && parent != std::path::Path::new("") + { + ctx.fs.mkdir(parent, true).await?; + } + + ctx.fs.write_file(&target, &entry.data).await?; + output.push_str(&format!(" inflating: {}\n", entry.path)); + } + + Ok(ExecResult::ok(output)) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::fs::{FileSystem, InMemoryFs}; + use std::collections::HashMap; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + + async fn run_zip(args: &[&str], files: &[(&str, &[u8])]) -> (ExecResult, Arc) { + let fs = Arc::new(InMemoryFs::new()); + let fs_trait = fs.clone() as Arc; + for (path, content) in files { + let p = Path::new(path); + if let Some(parent) = p.parent() + && parent != Path::new("/") + { + let _ = fs_trait.mkdir(parent, true).await; + } + fs_trait.write_file(p, content).await.unwrap(); + } + + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs_trait, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + let result = Zip.execute(ctx).await.unwrap(); + (result, fs) + } + + async fn run_unzip(args: &[&str], fs: Arc) -> ExecResult { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let fs_dyn = fs as Arc; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs_dyn, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + Unzip.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn test_zip_and_unzip_basic() { + let (result, fs) = run_zip( + &["/archive.zip", "/a.txt", "/b.txt"], + &[("/a.txt", b"hello"), ("/b.txt", b"world")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("adding: /a.txt")); + + // Now extract to /out/ + let result = run_unzip(&["-d", "/out", "/archive.zip"], fs).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("inflating")); + } + + #[tokio::test] + async fn test_zip_missing_archive() { + let fs = Arc::new(InMemoryFs::new()); + let fs_trait = fs.clone() as Arc; + let args: Vec = vec![]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs_trait, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + let result = Zip.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("missing archive")); + } + + #[tokio::test] + async fn test_zip_missing_files_arg() { + let fs = Arc::new(InMemoryFs::new()) as Arc; + let args = vec!["archive.zip".to_string()]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + 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 = Zip.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("missing files")); + } + + #[tokio::test] + async fn test_zip_file_not_found() { + let (result, _fs) = run_zip(&["/archive.zip", "/nonexistent.txt"], &[]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("/nonexistent.txt")); + } + + #[tokio::test] + async fn test_zip_directory_without_recursive() { + let (result, _fs) = + run_zip(&["/archive.zip", "/dir"], &[("/dir/file.txt", b"content")]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("is a directory")); + } + + #[tokio::test] + async fn test_zip_recursive_directory() { + let (result, fs) = run_zip( + &["-r", "/archive.zip", "/dir"], + &[("/dir/a.txt", b"aaa"), ("/dir/sub/b.txt", b"bbb")], + ) + .await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("adding:")); + + // Verify archive exists + let fs_trait = fs as Arc; + assert!(fs_trait.exists(Path::new("/archive.zip")).await.unwrap()); + } + + #[tokio::test] + async fn test_unzip_list() { + let (_, fs) = run_zip(&["/archive.zip", "/a.txt"], &[("/a.txt", b"hello world")]).await; + + let result = run_unzip(&["-l", "/archive.zip"], fs).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("/a.txt")); + assert!(result.stdout.contains("11")); // "hello world" is 11 bytes + assert!(result.stdout.contains("1 file(s)")); + } + + #[tokio::test] + async fn test_unzip_extract_dir() { + let (_, fs) = run_zip( + &["/archive.zip", "/a.txt", "/b.txt"], + &[("/a.txt", b"aaa"), ("/b.txt", b"bbb")], + ) + .await; + + let result = run_unzip(&["-d", "/extracted", "/archive.zip"], fs.clone()).await; + assert_eq!(result.exit_code, 0); + let fs_trait = fs as Arc; + let content = fs_trait + .read_file(Path::new("/extracted/a.txt")) + .await + .unwrap(); + assert_eq!(&content, b"aaa"); + } + + #[tokio::test] + async fn test_unzip_skip_existing() { + let (_, fs) = run_zip(&["/archive.zip", "/a.txt"], &[("/a.txt", b"archived")]).await; + + // Write a different file at the target location + let fs_trait = fs.clone() as Arc; + fs_trait + .write_file(Path::new("/a.txt"), b"existing") + .await + .unwrap(); + + let result = run_unzip(&["/archive.zip"], fs.clone()).await; + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("skipping")); + + // Original file should be preserved + let content = fs_trait.read_file(Path::new("/a.txt")).await.unwrap(); + assert_eq!(&content, b"existing"); + } + + #[tokio::test] + async fn test_unzip_overwrite() { + let (_, fs) = run_zip(&["/archive.zip", "/a.txt"], &[("/a.txt", b"archived")]).await; + + // Write different content + let fs_trait = fs.clone() as Arc; + fs_trait + .write_file(Path::new("/a.txt"), b"existing") + .await + .unwrap(); + + let result = run_unzip(&["-o", "/archive.zip"], fs.clone()).await; + assert_eq!(result.exit_code, 0); + + let content = fs_trait.read_file(Path::new("/a.txt")).await.unwrap(); + assert_eq!(&content, b"archived"); + } + + #[tokio::test] + async fn test_unzip_missing_archive() { + let fs = Arc::new(InMemoryFs::new()); + let result = run_unzip(&["/nonexistent.zip"], fs).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("unzip:")); + } + + #[tokio::test] + async fn test_unzip_invalid_archive() { + let fs = Arc::new(InMemoryFs::new()); + let fs_trait = fs.clone() as Arc; + fs_trait + .write_file(Path::new("/bad.zip"), b"not a zip file") + .await + .unwrap(); + let result = run_unzip(&["/bad.zip"], fs).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("not a valid archive")); + } + + #[tokio::test] + async fn test_encode_decode_roundtrip() { + let entries = vec![ + ArchiveEntry { + path: "hello.txt".to_string(), + data: b"hello world".to_vec(), + }, + ArchiveEntry { + path: "dir/nested.txt".to_string(), + data: b"nested content".to_vec(), + }, + ]; + let encoded = encode_archive(&entries); + let decoded = decode_archive(&encoded).unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].path, "hello.txt"); + assert_eq!(decoded[0].data, b"hello world"); + assert_eq!(decoded[1].path, "dir/nested.txt"); + assert_eq!(decoded[1].data, b"nested content"); + } + + #[tokio::test] + async fn test_decode_empty_data() { + let result = decode_archive(b""); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_unzip_no_args() { + let fs = Arc::new(InMemoryFs::new()); + let fs_trait = fs.clone() as Arc; + let args: Vec = vec![]; + let env = HashMap::new(); + let mut variables = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs_trait, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + let result = Unzip.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("missing archive")); + } +} diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index e0315b00..3f2e2d16 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -489,6 +489,21 @@ impl Interpreter { builtins.insert("retry".to_string(), Box::new(builtins::Retry)); builtins.insert("semver".to_string(), Box::new(builtins::Semver)); builtins.insert("verify".to_string(), Box::new(builtins::Verify)); + builtins.insert("compgen".to_string(), Box::new(builtins::Compgen)); + builtins.insert("csv".to_string(), Box::new(builtins::Csv)); + builtins.insert("fc".to_string(), Box::new(builtins::Fc)); + builtins.insert("help".to_string(), Box::new(builtins::Help)); + builtins.insert("http".to_string(), Box::new(builtins::Http)); + builtins.insert("iconv".to_string(), Box::new(builtins::Iconv)); + builtins.insert("json".to_string(), Box::new(builtins::Json)); + builtins.insert("parallel".to_string(), Box::new(builtins::Parallel)); + builtins.insert("patch".to_string(), Box::new(builtins::Patch)); + builtins.insert("rg".to_string(), Box::new(builtins::Rg)); + builtins.insert("template".to_string(), Box::new(builtins::Template)); + builtins.insert("tomlq".to_string(), Box::new(builtins::Tomlq)); + builtins.insert("yaml".to_string(), Box::new(builtins::Yaml)); + builtins.insert("zip".to_string(), Box::new(builtins::Zip)); + builtins.insert("unzip".to_string(), Box::new(builtins::Unzip)); // Merge custom builtins (override defaults if same name) for (name, builtin) in custom_builtins {