1use std::fmt;
53use std::path::{Path, PathBuf};
54use std::sync::Arc;
55
56use serde::{Deserialize, Serialize};
57use tokio::sync::RwLock;
58
59pub use super::events::HookEvent;
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum HookMode {
87 Exec,
88 Daemon,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum FailMode {
114 Open,
115 Closed,
116}
117
118impl Default for FailMode {
119 fn default() -> Self {
120 FailMode::Open
121 }
122}
123
124fn default_fail_mode() -> FailMode {
128 FailMode::Open
129}
130
131pub const MAX_TIMEOUT_MS: u32 = 30_000;
139
140#[must_use]
157pub fn default_mode_for_event(event: HookEvent) -> HookMode {
158 match event {
159 HookEvent::PostRecall | HookEvent::PostSearch | HookEvent::PreRecallExpand => {
160 HookMode::Daemon
161 }
162 _ => HookMode::Exec,
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
174pub struct HookConfig {
175 pub event: HookEvent,
176 pub command: PathBuf,
177 pub priority: i32,
178 pub timeout_ms: u32,
179 pub mode: HookMode,
180 pub enabled: bool,
181 pub namespace: String,
182 #[serde(default = "default_fail_mode")]
189 pub fail_mode: FailMode,
190}
191
192#[derive(Debug, Deserialize)]
199struct HookConfigRaw {
200 event: HookEvent,
201 command: PathBuf,
202 priority: i32,
203 timeout_ms: u32,
204 #[serde(default)]
207 mode: Option<HookMode>,
208 enabled: bool,
209 namespace: String,
210 #[serde(default = "default_fail_mode")]
211 fail_mode: FailMode,
212}
213
214impl From<HookConfigRaw> for HookConfig {
215 fn from(raw: HookConfigRaw) -> Self {
216 let mode = raw
217 .mode
218 .unwrap_or_else(|| default_mode_for_event(raw.event));
219 HookConfig {
220 event: raw.event,
221 command: raw.command,
222 priority: raw.priority,
223 timeout_ms: raw.timeout_ms,
224 mode,
225 enabled: raw.enabled,
226 namespace: raw.namespace,
227 fail_mode: raw.fail_mode,
228 }
229 }
230}
231
232impl<'de> serde::Deserialize<'de> for HookConfig {
237 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
238 where
239 D: serde::Deserializer<'de>,
240 {
241 HookConfigRaw::deserialize(deserializer).map(Into::into)
242 }
243}
244
245#[derive(Debug, Deserialize)]
248struct HooksFile {
249 #[serde(default, rename = "hook")]
250 hooks: Vec<HookConfig>,
251}
252
253impl HookConfig {
254 pub fn load_from_file(path: &Path) -> Result<Vec<HookConfig>, HooksConfigError> {
260 let contents = std::fs::read_to_string(path).map_err(HooksConfigError::Io)?;
261 Self::load_from_str(&contents)
262 }
263
264 pub fn load_from_str(contents: &str) -> Result<Vec<HookConfig>, HooksConfigError> {
268 let parsed: HooksFile = toml::from_str(contents).map_err(|e| {
269 let (line, col) = e
273 .span()
274 .map(|s| byte_offset_to_line_col(contents, s.start))
275 .unwrap_or((0, 0));
276 HooksConfigError::Toml {
277 line,
278 column: col,
279 message: e.to_string(),
280 }
281 })?;
282
283 for (idx, h) in parsed.hooks.iter().enumerate() {
284 validate_hook(idx, h)?;
285 }
286
287 Ok(parsed.hooks)
288 }
289
290 pub fn default_path() -> Option<PathBuf> {
293 dirs::config_dir().map(|p| p.join("ai-memory/hooks.toml"))
294 }
295}
296
297fn validate_hook(idx: usize, h: &HookConfig) -> Result<(), HooksConfigError> {
298 if h.timeout_ms > MAX_TIMEOUT_MS {
299 return Err(HooksConfigError::Validation {
300 field: format!("hook[{idx}].timeout_ms"),
301 reason: format!("{} exceeds maximum {MAX_TIMEOUT_MS}ms", h.timeout_ms),
302 });
303 }
304 if h.command.as_os_str().is_empty() {
305 return Err(HooksConfigError::Validation {
306 field: format!("hook[{idx}].command"),
307 reason: "must be a non-empty path".into(),
308 });
309 }
310 if h.namespace.trim().is_empty() {
316 return Err(HooksConfigError::Validation {
317 field: format!("hook[{idx}].namespace"),
318 reason: "must be a non-empty pattern (use \"*\" to match all)".into(),
319 });
320 }
321 Ok(())
322}
323
324fn byte_offset_to_line_col(s: &str, offset: usize) -> (usize, usize) {
327 let mut line = 1usize;
328 let mut col = 1usize;
329 for (i, ch) in s.char_indices() {
330 if i >= offset {
331 break;
332 }
333 if ch == '\n' {
334 line += 1;
335 col = 1;
336 } else {
337 col += 1;
338 }
339 }
340 (line, col)
341}
342
343#[derive(Debug)]
349pub enum HooksConfigError {
350 Io(std::io::Error),
352 Toml {
356 line: usize,
357 column: usize,
358 message: String,
359 },
360 Validation { field: String, reason: String },
365}
366
367impl fmt::Display for HooksConfigError {
368 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
369 match self {
370 HooksConfigError::Io(e) => write!(f, "hooks.toml read error: {e}"),
371 HooksConfigError::Toml {
372 line,
373 column,
374 message,
375 } => {
376 if *line == 0 {
377 write!(f, "hooks.toml parse error: {message}")
378 } else {
379 write!(
380 f,
381 "hooks.toml parse error at line {line}, column {column}: {message}"
382 )
383 }
384 }
385 HooksConfigError::Validation { field, reason } => {
386 write!(f, "hooks.toml validation error in {field}: {reason}")
387 }
388 }
389 }
390}
391
392impl std::error::Error for HooksConfigError {
393 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
394 match self {
395 HooksConfigError::Io(e) => Some(e),
396 _ => None,
397 }
398 }
399}
400
401pub type HookConfigSnapshot = RwLock<Vec<HookConfig>>;
410
411#[cfg(unix)]
424pub fn spawn_reload_task(
425 path: PathBuf,
426 snapshot: Arc<HookConfigSnapshot>,
427) -> tokio::task::JoinHandle<()> {
428 use tokio::signal::unix::{SignalKind, signal};
429
430 tokio::spawn(async move {
431 let mut sighup = match signal(SignalKind::hangup()) {
432 Ok(s) => s,
433 Err(e) => {
434 tracing::error!(error = %e, "hooks: failed to install SIGHUP handler");
435 return;
436 }
437 };
438
439 while sighup.recv().await.is_some() {
440 match HookConfig::load_from_file(&path) {
441 Ok(new_cfg) => {
442 let count = new_cfg.len();
443 let mut guard = snapshot.write().await;
444 *guard = new_cfg;
445 tracing::info!(
446 path = %path.display(),
447 hooks = count,
448 "hooks: reloaded config on SIGHUP"
449 );
450 }
451 Err(e) => {
452 tracing::error!(
457 path = %path.display(),
458 error = %e,
459 "hooks: SIGHUP reload failed; keeping previous config"
460 );
461 }
462 }
463 }
464 })
465}
466
467#[cfg(not(unix))]
473pub fn spawn_reload_task(
474 _path: PathBuf,
475 _snapshot: Arc<HookConfigSnapshot>,
476) -> tokio::task::JoinHandle<()> {
477 tokio::spawn(async move {
478 tracing::warn!("hooks: SIGHUP reload not supported on this platform");
479 })
480}
481
482#[cfg(test)]
487mod tests {
488 use super::*;
489 use std::io::Write;
490
491 const VALID_CANONICAL: &str = r#"
492[[hook]]
493event = "post_store"
494command = "/usr/local/bin/auto-link-detector"
495priority = 100
496timeout_ms = 5000
497mode = "daemon"
498enabled = true
499namespace = "team/*"
500"#;
501
502 #[test]
503 fn parses_canonical_example() {
504 let hooks = HookConfig::load_from_str(VALID_CANONICAL).expect("parses");
505 assert_eq!(hooks.len(), 1);
506 let h = &hooks[0];
507 assert_eq!(h.event, HookEvent::PostStore);
508 assert_eq!(
509 h.command,
510 PathBuf::from("/usr/local/bin/auto-link-detector")
511 );
512 assert_eq!(h.priority, 100);
513 assert_eq!(h.timeout_ms, 5_000);
514 assert_eq!(h.mode, HookMode::Daemon);
515 assert!(h.enabled);
516 assert_eq!(h.namespace, "team/*");
517 }
518
519 #[test]
520 fn rejects_timeout_over_cap() {
521 let toml_src = r#"
522[[hook]]
523event = "post_recall"
524command = "/bin/true"
525priority = 0
526timeout_ms = 60000
527mode = "exec"
528enabled = true
529namespace = "*"
530"#;
531 let err = HookConfig::load_from_str(toml_src).unwrap_err();
532 match err {
533 HooksConfigError::Validation { field, reason } => {
534 assert!(field.ends_with("timeout_ms"), "field was {field}");
535 assert!(reason.contains("30000"), "reason was {reason}");
536 }
537 other => panic!("expected Validation, got {other:?}"),
538 }
539 }
540
541 #[test]
542 fn invalid_toml_reports_line_number() {
543 let toml_src = "\n\n[[hook]]\nevent = \"post_store\"\nmode = \n";
547 let err = HookConfig::load_from_str(toml_src).unwrap_err();
548 match err {
549 HooksConfigError::Toml {
550 line, ref message, ..
551 } => {
552 assert!(line > 0, "expected non-zero line, got {line}");
553 let displayed = err.to_string();
554 assert!(
555 displayed.contains(&format!("line {line}")),
556 "Display did not surface line: {displayed} (raw msg: {message})"
557 );
558 }
559 other => panic!("expected Toml, got {other:?}"),
560 }
561 }
562
563 #[test]
564 fn multiple_hooks_same_event_preserve_order() {
565 let toml_src = r#"
566[[hook]]
567event = "post_store"
568command = "/bin/first"
569priority = 10
570timeout_ms = 1000
571mode = "exec"
572enabled = true
573namespace = "*"
574
575[[hook]]
576event = "post_store"
577command = "/bin/second"
578priority = 5
579timeout_ms = 1000
580mode = "exec"
581enabled = true
582namespace = "*"
583
584[[hook]]
585event = "post_store"
586command = "/bin/third"
587priority = 50
588timeout_ms = 1000
589mode = "exec"
590enabled = true
591namespace = "*"
592"#;
593 let hooks = HookConfig::load_from_str(toml_src).expect("parses");
594 assert_eq!(hooks.len(), 3);
595 assert_eq!(hooks[0].command, PathBuf::from("/bin/first"));
596 assert_eq!(hooks[1].command, PathBuf::from("/bin/second"));
597 assert_eq!(hooks[2].command, PathBuf::from("/bin/third"));
598 assert!(hooks.iter().all(|h| h.event == HookEvent::PostStore));
600 }
601
602 #[test]
603 fn rejects_empty_namespace() {
604 let toml_src = r#"
605[[hook]]
606event = "post_store"
607command = "/bin/true"
608priority = 0
609timeout_ms = 1000
610mode = "exec"
611enabled = true
612namespace = ""
613"#;
614 let err = HookConfig::load_from_str(toml_src).unwrap_err();
615 assert!(matches!(err, HooksConfigError::Validation { .. }));
616 }
617
618 #[test]
619 fn rejects_empty_command() {
620 let toml_src = r#"
621[[hook]]
622event = "post_store"
623command = ""
624priority = 0
625timeout_ms = 1000
626mode = "exec"
627enabled = true
628namespace = "*"
629"#;
630 let err = HookConfig::load_from_str(toml_src).unwrap_err();
631 match err {
632 HooksConfigError::Validation { field, .. } => {
633 assert!(field.ends_with("command"), "field was {field}");
634 }
635 other => panic!("expected Validation, got {other:?}"),
636 }
637 }
638
639 #[test]
640 fn empty_file_yields_zero_hooks() {
641 let hooks = HookConfig::load_from_str("").expect("parses");
642 assert!(hooks.is_empty());
643 }
644
645 #[test]
656 fn test_post_recall_default_mode_is_daemon() {
657 let toml_src = r#"
658[[hook]]
659event = "post_recall"
660command = "/bin/true"
661priority = 0
662timeout_ms = 1000
663enabled = true
664namespace = "*"
665"#;
666 let hooks = HookConfig::load_from_str(toml_src).expect("parses with no mode field");
667 assert_eq!(hooks.len(), 1);
668 assert_eq!(hooks[0].event, HookEvent::PostRecall);
669 assert_eq!(
670 hooks[0].mode,
671 HookMode::Daemon,
672 "post_recall must default to daemon mode (R3-S3)"
673 );
674 }
675
676 #[test]
680 fn test_post_search_default_mode_is_daemon() {
681 let toml_src = r#"
682[[hook]]
683event = "post_search"
684command = "/bin/true"
685priority = 0
686timeout_ms = 1000
687enabled = true
688namespace = "*"
689"#;
690 let hooks = HookConfig::load_from_str(toml_src).expect("parses with no mode field");
691 assert_eq!(hooks.len(), 1);
692 assert_eq!(
693 hooks[0].mode,
694 HookMode::Daemon,
695 "post_search must default to daemon mode (R3-S3)"
696 );
697 }
698
699 #[test]
702 fn test_pre_recall_expand_default_mode_is_daemon() {
703 let toml_src = r#"
704[[hook]]
705event = "pre_recall_expand"
706command = "/bin/true"
707priority = 0
708timeout_ms = 1000
709enabled = true
710namespace = "*"
711"#;
712 let hooks = HookConfig::load_from_str(toml_src).expect("parses with no mode field");
713 assert_eq!(hooks[0].mode, HookMode::Daemon);
714 }
715
716 #[test]
722 fn test_post_store_default_mode_is_exec() {
723 let toml_src = r#"
724[[hook]]
725event = "post_store"
726command = "/bin/true"
727priority = 0
728timeout_ms = 1000
729enabled = true
730namespace = "*"
731"#;
732 let hooks = HookConfig::load_from_str(toml_src).expect("parses with no mode field");
733 assert_eq!(
734 hooks[0].mode,
735 HookMode::Exec,
736 "cold-path events still default to exec mode (no R3-S3 change)"
737 );
738 }
739
740 #[test]
745 fn test_explicit_mode_overrides_default() {
746 let toml_src = r#"
747[[hook]]
748event = "post_recall"
749command = "/bin/true"
750priority = 0
751timeout_ms = 1000
752mode = "exec"
753enabled = true
754namespace = "*"
755"#;
756 let hooks = HookConfig::load_from_str(toml_src).expect("parses");
757 assert_eq!(
758 hooks[0].mode,
759 HookMode::Exec,
760 "explicit mode = \"exec\" must not be silently flipped to daemon"
761 );
762 }
763
764 #[test]
765 fn load_from_file_round_trip() {
766 let mut tmp = tempfile::NamedTempFile::new().expect("tempfile");
767 tmp.write_all(VALID_CANONICAL.as_bytes()).expect("write");
768 let hooks = HookConfig::load_from_file(tmp.path()).expect("loads");
769 assert_eq!(hooks.len(), 1);
770 assert_eq!(hooks[0].event, HookEvent::PostStore);
771 }
772
773 #[tokio::test]
785 async fn sighup_reload_swaps_snapshot() {
786 let mut tmp = tempfile::NamedTempFile::new().expect("tempfile");
787 tmp.write_all(VALID_CANONICAL.as_bytes()).expect("write A");
788
789 let snapshot: Arc<HookConfigSnapshot> = Arc::new(RwLock::new(
790 HookConfig::load_from_file(tmp.path()).expect("load A"),
791 ));
792
793 {
794 let guard = snapshot.read().await;
795 assert_eq!(guard.len(), 1);
796 assert_eq!(
797 guard[0].command,
798 PathBuf::from("/usr/local/bin/auto-link-detector")
799 );
800 }
801
802 let config_b = r#"
806[[hook]]
807event = "pre_store"
808command = "/opt/hooks/redact-pii"
809priority = 200
810timeout_ms = 2500
811mode = "exec"
812enabled = true
813namespace = "*"
814
815[[hook]]
816event = "post_recall"
817command = "/opt/hooks/expand-context"
818priority = 50
819timeout_ms = 100
820mode = "daemon"
821enabled = false
822namespace = "team/*"
823"#;
824 std::fs::write(tmp.path(), config_b).expect("rewrite to B");
825
826 let new_cfg = HookConfig::load_from_file(tmp.path()).expect("load B");
828 {
829 let mut guard = snapshot.write().await;
830 *guard = new_cfg;
831 }
832
833 let guard = snapshot.read().await;
834 assert_eq!(guard.len(), 2);
835 assert_eq!(guard[0].event, HookEvent::PreStore);
836 assert_eq!(guard[0].command, PathBuf::from("/opt/hooks/redact-pii"));
837 assert_eq!(guard[1].event, HookEvent::PostRecall);
838 assert!(!guard[1].enabled);
839 }
840
841 #[test]
842 fn default_path_is_under_config_dir() {
843 if let Some(p) = HookConfig::default_path() {
847 let s = p.to_string_lossy();
848 assert!(
849 s.ends_with("ai-memory/hooks.toml") || s.ends_with("ai-memory\\hooks.toml"),
850 "unexpected default path: {s}"
851 );
852 }
853 }
854
855 #[test]
856 fn hook_event_serde_uses_snake_case() {
857 let json = serde_json::to_string(&HookEvent::PreGovernanceDecision).unwrap();
860 assert_eq!(json, "\"pre_governance_decision\"");
861 let back: HookEvent = serde_json::from_str("\"on_index_eviction\"").unwrap();
862 assert_eq!(back, HookEvent::OnIndexEviction);
863 }
864
865 #[test]
866 fn hook_mode_serde_uses_snake_case() {
867 let exec_json = serde_json::to_string(&HookMode::Exec).unwrap();
868 let daemon_json = serde_json::to_string(&HookMode::Daemon).unwrap();
869 assert_eq!(exec_json, "\"exec\"");
870 assert_eq!(daemon_json, "\"daemon\"");
871 }
872
873 #[test]
874 fn fail_mode_default_is_open() {
875 assert_eq!(FailMode::default(), FailMode::Open);
876 assert_eq!(default_fail_mode(), FailMode::Open);
877 }
878
879 #[test]
880 fn fail_mode_serde_round_trip() {
881 let open = serde_json::to_string(&FailMode::Open).unwrap();
882 let closed = serde_json::to_string(&FailMode::Closed).unwrap();
883 assert_eq!(open, "\"open\"");
884 assert_eq!(closed, "\"closed\"");
885 let back: FailMode = serde_json::from_str("\"closed\"").unwrap();
886 assert_eq!(back, FailMode::Closed);
887 }
888
889 #[test]
890 fn default_mode_for_event_matrix() {
891 assert_eq!(
893 default_mode_for_event(HookEvent::PostRecall),
894 HookMode::Daemon
895 );
896 assert_eq!(
897 default_mode_for_event(HookEvent::PostSearch),
898 HookMode::Daemon
899 );
900 assert_eq!(
901 default_mode_for_event(HookEvent::PreRecallExpand),
902 HookMode::Daemon
903 );
904 assert_eq!(default_mode_for_event(HookEvent::PostStore), HookMode::Exec);
906 assert_eq!(default_mode_for_event(HookEvent::PreStore), HookMode::Exec);
907 assert_eq!(default_mode_for_event(HookEvent::PreDelete), HookMode::Exec);
908 }
909
910 #[test]
911 fn fail_mode_closed_is_parsed() {
912 let toml_src = r#"
913[[hook]]
914event = "post_store"
915command = "/bin/true"
916priority = 0
917timeout_ms = 1000
918mode = "exec"
919enabled = true
920namespace = "*"
921fail_mode = "closed"
922"#;
923 let hooks = HookConfig::load_from_str(toml_src).expect("parses");
924 assert_eq!(hooks[0].fail_mode, FailMode::Closed);
925 }
926
927 #[test]
928 fn fail_mode_omitted_defaults_to_open() {
929 let toml_src = r#"
930[[hook]]
931event = "post_store"
932command = "/bin/true"
933priority = 0
934timeout_ms = 1000
935mode = "exec"
936enabled = true
937namespace = "*"
938"#;
939 let hooks = HookConfig::load_from_str(toml_src).expect("parses");
940 assert_eq!(hooks[0].fail_mode, FailMode::Open);
941 }
942
943 #[test]
944 fn validation_error_display_surfaces_field_and_reason() {
945 let err = HooksConfigError::Validation {
946 field: "hook[0].timeout_ms".into(),
947 reason: "exceeds maximum".into(),
948 };
949 let s = err.to_string();
950 assert!(s.contains("hook[0].timeout_ms"));
951 assert!(s.contains("exceeds maximum"));
952 }
953
954 #[test]
955 fn io_error_display_and_source() {
956 let io_err = std::io::Error::other("simulated read failure");
957 let err = HooksConfigError::Io(io_err);
958 let s = err.to_string();
959 assert!(s.contains("hooks.toml read error"));
960 assert!(s.contains("simulated read failure"));
961 use std::error::Error;
963 assert!(err.source().is_some());
964 }
965
966 #[test]
967 fn toml_error_no_span_displays_without_line_marker() {
968 let err = HooksConfigError::Toml {
972 line: 0,
973 column: 0,
974 message: "no span here".into(),
975 };
976 let s = err.to_string();
977 assert!(s.contains("no span here"));
978 assert!(!s.contains("line 0"));
979 }
980
981 #[test]
982 fn toml_error_with_span_displays_line_and_column() {
983 let err = HooksConfigError::Toml {
984 line: 7,
985 column: 3,
986 message: "broken".into(),
987 };
988 let s = err.to_string();
989 assert!(s.contains("line 7"));
990 assert!(s.contains("column 3"));
991 }
992
993 #[test]
994 fn hooks_config_error_source_for_non_io_variants_is_none() {
995 use std::error::Error;
996 let v = HooksConfigError::Validation {
997 field: "x".into(),
998 reason: "y".into(),
999 };
1000 assert!(v.source().is_none());
1001 let t = HooksConfigError::Toml {
1002 line: 0,
1003 column: 0,
1004 message: "z".into(),
1005 };
1006 assert!(t.source().is_none());
1007 }
1008
1009 #[test]
1010 fn load_from_file_returns_io_error_for_missing_path() {
1011 let p = std::path::Path::new("/this/path/does/not/exist/hooks-test.toml");
1012 let err = HookConfig::load_from_file(p).unwrap_err();
1013 assert!(matches!(err, HooksConfigError::Io(_)));
1014 }
1015
1016 #[test]
1017 fn rejects_whitespace_only_namespace() {
1018 let toml_src = r#"
1019[[hook]]
1020event = "post_store"
1021command = "/bin/true"
1022priority = 0
1023timeout_ms = 1000
1024mode = "exec"
1025enabled = true
1026namespace = " "
1027"#;
1028 let err = HookConfig::load_from_str(toml_src).unwrap_err();
1029 match err {
1030 HooksConfigError::Validation { field, .. } => {
1031 assert!(field.ends_with("namespace"));
1032 }
1033 other => panic!("expected Validation, got {other:?}"),
1034 }
1035 }
1036
1037 #[test]
1038 fn byte_offset_to_line_col_handles_multiline_input() {
1039 let s = "first\nsecond\nthird";
1040 assert_eq!(byte_offset_to_line_col(s, 0), (1, 1));
1042 assert_eq!(byte_offset_to_line_col(s, 5), (1, 6));
1044 assert_eq!(byte_offset_to_line_col(s, 6), (2, 1));
1046 let (line, _) = byte_offset_to_line_col(s, 9_999);
1048 assert!(line >= 3);
1049 }
1050}