1use anyhow::{anyhow, Context, Result};
2use serde::{Deserialize, Serialize};
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::Duration;
7
8use crate::unit::Unit;
9
10const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum HookEvent {
19 PreCreate,
20 PostCreate,
21 PreUpdate,
22 PostUpdate,
23 PreClose,
24 PostClose,
25}
26
27impl HookEvent {
28 pub fn as_str(&self) -> &'static str {
30 match self {
31 HookEvent::PreCreate => "pre-create",
32 HookEvent::PostCreate => "post-create",
33 HookEvent::PreUpdate => "pre-update",
34 HookEvent::PostUpdate => "post-update",
35 HookEvent::PreClose => "pre-close",
36 HookEvent::PostClose => "post-close",
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct HookPayload {
47 pub event: String,
48 pub unit: Unit,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub reason: Option<String>,
51}
52
53impl HookPayload {
54 pub fn new(event: HookEvent, unit: Unit, reason: Option<String>) -> Self {
56 Self {
57 event: event.as_str().to_string(),
58 unit,
59 reason,
60 }
61 }
62
63 pub fn to_json(&self) -> Result<String> {
65 serde_json::to_string(self).context("Failed to serialize hook payload to JSON")
66 }
67}
68
69pub fn get_hook_path(project_dir: &Path, event: HookEvent) -> PathBuf {
75 project_dir.join(".mana").join("hooks").join(event.as_str())
76}
77
78pub fn is_hook_executable(path: &Path) -> bool {
80 if !path.exists() {
81 return false;
82 }
83
84 #[cfg(unix)]
86 {
87 use std::fs;
88 use std::os::unix::fs::PermissionsExt;
89
90 if let Ok(metadata) = fs::metadata(path) {
91 let mode = metadata.permissions().mode();
92 (mode & 0o111) != 0
94 } else {
95 false
96 }
97 }
98
99 #[cfg(windows)]
101 {
102 let path_str = path.to_string_lossy();
103 let exe_extensions = [".exe", ".bat", ".cmd", ".ps1", ".com"];
104 exe_extensions.iter().any(|ext| path_str.ends_with(ext))
105 }
106
107 #[cfg(not(any(unix, windows)))]
109 true
110}
111
112pub fn execute_hook(
131 event: HookEvent,
132 unit: &Unit,
133 project_dir: &Path,
134 reason: Option<String>,
135) -> Result<bool> {
136 if !is_trusted(project_dir) {
140 return Ok(true);
141 }
142
143 let hook_path = get_hook_path(project_dir, event);
144
145 if !hook_path.exists() {
147 return Ok(true);
148 }
149
150 if !is_hook_executable(&hook_path) {
152 return Err(anyhow!(
153 "Hook {} exists but is not executable",
154 hook_path.display()
155 ));
156 }
157
158 let payload = HookPayload::new(event, unit.clone(), reason);
160 let json_payload = payload.to_json()?;
161
162 let mut child = Command::new(&hook_path)
167 .stdin(Stdio::piped())
168 .stdout(Stdio::null())
169 .stderr(Stdio::null())
170 .current_dir(project_dir)
171 .spawn()
172 .with_context(|| format!("Failed to spawn hook {}", hook_path.display()))?;
173
174 {
176 let stdin = child
177 .stdin
178 .as_mut()
179 .ok_or_else(|| anyhow!("Failed to open stdin for hook"))?;
180 stdin
181 .write_all(json_payload.as_bytes())
182 .context("Failed to write payload to hook stdin")?;
183 }
184
185 let start = std::time::Instant::now();
187 loop {
188 match child.try_wait() {
189 Ok(Some(status)) => return Ok(status.success()),
190 Ok(None) => {
191 if start.elapsed() > HOOK_TIMEOUT {
192 let _ = child.kill();
193 let _ = child.wait(); return Err(anyhow!(
195 "Hook {} timed out after {}s",
196 hook_path.display(),
197 HOOK_TIMEOUT.as_secs()
198 ));
199 }
200 std::thread::sleep(Duration::from_millis(100));
201 }
202 Err(e) => {
203 return Err(
204 anyhow!(e).context(format!("Failed to wait for hook {}", hook_path.display()))
205 );
206 }
207 }
208 }
209}
210
211pub fn is_trusted(project_dir: &Path) -> bool {
220 project_dir.join(".mana").join(".hooks-trusted").exists()
221}
222
223pub fn create_trust(project_dir: &Path) -> Result<()> {
230 let trust_path = project_dir.join(".mana").join(".hooks-trusted");
231
232 let parent = trust_path
233 .parent()
234 .ok_or_else(|| anyhow!("Invalid trust path"))?;
235 std::fs::create_dir_all(parent).context("Failed to create .mana directory for trust file")?;
236
237 let metadata = format!("Hooks enabled at {}\n", chrono::Utc::now());
238 std::fs::write(&trust_path, metadata).context("Failed to create trust file")
239}
240
241pub fn revoke_trust(project_dir: &Path) -> Result<()> {
248 let trust_path = project_dir.join(".mana").join(".hooks-trusted");
249
250 if !trust_path.exists() {
251 return Err(anyhow!("Trust file does not exist"));
252 }
253
254 std::fs::remove_file(&trust_path).context("Failed to revoke hook trust")
255}
256
257#[derive(Debug, Default)]
266pub struct HookVars {
267 pub id: Option<String>,
268 pub title: Option<String>,
269 pub status: Option<String>,
270 pub attempt: Option<u32>,
271 pub output: Option<String>,
272 pub parent: Option<String>,
273 pub children: Option<String>,
274 pub branch: Option<String>,
275}
276
277pub fn expand_template(template: &str, vars: &HookVars) -> String {
283 let mut result = template.to_string();
284
285 if let Some(ref v) = vars.id {
286 result = result.replace("{id}", v);
287 }
288 if let Some(ref v) = vars.title {
289 result = result.replace("{title}", v);
290 }
291 if let Some(ref v) = vars.status {
292 result = result.replace("{status}", v);
293 }
294 if let Some(attempt) = vars.attempt {
295 result = result.replace("{attempt}", &attempt.to_string());
296 }
297 if let Some(ref v) = vars.output {
298 let truncated = if v.len() > 1000 {
300 &v[..1000]
301 } else {
302 v.as_str()
303 };
304 result = result.replace("{output}", truncated);
305 }
306 if let Some(ref v) = vars.parent {
307 result = result.replace("{parent}", v);
308 }
309 if let Some(ref v) = vars.children {
310 result = result.replace("{children}", v);
311 }
312 if let Some(ref v) = vars.branch {
313 result = result.replace("{branch}", v);
314 }
315
316 result
317}
318
319pub fn current_git_branch() -> Option<String> {
321 Command::new("git")
322 .args(["rev-parse", "--abbrev-ref", "HEAD"])
323 .stdout(Stdio::piped())
324 .stderr(Stdio::null())
325 .output()
326 .ok()
327 .and_then(|o| {
328 if o.status.success() {
329 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
330 } else {
331 None
332 }
333 })
334}
335
336pub fn execute_config_hook(hook_name: &str, template: &str, vars: &HookVars, project_dir: &Path) {
349 let cmd = expand_template(template, vars);
350
351 match Command::new("sh")
352 .args(["-c", &cmd])
353 .current_dir(project_dir)
354 .stdin(Stdio::null())
355 .stdout(Stdio::null())
356 .stderr(Stdio::null())
357 .spawn()
358 {
359 Ok(_child) => {
360 }
362 Err(e) => {
363 eprintln!("Warning: {} hook failed to spawn: {}", hook_name, e);
364 }
365 }
366}
367
368#[cfg(test)]
373mod tests {
374 use super::*;
375 use std::fs;
376 use tempfile::TempDir;
377
378 fn create_test_unit() -> Unit {
379 Unit::new("1", "Test Unit")
380 }
381
382 fn create_test_dir() -> TempDir {
383 TempDir::new().unwrap()
384 }
385
386 #[test]
387 fn test_hook_event_string_representation() {
388 assert_eq!(HookEvent::PreCreate.as_str(), "pre-create");
389 assert_eq!(HookEvent::PostCreate.as_str(), "post-create");
390 assert_eq!(HookEvent::PreUpdate.as_str(), "pre-update");
391 assert_eq!(HookEvent::PostUpdate.as_str(), "post-update");
392 assert_eq!(HookEvent::PreClose.as_str(), "pre-close");
393 assert_eq!(HookEvent::PostClose.as_str(), "post-close");
394 }
395
396 #[test]
397 fn test_hook_payload_serializes_to_json() {
398 let unit = create_test_unit();
399 let payload = HookPayload::new(HookEvent::PreCreate, unit.clone(), None);
400
401 let json = payload.to_json().unwrap();
402 assert!(json.contains("\"event\":\"pre-create\""));
403 assert!(json.contains("\"id\":\"1\""));
404 assert!(json.contains("\"title\":\"Test Unit\""));
405 assert!(!json.contains("\"reason\"") || json.contains("\"reason\":null"));
406 }
407
408 #[test]
409 fn test_hook_payload_with_reason() {
410 let unit = create_test_unit();
411 let payload = HookPayload::new(
412 HookEvent::PreClose,
413 unit,
414 Some("Completed successfully".to_string()),
415 );
416
417 let json = payload.to_json().unwrap();
418 assert!(json.contains("\"event\":\"pre-close\""));
419 assert!(json.contains("\"reason\":\"Completed successfully\""));
420 }
421
422 #[test]
423 fn test_get_hook_path() {
424 let temp_dir = create_test_dir();
425 let hook_path = get_hook_path(temp_dir.path(), HookEvent::PreCreate);
426
427 assert!(hook_path.ends_with(".mana/hooks/pre-create"));
428 }
429
430 #[test]
431 fn test_missing_hook_returns_ok_true() {
432 let temp_dir = create_test_dir();
433 let unit = create_test_unit();
434
435 let result = execute_hook(HookEvent::PreCreate, &unit, temp_dir.path(), None);
437 assert!(result.is_ok());
438 assert!(result.unwrap());
439 }
440
441 #[test]
442 fn test_non_executable_hook_returns_error() {
443 let temp_dir = create_test_dir();
444 let project_dir = temp_dir.path();
445 let hooks_dir = project_dir.join(".mana").join("hooks");
446 fs::create_dir_all(&hooks_dir).unwrap();
447
448 create_trust(project_dir).unwrap();
450
451 let hook_path = hooks_dir.join("pre-create");
452 fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
453 let unit = create_test_unit();
456 let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
457
458 assert!(result.is_err());
459 assert!(result.unwrap_err().to_string().contains("not executable"));
460 }
461
462 #[test]
463 fn test_successful_hook_execution() {
464 let temp_dir = create_test_dir();
465 let project_dir = temp_dir.path();
466 let hooks_dir = project_dir.join(".mana").join("hooks");
467 fs::create_dir_all(&hooks_dir).unwrap();
468
469 create_trust(project_dir).unwrap();
471
472 let hook_path = hooks_dir.join("pre-create");
473 fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
475
476 #[cfg(unix)]
478 {
479 use std::os::unix::fs::PermissionsExt;
480 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
481 }
482
483 let unit = create_test_unit();
484 let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
485
486 assert!(result.is_ok(), "Hook execution failed: {:?}", result.err());
487 assert!(result.unwrap());
488 }
489
490 #[test]
491 fn test_hook_execution_with_failure_exit_code() {
492 let temp_dir = create_test_dir();
493 let project_dir = temp_dir.path();
494 let hooks_dir = project_dir.join(".mana").join("hooks");
495 fs::create_dir_all(&hooks_dir).unwrap();
496
497 create_trust(project_dir).unwrap();
499
500 let hook_path = hooks_dir.join("pre-create");
501 fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
502
503 #[cfg(unix)]
505 {
506 use std::os::unix::fs::PermissionsExt;
507 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
508 }
509
510 let unit = create_test_unit();
511 let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
512
513 assert!(result.is_ok(), "Hook execution failed: {:?}", result.err());
514 assert!(!result.unwrap());
515 }
516
517 #[test]
518 fn test_hook_receives_json_payload_on_stdin() {
519 let unit = create_test_unit();
521 let payload = HookPayload::new(HookEvent::PreCreate, unit, None);
522
523 let json = payload.to_json().unwrap();
524
525 assert!(json.contains("\"event\":\"pre-create\""));
527 assert!(json.contains("\"unit\":{"));
528 assert!(json.contains("\"id\":\"1\""));
529 assert!(json.contains("\"title\":\"Test Unit\""));
530 assert!(json.contains("\"status\":"));
531 }
532
533 #[test]
534 #[cfg(unix)]
535 fn test_hook_timeout() {
536 let temp_dir = create_test_dir();
537 let project_dir = temp_dir.path();
538 let hooks_dir = project_dir.join(".mana").join("hooks");
539 fs::create_dir_all(&hooks_dir).unwrap();
540
541 create_trust(project_dir).unwrap();
543
544 let hook_path = hooks_dir.join("pre-create");
545 fs::write(&hook_path, "#!/bin/bash\nsleep 60\nexit 0").unwrap();
547
548 use std::os::unix::fs::PermissionsExt;
549 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
550
551 let unit = create_test_unit();
552 let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
553
554 assert!(result.is_err());
555 assert!(result.unwrap_err().to_string().contains("timed out"));
556 }
557
558 #[test]
559 fn test_is_hook_executable_with_missing_file() {
560 let temp_dir = create_test_dir();
561 let hook_path = temp_dir.path().join("nonexistent");
562
563 assert!(!is_hook_executable(&hook_path));
564 }
565
566 #[test]
567 #[cfg(unix)]
568 fn test_is_hook_executable_with_executable_file() {
569 let temp_dir = create_test_dir();
570 let hook_path = temp_dir.path().join("executable");
571 fs::write(&hook_path, "#!/bin/bash\necho test").unwrap();
572
573 use std::os::unix::fs::PermissionsExt;
574 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
575
576 assert!(is_hook_executable(&hook_path));
577 }
578
579 #[test]
580 #[cfg(unix)]
581 fn test_is_hook_executable_with_non_executable_file() {
582 let temp_dir = create_test_dir();
583 let hook_path = temp_dir.path().join("non-executable");
584 fs::write(&hook_path, "#!/bin/bash\necho test").unwrap();
585
586 use std::os::unix::fs::PermissionsExt;
587 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o644)).unwrap();
588
589 assert!(!is_hook_executable(&hook_path));
590 }
591
592 #[test]
593 fn test_hook_payload_with_all_unit_fields() {
594 let mut unit = create_test_unit();
595 unit.description = Some("Test description".to_string());
596 unit.acceptance = Some("Test acceptance".to_string());
597 unit.labels = vec!["test".to_string(), "important".to_string()];
598
599 let payload = HookPayload::new(HookEvent::PostCreate, unit, None);
600 let json = payload.to_json().unwrap();
601
602 assert!(json.contains("description"));
603 assert!(json.contains("Test description"));
604 assert!(json.contains("labels"));
605 assert!(json.contains("test"));
606 }
607
608 #[test]
613 fn test_is_trusted_returns_false_when_trust_file_does_not_exist() {
614 let temp_dir = create_test_dir();
615 let project_dir = temp_dir.path();
616
617 fs::create_dir_all(project_dir.join(".mana")).unwrap();
619
620 assert!(!is_trusted(project_dir));
622 }
623
624 #[test]
625 fn test_is_trusted_returns_true_when_trust_file_exists() {
626 let temp_dir = create_test_dir();
627 let project_dir = temp_dir.path();
628
629 fs::create_dir_all(project_dir.join(".mana")).unwrap();
631 fs::write(project_dir.join(".mana").join(".hooks-trusted"), "").unwrap();
632
633 assert!(is_trusted(project_dir));
635 }
636
637 #[test]
638 fn test_create_trust_creates_trust_file() {
639 let temp_dir = create_test_dir();
640 let project_dir = temp_dir.path();
641
642 fs::create_dir_all(project_dir.join(".mana")).unwrap();
644
645 assert!(!is_trusted(project_dir));
647
648 let result = create_trust(project_dir);
650 assert!(result.is_ok());
651
652 assert!(is_trusted(project_dir));
654
655 let content = fs::read_to_string(project_dir.join(".mana").join(".hooks-trusted")).unwrap();
657 assert!(content.contains("Hooks enabled"));
658 }
659
660 #[test]
661 fn test_revoke_trust_removes_trust_file() {
662 let temp_dir = create_test_dir();
663 let project_dir = temp_dir.path();
664
665 fs::create_dir_all(project_dir.join(".mana")).unwrap();
667 fs::write(project_dir.join(".mana").join(".hooks-trusted"), "").unwrap();
668
669 assert!(is_trusted(project_dir));
671
672 let result = revoke_trust(project_dir);
674 assert!(result.is_ok());
675
676 assert!(!is_trusted(project_dir));
678 }
679
680 #[test]
681 fn test_revoke_trust_errors_if_file_does_not_exist() {
682 let temp_dir = create_test_dir();
683 let project_dir = temp_dir.path();
684
685 fs::create_dir_all(project_dir.join(".mana")).unwrap();
687
688 let result = revoke_trust(project_dir);
690 assert!(result.is_err());
691 assert!(result
692 .unwrap_err()
693 .to_string()
694 .contains("Trust file does not exist"));
695 }
696
697 #[test]
698 fn test_execute_hook_skips_when_not_trusted() {
699 let temp_dir = create_test_dir();
700 let project_dir = temp_dir.path();
701 let hooks_dir = project_dir.join(".mana").join("hooks");
702 fs::create_dir_all(&hooks_dir).unwrap();
703
704 let hook_path = hooks_dir.join("pre-create");
706 fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
707
708 #[cfg(unix)]
709 {
710 use std::os::unix::fs::PermissionsExt;
711 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
712 }
713
714 let unit = create_test_unit();
715
716 let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
719 assert!(result.is_ok());
720 assert!(result.unwrap()); }
722
723 #[test]
724 fn test_execute_hook_runs_when_trusted() {
725 let temp_dir = create_test_dir();
726 let project_dir = temp_dir.path();
727 let hooks_dir = project_dir.join(".mana").join("hooks");
728 fs::create_dir_all(&hooks_dir).unwrap();
729
730 create_trust(project_dir).unwrap();
732
733 let hook_path = hooks_dir.join("pre-create");
735 fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
736
737 #[cfg(unix)]
738 {
739 use std::os::unix::fs::PermissionsExt;
740 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
741 }
742
743 let unit = create_test_unit();
744
745 let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
747 assert!(result.is_ok());
748 assert!(result.unwrap());
749 }
750
751 #[test]
752 fn test_execute_hook_respects_non_trusted_status() {
753 let temp_dir = create_test_dir();
754 let project_dir = temp_dir.path();
755 let hooks_dir = project_dir.join(".mana").join("hooks");
756 fs::create_dir_all(&hooks_dir).unwrap();
757
758 let hook_path = hooks_dir.join("pre-create");
760 fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
761
762 #[cfg(unix)]
763 {
764 use std::os::unix::fs::PermissionsExt;
765 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
766 }
767
768 let unit = create_test_unit();
769
770 let result = execute_hook(HookEvent::PreCreate, &unit, project_dir, None);
772 assert!(result.is_ok());
773 assert!(result.unwrap());
774 }
775
776 #[test]
781 fn test_expand_template_with_all_vars() {
782 let vars = HookVars {
783 id: Some("42".into()),
784 title: Some("Fix the bug".into()),
785 status: Some("closed".into()),
786 attempt: Some(3),
787 output: Some("FAIL: test_foo".into()),
788 parent: Some("10".into()),
789 children: Some("10.1,10.2".into()),
790 branch: Some("main".into()),
791 };
792
793 let result = expand_template(
794 "echo {id} {title} {status} {attempt} {output} {parent} {children} {branch}",
795 &vars,
796 );
797 assert_eq!(
798 result,
799 "echo 42 Fix the bug closed 3 FAIL: test_foo 10 10.1,10.2 main"
800 );
801 }
802
803 #[test]
804 fn test_expand_template_missing_vars_left_as_is() {
805 let vars = HookVars {
806 id: Some("1".into()),
807 ..Default::default()
808 };
809
810 let result = expand_template("echo {id} {title} {unknown}", &vars);
811 assert_eq!(result, "echo 1 {title} {unknown}");
812 }
813
814 #[test]
815 fn test_expand_template_output_truncated_to_1000_chars() {
816 let long_output = "x".repeat(2000);
817 let vars = HookVars {
818 output: Some(long_output),
819 ..Default::default()
820 };
821
822 let result = expand_template("echo {output}", &vars);
823 assert_eq!(result.len(), 5 + 1000);
825 }
826
827 #[test]
828 fn test_expand_template_empty_template() {
829 let vars = HookVars::default();
830 let result = expand_template("", &vars);
831 assert_eq!(result, "");
832 }
833
834 #[test]
835 fn test_expand_template_no_placeholders() {
836 let vars = HookVars {
837 id: Some("1".into()),
838 ..Default::default()
839 };
840 let result = expand_template("echo hello world", &vars);
841 assert_eq!(result, "echo hello world");
842 }
843
844 #[test]
845 fn test_expand_template_multiple_same_var() {
846 let vars = HookVars {
847 id: Some("5".into()),
848 ..Default::default()
849 };
850 let result = expand_template("{id} and {id} again", &vars);
851 assert_eq!(result, "5 and 5 again");
852 }
853
854 #[test]
859 fn test_execute_config_hook_writes_to_file() {
860 let temp_dir = create_test_dir();
861 let project_dir = temp_dir.path();
862 let output_file = project_dir.join("hook_output.txt");
863
864 let vars = HookVars {
865 id: Some("99".into()),
866 title: Some("Test unit".into()),
867 ..Default::default()
868 };
869
870 let template = format!("echo '{{id}}' > {}", output_file.display());
872 execute_config_hook("on_close", &template, &vars, project_dir);
873
874 std::thread::sleep(Duration::from_millis(500));
876
877 let content = fs::read_to_string(&output_file).unwrap();
878 assert_eq!(content.trim(), "99");
879 }
880
881 #[test]
882 fn test_execute_config_hook_failure_does_not_panic() {
883 let temp_dir = create_test_dir();
884 let project_dir = temp_dir.path();
885
886 execute_config_hook(
888 "on_close",
889 "/nonexistent/command/that/does/not/exist",
890 &HookVars::default(),
891 project_dir,
892 );
893
894 }
896
897 #[test]
898 fn test_execute_config_hook_with_template_expansion() {
899 let temp_dir = create_test_dir();
900 let project_dir = temp_dir.path();
901 let output_file = project_dir.join("expanded.txt");
902
903 let vars = HookVars {
904 id: Some("7".into()),
905 title: Some("My Task".into()),
906 status: Some("closed".into()),
907 branch: Some("feature-x".into()),
908 ..Default::default()
909 };
910
911 let template = format!(
912 "echo '{{id}}|{{title}}|{{status}}|{{branch}}' > {}",
913 output_file.display()
914 );
915 execute_config_hook("on_close", &template, &vars, project_dir);
916
917 std::thread::sleep(Duration::from_millis(500));
918
919 let content = fs::read_to_string(&output_file).unwrap();
920 assert_eq!(content.trim(), "7|My Task|closed|feature-x");
921 }
922}