1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use async_trait::async_trait;
11use tokio::process::Command;
12use tracing::{debug, error, info, warn};
13
14use crate::error::{Aria2Error, Result};
15use crate::request::request_group::GroupId;
16
17pub use crate::request::request_group::DownloadStatus;
19
20#[derive(Clone, Debug)]
26pub struct HookContext {
27 pub gid: GroupId,
29 pub file_path: PathBuf,
31 pub status: DownloadStatus,
33 pub stats: DownloadStats,
35 pub error: Option<String>,
37}
38
39impl HookContext {
40 pub fn new(
50 gid: GroupId,
51 file_path: PathBuf,
52 status: DownloadStatus,
53 stats: DownloadStats,
54 error: Option<String>,
55 ) -> Self {
56 Self {
57 gid,
58 file_path,
59 status,
60 stats,
61 error,
62 }
63 }
64
65 pub fn filename(&self) -> &str {
67 self.file_path
68 .file_name()
69 .and_then(|n| n.to_str())
70 .unwrap_or("unknown")
71 }
72
73 pub fn extension(&self) -> &str {
75 self.file_path
76 .extension()
77 .and_then(|e| e.to_str())
78 .unwrap_or("")
79 }
80
81 pub fn directory(&self) -> &Path {
83 self.file_path.parent().unwrap_or(self.file_path.as_path())
84 }
85}
86
87#[derive(Clone, Debug)]
89pub struct DownloadStats {
90 pub uploaded_bytes: u64,
92 pub downloaded_bytes: u64,
94 pub upload_speed: f64,
96 pub download_speed: f64,
98 pub elapsed_seconds: u64,
100}
101
102impl Default for DownloadStats {
103 fn default() -> Self {
104 Self {
105 uploaded_bytes: 0,
106 downloaded_bytes: 0,
107 upload_speed: 0.0,
108 download_speed: 0.0,
109 elapsed_seconds: 0,
110 }
111 }
112}
113
114impl std::fmt::Display for DownloadStats {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 write!(
117 f,
118 "downloaded={}, uploaded={}, dl_speed={:.2}B/s, ul_speed={:.2}B/s, elapsed={}s",
119 self.downloaded_bytes,
120 self.uploaded_bytes,
121 self.download_speed,
122 self.upload_speed,
123 self.elapsed_seconds
124 )
125 }
126}
127
128#[derive(Clone, Debug)]
130pub struct HookConfig {
131 pub stop_on_error: bool,
133 pub timeout: std::time::Duration,
135}
136
137impl Default for HookConfig {
138 fn default() -> Self {
139 Self {
140 stop_on_error: false,
141 timeout: std::time::Duration::from_secs(30),
142 }
143 }
144}
145
146#[async_trait]
155pub trait PostDownloadHook: Send + Sync {
156 async fn on_complete(&self, context: &HookContext) -> Result<()>;
166
167 async fn on_error(&self, context: &HookContext, error: &str) -> Result<()>;
178
179 fn name(&self) -> &'static str;
181}
182
183#[derive(Clone, Debug)]
191pub struct MoveHook {
192 target_dir: PathBuf,
194 create_dirs: bool,
196}
197
198impl MoveHook {
199 pub fn new(target_dir: PathBuf, create_dirs: bool) -> Self {
206 Self {
207 target_dir,
208 create_dirs,
209 }
210 }
211}
212
213#[async_trait]
214impl PostDownloadHook for MoveHook {
215 async fn on_complete(&self, context: &HookContext) -> Result<()> {
220 let source = &context.file_path;
221
222 if !source.exists() {
224 return Err(Aria2Error::Fatal(crate::error::FatalError::FileNotFound {
225 path: source.to_string_lossy().to_string(),
226 }));
227 }
228
229 if self.create_dirs && !self.target_dir.exists() {
231 debug!(
232 hook = "MoveHook",
233 target_dir = %self.target_dir.display(),
234 "Creating target directory"
235 );
236 tokio::fs::create_dir_all(&self.target_dir)
237 .await
238 .map_err(|e| {
239 Aria2Error::Io(format!(
240 "Failed to create directory {}: {}",
241 self.target_dir.display(),
242 e
243 ))
244 })?;
245 }
246
247 let filename = context.filename();
249 let destination = self.target_dir.join(filename);
250
251 info!(
252 hook = "MoveHook",
253 source = %source.display(),
254 dest = %destination.display(),
255 "Moving file"
256 );
257
258 tokio::fs::rename(source, &destination).await.map_err(|e| {
260 Aria2Error::Io(format!(
261 "Failed to move file from {} to {}: {}",
262 source.display(),
263 destination.display(),
264 e
265 ))
266 })?;
267
268 Ok(())
269 }
270
271 async fn on_error(&self, _context: &HookContext, _error: &str) -> Result<()> {
273 Ok(())
275 }
276
277 fn name(&self) -> &'static str {
278 "MoveHook"
279 }
280}
281
282#[derive(Clone, Debug)]
292pub struct RenameHook {
293 pattern: String,
295}
296
297impl RenameHook {
298 pub fn new(pattern: String) -> Self {
304 Self { pattern }
305 }
306
307 pub fn expand_pattern(&self, context: &HookContext) -> String {
319 let timestamp = SystemTime::now()
320 .duration_since(UNIX_EPOCH)
321 .unwrap_or_default()
322 .as_secs();
323
324 self.pattern
325 .replace("%d", &context.directory().to_string_lossy())
326 .replace("%f", context.filename())
327 .replace("%e", context.extension())
328 .replace("%i", &context.gid.value().to_string())
329 .replace("%t", ×tamp.to_string())
330 }
331}
332
333#[async_trait]
334impl PostDownloadHook for RenameHook {
335 async fn on_complete(&self, context: &HookContext) -> Result<()> {
339 let source = &context.file_path;
340
341 if !source.exists() {
342 return Err(Aria2Error::Fatal(crate::error::FatalError::FileNotFound {
343 path: source.to_string_lossy().to_string(),
344 }));
345 }
346
347 let new_name = self.expand_pattern(context);
348
349 let destination = if new_name.contains(std::path::MAIN_SEPARATOR)
351 || (std::path::MAIN_SEPARATOR == '\\' && new_name.contains('/'))
352 {
353 PathBuf::from(&new_name)
354 } else {
355 context.directory().join(&new_name)
356 };
357
358 info!(
359 hook = "RenameHook",
360 source = %source.display(),
361 dest = %destination.display(),
362 pattern = %self.pattern,
363 "Renaming file"
364 );
365
366 tokio::fs::rename(source, &destination).await.map_err(|e| {
367 Aria2Error::Io(format!(
368 "Failed to rename file to {}: {}",
369 destination.display(),
370 e
371 ))
372 })?;
373
374 Ok(())
375 }
376
377 async fn on_error(&self, _context: &HookContext, _error: &str) -> Result<()> {
379 Ok(())
380 }
381
382 fn name(&self) -> &'static str {
383 "RenameHook"
384 }
385}
386
387#[derive(Clone, Debug)]
391pub struct TouchHook;
392
393impl TouchHook {
394 pub fn new() -> Self {
396 Self
397 }
398}
399
400impl Default for TouchHook {
401 fn default() -> Self {
402 Self::new()
403 }
404}
405
406#[async_trait]
407impl PostDownloadHook for TouchHook {
408 async fn on_complete(&self, context: &HookContext) -> Result<()> {
412 let path = &context.file_path;
413
414 if !path.exists() {
415 return Err(Aria2Error::Fatal(crate::error::FatalError::FileNotFound {
416 path: path.to_string_lossy().to_string(),
417 }));
418 }
419
420 let now = SystemTime::now();
421
422 debug!(
423 hook = "TouchHook",
424 path = %path.display(),
425 "Updating file timestamps"
426 );
427
428 #[cfg(unix)]
430 {
431 let _metadata = tokio::fs::metadata(path).await.map_err(|e| {
433 Aria2Error::Io(format!(
434 "Failed to get metadata for {}: {}",
435 path.display(),
436 e
437 ))
438 })?;
439
440 let duration = now.duration_since(UNIX_EPOCH).unwrap_or_default();
442 let times: [libc::timespec; 2] = [
443 libc::timespec {
444 tv_sec: duration.as_secs() as _,
445 tv_nsec: duration.subsec_nanos() as _,
446 },
447 libc::timespec {
448 tv_sec: duration.as_secs() as _,
449 tv_nsec: duration.subsec_nanos() as _,
450 },
451 ];
452
453 let c_path = std::ffi::CString::new(path.to_string_lossy().as_bytes())
454 .map_err(|e| Aria2Error::Io(format!("Invalid path: {}", e)))?;
455
456 unsafe {
457 if libc::utimensat(0, c_path.as_ptr(), times.as_ptr(), 0) != 0 {
458 return Err(Aria2Error::Io(format!(
459 "Failed to update timestamps for {}",
460 path.display()
461 )));
462 }
463 }
464 }
465
466 #[cfg(windows)]
467 {
468 use std::fs;
471
472 let file = fs::OpenOptions::new().write(true).open(path).map_err(|e| {
474 Aria2Error::Io(format!("Failed to open file {}: {}", path.display(), e))
475 })?;
476
477 file.set_modified(now).map_err(|e| {
478 Aria2Error::Io(format!(
479 "Failed to set modified time for {}: {}",
480 path.display(),
481 e
482 ))
483 })?;
484 }
485
486 info!(
487 hook = "TouchHook",
488 path = %path.display(),
489 "File timestamps updated"
490 );
491
492 Ok(())
493 }
494
495 async fn on_error(&self, _context: &HookContext, _error: &str) -> Result<()> {
497 Ok(())
498 }
499
500 fn name(&self) -> &'static str {
501 "TouchHook"
502 }
503}
504
505#[derive(Clone, Debug)]
518pub struct ExecHook {
519 command: String,
521 env_vars: HashMap<String, String>,
523}
524
525impl ExecHook {
526 pub fn new(command: String, env_vars: HashMap<String, String>) -> Self {
533 Self { command, env_vars }
534 }
535
536 fn build_env(
540 &self,
541 context: &HookContext,
542 status_override: Option<&str>,
543 ) -> HashMap<String, String> {
544 let mut env = HashMap::new();
545
546 env.insert("ARIA2_GID".to_string(), context.gid.value().to_string());
548 env.insert(
549 "ARIA2_PATH".to_string(),
550 context.file_path.to_string_lossy().to_string(),
551 );
552 env.insert(
553 "ARIA2_STATUS".to_string(),
554 status_override
555 .unwrap_or(&context.status.to_string())
556 .to_string(),
557 );
558 if let Some(ref err) = context.error {
559 env.insert("ARIA2_ERROR".to_string(), err.clone());
560 }
561 env.insert(
562 "ARIA2_DOWNLOADED_BYTES".to_string(),
563 context.stats.downloaded_bytes.to_string(),
564 );
565 env.insert(
566 "ARIA2_UPLOADED_BYTES".to_string(),
567 context.stats.uploaded_bytes.to_string(),
568 );
569 env.insert(
570 "ARIA2_DOWNLOAD_SPEED".to_string(),
571 context.stats.download_speed.to_string(),
572 );
573 env.insert(
574 "ARIA2_UPLOAD_SPEED".to_string(),
575 context.stats.upload_speed.to_string(),
576 );
577
578 for (k, v) in &self.env_vars {
580 env.insert(k.clone(), v.clone());
581 }
582
583 env
584 }
585}
586
587#[async_trait]
588impl PostDownloadHook for ExecHook {
589 async fn on_complete(&self, context: &HookContext) -> Result<()> {
594 let env = self.build_env(context, None);
595
596 info!(
597 hook = "ExecHook",
598 command = %self.command,
599 "Executing command on complete"
600 );
601
602 let output = Command::new("sh")
603 .arg("-c")
604 .arg(&self.command)
605 .envs(&env)
606 .output()
607 .await
608 .map_err(|e| {
609 Aria2Error::Io(format!(
610 "Failed to execute command '{}': {}",
611 self.command, e
612 ))
613 })?;
614
615 if !output.status.success() {
616 let stderr = String::from_utf8_lossy(&output.stderr);
617 warn!(
618 hook = "ExecHook",
619 command = %self.command,
620 exit_code = ?output.status.code(),
621 stderr = %stderr,
622 "Command failed with non-zero exit code"
623 );
624 return Err(Aria2Error::DownloadFailed(format!(
625 "Command '{}' failed with exit code {:?}: {}",
626 self.command,
627 output.status.code(),
628 stderr.trim()
629 )));
630 }
631
632 debug!(
633 hook = "ExecHook",
634 stdout = %String::from_utf8_lossy(&output.stdout),
635 "Command executed successfully"
636 );
637
638 Ok(())
639 }
640
641 async fn on_error(&self, context: &HookContext, error: &str) -> Result<()> {
645 let mut ctx_with_error = context.clone();
646 ctx_with_error.error = Some(error.to_string());
647 let env = self.build_env(&ctx_with_error, Some("error"));
648
649 info!(
650 hook = "ExecHook",
651 command = %self.command,
652 "Executing command on error"
653 );
654
655 let output = Command::new("sh")
656 .arg("-c")
657 .arg(&self.command)
658 .envs(&env)
659 .output()
660 .await
661 .map_err(|e| {
662 Aria2Error::Io(format!(
663 "Failed to execute command '{}': {}",
664 self.command, e
665 ))
666 })?;
667
668 if !output.status.success() {
669 let stderr = String::from_utf8_lossy(&output.stderr);
670 return Err(Aria2Error::DownloadFailed(format!(
671 "Command '{}' failed with exit code {:?}: {}",
672 self.command,
673 output.status.code(),
674 stderr.trim()
675 )));
676 }
677
678 Ok(())
679 }
680
681 fn name(&self) -> &'static str {
682 "ExecHook"
683 }
684}
685
686pub struct HookManager {
695 hooks: Vec<Box<dyn PostDownloadHook>>,
697 config: HookConfig,
699}
700
701impl HookManager {
702 pub fn new(config: HookConfig) -> Self {
708 Self {
709 hooks: Vec::new(),
710 config,
711 }
712 }
713
714 pub fn add_hook(&mut self, hook: Box<dyn PostDownloadHook>) {
722 info!(hook_name = hook.name(), "Adding hook to chain");
723 self.hooks.push(hook);
724 }
725
726 pub fn remove_hook(&mut self, name: &str) -> Option<Box<dyn PostDownloadHook>> {
736 let pos = self.hooks.iter().position(|h| h.name() == name)?;
737 info!(hook_name = name, "Removing hook from chain");
738 Some(self.hooks.remove(pos))
739 }
740
741 pub async fn fire_complete(&self, context: &HookContext) -> Result<Vec<String>> {
755 let mut results = Vec::with_capacity(self.hooks.len());
756
757 for hook in &self.hooks {
758 let hook_name = hook.name();
759 debug!(hook = hook_name, event = "complete", "Executing hook");
760
761 match hook.on_complete(context).await {
762 Ok(()) => {
763 let msg = format!("[{}] complete succeeded", hook_name);
764 info!("{}", msg);
765 results.push(msg);
766 }
767 Err(e) => {
768 let msg = format!("[{}] complete failed: {}", hook_name, e);
769 error!("{}", msg);
770
771 if self.config.stop_on_error {
772 return Err(Aria2Error::DownloadFailed(format!(
773 "Hook '{}' execution aborted due to stop_on_error setting: {}",
774 hook_name, e
775 )));
776 }
777
778 results.push(msg);
779 }
780 }
781 }
782
783 Ok(results)
784 }
785
786 pub async fn fire_error(&self, context: &HookContext, error: &str) -> Result<Vec<String>> {
799 let mut results = Vec::with_capacity(self.hooks.len());
800 let error_owned = error.to_string();
801
802 for hook in &self.hooks {
803 let hook_name = hook.name();
804 debug!(hook = hook_name, event = "error", "Executing hook");
805
806 match hook.on_error(context, &error_owned).await {
807 Ok(()) => {
808 let msg = format!("[{}] error handled successfully", hook_name);
809 info!("{}", msg);
810 results.push(msg);
811 }
812 Err(e) => {
813 let msg = format!("[{}] error handling failed: {}", hook_name, e);
814 error!("{}", msg);
815
816 if self.config.stop_on_error {
817 return Err(Aria2Error::DownloadFailed(format!(
818 "Hook '{}' error handler aborted due to stop_on_error setting: {}",
819 hook_name, e
820 )));
821 }
822
823 results.push(msg);
824 }
825 }
826 }
827
828 Ok(results)
829 }
830
831 pub fn hook_count(&self) -> usize {
833 self.hooks.len()
834 }
835
836 pub fn clear_hooks(&mut self) {
838 info!("Clearing all hooks");
839 self.hooks.clear();
840 }
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 fn create_test_context(file_path: &Path) -> HookContext {
849 HookContext {
850 gid: GroupId::new(42),
851 file_path: file_path.to_path_buf(),
852 status: DownloadStatus::Complete,
853 stats: DownloadStats {
854 uploaded_bytes: 1024,
855 downloaded_bytes: 2048,
856 upload_speed: 100.0,
857 download_speed: 200.0,
858 elapsed_seconds: 10,
859 },
860 error: None,
861 }
862 }
863
864 #[tokio::test]
865 async fn test_move_hook_basic() {
866 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
867 let src_file = temp_dir.path().join("test_file.txt");
868
869 tokio::fs::write(&src_file, b"test content")
871 .await
872 .expect("Failed to write test file");
873
874 let target_dir = temp_dir.path().join("target");
875 let hook = MoveHook::new(target_dir.clone(), false);
876
877 tokio::fs::create_dir_all(&target_dir)
879 .await
880 .expect("Failed to create target dir");
881
882 let context = create_test_context(&src_file);
883
884 assert!(hook.on_complete(&context).await.is_ok());
885
886 let moved_file = target_dir.join("test_file.txt");
888 assert!(
889 moved_file.exists(),
890 "File should be moved to target directory"
891 );
892 assert!(!src_file.exists(), "Source file should no longer exist");
893 }
894
895 #[tokio::test]
896 async fn test_move_hook_create_dirs() {
897 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
898 let src_file = temp_dir.path().join("test_file.txt");
899
900 tokio::fs::write(&src_file, b"test content")
901 .await
902 .expect("Failed to write test file");
903
904 let target_dir = temp_dir.path().join("nested").join("deep").join("target");
906 let hook = MoveHook::new(target_dir.clone(), true);
907
908 let context = create_test_context(&src_file);
909
910 assert!(hook.on_complete(&context).await.is_ok());
911
912 let moved_file = target_dir.join("test_file.txt");
914 assert!(
915 moved_file.exists(),
916 "File should be moved to auto-created directory"
917 );
918 }
919
920 #[tokio::test]
921 async fn test_rename_hook_pattern_expansion() {
922 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
923 let src_file = temp_dir.path().join("archive.tar.gz");
924
925 tokio::fs::write(&src_file, b"content")
926 .await
927 .expect("Failed to write test file");
928
929 let hook = RenameHook::new("%f.renamed".to_string());
930 let context = create_test_context(&src_file);
931
932 let expanded = hook.expand_pattern(&context);
934 assert!(
935 expanded.contains("archive.tar.gz.renamed"),
936 "Pattern should contain original filename"
937 );
938
939 assert!(hook.on_complete(&context).await.is_ok());
941
942 let renamed_file = temp_dir.path().join("archive.tar.gz.renamed");
943 assert!(
944 renamed_file.exists(),
945 "File should be renamed according to pattern"
946 );
947 }
948
949 #[tokio::test]
950 async fn test_touch_hook_updates_mtime() {
951 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
952 let src_file = temp_dir.path().join("timestamp_test.txt");
953
954 tokio::fs::write(&src_file, b"touch test")
955 .await
956 .expect("Failed to write test file");
957
958 let before_metadata = tokio::fs::metadata(&src_file)
960 .await
961 .expect("Failed to get metadata");
962 let before_mtime = before_metadata.modified().expect("Failed to get mtime");
963
964 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
967
968 let hook = TouchHook::new();
969 let context = create_test_context(&src_file);
970
971 assert!(hook.on_complete(&context).await.is_ok());
972
973 let after_metadata = tokio::fs::metadata(&src_file)
975 .await
976 .expect("Failed to get metadata after touch");
977 let after_mtime = after_metadata
978 .modified()
979 .expect("Failed to get mtime after touch");
980
981 assert!(
982 after_mtime >= before_mtime,
983 "Modification time should be updated to current time (before: {:?}, after: {:?})",
984 before_mtime,
985 after_mtime
986 );
987 }
988
989 #[tokio::test]
990 async fn test_exec_hook_env_vars_injected() {
991 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
993 let output_file = temp_dir.path().join("env_output.txt");
994
995 let cmd = format!("echo $ARIA2_GID > {}", output_file.display());
997
998 let mut env_vars = HashMap::new();
999 env_vars.insert("CUSTOM_VAR".to_string(), "custom_value".to_string());
1000
1001 let hook = ExecHook::new(cmd, env_vars);
1002 let context = create_test_context(&temp_dir.path().join("dummy.txt"));
1003
1004 #[cfg(unix)]
1006 {
1007 let result = hook.on_complete(&context).await;
1008 let _ = result;
1010 }
1011
1012 let built_env = hook.build_env(&context, None);
1014 assert_eq!(
1015 built_env.get("ARIA2_GID").unwrap(),
1016 "42",
1017 "GID should be injected"
1018 );
1019 assert_eq!(
1020 built_env.get("ARIA2_STATUS").unwrap(),
1021 "complete",
1022 "Status should be complete"
1023 );
1024 assert_eq!(
1025 built_env.get("CUSTOM_VAR").unwrap(),
1026 "custom_value",
1027 "Custom var should be preserved"
1028 );
1029 assert_eq!(
1030 built_env.get("ARIA2_DOWNLOADED_BYTES").unwrap(),
1031 "2048",
1032 "Download bytes should be correct"
1033 );
1034 }
1035
1036 #[tokio::test]
1037 async fn test_exec_hook_nonzero_exit_code() {
1038 let hook = ExecHook::new("exit 1".to_string(), HashMap::new());
1039 let context = create_test_context(Path::new("/tmp/nonexistent"));
1040
1041 let result = hook.on_complete(&context).await;
1042 assert!(result.is_err(), "Non-zero exit code should return error");
1043
1044 let err_msg = format!("{:?}", result.unwrap_err());
1045 assert!(
1046 err_msg.contains("failed")
1047 || err_msg.contains("exit code")
1048 || err_msg.contains("Failed")
1049 || err_msg.contains("execute"),
1050 "Error message should indicate failure, got: {}",
1051 err_msg
1052 );
1053 }
1054
1055 #[tokio::test]
1056 async fn test_hook_chain_execution_order() {
1057 let mut manager = HookManager::new(HookConfig::default());
1058
1059 manager.add_hook(Box::new(TouchHook));
1061 manager.add_hook(Box::new(RenameHook::new("%f.copy".to_string())));
1062
1063 assert_eq!(manager.hook_count(), 2, "Should have 2 hooks registered");
1064
1065 let removed = manager.remove_hook("RenameHook");
1067 assert!(removed.is_some(), "Should be able to remove RenameHook");
1068 assert_eq!(
1069 manager.hook_count(),
1070 1,
1071 "Should have 1 hook remaining after removal"
1072 );
1073 }
1074
1075 #[tokio::test]
1076 async fn test_hook_failure_isolation() {
1077 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1078 let config = HookConfig {
1079 stop_on_error: false,
1080 ..Default::default()
1081 };
1082 let mut manager = HookManager::new(config);
1083
1084 manager.add_hook(Box::new(ExecHook::new(
1086 "exit 1".to_string(),
1087 HashMap::new(),
1088 )));
1089
1090 let context = create_test_context(&temp_dir.path().join("test.txt"));
1091
1092 let results = manager.fire_complete(&context).await;
1094 assert!(results.is_ok(), "Should not fail when stop_on_error=false");
1095
1096 let results_vec = results.unwrap();
1097 assert_eq!(results_vec.len(), 1, "Should have one result entry");
1098 assert!(
1099 results_vec[0].contains("failed"),
1100 "Result should indicate failure of the first hook"
1101 );
1102 }
1103
1104 #[tokio::test]
1105 async fn test_hook_config_stop_on_error() {
1106 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1107 let config = HookConfig {
1108 stop_on_error: true,
1109 ..Default::default()
1110 };
1111 let mut manager = HookManager::new(config);
1112
1113 manager.add_hook(Box::new(ExecHook::new(
1115 "exit 1".to_string(),
1116 HashMap::new(),
1117 )));
1118 manager.add_hook(Box::new(ExecHook::new(
1120 "echo success".to_string(),
1121 HashMap::new(),
1122 )));
1123
1124 let context = create_test_context(&temp_dir.path().join("test.txt"));
1125
1126 let result = manager.fire_complete(&context).await;
1127 assert!(
1128 result.is_err(),
1129 "Should return error when stop_on_error=true and first hook fails"
1130 );
1131 }
1132
1133 #[tokio::test]
1134 async fn test_hook_remove_by_name() {
1135 let mut manager = HookManager::new(HookConfig::default());
1136
1137 manager.add_hook(Box::new(TouchHook));
1138 manager.add_hook(Box::new(MoveHook::new(PathBuf::from("/tmp"), false)));
1139
1140 assert_eq!(manager.hook_count(), 2);
1141
1142 let removed = manager.remove_hook("TouchHook");
1143 assert!(removed.is_some(), "Should find and remove TouchHook");
1144 assert_eq!(removed.unwrap().name(), "TouchHook");
1145 assert_eq!(manager.hook_count(), 1, "Should have 1 hook remaining");
1146
1147 let not_found = manager.remove_hook("NonExistentHook");
1149 assert!(
1150 not_found.is_none(),
1151 "Should return None for non-existent hook"
1152 );
1153 }
1154
1155 #[test]
1156 fn test_hook_context_creation() {
1157 let context = HookContext::new(
1158 GroupId::new(123),
1159 PathBuf::from("/downloads/file.zip"),
1160 DownloadStatus::Complete,
1161 DownloadStats {
1162 downloaded_bytes: 9999,
1163 ..Default::default()
1164 },
1165 None,
1166 );
1167
1168 assert_eq!(context.gid.value(), 123);
1169 assert_eq!(context.filename(), "file.zip");
1170 assert_eq!(context.extension(), "zip");
1171 assert_eq!(context.status, DownloadStatus::Complete);
1172 assert!(context.error.is_none());
1173 assert_eq!(context.stats.downloaded_bytes, 9999);
1174 }
1175
1176 #[test]
1177 fn test_download_stats_display() {
1178 let stats = DownloadStats {
1179 uploaded_bytes: 1024,
1180 downloaded_bytes: 2048,
1181 upload_speed: 100.5,
1182 download_speed: 200.25,
1183 elapsed_seconds: 30,
1184 };
1185
1186 let display = format!("{}", stats);
1187 assert!(
1188 display.contains("downloaded=2048"),
1189 "Should contain downloaded bytes"
1190 );
1191 assert!(
1192 display.contains("uploaded=1024"),
1193 "Should contain uploaded bytes"
1194 );
1195 assert!(display.contains("200.25"), "Should contain download speed");
1196 assert!(
1197 display.contains("elapsed=30s"),
1198 "Should contain elapsed time"
1199 );
1200 }
1201}