Skip to main content

ai_memory/hooks/
config.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// v0.7 Track G — Task G1: hook configuration schema + SIGHUP hot reload.
5//
6// # Canonical `hooks.toml` schema
7//
8// ```toml
9// [[hook]]
10// event = "post_store"
11// command = "/usr/local/bin/auto-link-detector"
12// priority = 100
13// timeout_ms = 5000
14// mode = "daemon"
15// enabled = true
16// namespace = "team/*"
17// ```
18//
19// Multiple `[[hook]]` blocks may target the same event; insertion
20// order is preserved so G5's chain-ordering pass can apply
21// priority-descending sort deterministically.
22//
23// # Default config path
24//
25// `dirs::config_dir().join("ai-memory/hooks.toml")`. On Linux that
26// resolves to `~/.config/ai-memory/hooks.toml`; on macOS,
27// `~/Library/Application Support/ai-memory/hooks.toml`.
28//
29// # Hot reload
30//
31// `spawn_reload_task` listens for `SIGHUP` and atomically swaps
32// the config snapshot held behind an `Arc<RwLock<…>>`. In-flight
33// hook executions (landing in G3) read the snapshot once at
34// dispatch time, so a reload mid-fire never tears.
35//
36// # Validation rules (G1)
37//
38// * `priority` — any `i32` (descending sort lives in G5).
39// * `timeout_ms` — `u32`, capped at 30_000ms. Larger values are
40//   rejected with a named [`HooksConfigError::Validation`].
41// * `command` — must be non-empty. Path existence is *not*
42//   checked here; the executor (G3) is the right layer for that
43//   so a missing binary surfaces as an executor error with full
44//   context, not a config-parse error before the daemon boots.
45// * `namespace` — non-empty string. A real glob/pattern matcher
46//   does not yet exist in this crate; G2/G3 will swap in the
47//   real one when it ships. See the TODO below.
48// * Parse errors include the failing TOML span (line:col) via
49//   `toml::de::Error::span()` when the underlying error carries
50//   one.
51
52use std::fmt;
53use std::path::{Path, PathBuf};
54use std::sync::Arc;
55
56use serde::{Deserialize, Serialize};
57use tokio::sync::RwLock;
58
59// ---------------------------------------------------------------------------
60// HookEvent
61// ---------------------------------------------------------------------------
62//
63// G1 shipped a 20-variant stub of `HookEvent` here so the
64// configuration loader had a tag type to deserialize against.
65// G2 lifts the canonical definition into `crate::hooks::events`
66// and attaches a payload struct to every variant. The re-export
67// below preserves `use crate::hooks::config::HookEvent` for any
68// caller that landed against the G1 path.
69
70pub use super::events::HookEvent;
71
72// ---------------------------------------------------------------------------
73// HookMode
74// ---------------------------------------------------------------------------
75
76/// Execution mode for a hook entry.
77///
78/// * [`HookMode::Exec`] — subprocess per fire; JSON over stdio.
79/// * [`HookMode::Daemon`] — long-lived child; JSON-RPC framed.
80///
81/// G3 implements both. Hot-path events (`post_recall`,
82/// `post_search`) default to `daemon` to preserve the v0.6.3
83/// 50ms recall budget.
84#[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// ---------------------------------------------------------------------------
92// FailMode
93// ---------------------------------------------------------------------------
94
95/// Hook crash-handling posture, consumed by G5's chain runner.
96///
97/// * [`FailMode::Open`] — when the executor returns `Err` (spawn
98///   failure, decode failure, timeout, daemon unavailable, …) the
99///   chain logs a warning and treats the failed fire as `Allow`. This
100///   is the v0.7 default because the bias on the request path is
101///   "fail open, log loudly" — a buggy hook must not brick recall.
102/// * [`FailMode::Closed`] — the chain converts the executor error
103///   into `ChainResult::Deny` and short-circuits the chain. Reserved
104///   for hooks that gate compliance-critical paths (PII redaction,
105///   regulated-tenant access control) where a silent fail-open is
106///   worse than a hard refusal.
107///
108/// The field is optional in `hooks.toml`; missing entries default to
109/// [`FailMode::Open`] so G3-era configs keep their behaviour after
110/// G5 lands.
111#[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
124/// Serde default helper — `serde(default)` only reaches for `Default::default`
125/// on the *field type*; this named function lets the
126/// `#[serde(default = "...")]` form work without a wrapper newtype.
127fn default_fail_mode() -> FailMode {
128    FailMode::Open
129}
130
131// ---------------------------------------------------------------------------
132// HookConfig
133// ---------------------------------------------------------------------------
134
135/// Maximum allowed `timeout_ms`. A hook taking longer than 30s
136/// is almost certainly a bug; the chain-orchestrator (G5/G6)
137/// would otherwise stall the memory operation that fired it.
138pub const MAX_TIMEOUT_MS: u32 = 30_000;
139
140/// v0.7.0 R3-S3 — default execution mode for a given event.
141///
142/// CLAUDE.md and ROADMAP §4.7 both call out that hot-path events
143/// must default to `mode = "daemon"` so a configured-but-unspecified
144/// hook does not pay subprocess spawn cost on every recall / search.
145/// Pre-R3 this was a documentation-only assertion: `HookConfig.mode`
146/// was a required field, so omitting it produced a parse error rather
147/// than the documented daemon default. R3-S3 closes the gap by
148/// making `mode` optional in TOML and selecting daemon-mode for hot-
149/// path events (`post_recall`, `post_search`, `pre_recall_expand`)
150/// when the operator did not supply one.
151///
152/// Non-hot-path events default to `Exec` — the subprocess-per-fire
153/// posture is the historical and lower-risk choice for cold-path
154/// hooks that may not be written defensively against a long-lived
155/// JSON-RPC framed lifecycle.
156#[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/// One `[[hook]]` block from `hooks.toml`.
167///
168/// v0.7.0 R3-S3 — `mode` is now optional in TOML; missing values are
169/// resolved via [`default_mode_for_event`] (daemon for hot-path
170/// events, exec otherwise). The struct field stays required so the
171/// in-memory representation is unambiguous; only the wire shape is
172/// relaxed.
173#[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    /// G5 — chain crash-handling posture. Defaults to
183    /// [`FailMode::Open`] so existing G3-era configs keep firing
184    /// fail-open even after G5 wires the chain runner in. Hooks
185    /// that gate compliance-critical paths set
186    /// `fail_mode = "closed"` to convert executor errors into a
187    /// chain-level `Deny`.
188    #[serde(default = "default_fail_mode")]
189    pub fail_mode: FailMode,
190}
191
192/// Wire-shape mirror of [`HookConfig`] used only for TOML
193/// deserialization. `mode` is `Option<HookMode>` so missing values
194/// can be filled in from [`default_mode_for_event`] at parse time
195/// (serde defaults can't see sibling fields). The compiled
196/// representation in [`HookConfig`] is unambiguous — the operator-
197/// facing relaxation lives in this struct only.
198#[derive(Debug, Deserialize)]
199struct HookConfigRaw {
200    event: HookEvent,
201    command: PathBuf,
202    priority: i32,
203    timeout_ms: u32,
204    /// v0.7.0 R3-S3 — optional; falls back to
205    /// [`default_mode_for_event`] when missing.
206    #[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
232/// Adapter shape implementing `Deserialize` via [`HookConfigRaw`].
233/// Kept separate from [`HookConfig`] so the public type stays
234/// derive-`Deserialize`-able (callers that build a `HookConfig`
235/// in-memory and then `serde_json::from_value` still work).
236impl<'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/// Top-level TOML shape: `[[hook]]` blocks collect into
246/// `hooks: Vec<HookConfig>`.
247#[derive(Debug, Deserialize)]
248struct HooksFile {
249    #[serde(default, rename = "hook")]
250    hooks: Vec<HookConfig>,
251}
252
253impl HookConfig {
254    /// Load and validate the hook config file at `path`.
255    ///
256    /// Returns the hook entries in their original on-disk order;
257    /// G5's chain ordering pass is responsible for the
258    /// priority-descending sort.
259    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    /// Parse + validate from a TOML string. Split out from
265    /// [`Self::load_from_file`] so unit tests can exercise the
266    /// parser without touching disk.
267    pub fn load_from_str(contents: &str) -> Result<Vec<HookConfig>, HooksConfigError> {
268        let parsed: HooksFile = toml::from_str(contents).map_err(|e| {
269            // toml 0.8's `de::Error::span()` returns a byte range
270            // into the input; convert to (line, col) for the
271            // operator-facing error message.
272            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    /// `dirs::config_dir().join("ai-memory/hooks.toml")` — the
291    /// platform-correct default location.
292    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    // TODO(G2/G3): validate namespace against the real
311    // pattern matcher once it ships. Today no glob matcher
312    // exists in src/ — `db::matches_subtree` is prefix-only
313    // and not callable from this layer. For now we accept any
314    // non-empty string.
315    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
324/// Convert a byte offset into a 1-indexed (line, column) pair
325/// suitable for human-facing error messages.
326fn 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// ---------------------------------------------------------------------------
344// HooksConfigError
345// ---------------------------------------------------------------------------
346
347/// Errors surfaced by the hook config loader.
348#[derive(Debug)]
349pub enum HooksConfigError {
350    /// Could not read the config file.
351    Io(std::io::Error),
352    /// TOML parse failure. `line` / `column` are 1-indexed when
353    /// the underlying error carried a span; otherwise both are
354    /// `0` to signal "location unknown".
355    Toml {
356        line: usize,
357        column: usize,
358        message: String,
359    },
360    /// Schema-level validation failure (e.g. `timeout_ms` over
361    /// the 30s ceiling). `field` names the offending entry using
362    /// `hook[<idx>].<field>` so operators can locate it in the
363    /// source TOML.
364    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
401// ---------------------------------------------------------------------------
402// Hot reload (SIGHUP)
403// ---------------------------------------------------------------------------
404
405/// Shared, hot-swappable snapshot of the loaded hook config. The
406/// executor (G3) holds an `Arc<HookConfigSnapshot>` and reads it
407/// once per dispatch so an in-flight execution always lands on a
408/// consistent view of the config — even if SIGHUP arrives mid-fire.
409pub type HookConfigSnapshot = RwLock<Vec<HookConfig>>;
410
411/// Spawn a tokio task that listens for `SIGHUP` and reloads
412/// `path` into `snapshot` on every signal.
413///
414/// G1 ships the signal-handler plumbing; the hooks loaded into
415/// the snapshot become live as soon as G3's executor starts
416/// reading from it. Until then this is a no-op observable only
417/// via a `tracing::info!` log line per reload — exactly what the
418/// G1 epic doc calls for ("for now just load + emit a tracing
419/// info on reload").
420///
421/// Returns the [`tokio::task::JoinHandle`] so the daemon main
422/// loop can shut the task down on graceful exit.
423#[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                    // Reload failure leaves the previous
453                    // snapshot in place — operators get a loud
454                    // error log but the running daemon keeps
455                    // serving with the last-known-good config.
456                    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// On non-unix platforms SIGHUP doesn't exist. The daemon is
468// unix-only in practice (the Linux/macOS systemd + launchd
469// units are the only supported deployments), so this is a stub
470// to keep the windows build green for tooling like `cargo
471// check --target x86_64-pc-windows-msvc`.
472#[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// ---------------------------------------------------------------------------
483// Tests
484// ---------------------------------------------------------------------------
485
486#[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        // `mode = ` with no value — the parser will fail on the
544        // line carrying the broken assignment. We assert the
545        // error names a non-zero line so operators can grep for it.
546        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        // All three target the same event.
599        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    // -----------------------------------------------------------------
646    // v0.7.0 R3-S3 — hot-path daemon-mode default
647    // -----------------------------------------------------------------
648
649    /// `test_post_recall_default_mode_is_daemon` — when a `post_recall`
650    /// hook block omits `mode`, the loader fills it in with `Daemon`
651    /// per CLAUDE.md + ROADMAP §4.7. Pre-R3 this was a doc-only
652    /// claim: `mode` was a required field, so an unspecified `mode`
653    /// produced a parse error rather than the documented daemon
654    /// default. R3-S3 closes the gap.
655    #[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_post_search_default_mode_is_daemon` — sibling of the
677    /// post_recall test; post_search is the second documented
678    /// hot-path event and must share the daemon default.
679    #[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    /// `pre_recall_expand` shares the hot-path budget (G10) so it
700    /// must also default to daemon mode.
701    #[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    /// Non-hot-path events keep the historical `Exec` default. R3-S3
717    /// only narrows the daemon-default to events that pay subprocess
718    /// spawn cost on the recall p95 budget; cold-path hooks remain
719    /// `Exec` for compatibility with hook scripts written against the
720    /// per-fire subprocess lifecycle.
721    #[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    /// Explicit `mode` in TOML still wins — the R3-S3 default kicks
741    /// in only when the field is *absent*. This preserves any
742    /// existing operator configuration that opted into a specific
743    /// mode against the new default.
744    #[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    /// Hot-reload smoke test: load config A, replace the file
774    /// on disk, call `load_from_file` again (the same code path
775    /// the SIGHUP task drives), assert the snapshot now reflects
776    /// config B.
777    ///
778    /// We exercise the loader directly rather than spawning the
779    /// signal task because portable + deterministic test signal
780    /// delivery on macOS+Linux is fiddly enough that the value
781    /// add lives in the loader, not in tokio's signal plumbing.
782    /// G3 will gain an end-to-end SIGHUP integration test once
783    /// the executor is wired in.
784    #[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        // Replace on-disk content with config B (different
803        // command, two entries) — this mirrors what an operator
804        // does before sending SIGHUP.
805        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        // Drive the same code path the SIGHUP task uses.
827        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        // We can't assert the full path on every platform but we
844        // can verify it ends with `ai-memory/hooks.toml` when
845        // `dirs::config_dir()` resolves at all.
846        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        // Sanity-check the rename — config files use
858        // `pre_governance_decision` not `PreGovernanceDecision`.
859        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        // Hot-path events default to Daemon.
892        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        // Cold-path events default to Exec.
905        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        // source() returns Some for Io variant
962        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        // Manually construct (we can't easily force toml to produce a
969        // no-span error from a public API, but this covers the `line == 0`
970        // branch of Display).
971        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        // offset 0 = line 1, col 1
1041        assert_eq!(byte_offset_to_line_col(s, 0), (1, 1));
1042        // offset 5 (newline after "first") still on line 1
1043        assert_eq!(byte_offset_to_line_col(s, 5), (1, 6));
1044        // offset 6 (start of "second") = line 2
1045        assert_eq!(byte_offset_to_line_col(s, 6), (2, 1));
1046        // offset way past end = still walks to end
1047        let (line, _) = byte_offset_to_line_col(s, 9_999);
1048        assert!(line >= 3);
1049    }
1050}