1use std::process::{Command, Stdio};
10use std::time::Duration;
11
12use log::warn;
13use serde::{Deserialize, Serialize};
14
15use crate::scheduler::{BreakKind, Settings};
16
17pub const MAX_HOOKS_PER_EVENT: usize = 32;
22
23const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
29
30#[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 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#[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#[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 pub fn empty() -> Self {
81 Self::default()
82 }
83
84 pub fn with_kind(kind: BreakKind) -> Self {
86 Self {
87 kind: Some(kind),
88 ..Self::default()
89 }
90 }
91
92 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 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
121pub 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
143pub 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
157pub 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
175pub 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
212fn 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 cmd.stdin(Stdio::null())
238 .stdout(Stdio::null())
239 .stderr(Stdio::null());
240 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 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
268pub const HOOK_TEST_TIMEOUT: Duration = Duration::from_secs(10);
272
273const MAX_TEST_OUTPUT_BYTES: usize = 8 * 1024;
276
277#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
280pub struct HookTestOutcome {
281 pub ok: bool,
283 pub exit_code: Option<i32>,
285 pub stdout: String,
286 pub stderr: String,
287 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
303fn 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
317pub fn sample_test_env() -> Vec<(String, String)> {
320 build_env(
321 HookEvent::BreakStart,
322 &HookContext::with_kind_duration(BreakKind::Micro, 300),
323 )
324}
325
326pub 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 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 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 #[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 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 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 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 "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 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 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 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 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 let multi = "é".repeat(50); 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 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}