Skip to main content

orcs_lua/
hook_helpers.rs

1//! Hook helpers for Lua integration.
2//!
3//! Provides [`LuaHook`] (a Lua-backed [`Hook`] implementation),
4//! shorthand descriptor parsing, and `HookContext ↔ Lua table`
5//! conversion utilities.
6//!
7//! # `orcs.hook()` API
8//!
9//! Two calling conventions:
10//!
11//! ```lua
12//! -- Shorthand: "fql:hook_point"
13//! orcs.hook("builtin::llm:request.pre_dispatch", function(ctx)
14//!     ctx.payload.injected = true
15//!     return ctx
16//! end)
17//!
18//! -- Table form (full control)
19//! orcs.hook({
20//!     fql      = "builtin::llm",
21//!     point    = "request.pre_dispatch",
22//!     handler  = function(ctx) return ctx end,
23//!     priority = 50,   -- optional (default 100)
24//!     id       = "my-hook",  -- optional
25//! })
26//! ```
27//!
28//! # Return value conventions
29//!
30//! | Lua return | HookAction |
31//! |------------|------------|
32//! | `nil` | `Continue(original_ctx)` |
33//! | context table (has `hook_point`) | `Continue(parsed_ctx)` |
34//! | `{ action = "continue", ctx = ... }` | `Continue(...)` |
35//! | `{ action = "skip", result = ... }` | `Skip(value)` |
36//! | `{ action = "abort", reason = "..." }` | `Abort { reason }` |
37//! | `{ action = "replace", result = ... }` | `Replace(value)` |
38
39use 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
46// ── LuaHook ──────────────────────────────────────────────────────
47
48/// A hook backed by a Lua function.
49///
50/// The handler function is stored in Lua's registry via [`mlua::RegistryKey`].
51/// A cloned [`Lua`] handle provides thread-safe access to the same Lua state
52/// (mlua uses internal locking with the `send` feature).
53///
54/// Thread-safety: `Mutex<Lua>` is `Send + Sync` because `Lua: Send`.
55pub 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    /// Creates a new `LuaHook`.
66    ///
67    /// Stores `func` in Lua's registry and clones the `Lua` handle
68    /// for later invocation from any thread.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the function cannot be stored in the registry.
73    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    /// Attempts to execute the hook, returning errors as `mlua::Error`.
93    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
143// ── Descriptor parsing ──────────────────────────────────────────
144
145/// Parses a hook descriptor shorthand into FQL pattern and HookPoint.
146///
147/// Uses known-prefix matching (ADR 16.2 B案) to split the FQL part
148/// from the HookPoint suffix at the *last* `:` whose right-hand side
149/// starts with one of [`HookPoint::KNOWN_PREFIXES`].
150///
151/// # Examples
152///
153/// ```
154/// # use orcs_lua::hook_helpers::parse_hook_descriptor;
155/// let (fql, point) = parse_hook_descriptor("builtin::llm:request.pre_dispatch").unwrap();
156/// assert_eq!(fql.to_string(), "builtin::llm");
157/// assert_eq!(point.to_string(), "request.pre_dispatch");
158/// ```
159pub fn parse_hook_descriptor(descriptor: &str) -> Result<(FqlPattern, HookPoint), String> {
160    // Scan for the last `:` where the suffix is a known HookPoint prefix.
161    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
194// ── HookContext ↔ Lua conversion ────────────────────────────────
195
196/// Converts a [`HookContext`] to a Lua value (table) via serde.
197///
198/// # Errors
199///
200/// Returns an error if serialization fails.
201pub fn hook_context_to_lua(lua: &Lua, ctx: &HookContext) -> Result<Value, mlua::Error> {
202    lua.to_value(ctx)
203}
204
205/// Converts a Lua value (table) back to a [`HookContext`] via serde.
206///
207/// # Errors
208///
209/// Returns an error if deserialization fails.
210pub fn lua_to_hook_context(lua: &Lua, value: Value) -> Result<HookContext, mlua::Error> {
211    lua.from_value(value)
212}
213
214// ── Return-value parsing ────────────────────────────────────────
215
216/// Parses the return value from a Lua hook handler into a [`HookAction`].
217///
218/// See module-level docs for the full return-value conventions.
219///
220/// # Errors
221///
222/// Returns an error for unrecognised action strings or type mismatches.
223pub fn parse_hook_return(
224    lua: &Lua,
225    result: Value,
226    original_ctx: &HookContext,
227) -> Result<HookAction, mlua::Error> {
228    match result {
229        // nil → pass through unchanged
230        Value::Nil => Ok(HookAction::Continue(Box::new(original_ctx.clone()))),
231
232        Value::Table(ref table) => {
233            // Check for explicit action table (has "action" key)
234            if let Ok(action_str) = table.get::<String>("action") {
235                return parse_action_table(lua, &action_str, table, original_ctx);
236            }
237
238            // Check for context table (has "hook_point" key)
239            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            // Unknown table: treat as modified payload
245            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
257/// Parses an explicit `{ action = "...", ... }` table.
258fn 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
296// ── orcs.hook() registration ────────────────────────────────────
297
298/// Registers the `orcs.hook()` function on the `orcs` global table.
299///
300/// Requires:
301/// - `registry` — the shared hook registry for storing hooks
302/// - `component_id` — the owning component (for `register_owned()`)
303///
304/// The registered hooks are automatically cleaned up when
305/// `HookRegistry::unregister_by_owner()` is called with the same
306/// component ID (typically on component shutdown).
307///
308/// # Errors
309///
310/// Returns an error if Lua function creation or table insertion fails.
311pub 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            // ── Shorthand: orcs.hook("fql:point", handler) ──
326            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            // ── Table form: orcs.hook({ fql=..., point=..., handler=... }) ──
352            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
414/// Registers a default (deny) `orcs.hook()` stub.
415///
416/// Used when no hook registry is available yet.
417/// Returns an error table `{ ok = false, error = "..." }`.
418pub 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(()); // Already registered (possibly the real one)
423    }
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
435// ── orcs.unhook() registration ───────────────────────────────────
436
437/// Registers the `orcs.unhook(id)` function on the `orcs` global table.
438///
439/// Removes a previously registered hook by its ID. Returns `true` if
440/// the hook was found and removed, `false` otherwise.
441///
442/// # Errors
443///
444/// Returns an error if Lua function creation or table insertion fails.
445pub 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// ── Config-based hook loading ────────────────────────────────────
464
465/// Error from loading a single hook definition.
466#[derive(Debug)]
467pub struct HookLoadError {
468    /// Label of the hook that failed (id or `<anonymous>`).
469    pub hook_label: String,
470    /// Description of the error.
471    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/// Result of loading hooks from configuration.
481#[derive(Debug)]
482pub struct HookLoadResult {
483    /// Number of hooks successfully loaded.
484    pub loaded: usize,
485    /// Number of disabled hooks that were skipped.
486    pub skipped: usize,
487    /// Errors encountered during loading (non-fatal per hook).
488    pub errors: Vec<HookLoadError>,
489}
490
491/// Loads hooks from a `HooksConfig` into a [`SharedHookRegistry`].
492///
493/// For each enabled hook definition:
494/// - `handler_inline`: Evaluates the inline Lua code as a function
495/// - `script`: Reads and evaluates the script file relative to `base_path`
496///
497/// Disabled hooks are silently skipped. Invalid hooks produce errors
498/// but do not prevent other hooks from loading.
499///
500/// # Arguments
501///
502/// * `config` - Hook definitions from TOML/JSON configuration
503/// * `registry` - Shared hook registry to register into
504/// * `base_path` - Base directory for resolving relative script paths
505///
506/// # Returns
507///
508/// A summary of loaded hooks, skipped hooks, and any errors.
509pub 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
618/// Loads an inline handler string as a Lua function.
619///
620/// Tries the code as-is first, then wraps with `return` if needed.
621fn load_inline_handler(lua: &Lua, inline: &str) -> Result<Function, mlua::Error> {
622    // Try with "return" prefix first (most common: bare function literal)
623    let with_return = format!("return {inline}");
624    lua.load(&with_return).eval::<Function>().or_else(|_| {
625        // Try as-is (user wrote "return function(...) ... end")
626        lua.load(inline).eval::<Function>()
627    })
628}
629
630/// Loads a script file as a Lua function.
631///
632/// The script should return a function when evaluated.
633fn 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// ── Tests ───────────────────────────────────────────────────────
653
654#[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    // ── parse_hook_descriptor ───────────────────────────────────
682
683    #[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    // ── HookContext ↔ Lua roundtrip ─────────────────────────────
753
754    #[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    // ── parse_hook_return ───────────────────────────────────────
780
781    #[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        // Create a context table in Lua (has hook_point key)
797        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    // ── LuaHook execution ───────────────────────────────────────
931
932    #[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            // Original fields preserved
990            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        // On error, should fall back to Continue with original ctx
1065        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") // returns nil
1076            .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    // ── register_hook_function integration ──────────────────────
1094
1095    #[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(&registry), 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(&registry), 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(&registry), 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(&registry), 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(&registry), comp_id)
1197            .expect("should register hook function for wrong args test");
1198
1199        // Too many args
1200        let result = lua.load(r#"orcs.hook("a", "b", "c")"#).exec();
1201        assert!(result.is_err());
1202
1203        // Zero args
1204        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    // ── LuaHook with registry dispatch ──────────────────────────
1232
1233    #[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(&registry), comp_id)
1240            .expect("should register hook function for dispatch test");
1241
1242        // Register a hook that adds a field to the payload
1243        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        // Dispatch through the registry
1255        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(&registry), 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    // ── register_unhook_function ─────────────────────────────────
1310
1311    #[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(&registry), comp_id)
1318            .expect("should register hook function for unhook test");
1319        register_unhook_function(&lua, Arc::clone(&registry))
1320            .expect("should register unhook function");
1321
1322        // Register a hook
1323        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        // Unhook it
1345        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(&registry))
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(&registry), comp_id)
1381            .expect("should register hook function for roundtrip test");
1382        register_unhook_function(&lua, Arc::clone(&registry))
1383            .expect("should register unhook function for roundtrip test");
1384
1385        // Register 2 hooks, remove 1
1386        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        // Remove the auto-named one (lua:builtin::test-comp:request.pre_dispatch)
1409        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    // ── load_hooks_from_config ──────────────────────────────────
1424
1425    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, &registry, 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, &registry, 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, &registry, 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                // fql "invalid" won't parse (needs "::")
1510                def.fql = "invalid".to_string();
1511                def
1512            }],
1513        };
1514        let registry = test_registry();
1515        let result = load_hooks_from_config(&config, &registry, 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, &registry, 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, &registry, 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, &registry, 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        // Create a temp file with a Lua function
1606        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, &registry, dir.path());
1627
1628        assert_eq!(result.loaded, 1);
1629        assert!(result.errors.is_empty());
1630
1631        // Verify the hook dispatches correctly
1632        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        // User writes "return function(...)" explicitly
1651        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, &registry, 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, &registry, 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}