1use crate::{FqlPattern, HookError, HookPoint};
30use serde::{Deserialize, Serialize};
31use std::str::FromStr;
32use thiserror::Error;
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
39#[serde(default)]
40pub struct HooksConfig {
41 pub hooks: Vec<HookDef>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct HookDef {
50 pub id: Option<String>,
52
53 pub fql: String,
55
56 pub point: String,
58
59 pub script: Option<String>,
61
62 pub handler_inline: Option<String>,
64
65 #[serde(default = "default_priority")]
67 pub priority: i32,
68
69 #[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#[derive(Debug, Clone, PartialEq, Eq, Error)]
84pub enum HookDefValidationError {
85 #[error("hook '{label}': neither 'script' nor 'handler_inline' specified")]
87 NoHandler { label: String },
88
89 #[error("hook '{label}': both 'script' and 'handler_inline' specified (use one)")]
91 BothHandlers { label: String },
92
93 #[error("hook '{label}': {source}")]
95 InvalidFql { label: String, source: HookError },
96
97 #[error("hook '{label}': {source}")]
99 InvalidPoint { label: String, source: HookError },
100}
101
102impl HookDef {
103 pub fn validate(&self) -> Result<(), HookDefValidationError> {
110 let label = self.id.as_deref().unwrap_or("<anonymous>").to_string();
111
112 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 FqlPattern::parse(&self.fql).map_err(|e| HookDefValidationError::InvalidFql {
125 label: label.clone(),
126 source: e,
127 })?;
128
129 HookPoint::from_str(&self.point)
131 .map_err(|e| HookDefValidationError::InvalidPoint { label, source: e })?;
132
133 Ok(())
134 }
135}
136
137impl HooksConfig {
138 pub fn merge(&mut self, other: &Self) {
145 for hook in &other.hooks {
146 if let Some(id) = &hook.id {
147 self.hooks.retain(|h| h.id.as_deref() != Some(id));
149 }
150 self.hooks.push(hook.clone());
151 }
152 }
153
154 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 #[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 #[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 #[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 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 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 #[test]
433 fn validate_all_collects_all_errors() {
434 let cfg = HooksConfig {
435 hooks: vec![
436 make_hook_def("ok", "*::*", "request.pre_dispatch", Some("ok.lua")),
438 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 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 #[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 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 #[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 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}