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