Skip to main content

aria2_core/engine/
bt_post_download_handler.rs

1//! BitTorrent 下载后处理钩子系统
2//!
3//! 提供下载完成后的自定义处理能力,支持文件移动、重命名、时间戳更新和外部命令执行等功能。
4//! 通过 HookManager 管理多个钩子的执行链,支持配置错误处理策略。
5
6use 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
17// Re-export DownloadStatus for backward compatibility
18pub use crate::request::request_group::DownloadStatus;
19
20// ============================================================================
21// 核心数据结构
22// ============================================================================
23
24/// 钩子执行的上下文信息,包含下载任务的状态和数据统计
25#[derive(Clone, Debug)]
26pub struct HookContext {
27    /// 下载任务的唯一标识符
28    pub gid: GroupId,
29    /// 下载文件的完整路径
30    pub file_path: PathBuf,
31    /// 当前下载状态
32    pub status: DownloadStatus,
33    /// 下载统计数据
34    pub stats: DownloadStats,
35    /// 错误信息(如果有)
36    pub error: Option<String>,
37}
38
39impl HookContext {
40    /// 创建一个新的钩子上下文
41    ///
42    /// # Arguments
43    ///
44    /// * `gid` - 下载任务组 ID
45    /// * `file_path` - 下载文件路径
46    /// * `status` - 下载状态
47    /// * `stats` - 下载统计信息
48    /// * `error` - 可选的错误信息
49    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    /// 获取文件名(不含路径)
66    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    /// 获取文件扩展名
74    pub fn extension(&self) -> &str {
75        self.file_path
76            .extension()
77            .and_then(|e| e.to_str())
78            .unwrap_or("")
79    }
80
81    /// 获取父目录路径
82    pub fn directory(&self) -> &Path {
83        self.file_path.parent().unwrap_or(self.file_path.as_path())
84    }
85}
86
87/// 下载统计数据
88#[derive(Clone, Debug)]
89pub struct DownloadStats {
90    /// 已上传的字节数
91    pub uploaded_bytes: u64,
92    /// 已下载的字节数
93    pub downloaded_bytes: u64,
94    /// 上传速度(字节/秒)
95    pub upload_speed: f64,
96    /// 下载速度(字节/秒)
97    pub download_speed: f64,
98    /// 已用时间(秒)
99    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/// 钩子系统配置
129#[derive(Clone, Debug)]
130pub struct HookConfig {
131    /// 是否在遇到错误时停止后续钩子执行
132    pub stop_on_error: bool,
133    /// 单个钩子执行的超时时间
134    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// ============================================================================
147// PostDownloadHook Trait 定义
148// ============================================================================
149
150/// 下载后处理钩子 trait
151///
152/// 实现此 trait 可以自定义下载完成后的行为。
153/// 所有方法都是异步的,支持在异步上下文中执行耗时操作。
154#[async_trait]
155pub trait PostDownloadHook: Send + Sync {
156    /// 下载成功完成时的回调
157    ///
158    /// # Arguments
159    ///
160    /// * `context` - 包含下载任务信息的上下文
161    ///
162    /// # Returns
163    ///
164    /// 返回 `Ok(())` 表示处理成功,`Err(e)` 表示处理失败
165    async fn on_complete(&self, context: &HookContext) -> Result<()>;
166
167    /// 下载失败时的回调
168    ///
169    /// # Arguments
170    ///
171    /// * `context` - 包含下载任务信息的上下文
172    /// * `error` - 错误描述字符串
173    ///
174    /// # Returns
175    ///
176    /// 返回 `Ok(())` 表示错误处理成功,`Err(e)` 表示错误处理本身失败
177    async fn on_error(&self, context: &HookContext, error: &str) -> Result<()>;
178
179    /// 返回钩子的名称,用于日志记录和管理
180    fn name(&self) -> &'static str;
181}
182
183// ============================================================================
184// 内置钩子实现
185// ============================================================================
186
187/// 文件移动钩子
188///
189/// 在下载完成后将文件移动到指定目录。支持自动创建目标目录结构。
190#[derive(Clone, Debug)]
191pub struct MoveHook {
192    /// 目标目录路径
193    target_dir: PathBuf,
194    /// 是否自动创建不存在的目录
195    create_dirs: bool,
196}
197
198impl MoveHook {
199    /// 创建新的移动钩子
200    ///
201    /// # Arguments
202    ///
203    /// * `target_dir` - 目标目录路径
204    /// * `create_dirs` - 是否自动创建目录
205    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    /// 执行文件移动操作
216    ///
217    /// 验证源文件存在后,将其移动到目标目录。
218    /// 如果 `create_dirs` 为 true,会自动创建所需的目录层级。
219    async fn on_complete(&self, context: &HookContext) -> Result<()> {
220        let source = &context.file_path;
221
222        // 验证源文件存在
223        if !source.exists() {
224            return Err(Aria2Error::Fatal(crate::error::FatalError::FileNotFound {
225                path: source.to_string_lossy().to_string(),
226            }));
227        }
228
229        // 创建目标目录(如果需要)
230        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        // 构建目标路径
248        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        // 执行移动操作
259        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    /// 移动钩子在错误时不执行任何操作
272    async fn on_error(&self, _context: &HookContext, _error: &str) -> Result<()> {
273        // 文件移动在错误情况下通常不需要执行
274        Ok(())
275    }
276
277    fn name(&self) -> &'static str {
278        "MoveHook"
279    }
280}
281
282/// 文件重命名钩子
283///
284/// 使用模板模式对下载的文件进行重命名。
285/// 支持以下占位符:
286/// - `%d`: 源文件所在目录
287/// - `%f`: 原始文件名
288/// - `%e`: 文件扩展名
289/// - `%i`: 下载任务 GID
290/// - `%t`: 当前时间戳(Unix 时间戳)
291#[derive(Clone, Debug)]
292pub struct RenameHook {
293    /// 重命名模板模式
294    pattern: String,
295}
296
297impl RenameHook {
298    /// 创建新的重命名钩子
299    ///
300    /// # Arguments
301    ///
302    /// * `pattern` - 重命名模板,支持占位符替换
303    pub fn new(pattern: String) -> Self {
304        Self { pattern }
305    }
306
307    /// 展开模板中的占位符
308    ///
309    /// 将模板字符串中的特殊标记替换为实际值。
310    ///
311    /// # Arguments
312    ///
313    /// * `context` - 钩子上下文,用于获取替换值
314    ///
315    /// # Returns
316    ///
317    /// 替换后的完整文件名
318    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", &timestamp.to_string())
330    }
331}
332
333#[async_trait]
334impl PostDownloadHook for RenameHook {
335    /// 执行文件重命名操作
336    ///
337    /// 根据模板模式生成新文件名并重命名文件。
338    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        // 如果新名称包含路径分隔符,视为完整路径;否则在同一目录下重命名
350        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    /// 重命名钩子在错误时不执行
378    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/// 文件时间戳更新钩子
388///
389/// 在下载完成后更新文件的修改时间和访问时间为当前系统时间。
390#[derive(Clone, Debug)]
391pub struct TouchHook;
392
393impl TouchHook {
394    /// 创建新的 TouchHook 实例
395    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    /// 更新文件的修改时间和访问时间
409    ///
410    /// 将文件的 mtime 和 atime 都设置为当前系统时间。
411    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        // 使用 filetime crate 或标准库设置时间
429        #[cfg(unix)]
430        {
431            // 获取现有权限以保持不变
432            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            // Use utimensat to set timestamps with nanosecond precision
441            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            // Windows 下使用标准库的 set_times 功能(需要 Rust 1.75+)
469            // 或者通过重新写入文件来更新时间戳
470            use std::fs;
471
472            // 简单方案:读取文件元数据并设置时间
473            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    /// TouchHook 在错误时不执行
496    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/// 外部命令执行钩子
506///
507/// 在下载完成后执行指定的外部命令,并将下载相关信息作为环境变量注入。
508/// 支持的环境变量:
509/// - `ARIA2_GID`: 任务组 ID
510/// - `ARIA2_PATH`: 文件路径
511/// - `ARIA2_STATUS`: 下载状态
512/// - `ARIA2_ERROR`: 错误信息(如有)
513/// - `ARIA2_DOWNLOADED_BYTES`: 已下载字节数
514/// - `ARIA2_UPLOADED_BYTES`: 已上传字节数
515/// - `ARIA2_DOWNLOAD_SPEED`: 下载速度
516/// - `ARIA2_UPLOAD_SPEED`: 上传速度
517#[derive(Clone, Debug)]
518pub struct ExecHook {
519    /// 要执行的命令
520    command: String,
521    /// 额外的环境变量
522    env_vars: HashMap<String, String>,
523}
524
525impl ExecHook {
526    /// 创建新的命令执行钩子
527    ///
528    /// # Arguments
529    ///
530    /// * `command` - 要执行的 shell 命令
531    /// * `env_vars` - 额外的环境变量键值对
532    pub fn new(command: String, env_vars: HashMap<String, String>) -> Self {
533        Self { command, env_vars }
534    }
535
536    /// 构建环境变量映射
537    ///
538    /// 合并用户自定义环境变量和 aria2 内置环境变量。
539    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        // 注入 aria2 特定环境变量
547        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        // 合并用户自定义环境变量(可覆盖内置变量)
579        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    /// 执行外部命令
590    ///
591    /// 通过 shell 执行配置的命令,注入 aria2 相关环境变量。
592    /// 非 zero 退出码会被视为执行失败。
593    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    /// 在下载错误时执行命令
642    ///
643    /// 与 `on_complete` 类似,但状态会被设置为 "error"。
644    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
686// ============================================================================
687// HookManager 钩子链管理器
688// ============================================================================
689
690/// 钩子链管理器
691///
692/// 负责管理和协调多个下载后处理钩子的执行。
693/// 支持按顺序执行钩子链,并根据配置决定遇到错误时的处理策略。
694pub struct HookManager {
695    /// 注册的钩子列表(按注册顺序执行)
696    hooks: Vec<Box<dyn PostDownloadHook>>,
697    /// 钩子系统配置
698    config: HookConfig,
699}
700
701impl HookManager {
702    /// 创建新的钩子管理器
703    ///
704    /// # Arguments
705    ///
706    /// * `config` - 钩子系统配置选项
707    pub fn new(config: HookConfig) -> Self {
708        Self {
709            hooks: Vec::new(),
710            config,
711        }
712    }
713
714    /// 向钩子链添加一个新的钩子
715    ///
716    /// 钩子将按照添加的顺序被执行。
717    ///
718    /// # Arguments
719    ///
720    /// * `hook` - 要添加的钩子实例(必须实现 `PostDownloadHook` trait)
721    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    /// 按名称移除钩子
727    ///
728    /// # Arguments
729    ///
730    /// * `name` - 要移除的钩子名称
731    ///
732    /// # Returns
733    ///
734    /// 返回被移除的钩子(如果找到),否则返回 `None`
735    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    /// 触发所有钩子的 on_complete 回调
742    ///
743    /// 按注册顺序依次调用每个钩子的 `on_complete` 方法。
744    /// 根据 `config.stop_on_error` 决定是否在第一个失败时停止。
745    ///
746    /// # Arguments
747    ///
748    /// * `context` - 下载完成的上下文信息
749    ///
750    /// # Returns
751    ///
752    /// 返回每个钩子的执行结果描述向量。如果 `stop_on_error=true` 且某个钩子失败,
753    /// 返回 `Err` 包含该错误信息。
754    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    /// 触发所有钩子的 on_error 回调
787    ///
788    /// 与 `fire_complete` 类似,但调用的是 `on_error` 方法。
789    ///
790    /// # Arguments
791    ///
792    /// * `context` - 下载失败的上下文信息
793    /// * `error` - 错误描述字符串
794    ///
795    /// # Returns
796    ///
797    /// 返回每个钩子的执行结果描述向量
798    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    /// 获取当前注册的钩子数量
832    pub fn hook_count(&self) -> usize {
833        self.hooks.len()
834    }
835
836    /// 清空所有已注册的钩子
837    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    /// 辅助函数:创建测试用的 HookContext
848    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        // 创建测试文件
870        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        // 手动创建目标目录
878        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        // 验证文件已被移动
887        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        // 目标目录不存在且有多层嵌套
905        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        // 验证目录被自动创建且文件已移动
913        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        // 测试 expand_pattern
933        let expanded = hook.expand_pattern(&context);
934        assert!(
935            expanded.contains("archive.tar.gz.renamed"),
936            "Pattern should contain original filename"
937        );
938
939        // 测试实际重命名
940        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        // Get original modification time
959        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        // Wait for a longer time to ensure time difference is detectable on all platforms
965        // Windows FAT has 2-second resolution, NTFS has 100ns resolution
966        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        // Verify modification time has been updated
974        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        // 创建一个简单的测试脚本,输出环境变量到文件
992        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
993        let output_file = temp_dir.path().join("env_output.txt");
994
995        // 使用 echo 命令写入环境变量(跨平台兼容)
996        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        // 注意:这个测试在非 Unix 系统上可能需要调整
1005        #[cfg(unix)]
1006        {
1007            let result = hook.on_complete(&context).await;
1008            // 即使命令失败(因为可能没有 sh),我们主要验证构建逻辑正确性
1009            let _ = result;
1010        }
1011
1012        // 验证环境变量构建逻辑
1013        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        // 验证钩子按注册顺序添加和计数
1060        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        // 验证可以按名称移除钩子(从链的末尾开始)
1066        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        // 添加一个会失败的 ExecHook
1085        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        // 不应该因为第一个钩子失败而返回错误
1093        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        // 第一个钩子会失败
1114        manager.add_hook(Box::new(ExecHook::new(
1115            "exit 1".to_string(),
1116            HashMap::new(),
1117        )));
1118        // 第二个钩子不应该被执行
1119        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        // 尝试移除不存在的钩子
1148        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}