1use crate::error::LuaError;
40use crate::orcs_helpers::ensure_orcs_table;
41use mlua::{Function, Lua, LuaSerdeExt, Value};
42use orcs_hook::{FqlPattern, Hook, HookAction, HookContext, HookPoint, SharedHookRegistry};
43use orcs_types::ComponentId;
44use parking_lot::Mutex;
45
46pub struct LuaHook {
56 id: String,
57 fql: FqlPattern,
58 point: HookPoint,
59 priority: i32,
60 func_key: mlua::RegistryKey,
61 lua: Mutex<Lua>,
62}
63
64impl LuaHook {
65 pub fn new(
74 id: String,
75 fql: FqlPattern,
76 point: HookPoint,
77 priority: i32,
78 lua: &Lua,
79 func: &Function,
80 ) -> Result<Self, mlua::Error> {
81 let func_key = lua.create_registry_value(func.clone())?;
82 Ok(Self {
83 id,
84 fql,
85 point,
86 priority,
87 func_key,
88 lua: Mutex::new(lua.clone()),
89 })
90 }
91
92 fn try_execute(&self, ctx: &HookContext) -> Result<HookAction, mlua::Error> {
94 let lua = self.lua.lock();
95
96 let func: Function = lua.registry_value(&self.func_key)?;
97 let ctx_value: Value = lua.to_value(ctx)?;
98 let result: Value = func.call(ctx_value)?;
99
100 parse_hook_return(&lua, result, ctx)
101 }
102}
103
104impl Hook for LuaHook {
105 fn id(&self) -> &str {
106 &self.id
107 }
108
109 fn fql_pattern(&self) -> &FqlPattern {
110 &self.fql
111 }
112
113 fn hook_point(&self) -> HookPoint {
114 self.point
115 }
116
117 fn priority(&self) -> i32 {
118 self.priority
119 }
120
121 fn execute(&self, ctx: HookContext) -> HookAction {
122 match self.try_execute(&ctx) {
123 Ok(action) => action,
124 Err(e) => {
125 tracing::error!(hook_id = %self.id, "LuaHook execution failed: {e}");
126 HookAction::Continue(Box::new(ctx))
127 }
128 }
129 }
130}
131
132impl std::fmt::Debug for LuaHook {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.debug_struct("LuaHook")
135 .field("id", &self.id)
136 .field("fql", &self.fql)
137 .field("point", &self.point)
138 .field("priority", &self.priority)
139 .finish()
140 }
141}
142
143pub fn parse_hook_descriptor(descriptor: &str) -> Result<(FqlPattern, HookPoint), String> {
160 let mut split_pos = None;
162 for (i, ch) in descriptor.char_indices() {
163 if ch == ':' {
164 let suffix = &descriptor[i + 1..];
165 if HookPoint::KNOWN_PREFIXES
166 .iter()
167 .any(|p| suffix.starts_with(p))
168 {
169 split_pos = Some(i);
170 }
171 }
172 }
173
174 let pos = split_pos.ok_or_else(|| {
175 format!(
176 "invalid hook descriptor '{descriptor}': \
177 expected '<fql>:<hook_point>' \
178 (e.g., 'builtin::llm:request.pre_dispatch')"
179 )
180 })?;
181
182 let fql_str = &descriptor[..pos];
183 let point_str = &descriptor[pos + 1..];
184
185 let fql = FqlPattern::parse(fql_str)
186 .map_err(|e| format!("invalid FQL in descriptor '{descriptor}': {e}"))?;
187 let point: HookPoint = point_str
188 .parse()
189 .map_err(|e| format!("invalid hook point in descriptor '{descriptor}': {e}"))?;
190
191 Ok((fql, point))
192}
193
194pub fn hook_context_to_lua(lua: &Lua, ctx: &HookContext) -> Result<Value, mlua::Error> {
202 lua.to_value(ctx)
203}
204
205pub fn lua_to_hook_context(lua: &Lua, value: Value) -> Result<HookContext, mlua::Error> {
211 lua.from_value(value)
212}
213
214pub fn parse_hook_return(
224 lua: &Lua,
225 result: Value,
226 original_ctx: &HookContext,
227) -> Result<HookAction, mlua::Error> {
228 match result {
229 Value::Nil => Ok(HookAction::Continue(Box::new(original_ctx.clone()))),
231
232 Value::Table(ref table) => {
233 if let Ok(action_str) = table.get::<String>("action") {
235 return parse_action_table(lua, &action_str, table, original_ctx);
236 }
237
238 if table.contains_key("hook_point")? {
240 let ctx: HookContext = lua.from_value(result)?;
241 return Ok(HookAction::Continue(Box::new(ctx)));
242 }
243
244 let mut ctx = original_ctx.clone();
246 ctx.payload = lua.from_value(result)?;
247 Ok(HookAction::Continue(Box::new(ctx)))
248 }
249
250 _ => Err(mlua::Error::RuntimeError(format!(
251 "hook must return nil, a context table, or an action table (got {})",
252 result.type_name()
253 ))),
254 }
255}
256
257fn parse_action_table(
259 lua: &Lua,
260 action: &str,
261 table: &mlua::Table,
262 original_ctx: &HookContext,
263) -> Result<HookAction, mlua::Error> {
264 match action {
265 "continue" => {
266 let ctx_val: Value = table.get("ctx").unwrap_or(Value::Nil);
267 if ctx_val == Value::Nil {
268 Ok(HookAction::Continue(Box::new(original_ctx.clone())))
269 } else {
270 let ctx: HookContext = lua.from_value(ctx_val)?;
271 Ok(HookAction::Continue(Box::new(ctx)))
272 }
273 }
274 "skip" => {
275 let result_val: Value = table.get("result").unwrap_or(Value::Nil);
276 let json_val: serde_json::Value = lua.from_value(result_val)?;
277 Ok(HookAction::Skip(json_val))
278 }
279 "abort" => {
280 let reason: String = table
281 .get("reason")
282 .unwrap_or_else(|_| "aborted by lua hook".to_string());
283 Ok(HookAction::Abort { reason })
284 }
285 "replace" => {
286 let result_val: Value = table.get("result").unwrap_or(Value::Nil);
287 let json_val: serde_json::Value = lua.from_value(result_val)?;
288 Ok(HookAction::Replace(json_val))
289 }
290 other => Err(mlua::Error::RuntimeError(format!(
291 "unknown hook action: '{other}' (expected: continue, skip, abort, replace)"
292 ))),
293 }
294}
295
296pub fn register_hook_function(
312 lua: &Lua,
313 registry: SharedHookRegistry,
314 component_id: ComponentId,
315) -> Result<(), LuaError> {
316 let orcs_table = ensure_orcs_table(lua)?;
317
318 let comp_id = component_id.clone();
319 let comp_fqn = component_id.fqn();
320
321 let hook_fn = lua.create_function(move |lua, args: mlua::MultiValue| {
322 let args_vec: Vec<Value> = args.into_vec();
323
324 let (id, fql, point, priority, func) = match args_vec.len() {
325 2 => {
327 let descriptor = match &args_vec[0] {
328 Value::String(s) => s.to_str()?.to_string(),
329 _ => {
330 return Err(mlua::Error::RuntimeError(
331 "orcs.hook(): first arg must be a descriptor string".to_string(),
332 ))
333 }
334 };
335 let handler = match &args_vec[1] {
336 Value::Function(f) => f.clone(),
337 _ => {
338 return Err(mlua::Error::RuntimeError(
339 "orcs.hook(): second arg must be a function".to_string(),
340 ))
341 }
342 };
343
344 let (fql, point) =
345 parse_hook_descriptor(&descriptor).map_err(mlua::Error::RuntimeError)?;
346
347 let id = format!("lua:{comp_fqn}:{point}");
348 (id, fql, point, 100i32, handler)
349 }
350
351 1 => {
353 let table = match &args_vec[0] {
354 Value::Table(t) => t,
355 _ => {
356 return Err(mlua::Error::RuntimeError(
357 "orcs.hook(): arg must be a descriptor string or table".to_string(),
358 ))
359 }
360 };
361
362 let fql_str: String = table.get("fql").map_err(|_| {
363 mlua::Error::RuntimeError("orcs.hook() table: 'fql' field required".to_string())
364 })?;
365 let point_str: String = table.get("point").map_err(|_| {
366 mlua::Error::RuntimeError(
367 "orcs.hook() table: 'point' field required".to_string(),
368 )
369 })?;
370 let handler: Function = table.get("handler").map_err(|_| {
371 mlua::Error::RuntimeError(
372 "orcs.hook() table: 'handler' field required".to_string(),
373 )
374 })?;
375
376 let fql = FqlPattern::parse(&fql_str).map_err(|e| {
377 mlua::Error::RuntimeError(format!("orcs.hook(): invalid FQL: {e}"))
378 })?;
379 let point: HookPoint = point_str.parse().map_err(|e| {
380 mlua::Error::RuntimeError(format!("orcs.hook(): invalid hook point: {e}"))
381 })?;
382
383 let priority: i32 = table.get("priority").unwrap_or(100);
384 let id: String = table
385 .get("id")
386 .unwrap_or_else(|_| format!("lua:{comp_fqn}:{point}"));
387
388 (id, fql, point, priority, handler)
389 }
390
391 n => {
392 return Err(mlua::Error::RuntimeError(format!(
393 "orcs.hook() expects 1 or 2 arguments, got {n}"
394 )))
395 }
396 };
397
398 let lua_hook = LuaHook::new(id.clone(), fql, point, priority, lua, &func)
399 .map_err(|e| mlua::Error::RuntimeError(format!("failed to create LuaHook: {e}")))?;
400
401 let mut guard = registry
402 .write()
403 .map_err(|e| mlua::Error::RuntimeError(format!("hook registry lock poisoned: {e}")))?;
404 guard.register_owned(Box::new(lua_hook), comp_id.clone());
405
406 tracing::debug!(hook_id = %id, "Lua hook registered");
407 Ok(())
408 })?;
409
410 orcs_table.set("hook", hook_fn)?;
411 Ok(())
412}
413
414pub fn register_hook_stub(lua: &Lua) -> Result<(), LuaError> {
419 let orcs_table = ensure_orcs_table(lua)?;
420
421 if orcs_table.get::<Function>("hook").is_ok() {
422 return Ok(()); }
424
425 let stub = lua.create_function(|_, _args: mlua::MultiValue| {
426 Err::<(), _>(mlua::Error::RuntimeError(
427 "orcs.hook(): no hook registry available (ChildContext required)".to_string(),
428 ))
429 })?;
430
431 orcs_table.set("hook", stub)?;
432 Ok(())
433}
434
435pub fn register_unhook_function(lua: &Lua, registry: SharedHookRegistry) -> Result<(), LuaError> {
446 let orcs_table = ensure_orcs_table(lua)?;
447
448 let unhook_fn = lua.create_function(move |_, id: String| {
449 let mut guard = registry
450 .write()
451 .map_err(|e| mlua::Error::RuntimeError(format!("hook registry lock poisoned: {e}")))?;
452 let removed = guard.unregister(&id);
453 if removed {
454 tracing::debug!(hook_id = %id, "Lua hook unregistered");
455 }
456 Ok(removed)
457 })?;
458
459 orcs_table.set("unhook", unhook_fn)?;
460 Ok(())
461}
462
463#[derive(Debug)]
467pub struct HookLoadError {
468 pub hook_label: String,
470 pub error: String,
472}
473
474impl std::fmt::Display for HookLoadError {
475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476 write!(f, "hook '{}': {}", self.hook_label, self.error)
477 }
478}
479
480#[derive(Debug)]
482pub struct HookLoadResult {
483 pub loaded: usize,
485 pub skipped: usize,
487 pub errors: Vec<HookLoadError>,
489}
490
491pub fn load_hooks_from_config(
510 config: &orcs_hook::HooksConfig,
511 registry: &SharedHookRegistry,
512 base_path: &std::path::Path,
513) -> HookLoadResult {
514 let mut loaded = 0;
515 let mut skipped = 0;
516 let mut errors = Vec::new();
517
518 for def in &config.hooks {
519 let label = def.id.as_deref().unwrap_or("<anonymous>").to_string();
520
521 if !def.enabled {
522 skipped += 1;
523 tracing::debug!(hook = %label, "Skipping disabled hook");
524 continue;
525 }
526
527 if let Err(e) = def.validate() {
528 errors.push(HookLoadError {
529 hook_label: label,
530 error: e.to_string(),
531 });
532 continue;
533 }
534
535 let fql = match FqlPattern::parse(&def.fql) {
536 Ok(f) => f,
537 Err(e) => {
538 errors.push(HookLoadError {
539 hook_label: label,
540 error: format!("invalid FQL: {e}"),
541 });
542 continue;
543 }
544 };
545
546 let point: HookPoint = match def.point.parse() {
547 Ok(p) => p,
548 Err(e) => {
549 errors.push(HookLoadError {
550 hook_label: label,
551 error: format!("invalid hook point: {e}"),
552 });
553 continue;
554 }
555 };
556
557 let lua = Lua::new();
558
559 let func_result = if let Some(inline) = &def.handler_inline {
560 load_inline_handler(&lua, inline)
561 } else if let Some(script_path) = &def.script {
562 load_script_handler(&lua, base_path, script_path)
563 } else {
564 Err(mlua::Error::RuntimeError(
565 "no handler specified".to_string(),
566 ))
567 };
568
569 let func = match func_result {
570 Ok(f) => f,
571 Err(e) => {
572 errors.push(HookLoadError {
573 hook_label: label,
574 error: format!("failed to load handler: {e}"),
575 });
576 continue;
577 }
578 };
579
580 let hook_id = def
581 .id
582 .clone()
583 .unwrap_or_else(|| format!("config:{point}:{loaded}"));
584
585 let lua_hook = match LuaHook::new(hook_id.clone(), fql, point, def.priority, &lua, &func) {
586 Ok(h) => h,
587 Err(e) => {
588 errors.push(HookLoadError {
589 hook_label: label,
590 error: format!("failed to create LuaHook: {e}"),
591 });
592 continue;
593 }
594 };
595
596 match registry.write() {
597 Ok(mut guard) => {
598 guard.register(Box::new(lua_hook));
599 loaded += 1;
600 tracing::info!(hook = %label, point = %point, "Loaded hook from config");
601 }
602 Err(e) => {
603 errors.push(HookLoadError {
604 hook_label: label,
605 error: format!("registry lock poisoned: {e}"),
606 });
607 }
608 }
609 }
610
611 HookLoadResult {
612 loaded,
613 skipped,
614 errors,
615 }
616}
617
618fn load_inline_handler(lua: &Lua, inline: &str) -> Result<Function, mlua::Error> {
622 let with_return = format!("return {inline}");
624 lua.load(&with_return).eval::<Function>().or_else(|_| {
625 lua.load(inline).eval::<Function>()
627 })
628}
629
630fn load_script_handler(
634 lua: &Lua,
635 base_path: &std::path::Path,
636 script_path: &str,
637) -> Result<Function, mlua::Error> {
638 let full_path = base_path.join(script_path);
639 let script = std::fs::read_to_string(&full_path).map_err(|e| {
640 mlua::Error::RuntimeError(format!(
641 "failed to read script '{}': {e}",
642 full_path.display()
643 ))
644 })?;
645
646 let with_return = format!("return {script}");
647 lua.load(&script)
648 .eval::<Function>()
649 .or_else(|_| lua.load(&with_return).eval::<Function>())
650}
651
652#[cfg(test)]
655mod tests {
656 use super::*;
657 use orcs_hook::HookRegistry;
658 use orcs_types::{ChannelId, Principal};
659 use serde_json::json;
660 use std::sync::{Arc, RwLock};
661
662 fn test_lua() -> Lua {
663 Lua::new()
664 }
665
666 fn test_ctx() -> HookContext {
667 HookContext::new(
668 HookPoint::RequestPreDispatch,
669 ComponentId::builtin("llm"),
670 ChannelId::new(),
671 Principal::System,
672 12345,
673 json!({"operation": "chat"}),
674 )
675 }
676
677 fn test_registry() -> SharedHookRegistry {
678 Arc::new(RwLock::new(HookRegistry::new()))
679 }
680
681 #[test]
684 fn parse_descriptor_basic() {
685 let (fql, point) = parse_hook_descriptor("builtin::llm:request.pre_dispatch")
686 .expect("should parse valid descriptor with builtin::llm");
687 assert_eq!(fql.to_string(), "builtin::llm");
688 assert_eq!(point, HookPoint::RequestPreDispatch);
689 }
690
691 #[test]
692 fn parse_descriptor_wildcard_fql() {
693 let (fql, point) = parse_hook_descriptor("*::*:component.pre_init")
694 .expect("should parse wildcard FQL descriptor");
695 assert_eq!(fql.to_string(), "*::*");
696 assert_eq!(point, HookPoint::ComponentPreInit);
697 }
698
699 #[test]
700 fn parse_descriptor_with_child_path() {
701 let (fql, point) = parse_hook_descriptor("builtin::llm/agent-1:signal.pre_dispatch")
702 .expect("should parse descriptor with child path");
703 assert_eq!(fql.to_string(), "builtin::llm/agent-1");
704 assert_eq!(point, HookPoint::SignalPreDispatch);
705 }
706
707 #[test]
708 fn parse_descriptor_all_points() {
709 let descriptors = [
710 ("*::*:component.pre_init", HookPoint::ComponentPreInit),
711 ("*::*:component.post_init", HookPoint::ComponentPostInit),
712 ("*::*:request.pre_dispatch", HookPoint::RequestPreDispatch),
713 ("*::*:request.post_dispatch", HookPoint::RequestPostDispatch),
714 ("*::*:signal.pre_dispatch", HookPoint::SignalPreDispatch),
715 ("*::*:signal.post_dispatch", HookPoint::SignalPostDispatch),
716 ("*::*:child.pre_spawn", HookPoint::ChildPreSpawn),
717 ("*::*:tool.pre_execute", HookPoint::ToolPreExecute),
718 ("*::*:auth.pre_check", HookPoint::AuthPreCheck),
719 ("*::*:bus.pre_broadcast", HookPoint::BusPreBroadcast),
720 ];
721 for (desc, expected) in &descriptors {
722 let (_, point) = parse_hook_descriptor(desc)
723 .unwrap_or_else(|e| panic!("failed to parse '{desc}': {e}"));
724 assert_eq!(point, *expected, "mismatch for '{desc}'");
725 }
726 }
727
728 #[test]
729 fn parse_descriptor_invalid_no_hookpoint() {
730 let result = parse_hook_descriptor("builtin::llm");
731 assert!(result.is_err());
732 }
733
734 #[test]
735 fn parse_descriptor_invalid_empty() {
736 let result = parse_hook_descriptor("");
737 assert!(result.is_err());
738 }
739
740 #[test]
741 fn parse_descriptor_invalid_bad_fql() {
742 let result = parse_hook_descriptor("nocolon:request.pre_dispatch");
743 assert!(result.is_err());
744 }
745
746 #[test]
747 fn parse_descriptor_invalid_bad_point() {
748 let result = parse_hook_descriptor("builtin::llm:nonexistent.point");
749 assert!(result.is_err());
750 }
751
752 #[test]
755 fn context_lua_roundtrip() {
756 let lua = test_lua();
757 let ctx = test_ctx();
758
759 let lua_val = hook_context_to_lua(&lua, &ctx).expect("to_lua");
760 let restored = lua_to_hook_context(&lua, lua_val).expect("from_lua");
761
762 assert_eq!(restored.hook_point, ctx.hook_point);
763 assert_eq!(restored.payload, ctx.payload);
764 assert_eq!(restored.depth, ctx.depth);
765 assert_eq!(restored.max_depth, ctx.max_depth);
766 }
767
768 #[test]
769 fn context_with_metadata_roundtrip() {
770 let lua = test_lua();
771 let ctx = test_ctx().with_metadata("audit", json!("abc-123"));
772
773 let lua_val = hook_context_to_lua(&lua, &ctx).expect("to_lua");
774 let restored = lua_to_hook_context(&lua, lua_val).expect("from_lua");
775
776 assert_eq!(restored.metadata.get("audit"), Some(&json!("abc-123")));
777 }
778
779 #[test]
782 fn return_nil_is_continue() {
783 let lua = test_lua();
784 let ctx = test_ctx();
785
786 let action =
787 parse_hook_return(&lua, Value::Nil, &ctx).expect("should parse nil return as continue");
788 assert!(action.is_continue());
789 }
790
791 #[test]
792 fn return_context_table_is_continue() {
793 let lua = test_lua();
794 let ctx = test_ctx();
795
796 let ctx_value =
798 hook_context_to_lua(&lua, &ctx).expect("should convert context to lua value");
799 let action = parse_hook_return(&lua, ctx_value, &ctx)
800 .expect("should parse context table return as continue");
801 assert!(action.is_continue());
802 }
803
804 #[test]
805 fn return_action_skip() {
806 let lua = test_lua();
807 let ctx = test_ctx();
808
809 let table: Value = lua
810 .load(r#"return { action = "skip", result = { cached = true } }"#)
811 .eval()
812 .expect("should eval skip action table");
813
814 let action = parse_hook_return(&lua, table, &ctx).expect("should parse skip action");
815 assert!(action.is_skip());
816 if let HookAction::Skip(val) = action {
817 assert_eq!(val, json!({"cached": true}));
818 }
819 }
820
821 #[test]
822 fn return_action_abort() {
823 let lua = test_lua();
824 let ctx = test_ctx();
825
826 let table: Value = lua
827 .load(r#"return { action = "abort", reason = "policy violation" }"#)
828 .eval()
829 .expect("should eval abort action table");
830
831 let action = parse_hook_return(&lua, table, &ctx).expect("should parse abort action");
832 assert!(action.is_abort());
833 if let HookAction::Abort { reason } = action {
834 assert_eq!(reason, "policy violation");
835 }
836 }
837
838 #[test]
839 fn return_action_replace() {
840 let lua = test_lua();
841 let ctx = test_ctx();
842
843 let table: Value = lua
844 .load(r#"return { action = "replace", result = { new_data = 42 } }"#)
845 .eval()
846 .expect("should eval replace action table");
847
848 let action = parse_hook_return(&lua, table, &ctx).expect("should parse replace action");
849 assert!(action.is_replace());
850 if let HookAction::Replace(val) = action {
851 assert_eq!(val, json!({"new_data": 42}));
852 }
853 }
854
855 #[test]
856 fn return_action_continue_explicit() {
857 let lua = test_lua();
858 let ctx = test_ctx();
859
860 let table: Value = lua
861 .load(r#"return { action = "continue" }"#)
862 .eval()
863 .expect("should eval continue action table");
864
865 let action =
866 parse_hook_return(&lua, table, &ctx).expect("should parse explicit continue action");
867 assert!(action.is_continue());
868 }
869
870 #[test]
871 fn return_action_abort_default_reason() {
872 let lua = test_lua();
873 let ctx = test_ctx();
874
875 let table: Value = lua
876 .load(r#"return { action = "abort" }"#)
877 .eval()
878 .expect("should eval abort without reason");
879
880 let action =
881 parse_hook_return(&lua, table, &ctx).expect("should parse abort with default reason");
882 if let HookAction::Abort { reason } = action {
883 assert_eq!(reason, "aborted by lua hook");
884 } else {
885 panic!("expected Abort");
886 }
887 }
888
889 #[test]
890 fn return_unknown_action_errors() {
891 let lua = test_lua();
892 let ctx = test_ctx();
893
894 let table: Value = lua
895 .load(r#"return { action = "invalid" }"#)
896 .eval()
897 .expect("should eval invalid action table");
898
899 let result = parse_hook_return(&lua, table, &ctx);
900 assert!(result.is_err());
901 }
902
903 #[test]
904 fn return_number_errors() {
905 let lua = test_lua();
906 let ctx = test_ctx();
907
908 let result = parse_hook_return(&lua, Value::Integer(42), &ctx);
909 assert!(result.is_err());
910 }
911
912 #[test]
913 fn return_unknown_table_updates_payload() {
914 let lua = test_lua();
915 let ctx = test_ctx();
916
917 let table: Value = lua
918 .load(r#"return { custom_field = "hello" }"#)
919 .eval()
920 .expect("should eval custom field table");
921
922 let action = parse_hook_return(&lua, table, &ctx)
923 .expect("should parse unknown table as payload update");
924 assert!(action.is_continue());
925 if let HookAction::Continue(new_ctx) = action {
926 assert_eq!(new_ctx.payload, json!({"custom_field": "hello"}));
927 }
928 }
929
930 #[test]
933 fn lua_hook_pass_through() {
934 let lua = test_lua();
935 let func: Function = lua
936 .load("function(ctx) return ctx end")
937 .eval()
938 .expect("should eval pass-through function");
939
940 let hook = LuaHook::new(
941 "test-pass".to_string(),
942 FqlPattern::parse("*::*").expect("should parse wildcard FQL"),
943 HookPoint::RequestPreDispatch,
944 100,
945 &lua,
946 &func,
947 )
948 .expect("should create pass-through hook");
949
950 assert_eq!(hook.id(), "test-pass");
951 assert_eq!(hook.hook_point(), HookPoint::RequestPreDispatch);
952 assert_eq!(hook.priority(), 100);
953
954 let ctx = test_ctx();
955 let action = hook.execute(ctx);
956 assert!(action.is_continue());
957 }
958
959 #[test]
960 fn lua_hook_modifies_payload() {
961 let lua = test_lua();
962 let func: Function = lua
963 .load(
964 r#"
965 function(ctx)
966 ctx.payload.injected = true
967 return ctx
968 end
969 "#,
970 )
971 .eval()
972 .expect("should eval payload-modifying function");
973
974 let hook = LuaHook::new(
975 "test-mod".to_string(),
976 FqlPattern::parse("*::*").expect("should parse wildcard FQL"),
977 HookPoint::RequestPreDispatch,
978 100,
979 &lua,
980 &func,
981 )
982 .expect("should create payload-modifying hook");
983
984 let ctx = test_ctx();
985 let action = hook.execute(ctx);
986
987 if let HookAction::Continue(new_ctx) = action {
988 assert_eq!(new_ctx.payload["injected"], json!(true));
989 assert_eq!(new_ctx.payload["operation"], json!("chat"));
991 } else {
992 panic!("expected Continue");
993 }
994 }
995
996 #[test]
997 fn lua_hook_returns_skip() {
998 let lua = test_lua();
999 let func: Function = lua
1000 .load(r#"function(ctx) return { action = "skip", result = { cached = true } } end"#)
1001 .eval()
1002 .expect("should eval skip-returning function");
1003
1004 let hook = LuaHook::new(
1005 "test-skip".to_string(),
1006 FqlPattern::parse("*::*").expect("should parse wildcard FQL"),
1007 HookPoint::RequestPreDispatch,
1008 100,
1009 &lua,
1010 &func,
1011 )
1012 .expect("should create skip hook");
1013
1014 let action = hook.execute(test_ctx());
1015 assert!(action.is_skip());
1016 }
1017
1018 #[test]
1019 fn lua_hook_returns_abort() {
1020 let lua = test_lua();
1021 let func: Function = lua
1022 .load(r#"function(ctx) return { action = "abort", reason = "blocked" } end"#)
1023 .eval()
1024 .expect("should eval abort-returning function");
1025
1026 let hook = LuaHook::new(
1027 "test-abort".to_string(),
1028 FqlPattern::parse("*::*").expect("should parse wildcard FQL"),
1029 HookPoint::RequestPreDispatch,
1030 100,
1031 &lua,
1032 &func,
1033 )
1034 .expect("should create abort hook");
1035
1036 let action = hook.execute(test_ctx());
1037 assert!(action.is_abort());
1038 if let HookAction::Abort { reason } = action {
1039 assert_eq!(reason, "blocked");
1040 }
1041 }
1042
1043 #[test]
1044 fn lua_hook_error_falls_back_to_continue() {
1045 let lua = test_lua();
1046 let func: Function = lua
1047 .load(r#"function(ctx) error("intentional error") end"#)
1048 .eval()
1049 .expect("should eval error-throwing function");
1050
1051 let hook = LuaHook::new(
1052 "test-err".to_string(),
1053 FqlPattern::parse("*::*").expect("should parse wildcard FQL"),
1054 HookPoint::RequestPreDispatch,
1055 100,
1056 &lua,
1057 &func,
1058 )
1059 .expect("should create error-fallback hook");
1060
1061 let ctx = test_ctx();
1062 let action = hook.execute(ctx.clone());
1063
1064 assert!(action.is_continue());
1066 if let HookAction::Continue(result_ctx) = action {
1067 assert_eq!(result_ctx.payload, ctx.payload);
1068 }
1069 }
1070
1071 #[test]
1072 fn lua_hook_nil_return_is_continue() {
1073 let lua = test_lua();
1074 let func: Function = lua
1075 .load("function(ctx) end") .eval()
1077 .expect("should eval nil-returning function");
1078
1079 let hook = LuaHook::new(
1080 "test-nil".to_string(),
1081 FqlPattern::parse("*::*").expect("should parse wildcard FQL"),
1082 HookPoint::RequestPreDispatch,
1083 100,
1084 &lua,
1085 &func,
1086 )
1087 .expect("should create nil-return hook");
1088
1089 let action = hook.execute(test_ctx());
1090 assert!(action.is_continue());
1091 }
1092
1093 #[test]
1096 fn register_hook_from_lua_shorthand() {
1097 let lua = test_lua();
1098 let registry = test_registry();
1099 let comp_id = ComponentId::builtin("test-comp");
1100
1101 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1102 .expect("should register hook function");
1103
1104 lua.load(
1105 r#"
1106 orcs.hook("*::*:request.pre_dispatch", function(ctx)
1107 return ctx
1108 end)
1109 "#,
1110 )
1111 .exec()
1112 .expect("orcs.hook() should succeed");
1113
1114 let guard = registry
1115 .read()
1116 .expect("should acquire read lock on registry");
1117 assert_eq!(guard.len(), 1);
1118 }
1119
1120 #[test]
1121 fn register_hook_from_lua_table_form() {
1122 let lua = test_lua();
1123 let registry = test_registry();
1124 let comp_id = ComponentId::builtin("test-comp");
1125
1126 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1127 .expect("should register hook function for table form");
1128
1129 lua.load(
1130 r#"
1131 orcs.hook({
1132 fql = "builtin::llm",
1133 point = "request.pre_dispatch",
1134 handler = function(ctx) return ctx end,
1135 priority = 50,
1136 id = "custom-hook-id",
1137 })
1138 "#,
1139 )
1140 .exec()
1141 .expect("orcs.hook() table form should succeed");
1142
1143 let guard = registry
1144 .read()
1145 .expect("should acquire read lock on registry");
1146 assert_eq!(guard.len(), 1);
1147 }
1148
1149 #[test]
1150 fn register_multiple_hooks() {
1151 let lua = test_lua();
1152 let registry = test_registry();
1153 let comp_id = ComponentId::builtin("test-comp");
1154
1155 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1156 .expect("should register hook function for multiple hooks");
1157
1158 lua.load(
1159 r#"
1160 orcs.hook("*::*:request.pre_dispatch", function(ctx) return ctx end)
1161 orcs.hook("*::*:signal.pre_dispatch", function(ctx) return ctx end)
1162 orcs.hook("builtin::llm:component.pre_init", function(ctx) return ctx end)
1163 "#,
1164 )
1165 .exec()
1166 .expect("multiple hooks should succeed");
1167
1168 let guard = registry
1169 .read()
1170 .expect("should acquire read lock on registry");
1171 assert_eq!(guard.len(), 3);
1172 }
1173
1174 #[test]
1175 fn register_hook_invalid_descriptor_errors() {
1176 let lua = test_lua();
1177 let registry = test_registry();
1178 let comp_id = ComponentId::builtin("test-comp");
1179
1180 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1181 .expect("should register hook function for invalid descriptor test");
1182
1183 let result = lua
1184 .load(r#"orcs.hook("invalid_no_point", function(ctx) return ctx end)"#)
1185 .exec();
1186
1187 assert!(result.is_err());
1188 }
1189
1190 #[test]
1191 fn register_hook_wrong_args_errors() {
1192 let lua = test_lua();
1193 let registry = test_registry();
1194 let comp_id = ComponentId::builtin("test-comp");
1195
1196 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1197 .expect("should register hook function for wrong args test");
1198
1199 let result = lua.load(r#"orcs.hook("a", "b", "c")"#).exec();
1201 assert!(result.is_err());
1202
1203 let result = lua.load(r#"orcs.hook()"#).exec();
1205 assert!(result.is_err());
1206 }
1207
1208 #[test]
1209 fn hook_stub_returns_error() {
1210 let lua = test_lua();
1211 let orcs_table = lua.create_table().expect("should create orcs table");
1212 lua.globals()
1213 .set("orcs", orcs_table)
1214 .expect("should set orcs global");
1215
1216 register_hook_stub(&lua).expect("should register hook stub");
1217
1218 let result = lua
1219 .load(r#"orcs.hook("*::*:request.pre_dispatch", function(ctx) end)"#)
1220 .exec();
1221 assert!(result.is_err());
1222 let err = result
1223 .expect_err("should fail with no hook registry")
1224 .to_string();
1225 assert!(
1226 err.contains("no hook registry"),
1227 "expected 'no hook registry' in error, got: {err}"
1228 );
1229 }
1230
1231 #[test]
1234 fn lua_hook_dispatched_through_registry() {
1235 let lua = test_lua();
1236 let registry = test_registry();
1237 let comp_id = ComponentId::builtin("test-comp");
1238
1239 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1240 .expect("should register hook function for dispatch test");
1241
1242 lua.load(
1244 r#"
1245 orcs.hook("*::*:request.pre_dispatch", function(ctx)
1246 ctx.payload.hook_ran = true
1247 return ctx
1248 end)
1249 "#,
1250 )
1251 .exec()
1252 .expect("should register dispatch hook via lua");
1253
1254 let ctx = test_ctx();
1256 let guard = registry
1257 .read()
1258 .expect("should acquire read lock for dispatch");
1259 let action = guard.dispatch(
1260 HookPoint::RequestPreDispatch,
1261 &ComponentId::builtin("llm"),
1262 None,
1263 ctx,
1264 );
1265
1266 assert!(action.is_continue());
1267 if let HookAction::Continue(result_ctx) = action {
1268 assert_eq!(result_ctx.payload["hook_ran"], json!(true));
1269 assert_eq!(result_ctx.payload["operation"], json!("chat"));
1270 }
1271 }
1272
1273 #[test]
1274 fn lua_hook_abort_stops_dispatch() {
1275 let lua = test_lua();
1276 let registry = test_registry();
1277 let comp_id = ComponentId::builtin("test-comp");
1278
1279 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1280 .expect("should register hook function for abort dispatch test");
1281
1282 lua.load(
1283 r#"
1284 orcs.hook("*::*:request.pre_dispatch", function(ctx)
1285 return { action = "abort", reason = "denied by lua" }
1286 end)
1287 "#,
1288 )
1289 .exec()
1290 .expect("should register abort hook via lua");
1291
1292 let ctx = test_ctx();
1293 let guard = registry
1294 .read()
1295 .expect("should acquire read lock for abort dispatch");
1296 let action = guard.dispatch(
1297 HookPoint::RequestPreDispatch,
1298 &ComponentId::builtin("llm"),
1299 None,
1300 ctx,
1301 );
1302
1303 assert!(action.is_abort());
1304 if let HookAction::Abort { reason } = action {
1305 assert_eq!(reason, "denied by lua");
1306 }
1307 }
1308
1309 #[test]
1312 fn unhook_removes_registered_hook() {
1313 let lua = test_lua();
1314 let registry = test_registry();
1315 let comp_id = ComponentId::builtin("test-comp");
1316
1317 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1318 .expect("should register hook function for unhook test");
1319 register_unhook_function(&lua, Arc::clone(®istry))
1320 .expect("should register unhook function");
1321
1322 lua.load(
1324 r#"
1325 orcs.hook({
1326 fql = "*::*",
1327 point = "request.pre_dispatch",
1328 handler = function(ctx) return ctx end,
1329 id = "removable-hook",
1330 })
1331 "#,
1332 )
1333 .exec()
1334 .expect("should register removable hook via lua");
1335
1336 assert_eq!(
1337 registry
1338 .read()
1339 .expect("should acquire read lock after hook registration")
1340 .len(),
1341 1
1342 );
1343
1344 let removed: bool = lua
1346 .load(r#"return orcs.unhook("removable-hook")"#)
1347 .eval()
1348 .expect("should eval unhook call");
1349 assert!(removed);
1350 assert_eq!(
1351 registry
1352 .read()
1353 .expect("should acquire read lock after unhook")
1354 .len(),
1355 0
1356 );
1357 }
1358
1359 #[test]
1360 fn unhook_returns_false_for_unknown_id() {
1361 let lua = test_lua();
1362 let registry = test_registry();
1363
1364 register_unhook_function(&lua, Arc::clone(®istry))
1365 .expect("should register unhook function");
1366
1367 let removed: bool = lua
1368 .load(r#"return orcs.unhook("nonexistent")"#)
1369 .eval()
1370 .expect("should eval unhook for nonexistent id");
1371 assert!(!removed);
1372 }
1373
1374 #[test]
1375 fn unhook_after_hook_roundtrip() {
1376 let lua = test_lua();
1377 let registry = test_registry();
1378 let comp_id = ComponentId::builtin("test-comp");
1379
1380 register_hook_function(&lua, Arc::clone(®istry), comp_id)
1381 .expect("should register hook function for roundtrip test");
1382 register_unhook_function(&lua, Arc::clone(®istry))
1383 .expect("should register unhook function for roundtrip test");
1384
1385 lua.load(
1387 r#"
1388 orcs.hook("*::*:request.pre_dispatch", function(ctx) return ctx end)
1389 orcs.hook({
1390 fql = "*::*",
1391 point = "signal.pre_dispatch",
1392 handler = function(ctx) return ctx end,
1393 id = "keep-this",
1394 })
1395 "#,
1396 )
1397 .exec()
1398 .expect("should register two hooks via lua");
1399
1400 assert_eq!(
1401 registry
1402 .read()
1403 .expect("should acquire read lock after registering two hooks")
1404 .len(),
1405 2
1406 );
1407
1408 let removed: bool = lua
1410 .load(r#"return orcs.unhook("lua:builtin::test-comp:request.pre_dispatch")"#)
1411 .eval()
1412 .expect("should eval unhook for auto-named hook");
1413 assert!(removed);
1414 assert_eq!(
1415 registry
1416 .read()
1417 .expect("should acquire read lock after removing one hook")
1418 .len(),
1419 1
1420 );
1421 }
1422
1423 fn make_hook_def(id: &str, fql: &str, point: &str, inline: &str) -> orcs_hook::HookDef {
1426 orcs_hook::HookDef {
1427 id: Some(id.to_string()),
1428 fql: fql.to_string(),
1429 point: point.to_string(),
1430 script: None,
1431 handler_inline: Some(inline.to_string()),
1432 priority: 100,
1433 enabled: true,
1434 }
1435 }
1436
1437 #[test]
1438 fn load_config_inline_handler() {
1439 let config = orcs_hook::HooksConfig {
1440 hooks: vec![make_hook_def(
1441 "test-inline",
1442 "*::*",
1443 "request.pre_dispatch",
1444 "function(ctx) return ctx end",
1445 )],
1446 };
1447 let registry = test_registry();
1448 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1449
1450 assert_eq!(result.loaded, 1);
1451 assert_eq!(result.skipped, 0);
1452 assert!(
1453 result.errors.is_empty(),
1454 "errors: {:?}",
1455 result.errors.iter().map(|e| &e.error).collect::<Vec<_>>()
1456 );
1457 }
1458
1459 #[test]
1460 fn load_config_disabled_skipped() {
1461 let config = orcs_hook::HooksConfig {
1462 hooks: vec![{
1463 let mut def = make_hook_def(
1464 "disabled",
1465 "*::*",
1466 "request.pre_dispatch",
1467 "function(ctx) return ctx end",
1468 );
1469 def.enabled = false;
1470 def
1471 }],
1472 };
1473 let registry = test_registry();
1474 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1475
1476 assert_eq!(result.loaded, 0);
1477 assert_eq!(result.skipped, 1);
1478 assert!(result.errors.is_empty());
1479 }
1480
1481 #[test]
1482 fn load_config_invalid_handler_errors() {
1483 let config = orcs_hook::HooksConfig {
1484 hooks: vec![make_hook_def(
1485 "bad-syntax",
1486 "*::*",
1487 "request.pre_dispatch",
1488 "not a valid lua function %%%",
1489 )],
1490 };
1491 let registry = test_registry();
1492 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1493
1494 assert_eq!(result.loaded, 0);
1495 assert_eq!(result.errors.len(), 1);
1496 assert!(result.errors[0].error.contains("failed to load handler"));
1497 }
1498
1499 #[test]
1500 fn load_config_invalid_fql_errors() {
1501 let config = orcs_hook::HooksConfig {
1502 hooks: vec![{
1503 let mut def = make_hook_def(
1504 "bad-fql",
1505 "invalid",
1506 "request.pre_dispatch",
1507 "function(ctx) return ctx end",
1508 );
1509 def.fql = "invalid".to_string();
1511 def
1512 }],
1513 };
1514 let registry = test_registry();
1515 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1516
1517 assert_eq!(result.loaded, 0);
1518 assert_eq!(result.errors.len(), 1);
1519 }
1520
1521 #[test]
1522 fn load_config_no_handler_errors() {
1523 let config = orcs_hook::HooksConfig {
1524 hooks: vec![orcs_hook::HookDef {
1525 id: Some("no-handler".to_string()),
1526 fql: "*::*".to_string(),
1527 point: "request.pre_dispatch".to_string(),
1528 script: None,
1529 handler_inline: None,
1530 priority: 100,
1531 enabled: true,
1532 }],
1533 };
1534 let registry = test_registry();
1535 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1536
1537 assert_eq!(result.loaded, 0);
1538 assert_eq!(result.errors.len(), 1);
1539 }
1540
1541 #[test]
1542 fn load_config_multiple_hooks() {
1543 let config = orcs_hook::HooksConfig {
1544 hooks: vec![
1545 make_hook_def(
1546 "h1",
1547 "*::*",
1548 "request.pre_dispatch",
1549 "function(ctx) return ctx end",
1550 ),
1551 make_hook_def(
1552 "h2",
1553 "builtin::llm",
1554 "signal.pre_dispatch",
1555 "function(ctx) return ctx end",
1556 ),
1557 {
1558 let mut def = make_hook_def(
1559 "h3-disabled",
1560 "*::*",
1561 "tool.pre_execute",
1562 "function(ctx) return ctx end",
1563 );
1564 def.enabled = false;
1565 def
1566 },
1567 ],
1568 };
1569 let registry = test_registry();
1570 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1571
1572 assert_eq!(result.loaded, 2);
1573 assert_eq!(result.skipped, 1);
1574 assert!(result.errors.is_empty());
1575
1576 let guard = registry
1577 .read()
1578 .expect("should acquire read lock after loading multiple hooks");
1579 assert_eq!(guard.len(), 2);
1580 }
1581
1582 #[test]
1583 fn load_config_script_file_not_found() {
1584 let config = orcs_hook::HooksConfig {
1585 hooks: vec![orcs_hook::HookDef {
1586 id: Some("file-hook".to_string()),
1587 fql: "*::*".to_string(),
1588 point: "request.pre_dispatch".to_string(),
1589 script: Some("nonexistent/hook.lua".to_string()),
1590 handler_inline: None,
1591 priority: 100,
1592 enabled: true,
1593 }],
1594 };
1595 let registry = test_registry();
1596 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1597
1598 assert_eq!(result.loaded, 0);
1599 assert_eq!(result.errors.len(), 1);
1600 assert!(result.errors[0].error.contains("failed to load handler"));
1601 }
1602
1603 #[test]
1604 fn load_config_script_file_success() {
1605 let dir = tempfile::tempdir().expect("should create temp directory");
1607 let script_path = dir.path().join("test_hook.lua");
1608 std::fs::write(
1609 &script_path,
1610 "return function(ctx) ctx.payload.from_file = true return ctx end",
1611 )
1612 .expect("should write test hook lua file");
1613
1614 let config = orcs_hook::HooksConfig {
1615 hooks: vec![orcs_hook::HookDef {
1616 id: Some("file-hook".to_string()),
1617 fql: "*::*".to_string(),
1618 point: "request.pre_dispatch".to_string(),
1619 script: Some("test_hook.lua".to_string()),
1620 handler_inline: None,
1621 priority: 50,
1622 enabled: true,
1623 }],
1624 };
1625 let registry = test_registry();
1626 let result = load_hooks_from_config(&config, ®istry, dir.path());
1627
1628 assert_eq!(result.loaded, 1);
1629 assert!(result.errors.is_empty());
1630
1631 let guard = registry
1633 .read()
1634 .expect("should acquire read lock for file hook dispatch");
1635 let ctx = test_ctx();
1636 let action = guard.dispatch(
1637 HookPoint::RequestPreDispatch,
1638 &ComponentId::builtin("llm"),
1639 None,
1640 ctx,
1641 );
1642 assert!(action.is_continue());
1643 if let HookAction::Continue(result_ctx) = action {
1644 assert_eq!(result_ctx.payload["from_file"], json!(true));
1645 }
1646 }
1647
1648 #[test]
1649 fn load_config_inline_handler_with_return() {
1650 let config = orcs_hook::HooksConfig {
1652 hooks: vec![make_hook_def(
1653 "with-return",
1654 "*::*",
1655 "request.pre_dispatch",
1656 "return function(ctx) return ctx end",
1657 )],
1658 };
1659 let registry = test_registry();
1660 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1661
1662 assert_eq!(result.loaded, 1);
1663 assert!(result.errors.is_empty());
1664 }
1665
1666 #[test]
1667 fn load_config_anonymous_hook_gets_generated_id() {
1668 let config = orcs_hook::HooksConfig {
1669 hooks: vec![orcs_hook::HookDef {
1670 id: None,
1671 fql: "*::*".to_string(),
1672 point: "request.pre_dispatch".to_string(),
1673 script: None,
1674 handler_inline: Some("function(ctx) return ctx end".to_string()),
1675 priority: 100,
1676 enabled: true,
1677 }],
1678 };
1679 let registry = test_registry();
1680 let result = load_hooks_from_config(&config, ®istry, std::path::Path::new("."));
1681
1682 assert_eq!(result.loaded, 1);
1683 assert!(result.errors.is_empty());
1684 }
1685
1686 #[test]
1687 fn hook_load_error_display() {
1688 let err = HookLoadError {
1689 hook_label: "my-hook".to_string(),
1690 error: "syntax error".to_string(),
1691 };
1692 assert_eq!(err.to_string(), "hook 'my-hook': syntax error");
1693 }
1694}