Skip to main content

entracte_lib/
hooks.rs

1//! User-configurable shell commands that fire on scheduler events.
2//!
3//! Hooks are off by default and gated behind a confirmation dialog
4//! (see `scheduler::commands::hooks::set_hooks`). The threat model is
5//! documented in `docs/HOOKS.md` — anyone with write access to
6//! `settings.json` can run arbitrary code as the user, so the master
7//! `hooks_enabled` toggle is the sole trust boundary.
8
9use std::process::{Command, Stdio};
10
11use log::warn;
12use serde::{Deserialize, Serialize};
13
14use crate::scheduler::{BreakKind, Settings};
15
16/// Hard cap on hooks fired per event. A misconfigured (or malicious)
17/// `settings.json` could otherwise register thousands of entries and
18/// fork-bomb the host on every break boundary. 32 is well above any
19/// realistic per-event subscription count.
20pub const MAX_HOOKS_PER_EVENT: usize = 32;
21
22/// The scheduler events a hook can subscribe to. Serialised as the
23/// lowercase snake-case name (also the value passed in `$ENTRACTE_EVENT`).
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "snake_case")]
26pub enum HookEvent {
27    BreakStart,
28    BreakEnd,
29    BreakPostponed,
30    BreakSkipped,
31    PauseStart,
32    PauseEnd,
33}
34
35impl HookEvent {
36    /// The string form that goes into `$ENTRACTE_EVENT`.
37    pub fn as_str(self) -> &'static str {
38        match self {
39            HookEvent::BreakStart => "break_start",
40            HookEvent::BreakEnd => "break_end",
41            HookEvent::BreakPostponed => "break_postponed",
42            HookEvent::BreakSkipped => "break_skipped",
43            HookEvent::PauseStart => "pause_start",
44            HookEvent::PauseEnd => "pause_end",
45        }
46    }
47}
48
49/// One configured hook: subscribe to `event`, run `command` when it
50/// fires (if `enabled`). `command` is POSIX-style argv that we split
51/// with `shell-words` — no shell is involved, so pipes / redirects
52/// need an explicit `sh -c` wrapper.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct Hook {
55    pub event: HookEvent,
56    pub command: String,
57    pub enabled: bool,
58}
59
60/// Per-call context populated by the scheduler. Fields show up as
61/// `$ENTRACTE_KIND`, `$ENTRACTE_DURATION_SECS`, `$ENTRACTE_OUTCOME`
62/// when the hook child runs; empty when not applicable to the event.
63#[derive(Debug, Clone, Default)]
64pub struct HookContext {
65    pub kind: Option<BreakKind>,
66    pub duration_secs: Option<u64>,
67    pub outcome: Option<String>,
68}
69
70impl HookContext {
71    /// No kind / duration / outcome — used for pause events.
72    pub fn empty() -> Self {
73        Self::default()
74    }
75
76    /// Carry just the break kind. Used for `break_skipped` / `break_postponed`.
77    pub fn with_kind(kind: BreakKind) -> Self {
78        Self {
79            kind: Some(kind),
80            ..Self::default()
81        }
82    }
83
84    /// Carry the break kind plus its scheduled duration. Used for
85    /// `break_start`.
86    pub fn with_kind_duration(kind: BreakKind, duration_secs: u64) -> Self {
87        Self {
88            kind: Some(kind),
89            duration_secs: Some(duration_secs),
90            ..Self::default()
91        }
92    }
93
94    /// Carry the break kind plus an outcome string
95    /// (`"completed"` / `"dismissed"`). Used for `break_end`.
96    pub fn with_kind_outcome(kind: BreakKind, outcome: impl Into<String>) -> Self {
97        Self {
98            kind: Some(kind),
99            outcome: Some(outcome.into()),
100            ..Self::default()
101        }
102    }
103}
104
105fn kind_str(kind: BreakKind) -> &'static str {
106    match kind {
107        BreakKind::Micro => "micro",
108        BreakKind::Long => "long",
109        BreakKind::Sleep => "sleep",
110    }
111}
112
113/// Build the `(key, value)` env vars handed to the hook child:
114/// `ENTRACTE_EVENT`, `ENTRACTE_KIND`, `ENTRACTE_DURATION_SECS`,
115/// `ENTRACTE_OUTCOME`. Missing fields are empty strings so consumers
116/// can shell-test them uniformly.
117pub fn build_env(event: HookEvent, ctx: &HookContext) -> Vec<(String, String)> {
118    vec![
119        ("ENTRACTE_EVENT".to_string(), event.as_str().to_string()),
120        (
121            "ENTRACTE_KIND".to_string(),
122            ctx.kind.map(kind_str).unwrap_or("").to_string(),
123        ),
124        (
125            "ENTRACTE_DURATION_SECS".to_string(),
126            ctx.duration_secs.map(|d| d.to_string()).unwrap_or_default(),
127        ),
128        (
129            "ENTRACTE_OUTCOME".to_string(),
130            ctx.outcome.clone().unwrap_or_default(),
131        ),
132    ]
133}
134
135/// Return the subset of hooks that should fire for `event`. Returns
136/// empty when the master `hooks_enabled` toggle is off, regardless of
137/// per-hook `enabled` flags.
138pub fn matching_hooks(settings: &Settings, event: HookEvent) -> Vec<&Hook> {
139    if !settings.hooks_enabled {
140        return Vec::new();
141    }
142    settings
143        .hooks
144        .iter()
145        .filter(|h| h.enabled && h.event == event)
146        .collect()
147}
148
149/// Fire every matching hook for `event`. Each child runs on its own
150/// std::thread and inherits stdio from `/dev/null`. Fire-and-forget:
151/// we never block on the child or capture its output.
152///
153/// Capped at [`MAX_HOOKS_PER_EVENT`] — anything beyond is dropped with
154/// a warning. See [`run_hooks_with`] for a test-friendly version that
155/// reports back which hooks would fire without actually spawning.
156pub fn run_hooks(settings: &Settings, event: HookEvent, ctx: HookContext) {
157    run_hooks_with(settings, event, ctx, |hook, env| {
158        let command = hook.command.clone();
159        let env = env.to_vec();
160        std::thread::spawn(move || {
161            spawn_hook(&command, &env);
162        });
163    });
164}
165
166/// Same as [`run_hooks`] but delegates spawning to `spawn`. The callback
167/// receives each [`Hook`] (already filtered by `event` and `enabled`,
168/// and already truncated to [`MAX_HOOKS_PER_EVENT`]) plus the env vars
169/// that would be passed to its child. Used by tests to verify the cap
170/// without actually shelling out hundreds of processes.
171pub fn run_hooks_with(
172    settings: &Settings,
173    event: HookEvent,
174    ctx: HookContext,
175    mut spawn: impl FnMut(&Hook, &[(String, String)]),
176) {
177    let mut hooks: Vec<Hook> = matching_hooks(settings, event)
178        .into_iter()
179        .cloned()
180        .collect();
181    if hooks.is_empty() {
182        return;
183    }
184    if hooks.len() > MAX_HOOKS_PER_EVENT {
185        warn!(
186            "hooks: '{}' has {} entries, exceeding MAX_HOOKS_PER_EVENT={MAX_HOOKS_PER_EVENT}; \
187             firing only the first {MAX_HOOKS_PER_EVENT}",
188            event.as_str(),
189            hooks.len(),
190        );
191        hooks.truncate(MAX_HOOKS_PER_EVENT);
192    }
193    let env = build_env(event, &ctx);
194    for hook in &hooks {
195        spawn(hook, &env);
196    }
197}
198
199pub(crate) fn spawn_hook(command: &str, env: &[(String, String)]) {
200    let argv = match shell_words::split(command) {
201        Ok(v) => v,
202        Err(e) => {
203            warn!(
204                "hooks: failed to parse command (len={}): {e}",
205                command.len()
206            );
207            return;
208        }
209    };
210    let mut iter = argv.into_iter();
211    let program = match iter.next() {
212        Some(p) => p,
213        None => {
214            warn!("hooks: empty command");
215            return;
216        }
217    };
218    let args: Vec<String> = iter.collect();
219    let program_basename = program_log_label(&program);
220    let mut cmd = Command::new(&program);
221    cmd.args(&args);
222    // Detach the child from Entracte's stdio. Without this, hook children
223    // inherit our stdout/stderr — which in release builds includes the
224    // 0o600-tightened log file. A misbehaving hook could race writes into
225    // that fd and keep it open across log rotations.
226    cmd.stdin(Stdio::null())
227        .stdout(Stdio::null())
228        .stderr(Stdio::null());
229    for (k, v) in env {
230        cmd.env(k, v);
231    }
232    if let Err(e) = cmd.spawn() {
233        warn!(
234            "hooks: failed to spawn {program_basename} (argc={}): {e}",
235            args.len()
236        );
237    }
238}
239
240fn program_log_label(program: &str) -> String {
241    let basename = std::path::Path::new(program)
242        .file_name()
243        .and_then(|n| n.to_str())
244        .unwrap_or(program);
245    if basename.chars().count() > 64 {
246        let mut out: String = basename.chars().take(64).collect();
247        out.push('…');
248        out
249    } else {
250        basename.to_string()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn program_log_label_strips_path_components() {
260        assert_eq!(program_log_label("/usr/bin/curl"), "curl");
261        assert_eq!(program_log_label("curl"), "curl");
262        assert_eq!(program_log_label("/opt/bin/my-script.sh"), "my-script.sh");
263    }
264
265    #[test]
266    fn program_log_label_truncates_long_names() {
267        let s = "a".repeat(200);
268        let out = program_log_label(&s);
269        assert_eq!(out.chars().count(), 65);
270        assert!(out.ends_with('…'));
271    }
272
273    #[test]
274    fn program_log_label_handles_multibyte_chars_without_panic() {
275        // Pre-fix this byte-sliced at index 64 and panicked on the UTF-8 boundary.
276        let s = "/usr/bin/".to_string() + &"тест".repeat(40);
277        let out = program_log_label(&s);
278        assert!(out.chars().count() <= 65);
279        if out.ends_with('…') {
280            assert_eq!(out.chars().count(), 65);
281        }
282    }
283
284    #[test]
285    fn program_log_label_handles_emoji_path_without_panic() {
286        let s = "/opt/".to_string() + &"😀".repeat(70);
287        let out = program_log_label(&s);
288        assert_eq!(out.chars().count(), 65);
289        assert!(out.ends_with('…'));
290    }
291
292    fn env_get(env: &[(String, String)], key: &str) -> String {
293        env.iter()
294            .find(|(k, _)| k == key)
295            .map(|(_, v)| v.clone())
296            .unwrap_or_default()
297    }
298
299    #[test]
300    fn build_env_break_start_has_kind_and_duration() {
301        let env = build_env(
302            HookEvent::BreakStart,
303            &HookContext::with_kind_duration(BreakKind::Micro, 600),
304        );
305        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "break_start");
306        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "micro");
307        assert_eq!(env_get(&env, "ENTRACTE_DURATION_SECS"), "600");
308        assert_eq!(env_get(&env, "ENTRACTE_OUTCOME"), "");
309    }
310
311    #[test]
312    fn build_env_break_end_has_outcome() {
313        let env = build_env(
314            HookEvent::BreakEnd,
315            &HookContext::with_kind_outcome(BreakKind::Long, "completed"),
316        );
317        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "break_end");
318        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "long");
319        assert_eq!(env_get(&env, "ENTRACTE_DURATION_SECS"), "");
320        assert_eq!(env_get(&env, "ENTRACTE_OUTCOME"), "completed");
321    }
322
323    #[test]
324    fn build_env_break_postponed_kind_only() {
325        let env = build_env(
326            HookEvent::BreakPostponed,
327            &HookContext::with_kind(BreakKind::Micro),
328        );
329        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "break_postponed");
330        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "micro");
331        assert_eq!(env_get(&env, "ENTRACTE_DURATION_SECS"), "");
332        assert_eq!(env_get(&env, "ENTRACTE_OUTCOME"), "");
333    }
334
335    #[test]
336    fn build_env_break_skipped_kind_only() {
337        let env = build_env(
338            HookEvent::BreakSkipped,
339            &HookContext::with_kind(BreakKind::Long),
340        );
341        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "break_skipped");
342        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "long");
343    }
344
345    #[test]
346    fn build_env_pause_start_empty_context() {
347        let env = build_env(HookEvent::PauseStart, &HookContext::empty());
348        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "pause_start");
349        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "");
350        assert_eq!(env_get(&env, "ENTRACTE_DURATION_SECS"), "");
351        assert_eq!(env_get(&env, "ENTRACTE_OUTCOME"), "");
352    }
353
354    #[test]
355    fn build_env_pause_end_empty_context() {
356        let env = build_env(HookEvent::PauseEnd, &HookContext::empty());
357        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "pause_end");
358        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "");
359    }
360
361    #[test]
362    fn matching_hooks_returns_empty_when_master_toggle_off() {
363        let s = Settings {
364            hooks_enabled: false,
365            hooks: vec![Hook {
366                event: HookEvent::BreakStart,
367                command: "echo hi".into(),
368                enabled: true,
369            }],
370            ..Settings::default()
371        };
372        assert!(matching_hooks(&s, HookEvent::BreakStart).is_empty());
373    }
374
375    #[test]
376    fn matching_hooks_filters_by_event_and_enabled() {
377        let s = Settings {
378            hooks_enabled: true,
379            hooks: vec![
380                Hook {
381                    event: HookEvent::BreakStart,
382                    command: "a".into(),
383                    enabled: true,
384                },
385                Hook {
386                    event: HookEvent::BreakStart,
387                    command: "b".into(),
388                    enabled: false,
389                },
390                Hook {
391                    event: HookEvent::BreakEnd,
392                    command: "c".into(),
393                    enabled: true,
394                },
395            ],
396            ..Settings::default()
397        };
398        let m = matching_hooks(&s, HookEvent::BreakStart);
399        assert_eq!(m.len(), 1);
400        assert_eq!(m[0].command, "a");
401    }
402
403    #[test]
404    fn shell_words_splits_quoted_argv() {
405        let parts = shell_words::split(r#"cmd a b "c d""#).unwrap();
406        assert_eq!(parts, vec!["cmd", "a", "b", "c d"]);
407    }
408
409    #[test]
410    fn run_hooks_with_caps_at_max_per_event() {
411        let big: Vec<Hook> = (0..(MAX_HOOKS_PER_EVENT * 4))
412            .map(|i| Hook {
413                event: HookEvent::BreakStart,
414                command: format!("echo {i}"),
415                enabled: true,
416            })
417            .collect();
418        let s = Settings {
419            hooks_enabled: true,
420            hooks: big,
421            ..Settings::default()
422        };
423        let mut fired = 0usize;
424        run_hooks_with(&s, HookEvent::BreakStart, HookContext::empty(), |_, _| {
425            fired += 1;
426        });
427        assert_eq!(fired, MAX_HOOKS_PER_EVENT);
428    }
429
430    #[test]
431    fn run_hooks_with_fires_all_when_under_cap() {
432        let s = Settings {
433            hooks_enabled: true,
434            hooks: vec![
435                Hook {
436                    event: HookEvent::PauseStart,
437                    command: "a".into(),
438                    enabled: true,
439                },
440                Hook {
441                    event: HookEvent::PauseStart,
442                    command: "b".into(),
443                    enabled: true,
444                },
445            ],
446            ..Settings::default()
447        };
448        let mut fired = 0usize;
449        run_hooks_with(&s, HookEvent::PauseStart, HookContext::empty(), |_, _| {
450            fired += 1;
451        });
452        assert_eq!(fired, 2);
453    }
454
455    #[test]
456    fn run_hooks_with_passes_env_vars_to_spawn_callback() {
457        let s = Settings {
458            hooks_enabled: true,
459            hooks: vec![Hook {
460                event: HookEvent::BreakStart,
461                command: "echo".into(),
462                enabled: true,
463            }],
464            ..Settings::default()
465        };
466        let mut captured: Vec<(String, String)> = Vec::new();
467        run_hooks_with(
468            &s,
469            HookEvent::BreakStart,
470            HookContext::with_kind_duration(BreakKind::Long, 1200),
471            |_, env| captured = env.to_vec(),
472        );
473        let get = |k: &str| -> String {
474            captured
475                .iter()
476                .find(|(key, _)| key == k)
477                .map(|(_, v)| v.clone())
478                .unwrap_or_default()
479        };
480        assert_eq!(get("ENTRACTE_EVENT"), "break_start");
481        assert_eq!(get("ENTRACTE_KIND"), "long");
482        assert_eq!(get("ENTRACTE_DURATION_SECS"), "1200");
483    }
484
485    #[test]
486    fn hook_list_serde_roundtrip() {
487        let hooks = vec![
488            Hook {
489                event: HookEvent::BreakStart,
490                command: "echo start".into(),
491                enabled: true,
492            },
493            Hook {
494                event: HookEvent::PauseEnd,
495                command: "sh -c \"date >> /tmp/log\"".into(),
496                enabled: false,
497            },
498        ];
499        let json = serde_json::to_string(&hooks).unwrap();
500        let back: Vec<Hook> = serde_json::from_str(&json).unwrap();
501        assert_eq!(back, hooks);
502        assert!(json.contains("\"event\":\"break_start\""));
503        assert!(json.contains("\"event\":\"pause_end\""));
504    }
505
506    // Exec-path coverage. Writes a tiny script that records its env into
507    // a tempfile, runs `run_hooks` against it, then polls until the
508    // tempfile appears (with a 2s ceiling so a busy CI machine doesn't
509    // false-fail). Asserts the env contains the keys the public docs
510    // promise. Unix uses `/bin/sh`; Windows uses `cmd.exe /c` via a
511    // `.bat` script.
512
513    #[cfg(unix)]
514    fn write_recorder_script(
515        dir: &std::path::Path,
516        output: &std::path::Path,
517    ) -> std::path::PathBuf {
518        use std::io::Write;
519        use std::os::unix::fs::PermissionsExt;
520        let stem = output
521            .file_stem()
522            .and_then(|s| s.to_str())
523            .unwrap_or("record");
524        let script = dir.join(format!("record-env-{stem}.sh"));
525        let body = format!(
526            "#!/bin/sh\n\
527             {{\n\
528               printf 'ENTRACTE_EVENT=%s\\n' \"$ENTRACTE_EVENT\"\n\
529               printf 'ENTRACTE_KIND=%s\\n' \"$ENTRACTE_KIND\"\n\
530               printf 'ENTRACTE_DURATION_SECS=%s\\n' \"$ENTRACTE_DURATION_SECS\"\n\
531               printf 'ENTRACTE_OUTCOME=%s\\n' \"$ENTRACTE_OUTCOME\"\n\
532               printf 'ENTRACTE_DONE=1\\n'\n\
533             }} > '{}'\n",
534            output.display()
535        );
536        let mut f = std::fs::File::create(&script).unwrap();
537        f.write_all(body.as_bytes()).unwrap();
538        drop(f);
539        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
540        script
541    }
542
543    #[cfg(windows)]
544    fn write_recorder_script(
545        dir: &std::path::Path,
546        output: &std::path::Path,
547    ) -> std::path::PathBuf {
548        use std::io::Write;
549        let stem = output
550            .file_stem()
551            .and_then(|s| s.to_str())
552            .unwrap_or("record");
553        let script = dir.join(format!("record-env-{stem}.bat"));
554        // Quoting: `>` redirects, double-percent escapes the env-var sigil
555        // for batch. The script writes one KEY=VALUE per line so the test
556        // can grep for substrings without parsing. The trailing
557        // `ENTRACTE_DONE=1` is the sentinel `wait_for_file` polls for —
558        // cmd.exe's redirect can flush mid-block on slow runners, so
559        // returning on first non-empty read produced partial contents.
560        let body = format!(
561            "@echo off\r\n\
562             (\r\n\
563               echo ENTRACTE_EVENT=%ENTRACTE_EVENT%\r\n\
564               echo ENTRACTE_KIND=%ENTRACTE_KIND%\r\n\
565               echo ENTRACTE_DURATION_SECS=%ENTRACTE_DURATION_SECS%\r\n\
566               echo ENTRACTE_OUTCOME=%ENTRACTE_OUTCOME%\r\n\
567               echo ENTRACTE_DONE=1\r\n\
568             ) > \"{}\"\r\n",
569            output.display()
570        );
571        let mut f = std::fs::File::create(&script).unwrap();
572        f.write_all(body.as_bytes()).unwrap();
573        script
574    }
575
576    #[cfg(unix)]
577    fn invoke_command(script: &std::path::Path) -> String {
578        script.display().to_string()
579    }
580
581    #[cfg(windows)]
582    fn invoke_command(script: &std::path::Path) -> String {
583        // `Command::new("foo.bat")` does not execute .bat files on Windows;
584        // they must be run through cmd.exe. Forward slashes keep the path
585        // safe from shell_words backslash escaping.
586        let path = script.display().to_string().replace('\\', "/");
587        format!("cmd /c \"{path}\"")
588    }
589
590    fn wait_for_file(path: &std::path::Path) -> String {
591        // Wait for the recorder's `ENTRACTE_DONE=1` sentinel rather than
592        // just non-empty contents. On Windows, cmd.exe's `( ... ) > file`
593        // can flush mid-block, so a non-empty read can return only the
594        // first line and make later substring assertions fail.
595        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
596        loop {
597            if let Ok(s) = std::fs::read_to_string(path) {
598                if s.contains("ENTRACTE_DONE=1") {
599                    return s;
600                }
601            }
602            if std::time::Instant::now() > deadline {
603                panic!("hook script never produced output at {}", path.display());
604            }
605            std::thread::sleep(std::time::Duration::from_millis(25));
606        }
607    }
608
609    #[test]
610    fn spawn_hook_executes_script_with_env_vars() {
611        let dir = crate::test_support::temp_dir();
612        let output = dir.path().join("env.txt");
613        let script = write_recorder_script(dir.path(), &output);
614        let command = invoke_command(&script);
615        let env = build_env(
616            HookEvent::BreakStart,
617            &HookContext::with_kind_duration(BreakKind::Long, 1200),
618        );
619        spawn_hook(&command, &env);
620        let body = wait_for_file(&output);
621        assert!(body.contains("ENTRACTE_EVENT=break_start"), "got: {body}");
622        assert!(body.contains("ENTRACTE_KIND=long"), "got: {body}");
623        assert!(body.contains("ENTRACTE_DURATION_SECS=1200"), "got: {body}");
624    }
625
626    #[test]
627    fn run_hooks_dispatches_to_matching_event_only() {
628        // Two hooks subscribed to different events; only the matching one
629        // should fire. Asserted by checking which tempfile gets written.
630        let dir = crate::test_support::temp_dir();
631        let break_out = dir.path().join("break.txt");
632        let pause_out = dir.path().join("pause.txt");
633        let break_script = write_recorder_script(dir.path(), &break_out);
634        let pause_script = write_recorder_script(dir.path(), &pause_out);
635        let settings = Settings {
636            hooks_enabled: true,
637            hooks: vec![
638                Hook {
639                    event: HookEvent::BreakEnd,
640                    command: invoke_command(&break_script),
641                    enabled: true,
642                },
643                Hook {
644                    event: HookEvent::PauseStart,
645                    command: invoke_command(&pause_script),
646                    enabled: true,
647                },
648            ],
649            ..Settings::default()
650        };
651        run_hooks(
652            &settings,
653            HookEvent::BreakEnd,
654            HookContext::with_kind_outcome(BreakKind::Micro, "completed"),
655        );
656        let body = wait_for_file(&break_out);
657        assert!(body.contains("ENTRACTE_KIND=micro"), "got: {body}");
658        assert!(body.contains("ENTRACTE_OUTCOME=completed"), "got: {body}");
659        // The unrelated PauseStart hook must not have fired.
660        std::thread::sleep(std::time::Duration::from_millis(150));
661        assert!(!pause_out.exists(), "pause hook fired for break_end event");
662    }
663
664    #[test]
665    fn run_hooks_no_op_when_master_toggle_off() {
666        let dir = crate::test_support::temp_dir();
667        let output = dir.path().join("env.txt");
668        let script = write_recorder_script(dir.path(), &output);
669        let settings = Settings {
670            hooks_enabled: false,
671            hooks: vec![Hook {
672                event: HookEvent::BreakStart,
673                command: invoke_command(&script),
674                enabled: true,
675            }],
676            ..Settings::default()
677        };
678        run_hooks(
679            &settings,
680            HookEvent::BreakStart,
681            HookContext::with_kind_duration(BreakKind::Micro, 60),
682        );
683        std::thread::sleep(std::time::Duration::from_millis(150));
684        assert!(!output.exists(), "hook ran despite hooks_enabled=false");
685    }
686}