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};
10use std::time::Duration;
11
12use log::warn;
13use serde::{Deserialize, Serialize};
14
15use crate::scheduler::{BreakKind, Settings};
16
17/// Hard cap on hooks fired per event. A misconfigured (or malicious)
18/// `settings.json` could otherwise register thousands of entries and
19/// fork-bomb the host on every break boundary. 32 is well above any
20/// realistic per-event subscription count.
21pub const MAX_HOOKS_PER_EVENT: usize = 32;
22
23/// Hard cap on how long a hook child may run before it's killed. Hooks are
24/// fire-and-forget, so a hung one — an accidental infinite loop, a read that
25/// blocks despite the null stdin — would otherwise live until the app exits.
26/// 30s is generous for the quick notify/log commands hooks are meant for
27/// while still bounding a runaway.
28const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
29
30/// The scheduler events a hook can subscribe to. Serialised as the
31/// lowercase snake-case name (also the value passed in `$ENTRACTE_EVENT`).
32#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34pub enum HookEvent {
35    BreakStart,
36    BreakEnd,
37    BreakPostponed,
38    BreakSkipped,
39    PauseStart,
40    PauseEnd,
41}
42
43impl HookEvent {
44    /// The string form that goes into `$ENTRACTE_EVENT`.
45    pub fn as_str(self) -> &'static str {
46        match self {
47            HookEvent::BreakStart => "break_start",
48            HookEvent::BreakEnd => "break_end",
49            HookEvent::BreakPostponed => "break_postponed",
50            HookEvent::BreakSkipped => "break_skipped",
51            HookEvent::PauseStart => "pause_start",
52            HookEvent::PauseEnd => "pause_end",
53        }
54    }
55}
56
57/// One configured hook: subscribe to `event`, run `command` when it
58/// fires (if `enabled`). `command` is POSIX-style argv that we split
59/// with `shell-words` — no shell is involved, so pipes / redirects
60/// need an explicit `sh -c` wrapper.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct Hook {
63    pub event: HookEvent,
64    pub command: String,
65    pub enabled: bool,
66}
67
68/// Per-call context populated by the scheduler. Fields show up as
69/// `$ENTRACTE_KIND`, `$ENTRACTE_DURATION_SECS`, `$ENTRACTE_OUTCOME`
70/// when the hook child runs; empty when not applicable to the event.
71#[derive(Debug, Clone, Default)]
72pub struct HookContext {
73    pub kind: Option<BreakKind>,
74    pub duration_secs: Option<u64>,
75    pub outcome: Option<String>,
76}
77
78impl HookContext {
79    /// No kind / duration / outcome — used for pause events.
80    pub fn empty() -> Self {
81        Self::default()
82    }
83
84    /// Carry just the break kind. Used for `break_skipped` / `break_postponed`.
85    pub fn with_kind(kind: BreakKind) -> Self {
86        Self {
87            kind: Some(kind),
88            ..Self::default()
89        }
90    }
91
92    /// Carry the break kind plus its scheduled duration. Used for
93    /// `break_start`.
94    pub fn with_kind_duration(kind: BreakKind, duration_secs: u64) -> Self {
95        Self {
96            kind: Some(kind),
97            duration_secs: Some(duration_secs),
98            ..Self::default()
99        }
100    }
101
102    /// Carry the break kind plus an outcome string
103    /// (`"completed"` / `"dismissed"`). Used for `break_end`.
104    pub fn with_kind_outcome(kind: BreakKind, outcome: impl Into<String>) -> Self {
105        Self {
106            kind: Some(kind),
107            outcome: Some(outcome.into()),
108            ..Self::default()
109        }
110    }
111}
112
113fn kind_str(kind: BreakKind) -> &'static str {
114    match kind {
115        BreakKind::Micro => "micro",
116        BreakKind::Long => "long",
117        BreakKind::Sleep => "sleep",
118    }
119}
120
121/// Build the `(key, value)` env vars handed to the hook child:
122/// `ENTRACTE_EVENT`, `ENTRACTE_KIND`, `ENTRACTE_DURATION_SECS`,
123/// `ENTRACTE_OUTCOME`. Missing fields are empty strings so consumers
124/// can shell-test them uniformly.
125pub fn build_env(event: HookEvent, ctx: &HookContext) -> Vec<(String, String)> {
126    vec![
127        ("ENTRACTE_EVENT".to_string(), event.as_str().to_string()),
128        (
129            "ENTRACTE_KIND".to_string(),
130            ctx.kind.map(kind_str).unwrap_or("").to_string(),
131        ),
132        (
133            "ENTRACTE_DURATION_SECS".to_string(),
134            ctx.duration_secs.map(|d| d.to_string()).unwrap_or_default(),
135        ),
136        (
137            "ENTRACTE_OUTCOME".to_string(),
138            ctx.outcome.clone().unwrap_or_default(),
139        ),
140    ]
141}
142
143/// Return the subset of hooks that should fire for `event`. Returns
144/// empty when the master `hooks_enabled` toggle is off, regardless of
145/// per-hook `enabled` flags.
146pub fn matching_hooks(settings: &Settings, event: HookEvent) -> Vec<&Hook> {
147    if !settings.hooks_enabled {
148        return Vec::new();
149    }
150    settings
151        .hooks
152        .iter()
153        .filter(|h| h.enabled && h.event == event)
154        .collect()
155}
156
157/// Fire every matching hook for `event`. Each child runs on its own
158/// std::thread with stdio set to `/dev/null`. We don't capture output, but
159/// the thread does reap the child (and kill it after [`HOOK_TIMEOUT`]) so a
160/// fire-and-forget hook can't leave a zombie or a runaway behind.
161///
162/// Capped at [`MAX_HOOKS_PER_EVENT`] — anything beyond is dropped with
163/// a warning. See [`run_hooks_with`] for a test-friendly version that
164/// reports back which hooks would fire without actually spawning.
165pub fn run_hooks(settings: &Settings, event: HookEvent, ctx: HookContext) {
166    run_hooks_with(settings, event, ctx, |hook, env| {
167        let command = hook.command.clone();
168        let env = env.to_vec();
169        std::thread::spawn(move || {
170            spawn_hook(&command, &env);
171        });
172    });
173}
174
175/// Same as [`run_hooks`] but delegates spawning to `spawn`. The callback
176/// receives each [`Hook`] (already filtered by `event` and `enabled`,
177/// and already truncated to [`MAX_HOOKS_PER_EVENT`]) plus the env vars
178/// that would be passed to its child. Used by tests to verify the cap
179/// without actually shelling out hundreds of processes.
180pub fn run_hooks_with(
181    settings: &Settings,
182    event: HookEvent,
183    ctx: HookContext,
184    mut spawn: impl FnMut(&Hook, &[(String, String)]),
185) {
186    let mut hooks: Vec<Hook> = matching_hooks(settings, event)
187        .into_iter()
188        .cloned()
189        .collect();
190    if hooks.is_empty() {
191        return;
192    }
193    if hooks.len() > MAX_HOOKS_PER_EVENT {
194        warn!(
195            "hooks: '{}' has {} entries, exceeding MAX_HOOKS_PER_EVENT={MAX_HOOKS_PER_EVENT}; \
196             firing only the first {MAX_HOOKS_PER_EVENT}",
197            event.as_str(),
198            hooks.len(),
199        );
200        hooks.truncate(MAX_HOOKS_PER_EVENT);
201    }
202    let env = build_env(event, &ctx);
203    for hook in &hooks {
204        spawn(hook, &env);
205    }
206}
207
208pub(crate) fn spawn_hook(command: &str, env: &[(String, String)]) {
209    spawn_hook_with_timeout(command, env, HOOK_TIMEOUT);
210}
211
212/// Split a hook `command` into `(program, args)` using `shell-words` (no
213/// shell is involved). Errors are the user-facing strings the test-run
214/// surfaces; the fire path logs them. Pure, so the parse rules are testable.
215fn parse_command(command: &str) -> Result<(String, Vec<String>), String> {
216    let argv = shell_words::split(command).map_err(|e| format!("could not parse command: {e}"))?;
217    let mut iter = argv.into_iter();
218    let program = iter.next().ok_or_else(|| "command is empty".to_string())?;
219    Ok((program, iter.collect()))
220}
221
222fn spawn_hook_with_timeout(command: &str, env: &[(String, String)], timeout: Duration) {
223    let (program, args) = match parse_command(command) {
224        Ok(pa) => pa,
225        Err(e) => {
226            warn!("hooks: {e} (len={})", command.len());
227            return;
228        }
229    };
230    let program_basename = program_log_label(&program);
231    let mut cmd = Command::new(&program);
232    cmd.args(&args);
233    // Detach the child from Entracte's stdio. Without this, hook children
234    // inherit our stdout/stderr — which in release builds includes the
235    // 0o600-tightened log file. A misbehaving hook could race writes into
236    // that fd and keep it open across log rotations.
237    cmd.stdin(Stdio::null())
238        .stdout(Stdio::null())
239        .stderr(Stdio::null());
240    // Same reason the stdio above is nulled: a hook is background automation,
241    // not something the user is watching. On Windows a console-subsystem hook
242    // would otherwise flash its own window on every break (#303).
243    crate::proc::suppress_console(&mut cmd);
244    for (k, v) in env {
245        cmd.env(k, v);
246    }
247    let mut child = match cmd.spawn() {
248        Ok(child) => child,
249        Err(e) => {
250            let argc = args.len();
251            warn!("hooks: failed to spawn {program_basename} (argc={argc}): {e}");
252            return;
253        }
254    };
255    // We're on a detached per-hook thread (see `run_hooks`), so blocking here
256    // to reap the child is fine — and necessary, or a fire-and-forget hook
257    // leaves a zombie. A child that overruns `timeout` is killed and reaped.
258    // On a `try_wait` error (extraordinarily rare for a child we own) we stop
259    // waiting AND leave the child un-reaped — the one zombie this can't
260    // prevent — but it's not worth special-casing an essentially-unreachable
261    // path.
262    if let Ok(None) = crate::proc::reap_or_kill(&mut child, timeout) {
263        let secs = timeout.as_secs();
264        warn!("hooks: killed {program_basename} after exceeding {secs}s");
265    }
266}
267
268/// Timeout for a one-off hook test-run from the Settings UI. Shorter than
269/// the fire-path [`HOOK_TIMEOUT`] so a hung command doesn't leave the user
270/// staring at a spinner.
271pub const HOOK_TEST_TIMEOUT: Duration = Duration::from_secs(10);
272
273/// Cap on captured stdout/stderr returned to the renderer, so a chatty
274/// command can't balloon the IPC payload.
275const MAX_TEST_OUTPUT_BYTES: usize = 8 * 1024;
276
277/// Result of a one-off hook test-run, surfaced in the Settings UI so a user
278/// can see what a command does before relying on it.
279#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
280pub struct HookTestOutcome {
281    /// The command launched and ran to completion (regardless of exit code).
282    pub ok: bool,
283    /// Process exit code, or `None` if it was killed by a signal / timeout.
284    pub exit_code: Option<i32>,
285    pub stdout: String,
286    pub stderr: String,
287    /// Set when the command couldn't be parsed, spawned, or timed out.
288    pub error: Option<String>,
289}
290
291impl HookTestOutcome {
292    fn failed(error: String) -> Self {
293        Self {
294            ok: false,
295            exit_code: None,
296            stdout: String::new(),
297            stderr: String::new(),
298            error: Some(error),
299        }
300    }
301}
302
303/// Lossy-UTF8 a captured stream and cap it at `max` bytes (on a char
304/// boundary), flagging truncation. Pure.
305fn truncate_output(bytes: &[u8], max: usize) -> String {
306    let text = String::from_utf8_lossy(bytes);
307    if text.len() <= max {
308        return text.into_owned();
309    }
310    let mut cut = max;
311    while !text.is_char_boundary(cut) {
312        cut -= 1;
313    }
314    format!("{}…[truncated]", &text[..cut])
315}
316
317/// The env a test-run exposes, so a command using `$ENTRACTE_*` shows
318/// realistic values. Uses a representative break-start context.
319pub fn sample_test_env() -> Vec<(String, String)> {
320    build_env(
321        HookEvent::BreakStart,
322        &HookContext::with_kind_duration(BreakKind::Micro, 300),
323    )
324}
325
326/// Run `command` once with `env`, capturing stdout/stderr and exit code,
327/// killing it after `timeout`. Powers the Settings "Test" button. Like the
328/// fire path, no shell is involved — pipes/redirects need an explicit
329/// `sh -c` wrapper. The capture/spawn is the OS shim; the parsing
330/// ([`parse_command`]) and output handling ([`truncate_output`]) are pure
331/// and tested, and this whole function is exercised against real commands in
332/// the unit tests.
333pub fn run_command_capture(
334    command: &str,
335    env: &[(String, String)],
336    timeout: Duration,
337) -> HookTestOutcome {
338    use crate::proc::CommandTimeoutExt;
339    let (program, args) = match parse_command(command) {
340        Ok(pa) => pa,
341        Err(e) => return HookTestOutcome::failed(e),
342    };
343    let mut cmd = Command::new(&program);
344    cmd.args(&args);
345    for (k, v) in env {
346        cmd.env(k, v);
347    }
348    // Cap the capture one byte past the display limit so a flood is bounded
349    // at the read layer (#213) while `truncate_output` can still tell the
350    // output overran and add its marker.
351    match cmd.output_timeout_capped(timeout, MAX_TEST_OUTPUT_BYTES + 1) {
352        Ok(output) => HookTestOutcome {
353            ok: true,
354            exit_code: output.status.code(),
355            stdout: truncate_output(&output.stdout, MAX_TEST_OUTPUT_BYTES),
356            stderr: truncate_output(&output.stderr, MAX_TEST_OUTPUT_BYTES),
357            error: None,
358        },
359        Err(e) if e.kind() == std::io::ErrorKind::TimedOut => HookTestOutcome::failed(format!(
360            "command was still running after {}s and was stopped",
361            timeout.as_secs()
362        )),
363        Err(e) => HookTestOutcome::failed(format!("could not run command: {e}")),
364    }
365}
366
367fn program_log_label(program: &str) -> String {
368    let basename = std::path::Path::new(program)
369        .file_name()
370        .and_then(|n| n.to_str())
371        .unwrap_or(program);
372    if basename.chars().count() > 64 {
373        let mut out: String = basename.chars().take(64).collect();
374        out.push('…');
375        out
376    } else {
377        basename.to_string()
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn program_log_label_strips_path_components() {
387        assert_eq!(program_log_label("/usr/bin/curl"), "curl");
388        assert_eq!(program_log_label("curl"), "curl");
389        assert_eq!(program_log_label("/opt/bin/my-script.sh"), "my-script.sh");
390    }
391
392    #[test]
393    fn program_log_label_truncates_long_names() {
394        let s = "a".repeat(200);
395        let out = program_log_label(&s);
396        assert_eq!(out.chars().count(), 65);
397        assert!(out.ends_with('…'));
398    }
399
400    #[test]
401    fn program_log_label_handles_multibyte_chars_without_panic() {
402        // Pre-fix this byte-sliced at index 64 and panicked on the UTF-8 boundary.
403        let s = "/usr/bin/".to_string() + &"тест".repeat(40);
404        let out = program_log_label(&s);
405        assert!(out.chars().count() <= 65);
406        if out.ends_with('…') {
407            assert_eq!(out.chars().count(), 65);
408        }
409    }
410
411    #[test]
412    fn program_log_label_handles_emoji_path_without_panic() {
413        let s = "/opt/".to_string() + &"😀".repeat(70);
414        let out = program_log_label(&s);
415        assert_eq!(out.chars().count(), 65);
416        assert!(out.ends_with('…'));
417    }
418
419    fn env_get(env: &[(String, String)], key: &str) -> String {
420        env.iter()
421            .find(|(k, _)| k == key)
422            .map(|(_, v)| v.clone())
423            .unwrap_or_default()
424    }
425
426    #[test]
427    fn build_env_break_start_has_kind_and_duration() {
428        let env = build_env(
429            HookEvent::BreakStart,
430            &HookContext::with_kind_duration(BreakKind::Micro, 600),
431        );
432        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "break_start");
433        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "micro");
434        assert_eq!(env_get(&env, "ENTRACTE_DURATION_SECS"), "600");
435        assert_eq!(env_get(&env, "ENTRACTE_OUTCOME"), "");
436    }
437
438    #[test]
439    fn build_env_break_end_has_outcome() {
440        let env = build_env(
441            HookEvent::BreakEnd,
442            &HookContext::with_kind_outcome(BreakKind::Long, "completed"),
443        );
444        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "break_end");
445        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "long");
446        assert_eq!(env_get(&env, "ENTRACTE_DURATION_SECS"), "");
447        assert_eq!(env_get(&env, "ENTRACTE_OUTCOME"), "completed");
448    }
449
450    #[test]
451    fn build_env_break_postponed_kind_only() {
452        let env = build_env(
453            HookEvent::BreakPostponed,
454            &HookContext::with_kind(BreakKind::Micro),
455        );
456        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "break_postponed");
457        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "micro");
458        assert_eq!(env_get(&env, "ENTRACTE_DURATION_SECS"), "");
459        assert_eq!(env_get(&env, "ENTRACTE_OUTCOME"), "");
460    }
461
462    #[test]
463    fn build_env_break_skipped_kind_only() {
464        let env = build_env(
465            HookEvent::BreakSkipped,
466            &HookContext::with_kind(BreakKind::Long),
467        );
468        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "break_skipped");
469        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "long");
470    }
471
472    #[test]
473    fn build_env_pause_start_empty_context() {
474        let env = build_env(HookEvent::PauseStart, &HookContext::empty());
475        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "pause_start");
476        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "");
477        assert_eq!(env_get(&env, "ENTRACTE_DURATION_SECS"), "");
478        assert_eq!(env_get(&env, "ENTRACTE_OUTCOME"), "");
479    }
480
481    #[test]
482    fn build_env_pause_end_empty_context() {
483        let env = build_env(HookEvent::PauseEnd, &HookContext::empty());
484        assert_eq!(env_get(&env, "ENTRACTE_EVENT"), "pause_end");
485        assert_eq!(env_get(&env, "ENTRACTE_KIND"), "");
486    }
487
488    #[test]
489    fn matching_hooks_returns_empty_when_master_toggle_off() {
490        let s = Settings {
491            hooks_enabled: false,
492            hooks: vec![Hook {
493                event: HookEvent::BreakStart,
494                command: "echo hi".into(),
495                enabled: true,
496            }],
497            ..Settings::default()
498        };
499        assert!(matching_hooks(&s, HookEvent::BreakStart).is_empty());
500    }
501
502    #[test]
503    fn matching_hooks_filters_by_event_and_enabled() {
504        let s = Settings {
505            hooks_enabled: true,
506            hooks: vec![
507                Hook {
508                    event: HookEvent::BreakStart,
509                    command: "a".into(),
510                    enabled: true,
511                },
512                Hook {
513                    event: HookEvent::BreakStart,
514                    command: "b".into(),
515                    enabled: false,
516                },
517                Hook {
518                    event: HookEvent::BreakEnd,
519                    command: "c".into(),
520                    enabled: true,
521                },
522            ],
523            ..Settings::default()
524        };
525        let m = matching_hooks(&s, HookEvent::BreakStart);
526        assert_eq!(m.len(), 1);
527        assert_eq!(m[0].command, "a");
528    }
529
530    #[test]
531    fn shell_words_splits_quoted_argv() {
532        let parts = shell_words::split(r#"cmd a b "c d""#).unwrap();
533        assert_eq!(parts, vec!["cmd", "a", "b", "c d"]);
534    }
535
536    #[test]
537    fn run_hooks_with_caps_at_max_per_event() {
538        let big: Vec<Hook> = (0..(MAX_HOOKS_PER_EVENT * 4))
539            .map(|i| Hook {
540                event: HookEvent::BreakStart,
541                command: format!("echo {i}"),
542                enabled: true,
543            })
544            .collect();
545        let s = Settings {
546            hooks_enabled: true,
547            hooks: big,
548            ..Settings::default()
549        };
550        let mut fired = 0usize;
551        run_hooks_with(&s, HookEvent::BreakStart, HookContext::empty(), |_, _| {
552            fired += 1;
553        });
554        assert_eq!(fired, MAX_HOOKS_PER_EVENT);
555    }
556
557    #[test]
558    fn run_hooks_with_fires_all_when_under_cap() {
559        let s = Settings {
560            hooks_enabled: true,
561            hooks: vec![
562                Hook {
563                    event: HookEvent::PauseStart,
564                    command: "a".into(),
565                    enabled: true,
566                },
567                Hook {
568                    event: HookEvent::PauseStart,
569                    command: "b".into(),
570                    enabled: true,
571                },
572            ],
573            ..Settings::default()
574        };
575        let mut fired = 0usize;
576        run_hooks_with(&s, HookEvent::PauseStart, HookContext::empty(), |_, _| {
577            fired += 1;
578        });
579        assert_eq!(fired, 2);
580    }
581
582    #[test]
583    fn run_hooks_with_passes_env_vars_to_spawn_callback() {
584        let s = Settings {
585            hooks_enabled: true,
586            hooks: vec![Hook {
587                event: HookEvent::BreakStart,
588                command: "echo".into(),
589                enabled: true,
590            }],
591            ..Settings::default()
592        };
593        let mut captured: Vec<(String, String)> = Vec::new();
594        run_hooks_with(
595            &s,
596            HookEvent::BreakStart,
597            HookContext::with_kind_duration(BreakKind::Long, 1200),
598            |_, env| captured = env.to_vec(),
599        );
600        let get = |k: &str| -> String {
601            captured
602                .iter()
603                .find(|(key, _)| key == k)
604                .map(|(_, v)| v.clone())
605                .unwrap_or_default()
606        };
607        assert_eq!(get("ENTRACTE_EVENT"), "break_start");
608        assert_eq!(get("ENTRACTE_KIND"), "long");
609        assert_eq!(get("ENTRACTE_DURATION_SECS"), "1200");
610    }
611
612    #[test]
613    fn hook_list_serde_roundtrip() {
614        let hooks = vec![
615            Hook {
616                event: HookEvent::BreakStart,
617                command: "echo start".into(),
618                enabled: true,
619            },
620            Hook {
621                event: HookEvent::PauseEnd,
622                command: "sh -c \"date >> /tmp/log\"".into(),
623                enabled: false,
624            },
625        ];
626        let json = serde_json::to_string(&hooks).unwrap();
627        let back: Vec<Hook> = serde_json::from_str(&json).unwrap();
628        assert_eq!(back, hooks);
629        assert!(json.contains("\"event\":\"break_start\""));
630        assert!(json.contains("\"event\":\"pause_end\""));
631    }
632
633    // Exec-path coverage. Writes a tiny script that records its env into
634    // a tempfile, runs `run_hooks` against it, then polls until the
635    // tempfile appears (with a 2s ceiling so a busy CI machine doesn't
636    // false-fail). Asserts the env contains the keys the public docs
637    // promise. Unix uses `/bin/sh`; Windows uses `cmd.exe /c` via a
638    // `.bat` script.
639
640    #[cfg(unix)]
641    fn write_recorder_script(
642        dir: &std::path::Path,
643        output: &std::path::Path,
644    ) -> std::path::PathBuf {
645        use std::io::Write;
646        use std::os::unix::fs::PermissionsExt;
647        let stem = output
648            .file_stem()
649            .and_then(|s| s.to_str())
650            .unwrap_or("record");
651        let script = dir.join(format!("record-env-{stem}.sh"));
652        let body = format!(
653            "#!/bin/sh\n\
654             {{\n\
655               printf 'ENTRACTE_EVENT=%s\\n' \"$ENTRACTE_EVENT\"\n\
656               printf 'ENTRACTE_KIND=%s\\n' \"$ENTRACTE_KIND\"\n\
657               printf 'ENTRACTE_DURATION_SECS=%s\\n' \"$ENTRACTE_DURATION_SECS\"\n\
658               printf 'ENTRACTE_OUTCOME=%s\\n' \"$ENTRACTE_OUTCOME\"\n\
659               printf 'ENTRACTE_DONE=1\\n'\n\
660             }} > '{}'\n",
661            output.display()
662        );
663        let mut f = std::fs::File::create(&script).unwrap();
664        f.write_all(body.as_bytes()).unwrap();
665        drop(f);
666        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
667        script
668    }
669
670    #[cfg(windows)]
671    fn write_recorder_script(
672        dir: &std::path::Path,
673        output: &std::path::Path,
674    ) -> std::path::PathBuf {
675        use std::io::Write;
676        let stem = output
677            .file_stem()
678            .and_then(|s| s.to_str())
679            .unwrap_or("record");
680        let script = dir.join(format!("record-env-{stem}.bat"));
681        // Quoting: `>` redirects, double-percent escapes the env-var sigil
682        // for batch. The script writes one KEY=VALUE per line so the test
683        // can grep for substrings without parsing. The trailing
684        // `ENTRACTE_DONE=1` is the sentinel `wait_for_file` polls for —
685        // cmd.exe's redirect can flush mid-block on slow runners, so
686        // returning on first non-empty read produced partial contents.
687        let body = format!(
688            "@echo off\r\n\
689             (\r\n\
690               echo ENTRACTE_EVENT=%ENTRACTE_EVENT%\r\n\
691               echo ENTRACTE_KIND=%ENTRACTE_KIND%\r\n\
692               echo ENTRACTE_DURATION_SECS=%ENTRACTE_DURATION_SECS%\r\n\
693               echo ENTRACTE_OUTCOME=%ENTRACTE_OUTCOME%\r\n\
694               echo ENTRACTE_DONE=1\r\n\
695             ) > \"{}\"\r\n",
696            output.display()
697        );
698        let mut f = std::fs::File::create(&script).unwrap();
699        f.write_all(body.as_bytes()).unwrap();
700        script
701    }
702
703    #[cfg(unix)]
704    fn invoke_command(script: &std::path::Path) -> String {
705        script.display().to_string()
706    }
707
708    #[cfg(windows)]
709    fn invoke_command(script: &std::path::Path) -> String {
710        // `Command::new("foo.bat")` does not execute .bat files on Windows;
711        // they must be run through cmd.exe. Forward slashes keep the path
712        // safe from shell_words backslash escaping.
713        let path = script.display().to_string().replace('\\', "/");
714        format!("cmd /c \"{path}\"")
715    }
716
717    fn wait_for_file(path: &std::path::Path) -> String {
718        // Wait for the recorder's `ENTRACTE_DONE=1` sentinel rather than
719        // just non-empty contents. On Windows, cmd.exe's `( ... ) > file`
720        // can flush mid-block, so a non-empty read can return only the
721        // first line and make later substring assertions fail.
722        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
723        loop {
724            if let Ok(s) = std::fs::read_to_string(path) {
725                if s.contains("ENTRACTE_DONE=1") {
726                    return s;
727                }
728            }
729            if std::time::Instant::now() > deadline {
730                panic!("hook script never produced output at {}", path.display());
731            }
732            std::thread::sleep(std::time::Duration::from_millis(25));
733        }
734    }
735
736    #[test]
737    fn spawn_hook_executes_script_with_env_vars() {
738        let dir = crate::test_support::temp_dir();
739        let output = dir.path().join("env.txt");
740        let script = write_recorder_script(dir.path(), &output);
741        let command = invoke_command(&script);
742        let env = build_env(
743            HookEvent::BreakStart,
744            &HookContext::with_kind_duration(BreakKind::Long, 1200),
745        );
746        spawn_hook(&command, &env);
747        let body = wait_for_file(&output);
748        assert!(body.contains("ENTRACTE_EVENT=break_start"), "got: {body}");
749        assert!(body.contains("ENTRACTE_KIND=long"), "got: {body}");
750        assert!(body.contains("ENTRACTE_DURATION_SECS=1200"), "got: {body}");
751    }
752
753    #[cfg(unix)]
754    fn sleeping_hook_command() -> String {
755        "/bin/sleep 5".to_string()
756    }
757
758    #[cfg(windows)]
759    fn sleeping_hook_command() -> String {
760        // ping spaces its probes ~1s apart, so -n 6 sleeps ~5s.
761        "cmd /c ping -n 6 127.0.0.1".to_string()
762    }
763
764    #[test]
765    fn spawn_hook_handles_unspawnable_command_without_panic() {
766        // Parses fine, but the program doesn't exist — must hit the
767        // spawn-error arm and return cleanly rather than panic.
768        spawn_hook("/nonexistent/entracte-hook-binary arg1", &[]);
769    }
770
771    #[test]
772    fn spawn_hook_kills_a_child_that_overruns_its_timeout() {
773        let started = std::time::Instant::now();
774        spawn_hook_with_timeout(
775            &sleeping_hook_command(),
776            &[],
777            std::time::Duration::from_millis(150),
778        );
779        // The call blocks only until the overrun kill, not the full 5s sleep.
780        let elapsed = started.elapsed();
781        assert!(
782            elapsed < std::time::Duration::from_secs(3),
783            "spawn_hook should kill the overrunning child, took {elapsed:?}"
784        );
785    }
786
787    #[test]
788    fn run_hooks_dispatches_to_matching_event_only() {
789        // Two hooks subscribed to different events; only the matching one
790        // should fire. Asserted by checking which tempfile gets written.
791        let dir = crate::test_support::temp_dir();
792        let break_out = dir.path().join("break.txt");
793        let pause_out = dir.path().join("pause.txt");
794        let break_script = write_recorder_script(dir.path(), &break_out);
795        let pause_script = write_recorder_script(dir.path(), &pause_out);
796        let settings = Settings {
797            hooks_enabled: true,
798            hooks: vec![
799                Hook {
800                    event: HookEvent::BreakEnd,
801                    command: invoke_command(&break_script),
802                    enabled: true,
803                },
804                Hook {
805                    event: HookEvent::PauseStart,
806                    command: invoke_command(&pause_script),
807                    enabled: true,
808                },
809            ],
810            ..Settings::default()
811        };
812        run_hooks(
813            &settings,
814            HookEvent::BreakEnd,
815            HookContext::with_kind_outcome(BreakKind::Micro, "completed"),
816        );
817        let body = wait_for_file(&break_out);
818        assert!(body.contains("ENTRACTE_KIND=micro"), "got: {body}");
819        assert!(body.contains("ENTRACTE_OUTCOME=completed"), "got: {body}");
820        // The unrelated PauseStart hook must not have fired.
821        std::thread::sleep(std::time::Duration::from_millis(150));
822        assert!(!pause_out.exists(), "pause hook fired for break_end event");
823    }
824
825    #[test]
826    fn run_hooks_no_op_when_master_toggle_off() {
827        let dir = crate::test_support::temp_dir();
828        let output = dir.path().join("env.txt");
829        let script = write_recorder_script(dir.path(), &output);
830        let settings = Settings {
831            hooks_enabled: false,
832            hooks: vec![Hook {
833                event: HookEvent::BreakStart,
834                command: invoke_command(&script),
835                enabled: true,
836            }],
837            ..Settings::default()
838        };
839        run_hooks(
840            &settings,
841            HookEvent::BreakStart,
842            HookContext::with_kind_duration(BreakKind::Micro, 60),
843        );
844        std::thread::sleep(std::time::Duration::from_millis(150));
845        assert!(!output.exists(), "hook ran despite hooks_enabled=false");
846    }
847
848    #[test]
849    fn parse_command_splits_program_and_args() {
850        let (program, args) = parse_command("echo hello world").unwrap();
851        assert_eq!(program, "echo");
852        assert_eq!(args, vec!["hello", "world"]);
853    }
854
855    #[test]
856    fn parse_command_honours_quotes() {
857        let (program, args) = parse_command(r#"sh -c "echo a b""#).unwrap();
858        assert_eq!(program, "sh");
859        assert_eq!(args, vec!["-c", "echo a b"]);
860    }
861
862    #[test]
863    fn parse_command_rejects_empty_and_unbalanced() {
864        assert!(parse_command("   ").unwrap_err().contains("empty"));
865        assert!(parse_command(r#"echo "unterminated"#).is_err());
866    }
867
868    #[test]
869    fn truncate_output_passes_short_text_through() {
870        assert_eq!(truncate_output(b"hello", 64), "hello");
871    }
872
873    #[test]
874    fn truncate_output_caps_long_text_on_a_char_boundary() {
875        let big = "x".repeat(100);
876        let out = truncate_output(big.as_bytes(), 10);
877        assert!(out.starts_with("xxxxxxxxxx"));
878        assert!(out.ends_with("…[truncated]"));
879        // Multi-byte input must not be cut mid-codepoint.
880        let multi = "é".repeat(50); // 2 bytes each
881        let out = truncate_output(multi.as_bytes(), 5);
882        assert!(out.contains("…[truncated]"));
883    }
884
885    #[cfg(not(target_os = "windows"))]
886    mod capture {
887        use super::*;
888
889        #[test]
890        fn captures_stdout_and_zero_exit() {
891            let outcome = run_command_capture("/bin/echo hi there", &[], Duration::from_secs(5));
892            assert!(outcome.ok);
893            assert_eq!(outcome.exit_code, Some(0));
894            assert_eq!(outcome.stdout.trim(), "hi there");
895            assert!(outcome.error.is_none());
896        }
897
898        #[test]
899        fn captures_nonzero_exit_and_stderr() {
900            let outcome = run_command_capture(
901                r#"/bin/sh -c "echo oops 1>&2; exit 3""#,
902                &[],
903                Duration::from_secs(5),
904            );
905            assert!(outcome.ok);
906            assert_eq!(outcome.exit_code, Some(3));
907            assert_eq!(outcome.stderr.trim(), "oops");
908        }
909
910        #[test]
911        fn exposes_sample_env_to_the_command() {
912            let outcome = run_command_capture(
913                r#"/bin/sh -c "echo $ENTRACTE_EVENT $ENTRACTE_KIND""#,
914                &sample_test_env(),
915                Duration::from_secs(5),
916            );
917            assert_eq!(outcome.stdout.trim(), "break_start micro");
918        }
919
920        #[test]
921        fn reports_a_parse_error_without_running() {
922            let outcome = run_command_capture(r#"echo "unterminated"#, &[], Duration::from_secs(5));
923            assert!(!outcome.ok);
924            assert!(outcome.error.unwrap().contains("parse"));
925        }
926
927        #[test]
928        fn reports_a_spawn_failure() {
929            let outcome = run_command_capture("/no/such/program-xyz", &[], Duration::from_secs(5));
930            assert!(!outcome.ok);
931            assert!(outcome.error.is_some());
932        }
933
934        #[test]
935        fn kills_and_reports_a_command_that_overruns_the_timeout() {
936            let outcome =
937                run_command_capture("/bin/sh -c \"sleep 5\"", &[], Duration::from_millis(200));
938            assert!(!outcome.ok);
939            assert!(outcome.error.unwrap().contains("still running"));
940        }
941
942        #[test]
943        fn truncates_and_bounds_a_high_volume_command() {
944            // End-to-end smoke test that a flood still completes and is
945            // marked truncated: `yes` floods until `head` closes the pipe at
946            // 100 KiB, far past the 8 KiB display cap. The read-layer bounding
947            // itself is unit-tested in `proc::read_capped_*`.
948            let outcome = run_command_capture(
949                r#"/bin/sh -c "yes entracte | head -c 100000""#,
950                &[],
951                Duration::from_secs(5),
952            );
953            assert!(outcome.ok, "command should complete, got {outcome:?}");
954            assert!(
955                outcome.stdout.contains("…[truncated]"),
956                "expected a truncation marker"
957            );
958            let len = outcome.stdout.len();
959            assert!(
960                len <= MAX_TEST_OUTPUT_BYTES + 16,
961                "capture not bounded: {len} bytes"
962            );
963        }
964    }
965}