1use std::process::{Command, Stdio};
10
11use log::warn;
12use serde::{Deserialize, Serialize};
13
14use crate::scheduler::{BreakKind, Settings};
15
16pub const MAX_HOOKS_PER_EVENT: usize = 32;
21
22#[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 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#[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#[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 pub fn empty() -> Self {
73 Self::default()
74 }
75
76 pub fn with_kind(kind: BreakKind) -> Self {
78 Self {
79 kind: Some(kind),
80 ..Self::default()
81 }
82 }
83
84 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 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
113pub 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
135pub 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
149pub 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
166pub 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 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 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 #[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 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 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 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 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 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}