Skip to main content

orcs_hook/
config.rs

1//! Hook configuration — declarative hook definitions.
2//!
3//! Defines the TOML-serializable configuration for hooks.
4//! These types are used in `OrcsConfig` to declare hooks that are
5//! loaded at engine startup.
6//!
7//! # Example TOML
8//!
9//! ```toml
10//! [[hooks]]
11//! id = "audit-requests"
12//! fql = "builtin::*"
13//! point = "request.pre_dispatch"
14//! script = "hooks/audit.lua"
15//! priority = 50
16//!
17//! [[hooks]]
18//! id = "tool-metrics"
19//! fql = "*::*"
20//! point = "tool.post_execute"
21//! handler_inline = """
22//! function(ctx)
23//!     return { action = "continue", ctx = ctx }
24//! end
25//! """
26//! priority = 200
27//! ```
28
29use crate::{FqlPattern, HookError, HookPoint};
30use serde::{Deserialize, Serialize};
31use std::str::FromStr;
32use thiserror::Error;
33
34/// Top-level hooks configuration.
35///
36/// Contains a list of declarative hook definitions that are
37/// loaded and registered at engine startup.
38#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
39#[serde(default)]
40pub struct HooksConfig {
41    /// Declarative hook definitions.
42    pub hooks: Vec<HookDef>,
43}
44
45/// A single declarative hook definition.
46///
47/// Either `script` or `handler_inline` must be specified (but not both).
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct HookDef {
50    /// Unique hook ID. Auto-generated if not specified.
51    pub id: Option<String>,
52
53    /// FQL pattern: which components this hook targets.
54    pub fql: String,
55
56    /// Hook point: when this hook fires (e.g., "request.pre_dispatch").
57    pub point: String,
58
59    /// Path to Lua script handler (relative to `scripts.dirs`).
60    pub script: Option<String>,
61
62    /// Inline Lua handler (for simple hooks).
63    pub handler_inline: Option<String>,
64
65    /// Priority (lower = earlier). Default: 100.
66    #[serde(default = "default_priority")]
67    pub priority: i32,
68
69    /// Whether the hook is enabled. Default: true.
70    #[serde(default = "default_enabled")]
71    pub enabled: bool,
72}
73
74fn default_priority() -> i32 {
75    100
76}
77
78fn default_enabled() -> bool {
79    true
80}
81
82/// Errors from validating a `HookDef`.
83#[derive(Debug, Clone, PartialEq, Eq, Error)]
84pub enum HookDefValidationError {
85    /// Neither `script` nor `handler_inline` is specified.
86    #[error("hook '{label}': neither 'script' nor 'handler_inline' specified")]
87    NoHandler { label: String },
88
89    /// Both `script` and `handler_inline` are specified.
90    #[error("hook '{label}': both 'script' and 'handler_inline' specified (use one)")]
91    BothHandlers { label: String },
92
93    /// Invalid FQL pattern.
94    #[error("hook '{label}': {source}")]
95    InvalidFql { label: String, source: HookError },
96
97    /// Invalid hook point string.
98    #[error("hook '{label}': {source}")]
99    InvalidPoint { label: String, source: HookError },
100}
101
102impl HookDef {
103    /// Validates this hook definition.
104    ///
105    /// Checks:
106    /// - Exactly one of `script` or `handler_inline` is specified
107    /// - `fql` is a valid FQL pattern
108    /// - `point` is a valid HookPoint string
109    pub fn validate(&self) -> Result<(), HookDefValidationError> {
110        let label = self.id.as_deref().unwrap_or("<anonymous>").to_string();
111
112        // Handler exclusivity check
113        match (&self.script, &self.handler_inline) {
114            (None, None) => {
115                return Err(HookDefValidationError::NoHandler { label });
116            }
117            (Some(_), Some(_)) => {
118                return Err(HookDefValidationError::BothHandlers { label });
119            }
120            _ => {}
121        }
122
123        // Validate FQL pattern
124        FqlPattern::parse(&self.fql).map_err(|e| HookDefValidationError::InvalidFql {
125            label: label.clone(),
126            source: e,
127        })?;
128
129        // Validate hook point
130        HookPoint::from_str(&self.point)
131            .map_err(|e| HookDefValidationError::InvalidPoint { label, source: e })?;
132
133        Ok(())
134    }
135}
136
137impl HooksConfig {
138    /// Merges another config into this one.
139    ///
140    /// Hook definitions accumulate across config layers.
141    /// If a hook in `other` has an `id` that matches an existing hook,
142    /// the existing hook is replaced (override semantics).
143    /// New hooks (or anonymous hooks without `id`) are appended.
144    pub fn merge(&mut self, other: &Self) {
145        for hook in &other.hooks {
146            if let Some(id) = &hook.id {
147                // Override existing hook with same ID
148                self.hooks.retain(|h| h.id.as_deref() != Some(id));
149            }
150            self.hooks.push(hook.clone());
151        }
152    }
153
154    /// Validates all hook definitions in this config.
155    ///
156    /// Returns all validation errors (not just the first one).
157    pub fn validate_all(&self) -> Vec<HookDefValidationError> {
158        self.hooks
159            .iter()
160            .filter_map(|h| h.validate().err())
161            .collect()
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn make_hook_def(id: &str, fql: &str, point: &str, script: Option<&str>) -> HookDef {
170        HookDef {
171            id: Some(id.to_string()),
172            fql: fql.to_string(),
173            point: point.to_string(),
174            script: script.map(|s| s.to_string()),
175            handler_inline: None,
176            priority: default_priority(),
177            enabled: default_enabled(),
178        }
179    }
180
181    // ── Defaults ────────────────────────────────────────────
182
183    #[test]
184    fn default_priority_is_100() {
185        assert_eq!(default_priority(), 100);
186    }
187
188    #[test]
189    fn default_enabled_is_true() {
190        assert!(default_enabled());
191    }
192
193    #[test]
194    fn hooks_config_default_is_empty() {
195        let cfg = HooksConfig::default();
196        assert!(cfg.hooks.is_empty());
197    }
198
199    // ── Validation ──────────────────────────────────────────
200
201    #[test]
202    fn validate_valid_script_hook() {
203        let hook = make_hook_def(
204            "audit",
205            "builtin::*",
206            "request.pre_dispatch",
207            Some("hooks/audit.lua"),
208        );
209        assert!(hook.validate().is_ok());
210    }
211
212    #[test]
213    fn validate_valid_inline_hook() {
214        let hook = HookDef {
215            id: Some("inline".into()),
216            fql: "*::*".into(),
217            point: "tool.post_execute".into(),
218            script: None,
219            handler_inline: Some("function(ctx) return ctx end".into()),
220            priority: 100,
221            enabled: true,
222        };
223        assert!(hook.validate().is_ok());
224    }
225
226    #[test]
227    fn validate_no_handler_error() {
228        let hook = HookDef {
229            id: Some("bad".into()),
230            fql: "*::*".into(),
231            point: "request.pre_dispatch".into(),
232            script: None,
233            handler_inline: None,
234            priority: 100,
235            enabled: true,
236        };
237        let err = hook
238            .validate()
239            .expect_err("hook with no handler should fail validation");
240        assert!(matches!(err, HookDefValidationError::NoHandler { .. }));
241        assert!(err.to_string().contains("neither"));
242    }
243
244    #[test]
245    fn validate_both_handlers_error() {
246        let hook = HookDef {
247            id: Some("bad".into()),
248            fql: "*::*".into(),
249            point: "request.pre_dispatch".into(),
250            script: Some("hooks/foo.lua".into()),
251            handler_inline: Some("function(ctx) return ctx end".into()),
252            priority: 100,
253            enabled: true,
254        };
255        let err = hook
256            .validate()
257            .expect_err("hook with both handlers should fail validation");
258        assert!(matches!(err, HookDefValidationError::BothHandlers { .. }));
259        assert!(err.to_string().contains("both"));
260    }
261
262    #[test]
263    fn validate_invalid_fql() {
264        let hook = HookDef {
265            id: Some("bad-fql".into()),
266            fql: "not-valid".into(),
267            point: "request.pre_dispatch".into(),
268            script: Some("hooks/x.lua".into()),
269            handler_inline: None,
270            priority: 100,
271            enabled: true,
272        };
273        let err = hook
274            .validate()
275            .expect_err("hook with invalid FQL should fail validation");
276        assert!(matches!(err, HookDefValidationError::InvalidFql { .. }));
277    }
278
279    #[test]
280    fn validate_invalid_point() {
281        let hook = HookDef {
282            id: Some("bad-point".into()),
283            fql: "*::*".into(),
284            point: "not.a.real.point".into(),
285            script: Some("hooks/x.lua".into()),
286            handler_inline: None,
287            priority: 100,
288            enabled: true,
289        };
290        let err = hook
291            .validate()
292            .expect_err("hook with invalid point should fail validation");
293        assert!(matches!(err, HookDefValidationError::InvalidPoint { .. }));
294    }
295
296    #[test]
297    fn validate_anonymous_hook() {
298        let hook = HookDef {
299            id: None,
300            fql: "*::*".into(),
301            point: "request.pre_dispatch".into(),
302            script: Some("hooks/x.lua".into()),
303            handler_inline: None,
304            priority: 100,
305            enabled: true,
306        };
307        assert!(hook.validate().is_ok());
308    }
309
310    #[test]
311    fn validate_anonymous_error_display() {
312        let hook = HookDef {
313            id: None,
314            fql: "*::*".into(),
315            point: "request.pre_dispatch".into(),
316            script: None,
317            handler_inline: None,
318            priority: 100,
319            enabled: true,
320        };
321        let err = hook
322            .validate()
323            .expect_err("anonymous hook with no handler should fail validation");
324        assert!(err.to_string().contains("<anonymous>"));
325    }
326
327    // ── Merge ───────────────────────────────────────────────
328
329    #[test]
330    fn merge_appends_new_hooks() {
331        let mut base = HooksConfig {
332            hooks: vec![make_hook_def(
333                "h1",
334                "*::*",
335                "request.pre_dispatch",
336                Some("a.lua"),
337            )],
338        };
339        let overlay = HooksConfig {
340            hooks: vec![make_hook_def(
341                "h2",
342                "*::*",
343                "tool.pre_execute",
344                Some("b.lua"),
345            )],
346        };
347
348        base.merge(&overlay);
349        assert_eq!(base.hooks.len(), 2);
350        assert_eq!(base.hooks[0].id.as_deref(), Some("h1"));
351        assert_eq!(base.hooks[1].id.as_deref(), Some("h2"));
352    }
353
354    #[test]
355    fn merge_overrides_same_id() {
356        let mut base = HooksConfig {
357            hooks: vec![make_hook_def(
358                "h1",
359                "*::*",
360                "request.pre_dispatch",
361                Some("old.lua"),
362            )],
363        };
364        let overlay = HooksConfig {
365            hooks: vec![make_hook_def(
366                "h1",
367                "builtin::llm",
368                "tool.pre_execute",
369                Some("new.lua"),
370            )],
371        };
372
373        base.merge(&overlay);
374        assert_eq!(base.hooks.len(), 1);
375        assert_eq!(base.hooks[0].fql, "builtin::llm");
376        assert_eq!(base.hooks[0].script.as_deref(), Some("new.lua"));
377    }
378
379    #[test]
380    fn merge_anonymous_hooks_always_append() {
381        let mut base = HooksConfig {
382            hooks: vec![{
383                let mut h = make_hook_def("", "*::*", "request.pre_dispatch", Some("a.lua"));
384                h.id = None;
385                h
386            }],
387        };
388        let overlay = HooksConfig {
389            hooks: vec![{
390                let mut h = make_hook_def("", "*::*", "request.pre_dispatch", Some("b.lua"));
391                h.id = None;
392                h
393            }],
394        };
395
396        base.merge(&overlay);
397        // Both anonymous hooks should be present (no dedup on None id)
398        assert_eq!(base.hooks.len(), 2);
399    }
400
401    #[test]
402    fn merge_mixed_override_and_append() {
403        let mut base = HooksConfig {
404            hooks: vec![
405                make_hook_def("h1", "*::*", "request.pre_dispatch", Some("a.lua")),
406                make_hook_def("h2", "*::*", "signal.pre_dispatch", Some("b.lua")),
407            ],
408        };
409        let overlay = HooksConfig {
410            hooks: vec![
411                make_hook_def(
412                    "h1",
413                    "builtin::*",
414                    "request.pre_dispatch",
415                    Some("new-a.lua"),
416                ),
417                make_hook_def("h3", "*::*", "child.pre_spawn", Some("c.lua")),
418            ],
419        };
420
421        base.merge(&overlay);
422        assert_eq!(base.hooks.len(), 3);
423        // h2 remains, h1 replaced, h3 appended
424        assert_eq!(base.hooks[0].id.as_deref(), Some("h2"));
425        assert_eq!(base.hooks[1].id.as_deref(), Some("h1"));
426        assert_eq!(base.hooks[1].fql, "builtin::*");
427        assert_eq!(base.hooks[2].id.as_deref(), Some("h3"));
428    }
429
430    // ── validate_all ────────────────────────────────────────
431
432    #[test]
433    fn validate_all_collects_all_errors() {
434        let cfg = HooksConfig {
435            hooks: vec![
436                // Valid
437                make_hook_def("ok", "*::*", "request.pre_dispatch", Some("ok.lua")),
438                // No handler
439                HookDef {
440                    id: Some("bad1".into()),
441                    fql: "*::*".into(),
442                    point: "request.pre_dispatch".into(),
443                    script: None,
444                    handler_inline: None,
445                    priority: 100,
446                    enabled: true,
447                },
448                // Invalid FQL
449                HookDef {
450                    id: Some("bad2".into()),
451                    fql: "broken".into(),
452                    point: "request.pre_dispatch".into(),
453                    script: Some("x.lua".into()),
454                    handler_inline: None,
455                    priority: 100,
456                    enabled: true,
457                },
458            ],
459        };
460
461        let errors = cfg.validate_all();
462        assert_eq!(errors.len(), 2);
463    }
464
465    // ── Serde (JSON roundtrip) ──────────────────────────────
466
467    #[test]
468    fn serde_json_roundtrip() {
469        let cfg = HooksConfig {
470            hooks: vec![
471                make_hook_def(
472                    "h1",
473                    "builtin::*",
474                    "request.pre_dispatch",
475                    Some("hooks/audit.lua"),
476                ),
477                HookDef {
478                    id: Some("h2".into()),
479                    fql: "*::*".into(),
480                    point: "tool.post_execute".into(),
481                    script: None,
482                    handler_inline: Some("function(ctx) return ctx end".into()),
483                    priority: 200,
484                    enabled: false,
485                },
486            ],
487        };
488
489        let json =
490            serde_json::to_string_pretty(&cfg).expect("HooksConfig should serialize to JSON");
491        let restored: HooksConfig =
492            serde_json::from_str(&json).expect("HooksConfig should deserialize from JSON");
493        assert_eq!(cfg, restored);
494    }
495
496    #[test]
497    fn serde_json_defaults_applied() {
498        // Minimal JSON with only required fields
499        let json = r#"{
500            "hooks": [{
501                "fql": "*::*",
502                "point": "request.pre_dispatch",
503                "script": "test.lua"
504            }]
505        }"#;
506
507        let cfg: HooksConfig =
508            serde_json::from_str(json).expect("minimal JSON with defaults should deserialize");
509        assert_eq!(cfg.hooks.len(), 1);
510        assert_eq!(cfg.hooks[0].priority, 100);
511        assert!(cfg.hooks[0].enabled);
512        assert!(cfg.hooks[0].id.is_none());
513    }
514
515    // ── TOML roundtrip ──────────────────────────────────────
516
517    #[test]
518    fn toml_roundtrip() {
519        let toml_str = r#"
520[[hooks]]
521id = "audit-requests"
522fql = "builtin::*"
523point = "request.pre_dispatch"
524script = "hooks/audit.lua"
525priority = 50
526enabled = true
527
528[[hooks]]
529id = "tool-metrics"
530fql = "*::*"
531point = "tool.post_execute"
532handler_inline = "function(ctx) return ctx end"
533priority = 200
534enabled = true
535"#;
536
537        let cfg: HooksConfig =
538            toml::from_str(toml_str).expect("TOML with two hooks should deserialize");
539        assert_eq!(cfg.hooks.len(), 2);
540        assert_eq!(cfg.hooks[0].id.as_deref(), Some("audit-requests"));
541        assert_eq!(cfg.hooks[0].priority, 50);
542        assert_eq!(cfg.hooks[1].id.as_deref(), Some("tool-metrics"));
543        assert!(cfg.hooks[1].handler_inline.is_some());
544
545        // Serialize back and re-parse
546        let serialized =
547            toml::to_string_pretty(&cfg).expect("HooksConfig should serialize to TOML");
548        let restored: HooksConfig = toml::from_str(&serialized)
549            .expect("HooksConfig should deserialize from re-serialized TOML");
550        assert_eq!(cfg, restored);
551    }
552
553    #[test]
554    fn toml_minimal_with_defaults() {
555        let toml_str = r#"
556[[hooks]]
557fql = "*::*"
558point = "request.pre_dispatch"
559script = "test.lua"
560"#;
561
562        let cfg: HooksConfig =
563            toml::from_str(toml_str).expect("minimal TOML with defaults should deserialize");
564        assert_eq!(cfg.hooks.len(), 1);
565        assert_eq!(cfg.hooks[0].priority, 100);
566        assert!(cfg.hooks[0].enabled);
567    }
568
569    #[test]
570    fn toml_empty_hooks() {
571        let toml_str = "";
572        let cfg: HooksConfig =
573            toml::from_str(toml_str).expect("empty TOML should deserialize to empty HooksConfig");
574        assert!(cfg.hooks.is_empty());
575    }
576}