use async_trait::async_trait;
use bashkit::hooks::HookAction;
use bashkit::{Bash, Builtin, BuiltinContext, CommandResolver, ExecResult};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
struct Resolved {
name: String,
}
#[async_trait]
impl Builtin for Resolved {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
let mut out = format!("resolved:{}", self.name);
if !ctx.args.is_empty() {
out.push_str(&format!(" args:{}", ctx.args.join(",")));
}
if let Some(stdin) = ctx.stdin {
out.push_str(&format!(" stdin:{}", stdin.trim_end()));
}
out.push('\n');
Ok(ExecResult::ok(out))
}
}
struct ResolveAll {
calls: Arc<AtomicUsize>,
}
impl CommandResolver for ResolveAll {
fn resolve(&self, name: &str) -> Option<Arc<dyn Builtin>> {
self.calls.fetch_add(1, Ordering::SeqCst);
Some(Arc::new(Resolved { name: name.into() }))
}
}
struct ResolveNothing;
impl CommandResolver for ResolveNothing {
fn resolve(&self, _name: &str) -> Option<Arc<dyn Builtin>> {
None
}
}
fn resolve_all() -> (Bash, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let bash = Bash::builder()
.command_resolver(Arc::new(ResolveAll {
calls: Arc::clone(&calls),
}))
.build();
(bash, calls)
}
#[tokio::test]
async fn resolver_handles_an_otherwise_unknown_command() {
let (mut bash, calls) = resolve_all();
let result = bash.exec("definitely-not-a-command-xyz").await.unwrap();
assert_eq!(result.stdout, "resolved:definitely-not-a-command-xyz\n");
assert_eq!(result.exit_code, 0);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn resolver_receives_args_and_pipeline_stdin() {
let (mut bash, _) = resolve_all();
let result = bash
.exec("echo piped | some-host-tool --flag v")
.await
.unwrap();
assert_eq!(
result.stdout,
"resolved:some-host-tool args:--flag,v stdin:piped\n"
);
}
#[tokio::test]
async fn resolver_never_shadows_a_baked_in_builtin() {
let (mut bash, calls) = resolve_all();
let result = bash.exec("echo hello").await.unwrap();
assert_eq!(result.stdout, "hello\n");
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"resolver must not be consulted for a command that already resolves"
);
}
#[tokio::test]
async fn resolver_never_shadows_a_shell_function() {
let (mut bash, calls) = resolve_all();
let result = bash
.exec("greet() { echo from-function; }; greet")
.await
.unwrap();
assert_eq!(result.stdout, "from-function\n");
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn resolver_never_shadows_a_builder_registered_builtin() {
let calls = Arc::new(AtomicUsize::new(0));
let mut bash = Bash::builder()
.builtin(
"deploy",
Box::new(Resolved {
name: "registered".into(),
}),
)
.command_resolver(Arc::new(ResolveAll {
calls: Arc::clone(&calls),
}))
.build();
let result = bash.exec("deploy").await.unwrap();
assert_eq!(result.stdout, "resolved:registered\n");
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn resolver_returning_none_keeps_command_not_found() {
let mut bash = Bash::builder()
.command_resolver(Arc::new(ResolveNothing))
.build();
let result = bash.exec("definitely-not-a-command-xyz").await.unwrap();
assert_eq!(result.exit_code, 127);
assert!(
result.stderr.contains("command not found"),
"unexpected: {}",
result.stderr
);
}
#[tokio::test]
async fn no_resolver_keeps_command_not_found() {
let mut bash = Bash::new();
let result = bash.exec("definitely-not-a-command-xyz").await.unwrap();
assert_eq!(result.exit_code, 127);
}
#[tokio::test]
async fn before_tool_veto_blocks_a_resolved_command() {
let mut bash = Bash::builder()
.command_resolver(Arc::new(ResolveAll {
calls: Arc::new(AtomicUsize::new(0)),
}))
.before_tool(Box::new(|event| {
if event.name == "forbidden-tool" {
HookAction::Cancel("blocked by policy".into())
} else {
HookAction::Continue(event)
}
}))
.build();
let blocked = bash.exec("forbidden-tool").await.unwrap();
assert_ne!(blocked.exit_code, 0, "veto must not succeed");
assert!(
!blocked.stdout.contains("resolved:"),
"vetoed command still ran: {}",
blocked.stdout
);
let allowed = bash.exec("permitted-tool").await.unwrap();
assert_eq!(allowed.stdout, "resolved:permitted-tool\n");
}
#[tokio::test]
async fn before_tool_observes_the_resolved_name() {
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let recorder = Arc::clone(&seen);
let mut bash = Bash::builder()
.command_resolver(Arc::new(ResolveAll {
calls: Arc::new(AtomicUsize::new(0)),
}))
.before_tool(Box::new(move |event| {
recorder.lock().unwrap().push(event.name.clone());
HookAction::Continue(event)
}))
.build();
bash.exec("host-tool --version").await.unwrap();
assert_eq!(seen.lock().unwrap().as_slice(), ["host-tool"]);
}
#[tokio::test]
async fn resolved_exit_codes_drive_control_flow() {
struct Failing;
#[async_trait]
impl Builtin for Failing {
async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
Ok(ExecResult::err("boom", 3))
}
}
struct FailResolver;
impl CommandResolver for FailResolver {
fn resolve(&self, name: &str) -> Option<Arc<dyn Builtin>> {
(name == "failing-tool").then(|| Arc::new(Failing) as Arc<dyn Builtin>)
}
}
let mut bash = Bash::builder()
.command_resolver(Arc::new(FailResolver))
.build();
let result = bash.exec("failing-tool; echo after").await.unwrap();
assert!(
result.stdout.contains("after"),
"script aborted: {result:?}"
);
let result = bash.exec("failing-tool || echo recovered").await.unwrap();
assert!(result.stdout.contains("recovered"));
let result = bash.exec("failing-tool").await.unwrap();
assert_eq!(result.exit_code, 3);
}
#[tokio::test]
async fn resolved_names_are_not_enumerable() {
let (bash, _) = resolve_all();
assert!(
!bash
.builtin_names()
.contains(&"anything-at-all".to_string())
);
}