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")
156///     .expect("valid descriptor should parse");
157/// assert_eq!(fql.to_string(), "builtin::llm");
158/// assert_eq!(point.to_string(), "request.pre_dispatch");
159/// ```
160pub fn parse_hook_descriptor(descriptor: &str) -> Result<(FqlPattern, HookPoint), String> {
161    // Scan for the last `:` where the suffix is a known HookPoint prefix.
162    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
195// ── HookContext ↔ Lua conversion ────────────────────────────────
196
197/// Converts a [`HookContext`] to a Lua value (table) via serde.
198///
199/// # Errors
200///
201/// Returns an error if serialization fails.
202pub fn hook_context_to_lua(lua: &Lua, ctx: &HookContext) -> Result<Value, mlua::Error> {
203    lua.to_value(ctx)
204}
205
206/// Converts a Lua value (table) back to a [`HookContext`] via serde.
207///
208/// # Errors
209///
210/// Returns an error if deserialization fails.
211pub fn lua_to_hook_context(lua: &Lua, value: Value) -> Result<HookContext, mlua::Error> {
212    lua.from_value(value)
213}
214
215// ── Return-value parsing ────────────────────────────────────────
216
217/// Parses the return value from a Lua hook handler into a [`HookAction`].
218///
219/// See module-level docs for the full return-value conventions.
220///
221/// # Errors
222///
223/// Returns an error for unrecognised action strings or type mismatches.
224pub fn parse_hook_return(
225    lua: &Lua,
226    result: Value,
227    original_ctx: &HookContext,
228) -> Result<HookAction, mlua::Error> {
229    match result {
230        // nil → pass through unchanged
231        Value::Nil => Ok(HookAction::Continue(Box::new(original_ctx.clone()))),
232
233        Value::Table(ref table) => {
234            // Check for explicit action table (has "action" key)
235            if let Ok(action_str) = table.get::<String>("action") {
236                return parse_action_table(lua, &action_str, table, original_ctx);
237            }
238
239            // Check for context table (has "hook_point" key)
240            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            // Unknown table: treat as modified payload
246            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
258/// Parses an explicit `{ action = "...", ... }` table.
259fn 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
297// ── orcs.hook() registration ────────────────────────────────────
298
299/// Registers the `orcs.hook()` function on the `orcs` global table.
300///
301/// Requires:
302/// - `registry` — the shared hook registry for storing hooks
303/// - `component_id` — the owning component (for `register_owned()`)
304///
305/// The registered hooks are automatically cleaned up when
306/// `HookRegistry::unregister_by_owner()` is called with the same
307/// component ID (typically on component shutdown).
308///
309/// # Errors
310///
311/// Returns an error if Lua function creation or table insertion fails.
312pub 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            // ── Shorthand: orcs.hook("fql:point", handler) ──
327            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            // ── Table form: orcs.hook({ fql=..., point=..., handler=... }) ──
353            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
415/// Registers a default (deny) `orcs.hook()` stub.
416///
417/// Used when no hook registry is available yet.
418/// Returns an error table `{ ok = false, error = "..." }`.
419pub 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(()); // Already registered (possibly the real one)
424    }
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
436// ── orcs.unhook() registration ───────────────────────────────────
437
438/// Registers the `orcs.unhook(id)` function on the `orcs` global table.
439///
440/// Removes a previously registered hook by its ID. Returns `true` if
441/// the hook was found and removed, `false` otherwise.
442///
443/// # Errors
444///
445/// Returns an error if Lua function creation or table insertion fails.
446pub 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// ── Config-based hook loading ────────────────────────────────────
465
466/// Error from loading a single hook definition.
467#[derive(Debug)]
468pub struct HookLoadError {
469    /// Label of the hook that failed (id or `<anonymous>`).
470    pub hook_label: String,
471    /// Description of the error.
472    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/// Result of loading hooks from configuration.
482#[derive(Debug)]
483pub struct HookLoadResult {
484    /// Number of hooks successfully loaded.
485    pub loaded: usize,
486    /// Number of disabled hooks that were skipped.
487    pub skipped: usize,
488    /// Errors encountered during loading (non-fatal per hook).
489    pub errors: Vec<HookLoadError>,
490}
491
492/// Loads hooks from a `HooksConfig` into a [`SharedHookRegistry`].
493///
494/// For each enabled hook definition:
495/// - `handler_inline`: Evaluates the inline Lua code as a function
496/// - `script`: Reads and evaluates the script file relative to `base_path`
497///
498/// Disabled hooks are silently skipped. Invalid hooks produce errors
499/// but do not prevent other hooks from loading.
500///
501/// # Arguments
502///
503/// * `config` - Hook definitions from TOML/JSON configuration
504/// * `registry` - Shared hook registry to register into
505/// * `base_path` - Base directory for resolving relative script paths
506///
507/// # Returns
508///
509/// A summary of loaded hooks, skipped hooks, and any errors.
510pub 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
619/// Loads an inline handler string as a Lua function.
620///
621/// Tries the code as-is first, then wraps with `return` if needed.
622fn load_inline_handler(lua: &Lua, inline: &str) -> Result<Function, mlua::Error> {
623    // Try with "return" prefix first (most common: bare function literal)
624    let with_return = format!("return {inline}");
625    lua.load(&with_return).eval::<Function>().or_else(|_| {
626        // Try as-is (user wrote "return function(...) ... end")
627        lua.load(inline).eval::<Function>()
628    })
629}
630
631/// Loads a script file as a Lua function.
632///
633/// The script should return a function when evaluated.
634fn 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// ── Tests ───────────────────────────────────────────────────────
654
655#[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    // ── parse_hook_descriptor ───────────────────────────────────
683
684    #[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    // ── HookContext ↔ Lua roundtrip ─────────────────────────────
754
755    #[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    // ── parse_hook_return ───────────────────────────────────────
781
782    #[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        // Create a context table in Lua (has hook_point key)
798        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    // ── LuaHook execution ───────────────────────────────────────
932
933    #[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            // Original fields preserved
991            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        // On error, should fall back to Continue with original ctx
1066        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") // returns nil
1077            .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    // ── register_hook_function integration ──────────────────────
1095
1096    #[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(&registry), 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(&registry), 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(&registry), 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(&registry), 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(&registry), comp_id)
1198            .expect("should register hook function for wrong args test");
1199
1200        // Too many args
1201        let result = lua.load(r#"orcs.hook("a", "b", "c")"#).exec();
1202        assert!(result.is_err());
1203
1204        // Zero args
1205        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    // ── LuaHook with registry dispatch ──────────────────────────
1233
1234    #[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(&registry), comp_id)
1241            .expect("should register hook function for dispatch test");
1242
1243        // Register a hook that adds a field to the payload
1244        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        // Dispatch through the registry
1256        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(&registry), 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    // ── register_unhook_function ─────────────────────────────────
1311
1312    #[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(&registry), comp_id)
1319            .expect("should register hook function for unhook test");
1320        register_unhook_function(&lua, Arc::clone(&registry))
1321            .expect("should register unhook function");
1322
1323        // Register a hook
1324        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        // Unhook it
1346        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(&registry))
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(&registry), comp_id)
1382            .expect("should register hook function for roundtrip test");
1383        register_unhook_function(&lua, Arc::clone(&registry))
1384            .expect("should register unhook function for roundtrip test");
1385
1386        // Register 2 hooks, remove 1
1387        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        // Remove the auto-named one (lua:builtin::test-comp:request.pre_dispatch)
1410        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    // ── load_hooks_from_config ──────────────────────────────────
1425
1426    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, &registry, 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, &registry, 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, &registry, 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                // fql "invalid" won't parse (needs "::")
1511                def.fql = "invalid".to_string();
1512                def
1513            }],
1514        };
1515        let registry = test_registry();
1516        let result = load_hooks_from_config(&config, &registry, 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, &registry, 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, &registry, 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, &registry, 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        // Create a temp file with a Lua function
1607        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, &registry, dir.path());
1628
1629        assert_eq!(result.loaded, 1);
1630        assert!(result.errors.is_empty());
1631
1632        // Verify the hook dispatches correctly
1633        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        // User writes "return function(...)" explicitly
1652        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, &registry, 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, &registry, 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}