Skip to content

Commit 73f99bc

Browse files
authored
feat(builtins): add 7 non-standard builtins + alias/unalias docs (#585)
## Summary - Add 7 non-standard builtins: `assert`, `retry`, `log`, `semver`, `dotenv`, `verify`, `glob` - Document alias/unalias support in specs and remove stale "not implemented" status - All builtins include comprehensive unit tests (positive and negative paths) - All builtins registered in interpreter and mod.rs ## New Builtins | Builtin | Description | Tests | |---------|-------------|-------| | `assert` | Test assertions with failure messages | 19 tests | | `retry` | Retry config parser (virtual env) | 10 tests | | `log` | Structured logging with levels + JSON | 10 tests | | `semver` | Version comparison, parsing, bumping, sorting | 14 tests | | `dotenv` | .env file parser → shell variables | 8 tests | | `verify` | File hash verification (sha256/sha1/md5) | 8 tests | | `glob` | Programmatic glob matching (strings + files) | 8 tests | ## Test plan - [x] `cargo fmt --check` passes - [x] `cargo clippy --all-targets --all-features -- -D warnings` passes - [x] `cargo test --all-features -p bashkit --lib` — all 1730 tests pass - [x] Each new builtin has both positive and negative test scenarios Closes #560, #562, #563, #565, #568, #572, #573, #574
1 parent ce2ab0e commit 73f99bc

File tree

11 files changed

+2026
-1
lines changed

11 files changed

+2026
-1
lines changed
Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
//! assert builtin - assertion testing for scripts
2+
//!
3+
//! Non-standard builtin. Evaluates test expressions and fails with
4+
//! a message if the assertion is false.
5+
6+
use std::path::Path;
7+
use std::sync::Arc;
8+
9+
use async_trait::async_trait;
10+
11+
use super::{Builtin, Context};
12+
use crate::error::Result;
13+
use crate::fs::FileSystem;
14+
use crate::interpreter::ExecResult;
15+
16+
/// Assert builtin - evaluate test expressions with failure messages.
17+
///
18+
/// Usage: assert <test-expression> [message]
19+
///
20+
/// Supports:
21+
/// String: -z, -n, =, !=
22+
/// Numeric: -eq, -ne, -lt, -gt, -le, -ge
23+
/// File: -f, -d, -e
24+
///
25+
/// If the expression is true, exits 0 silently.
26+
/// If false, prints "assertion failed: <message>" to stderr and exits 1.
27+
pub struct Assert;
28+
29+
/// Evaluate a unary file/string test.
30+
async fn eval_unary(op: &str, arg: &str, fs: &Arc<dyn FileSystem>, cwd: &Path) -> bool {
31+
match op {
32+
"-z" => arg.is_empty(),
33+
"-n" => !arg.is_empty(),
34+
"-e" => {
35+
let path = super::resolve_path(cwd, arg);
36+
fs.exists(&path).await.unwrap_or(false)
37+
}
38+
"-f" => {
39+
let path = super::resolve_path(cwd, arg);
40+
if let Ok(meta) = fs.stat(&path).await {
41+
meta.file_type.is_file()
42+
} else {
43+
false
44+
}
45+
}
46+
"-d" => {
47+
let path = super::resolve_path(cwd, arg);
48+
if let Ok(meta) = fs.stat(&path).await {
49+
meta.file_type.is_dir()
50+
} else {
51+
false
52+
}
53+
}
54+
_ => false,
55+
}
56+
}
57+
58+
/// Evaluate a binary comparison.
59+
fn eval_binary(left: &str, op: &str, right: &str) -> Option<bool> {
60+
match op {
61+
"=" | "==" => Some(left == right),
62+
"!=" => Some(left != right),
63+
"-eq" => Some(parse_int(left) == parse_int(right)),
64+
"-ne" => Some(parse_int(left) != parse_int(right)),
65+
"-lt" => Some(parse_int(left) < parse_int(right)),
66+
"-gt" => Some(parse_int(left) > parse_int(right)),
67+
"-le" => Some(parse_int(left) <= parse_int(right)),
68+
"-ge" => Some(parse_int(left) >= parse_int(right)),
69+
_ => None,
70+
}
71+
}
72+
73+
fn parse_int(s: &str) -> i64 {
74+
s.trim().parse().unwrap_or(0)
75+
}
76+
77+
/// Extract the assertion message from args that come after the test expression.
78+
/// Returns (test_args, message).
79+
fn split_args(args: &[String]) -> (&[String], Option<String>) {
80+
// If args start with "[", find the matching "]" and take the rest as message
81+
if args.first().map(|s| s.as_str()) == Some("[")
82+
&& let Some(pos) = args.iter().position(|s| s == "]")
83+
{
84+
let test_args = &args[1..pos];
85+
let msg = if pos + 1 < args.len() {
86+
Some(args[pos + 1..].join(" "))
87+
} else {
88+
None
89+
};
90+
return (test_args, msg);
91+
}
92+
93+
// Heuristic: look for known operators to determine where test expr ends.
94+
// For unary: 2 args = operator + operand, rest is message
95+
// For binary: 3 args = left op right, rest is message
96+
if args.len() >= 3 && is_binary_op(&args[1]) {
97+
let msg = if args.len() > 3 {
98+
Some(args[3..].join(" "))
99+
} else {
100+
None
101+
};
102+
return (&args[..3], msg);
103+
}
104+
105+
if args.len() >= 2 && is_unary_op(&args[0]) {
106+
let msg = if args.len() > 2 {
107+
Some(args[2..].join(" "))
108+
} else {
109+
None
110+
};
111+
return (&args[..2], msg);
112+
}
113+
114+
// Single arg: non-empty string test
115+
if !args.is_empty() {
116+
let msg = if args.len() > 1 {
117+
Some(args[1..].join(" "))
118+
} else {
119+
None
120+
};
121+
return (&args[..1], msg);
122+
}
123+
124+
(args, None)
125+
}
126+
127+
fn is_unary_op(s: &str) -> bool {
128+
matches!(s, "-z" | "-n" | "-e" | "-f" | "-d")
129+
}
130+
131+
fn is_binary_op(s: &str) -> bool {
132+
matches!(
133+
s,
134+
"=" | "==" | "!=" | "-eq" | "-ne" | "-lt" | "-gt" | "-le" | "-ge"
135+
)
136+
}
137+
138+
#[async_trait]
139+
impl Builtin for Assert {
140+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
141+
if ctx.args.is_empty() {
142+
return Ok(ExecResult::err(
143+
"assert: usage: assert <test-expression> [message]\n".to_string(),
144+
1,
145+
));
146+
}
147+
148+
let (test_args, message) = split_args(ctx.args);
149+
let cwd = ctx.cwd.clone();
150+
151+
let passed = match test_args.len() {
152+
0 => false,
153+
1 => {
154+
// Single arg: true if non-empty
155+
!test_args[0].is_empty()
156+
}
157+
2 => {
158+
// Unary operator
159+
eval_unary(&test_args[0], &test_args[1], &ctx.fs, &cwd).await
160+
}
161+
3 => {
162+
// Binary operator
163+
eval_binary(&test_args[0], &test_args[1], &test_args[2]).unwrap_or(false)
164+
}
165+
_ => false,
166+
};
167+
168+
if passed {
169+
Ok(ExecResult::ok(String::new()))
170+
} else {
171+
let msg = message.unwrap_or_else(|| {
172+
test_args
173+
.iter()
174+
.map(|s| s.as_str())
175+
.collect::<Vec<_>>()
176+
.join(" ")
177+
});
178+
Ok(ExecResult::err(format!("assertion failed: {msg}\n"), 1))
179+
}
180+
}
181+
}
182+
183+
#[cfg(test)]
184+
#[allow(clippy::unwrap_used)]
185+
mod tests {
186+
use super::*;
187+
use std::collections::HashMap;
188+
use std::path::PathBuf;
189+
use std::sync::Arc;
190+
191+
use crate::fs::InMemoryFs;
192+
193+
async fn run_assert(args: &[&str]) -> ExecResult {
194+
let fs = Arc::new(InMemoryFs::new());
195+
let mut variables = HashMap::new();
196+
let env = HashMap::new();
197+
let mut cwd = PathBuf::from("/");
198+
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
199+
let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
200+
Assert.execute(ctx).await.unwrap()
201+
}
202+
203+
async fn run_assert_with_fs(args: &[&str], fs: Arc<InMemoryFs>) -> ExecResult {
204+
let mut variables = HashMap::new();
205+
let env = HashMap::new();
206+
let mut cwd = PathBuf::from("/");
207+
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
208+
let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs, None);
209+
Assert.execute(ctx).await.unwrap()
210+
}
211+
212+
#[tokio::test]
213+
async fn test_no_args() {
214+
let result = run_assert(&[]).await;
215+
assert_eq!(result.exit_code, 1);
216+
assert!(result.stderr.contains("usage"));
217+
}
218+
219+
#[tokio::test]
220+
async fn test_string_equal_pass() {
221+
let result = run_assert(&["hello", "=", "hello"]).await;
222+
assert_eq!(result.exit_code, 0);
223+
assert!(result.stdout.is_empty());
224+
}
225+
226+
#[tokio::test]
227+
async fn test_string_equal_fail() {
228+
let result = run_assert(&["hello", "=", "world"]).await;
229+
assert_eq!(result.exit_code, 1);
230+
assert!(result.stderr.contains("assertion failed"));
231+
}
232+
233+
#[tokio::test]
234+
async fn test_string_not_equal() {
235+
let result = run_assert(&["a", "!=", "b"]).await;
236+
assert_eq!(result.exit_code, 0);
237+
}
238+
239+
#[tokio::test]
240+
async fn test_numeric_eq_pass() {
241+
let result = run_assert(&["42", "-eq", "42"]).await;
242+
assert_eq!(result.exit_code, 0);
243+
}
244+
245+
#[tokio::test]
246+
async fn test_numeric_lt_pass() {
247+
let result = run_assert(&["1", "-lt", "10"]).await;
248+
assert_eq!(result.exit_code, 0);
249+
}
250+
251+
#[tokio::test]
252+
async fn test_numeric_gt_fail() {
253+
let result = run_assert(&["1", "-gt", "10"]).await;
254+
assert_eq!(result.exit_code, 1);
255+
assert!(result.stderr.contains("assertion failed"));
256+
}
257+
258+
#[tokio::test]
259+
async fn test_z_empty_string() {
260+
let result = run_assert(&["-z", ""]).await;
261+
assert_eq!(result.exit_code, 0);
262+
}
263+
264+
#[tokio::test]
265+
async fn test_n_nonempty_string() {
266+
let result = run_assert(&["-n", "value"]).await;
267+
assert_eq!(result.exit_code, 0);
268+
}
269+
270+
#[tokio::test]
271+
async fn test_file_exists() {
272+
let fs = Arc::new(InMemoryFs::new());
273+
fs.write_file(Path::new("/test.txt"), b"data")
274+
.await
275+
.unwrap();
276+
let result = run_assert_with_fs(&["-f", "/test.txt"], fs).await;
277+
assert_eq!(result.exit_code, 0);
278+
}
279+
280+
#[tokio::test]
281+
async fn test_file_not_exists() {
282+
let result = run_assert(&["-f", "/nope.txt"]).await;
283+
assert_eq!(result.exit_code, 1);
284+
assert!(result.stderr.contains("assertion failed"));
285+
}
286+
287+
#[tokio::test]
288+
async fn test_dir_exists() {
289+
let fs = Arc::new(InMemoryFs::new());
290+
fs.mkdir(Path::new("/mydir"), true).await.unwrap();
291+
let result = run_assert_with_fs(&["-d", "/mydir"], fs).await;
292+
assert_eq!(result.exit_code, 0);
293+
}
294+
295+
#[tokio::test]
296+
async fn test_bracket_syntax_pass() {
297+
let result = run_assert(&["[", "x", "=", "x", "]"]).await;
298+
assert_eq!(result.exit_code, 0);
299+
}
300+
301+
#[tokio::test]
302+
async fn test_bracket_syntax_fail_with_message() {
303+
let result = run_assert(&["[", "a", "=", "b", "]", "values", "differ"]).await;
304+
assert_eq!(result.exit_code, 1);
305+
assert!(result.stderr.contains("assertion failed: values differ"));
306+
}
307+
308+
#[tokio::test]
309+
async fn test_custom_message() {
310+
let result = run_assert(&["1", "-eq", "2", "expected", "equal"]).await;
311+
assert_eq!(result.exit_code, 1);
312+
assert!(result.stderr.contains("assertion failed: expected equal"));
313+
}
314+
315+
#[tokio::test]
316+
async fn test_e_exists() {
317+
let fs = Arc::new(InMemoryFs::new());
318+
fs.write_file(Path::new("/exists.txt"), b"x").await.unwrap();
319+
let result = run_assert_with_fs(&["-e", "/exists.txt"], fs).await;
320+
assert_eq!(result.exit_code, 0);
321+
}
322+
323+
#[tokio::test]
324+
async fn test_numeric_le() {
325+
let result = run_assert(&["5", "-le", "5"]).await;
326+
assert_eq!(result.exit_code, 0);
327+
}
328+
329+
#[tokio::test]
330+
async fn test_numeric_ge() {
331+
let result = run_assert(&["10", "-ge", "5"]).await;
332+
assert_eq!(result.exit_code, 0);
333+
}
334+
335+
#[tokio::test]
336+
async fn test_numeric_ne() {
337+
let result = run_assert(&["1", "-ne", "2"]).await;
338+
assert_eq!(result.exit_code, 0);
339+
}
340+
}

0 commit comments

Comments
 (0)