use crate::convert;
use crate::error::{Error, Result};
use crate::modules::stdio::read_stdin;
use crate::modules::{HostModule, InstallContext};
use crate::types::ModuleName;
#[derive(Debug)]
pub struct Hook {
name: ModuleName,
}
impl Hook {
#[must_use]
pub fn new() -> Self {
Self {
name: ModuleName::new("hook")
.unwrap_or_else(|_| unreachable!("`hook` is a valid module name")),
}
}
}
impl Default for Hook {
fn default() -> Self {
Self::new()
}
}
impl HostModule for Hook {
fn name(&self) -> &ModuleName {
&self.name
}
fn install(
&self,
lua: &mlua::Lua,
table: &mlua::Table,
_context: &InstallContext<'_>,
) -> Result<()> {
let fail = |e: mlua::Error| Error::ModuleInstall {
module: String::from("hook"),
reason: e.to_string(),
};
let payload = lua
.create_function(|lua, ()| {
let text = read_stdin()?;
if text.trim().is_empty() {
return Ok(mlua::Value::Table(lua.create_table()?));
}
convert::from_json(lua, &text).map_err(mlua::Error::from)
})
.map_err(fail)?;
table.set("payload", payload).map_err(fail)?;
let emit = lua
.create_function(|_, value: mlua::Value| {
let text = convert::to_json(&value)?;
write_stdout(&text).map_err(mlua::Error::from)
})
.map_err(fail)?;
table.set("emit", emit).map_err(fail)?;
let context = lua
.create_function(
|lua, (event, additional): (mlua::LuaString, mlua::LuaString)| {
let text = context_envelope(lua, event, additional)?;
write_stdout(&text).map_err(mlua::Error::from)
},
)
.map_err(fail)?;
table.set("context", context).map_err(fail)?;
Ok(())
}
}
fn context_envelope(
lua: &mlua::Lua,
event: mlua::LuaString,
additional: mlua::LuaString,
) -> mlua::Result<String> {
let specific = lua.create_table()?;
specific.set("hookEventName", event)?;
specific.set("additionalContext", additional)?;
let envelope = lua.create_table()?;
envelope.set("hookSpecificOutput", specific)?;
convert::to_json(&mlua::Value::Table(envelope)).map_err(mlua::Error::from)
}
fn write_stdout(text: &str) -> Result<()> {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
out.write_all(text.as_bytes()).map_err(|source| Error::Io {
operation: "emit",
path: String::from("<stdout>"),
source,
})?;
out.flush().map_err(|source| Error::Io {
operation: "emit",
path: String::from("<stdout>"),
source,
})
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::Hook;
use crate::{Engine, HostModule as _, Policy, Script};
fn eval(source: &str) -> String {
let engine = Engine::builder()
.policy(Policy::confined())
.build()
.unwrap();
engine
.eval_to::<String>(&Script::from_source(source, "test").unwrap())
.unwrap()
}
#[test]
fn the_module_is_named_hook() {
assert_eq!(Hook::new().name().as_str(), "hook");
}
#[test]
fn the_three_functions_are_installed() {
for name in ["payload", "emit", "context"] {
assert_eq!(
eval(&format!("return type(airsstack.hook.{name})")),
"function",
"{name}"
);
}
}
#[test]
fn the_module_is_available_under_a_confined_policy() {
assert_eq!(eval("return type(airsstack.hook)"), "table");
}
#[test]
fn the_emitted_envelope_matches_the_contract_the_plugin_scripts_use() {
let lua = mlua::Lua::new();
let event = lua.create_string("PreToolUse").unwrap();
let additional = lua.create_string("note").unwrap();
let json = super::context_envelope(&lua, event, additional).unwrap();
assert_eq!(
json,
r#"{"hookSpecificOutput":{"additionalContext":"note","hookEventName":"PreToolUse"}}"#
);
}
#[test]
fn the_context_envelope_carries_no_permission_decision_field() {
let lua = mlua::Lua::new();
let event = lua.create_string("PreToolUse").unwrap();
let additional = lua.create_string("note").unwrap();
let json = super::context_envelope(&lua, event, additional).unwrap();
let decoded: serde_json::Value = serde_json::from_str(&json).unwrap();
let specific = decoded["hookSpecificOutput"].as_object().unwrap();
assert!(
!specific.contains_key("permissionDecision"),
"envelope must not carry a permissionDecision field: {specific:?}"
);
}
#[test]
fn emit_accepts_a_table_and_does_not_raise() {
assert_eq!(
eval("airsstack.hook.emit({ok = true}); return 'done'"),
"done"
);
}
#[test]
fn context_accepts_the_event_name_and_the_text() {
assert_eq!(
eval("airsstack.hook.context('SessionStart', 'hello'); return 'done'"),
"done"
);
}
}