Skip to main content

subx_cli/core/parallel/
task.rs

1//! Task definition and utilities for parallel processing
2use crate::core::fs_util::{atomic_create_file, validate_write_target};
3use async_trait::async_trait;
4use std::fmt;
5use std::fs::File;
6use std::io;
7use std::path::Path;
8
9/// Returns true if the given I/O error indicates a cross-device link failure,
10/// which is the signal to fall back from `rename` to copy+delete.
11fn is_cross_device_error(err: &io::Error) -> bool {
12    #[cfg(unix)]
13    {
14        // EXDEV == 18 on Linux and most Unixes
15        if err.raw_os_error() == Some(18) {
16            return true;
17        }
18    }
19    // Fallback check: some platforms report cross-device via a message or kind.
20    matches!(err.kind(), io::ErrorKind::Unsupported)
21}
22
23/// Resolve filename conflicts by atomically creating the target.
24///
25/// Returns the resolved path together with the open file handle. Callers
26/// should write through this handle to avoid TOCTOU races between conflict
27/// resolution and file creation.
28fn resolve_filename_conflict(
29    target: std::path::PathBuf,
30) -> Result<(std::path::PathBuf, File), Box<dyn std::error::Error + Send + Sync>> {
31    match atomic_create_file(&target) {
32        Ok(f) => return Ok((target, f)),
33        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
34        Err(e) => return Err(e.into()),
35    }
36
37    let file_stem = target
38        .file_stem()
39        .and_then(|s| s.to_str())
40        .unwrap_or("file");
41    let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
42    let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
43
44    for i in 1..1000 {
45        let new_name = if extension.is_empty() {
46            format!("{}.{}", file_stem, i)
47        } else {
48            format!("{}.{}.{}", file_stem, i, extension)
49        };
50        let new_path = parent.join(new_name);
51        match atomic_create_file(&new_path) {
52            Ok(f) => return Ok((new_path, f)),
53            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
54            Err(e) => return Err(e.into()),
55        }
56    }
57
58    Err("Could not resolve filename conflict".into())
59}
60
61/// Trait defining a unit of work that can be executed asynchronously.
62///
63/// All tasks in the parallel processing system must implement this trait
64/// to provide execution logic and metadata.
65#[async_trait]
66pub trait Task: Send + Sync {
67    /// Executes the task and returns the result.
68    async fn execute(&self) -> TaskResult;
69    /// Returns the type identifier for this task.
70    fn task_type(&self) -> &'static str;
71    /// Returns a unique identifier for this specific task instance.
72    fn task_id(&self) -> String;
73    /// Returns an estimated duration for the task execution.
74    fn estimated_duration(&self) -> Option<std::time::Duration> {
75        None
76    }
77    /// Returns a human-readable description of the task.
78    fn description(&self) -> String {
79        format!("{} task", self.task_type())
80    }
81}
82
83/// Result of task execution indicating success, failure, or partial completion.
84///
85/// Provides detailed information about the outcome of a task execution,
86/// including success/failure status and descriptive messages.
87#[derive(Debug, Clone)]
88pub enum TaskResult {
89    /// Task completed successfully with a result message
90    Success(String),
91    /// Task failed with an error message
92    Failed(String),
93    /// Task was cancelled before completion
94    Cancelled,
95    /// Task partially completed with success and failure messages
96    PartialSuccess(String, String),
97}
98
99/// Current execution status of a task in the system.
100///
101/// Tracks the lifecycle of a task from initial queuing through completion
102/// or failure, providing detailed status information.
103#[derive(Debug, Clone)]
104pub enum TaskStatus {
105    /// Task is queued and waiting for execution
106    Pending,
107    /// Task is currently being executed
108    Running,
109    /// Task completed successfully or with partial success
110    Completed(TaskResult),
111    /// Task failed during execution
112    Failed(String),
113    /// Task was cancelled before or during execution
114    Cancelled,
115}
116
117impl fmt::Display for TaskResult {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match self {
120            TaskResult::Success(msg) => write!(f, "✓ {}", msg),
121            TaskResult::Failed(msg) => write!(f, "✗ {}", msg),
122            TaskResult::Cancelled => write!(f, "⚠ Task cancelled"),
123            TaskResult::PartialSuccess(success, warn) => {
124                write!(f, "⚠ {} (warning: {})", success, warn)
125            }
126        }
127    }
128}
129
130impl fmt::Display for TaskStatus {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        match self {
133            TaskStatus::Pending => write!(f, "Pending"),
134            TaskStatus::Running => write!(f, "Running"),
135            TaskStatus::Completed(result) => write!(f, "Completed: {}", result),
136            TaskStatus::Failed(msg) => write!(f, "Failed: {}", msg),
137            TaskStatus::Cancelled => write!(f, "Cancelled"),
138        }
139    }
140}
141
142/// Task for processing files (convert, sync, match, validate).
143///
144/// Represents a file processing operation that can be executed
145/// asynchronously in the parallel processing system.
146pub struct FileProcessingTask {
147    /// Path to the input file to be processed
148    pub input_path: std::path::PathBuf,
149    /// Optional output path for the processed file
150    pub output_path: Option<std::path::PathBuf>,
151    /// The specific operation to perform on the file
152    pub operation: ProcessingOperation,
153}
154
155/// Supported operations for file processing tasks.
156///
157/// Defines the different types of operations that can be performed
158/// on subtitle and video files in the processing system.
159#[derive(Debug, Clone)]
160pub enum ProcessingOperation {
161    /// Convert subtitle format from one type to another
162    ConvertFormat {
163        /// Source format (e.g., "srt", "ass")
164        from: String,
165        /// Target format (e.g., "srt", "ass")
166        to: String,
167    },
168    /// Synchronize subtitle timing with audio
169    SyncSubtitle {
170        /// Path to the audio file for synchronization
171        audio_path: std::path::PathBuf,
172    },
173    /// Match subtitle files with video files
174    MatchFiles {
175        /// Whether to search recursively in subdirectories
176        recursive: bool,
177    },
178    /// Validate subtitle file format and structure
179    ValidateFormat,
180    /// Copy subtitle file to video folder
181    CopyToVideoFolder {
182        /// Path to the source subtitle file to be copied
183        source: std::path::PathBuf,
184        /// Path to the target video folder where the subtitle will be copied
185        target: std::path::PathBuf,
186    },
187    /// Move subtitle file to video folder
188    MoveToVideoFolder {
189        /// Path to the source subtitle file to be moved
190        source: std::path::PathBuf,
191        /// Path to the target video folder where the subtitle will be moved
192        target: std::path::PathBuf,
193    },
194    /// Copy a file with a new name (local copy)
195    CopyWithRename {
196        /// Source file path
197        source: std::path::PathBuf,
198        /// Target file path
199        target: std::path::PathBuf,
200    },
201    /// Create a backup of a file
202    CreateBackup {
203        /// Original file path
204        source: std::path::PathBuf,
205        /// Backup file path
206        backup: std::path::PathBuf,
207    },
208    /// Rename (move) a file
209    RenameFile {
210        /// Original file path
211        source: std::path::PathBuf,
212        /// New file path after rename
213        target: std::path::PathBuf,
214    },
215}
216
217#[async_trait]
218impl Task for FileProcessingTask {
219    async fn execute(&self) -> TaskResult {
220        match &self.operation {
221            ProcessingOperation::ConvertFormat { from, to } => {
222                match self.convert_format(from, to).await {
223                    Ok(path) => TaskResult::Success(format!(
224                        "Successfully converted {} -> {}: {}",
225                        from,
226                        to,
227                        path.display()
228                    )),
229                    Err(e) => TaskResult::Failed(format!(
230                        "Conversion failed {}: {}",
231                        self.input_path.display(),
232                        e
233                    )),
234                }
235            }
236            ProcessingOperation::SyncSubtitle { .. } => {
237                // Sync not supported in parallel tasks
238                TaskResult::Failed("Sync functionality not implemented".to_string())
239            }
240            ProcessingOperation::MatchFiles { recursive } => {
241                match self.match_files(*recursive).await {
242                    Ok(m) => TaskResult::Success(format!(
243                        "File matching completed: found {} matches",
244                        m.len()
245                    )),
246                    Err(e) => TaskResult::Failed(format!("Matching failed: {}", e)),
247                }
248            }
249            ProcessingOperation::ValidateFormat => match self.validate_format().await {
250                Ok(true) => TaskResult::Success(format!(
251                    "Format validation passed: {}",
252                    self.input_path.display()
253                )),
254                Ok(false) => TaskResult::Failed(format!(
255                    "Format validation failed: {}",
256                    self.input_path.display()
257                )),
258                Err(e) => TaskResult::Failed(format!("Validation error: {}", e)),
259            },
260            ProcessingOperation::CopyToVideoFolder { source, target } => {
261                match self.execute_copy_operation(source, target).await {
262                    Ok(_) => TaskResult::Success(format!(
263                        "Copied: {} -> {}",
264                        source.display(),
265                        target.display()
266                    )),
267                    Err(e) => TaskResult::Failed(format!("Copy failed: {}", e)),
268                }
269            }
270            ProcessingOperation::MoveToVideoFolder { source, target } => {
271                match self.execute_move_operation(source, target).await {
272                    Ok(_) => TaskResult::Success(format!(
273                        "Moved: {} -> {}",
274                        source.display(),
275                        target.display()
276                    )),
277                    Err(e) => TaskResult::Failed(format!("Move failed: {}", e)),
278                }
279            }
280            ProcessingOperation::CopyWithRename { source, target } => {
281                match self
282                    .execute_copy_with_rename_operation(source, target)
283                    .await
284                {
285                    Ok(_) => TaskResult::Success(format!(
286                        "Copied: {} -> {}",
287                        source.display(),
288                        target.display()
289                    )),
290                    Err(e) => TaskResult::Failed(format!("Copy failed: {}", e)),
291                }
292            }
293            ProcessingOperation::CreateBackup { source, backup } => {
294                match self.execute_create_backup_operation(source, backup).await {
295                    Ok(_) => TaskResult::Success(format!(
296                        "Backup created: {} -> {}",
297                        source.display(),
298                        backup.display()
299                    )),
300                    Err(e) => TaskResult::Failed(format!("Backup failed: {}", e)),
301                }
302            }
303            ProcessingOperation::RenameFile { source, target } => {
304                match self.execute_rename_file_operation(source, target).await {
305                    Ok(_) => TaskResult::Success(format!(
306                        "Renamed: {} -> {}",
307                        source.display(),
308                        target.display()
309                    )),
310                    Err(e) => TaskResult::Failed(format!("Rename failed: {}", e)),
311                }
312            }
313        }
314    }
315
316    fn task_type(&self) -> &'static str {
317        match &self.operation {
318            ProcessingOperation::ConvertFormat { .. } => "convert",
319            ProcessingOperation::SyncSubtitle { .. } => "sync",
320            ProcessingOperation::MatchFiles { .. } => "match",
321            ProcessingOperation::ValidateFormat => "validate",
322            ProcessingOperation::CopyToVideoFolder { .. } => "copy_to_video_folder",
323            ProcessingOperation::MoveToVideoFolder { .. } => "move_to_video_folder",
324            ProcessingOperation::CopyWithRename { .. } => "copy_with_rename",
325            ProcessingOperation::CreateBackup { .. } => "create_backup",
326            ProcessingOperation::RenameFile { .. } => "rename_file",
327        }
328    }
329
330    fn task_id(&self) -> String {
331        use std::collections::hash_map::DefaultHasher;
332        use std::hash::{Hash, Hasher};
333        let mut hasher = DefaultHasher::new();
334        self.input_path.hash(&mut hasher);
335        self.operation.hash(&mut hasher);
336        format!("{}_{:x}", self.task_type(), hasher.finish())
337    }
338
339    fn estimated_duration(&self) -> Option<std::time::Duration> {
340        if let Ok(meta) = std::fs::metadata(&self.input_path) {
341            let size_mb = meta.len() as f64 / 1_048_576.0;
342            let secs = match &self.operation {
343                ProcessingOperation::ConvertFormat { .. } => size_mb * 0.1,
344                ProcessingOperation::SyncSubtitle { .. } => size_mb * 0.5,
345                ProcessingOperation::MatchFiles { .. } => 2.0,
346                ProcessingOperation::ValidateFormat => size_mb * 0.05,
347                ProcessingOperation::CopyToVideoFolder { .. } => size_mb * 0.01, // Fast copy
348                ProcessingOperation::MoveToVideoFolder { .. } => size_mb * 0.005, // Even faster move
349                ProcessingOperation::CopyWithRename { .. } => size_mb * 0.01,
350                ProcessingOperation::CreateBackup { .. } => size_mb * 0.01,
351                ProcessingOperation::RenameFile { .. } => size_mb * 0.005,
352            };
353            Some(std::time::Duration::from_secs_f64(secs))
354        } else {
355            None
356        }
357    }
358
359    fn description(&self) -> String {
360        match &self.operation {
361            ProcessingOperation::ConvertFormat { from, to } => {
362                format!(
363                    "Convert {} from {} to {}",
364                    self.input_path.display(),
365                    from,
366                    to
367                )
368            }
369            ProcessingOperation::SyncSubtitle { audio_path } => format!(
370                "Sync subtitle {} with audio {}",
371                self.input_path.display(),
372                audio_path.display()
373            ),
374            ProcessingOperation::MatchFiles { recursive } => format!(
375                "Match files in {}{}",
376                self.input_path.display(),
377                if *recursive { " (recursive)" } else { "" }
378            ),
379            ProcessingOperation::ValidateFormat => {
380                format!("Validate format of {}", self.input_path.display())
381            }
382            ProcessingOperation::CopyToVideoFolder { source, target } => {
383                format!("Copy {} to {}", source.display(), target.display())
384            }
385            ProcessingOperation::MoveToVideoFolder { source, target } => {
386                format!("Move {} to {}", source.display(), target.display())
387            }
388            ProcessingOperation::CopyWithRename { source, target } => {
389                format!(
390                    "CopyWithRename {} to {}",
391                    source.display(),
392                    target.display()
393                )
394            }
395            ProcessingOperation::CreateBackup { source, backup } => {
396                format!("CreateBackup {} to {}", source.display(), backup.display())
397            }
398            ProcessingOperation::RenameFile { source, target } => {
399                format!("Rename {} to {}", source.display(), target.display())
400            }
401        }
402    }
403}
404
405impl FileProcessingTask {
406    /// Create a new file processing task with operation
407    pub fn new(
408        input_path: std::path::PathBuf,
409        output_path: Option<std::path::PathBuf>,
410        operation: ProcessingOperation,
411    ) -> Self {
412        FileProcessingTask {
413            input_path,
414            output_path,
415            operation,
416        }
417    }
418
419    /// Execute copy operation for file relocation
420    async fn execute_copy_operation(
421        &self,
422        source: &Path,
423        target: &Path,
424    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
425        let source = source.to_path_buf();
426        let target = target.to_path_buf();
427        tokio::task::spawn_blocking(
428            move || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
429                // Create target directory if it doesn't exist
430                if let Some(parent) = target.parent() {
431                    std::fs::create_dir_all(parent)?;
432                }
433
434                // Atomically resolve filename conflicts and obtain an open file handle.
435                let (final_target, mut file) = resolve_filename_conflict(target)?;
436
437                if let Some(parent) = final_target.parent() {
438                    validate_write_target(&final_target, parent)?;
439                }
440
441                // Stream the source file contents through the exclusive handle.
442                let mut src = std::fs::File::open(&source)?;
443                std::io::copy(&mut src, &mut file)?;
444                Ok(())
445            },
446        )
447        .await?
448    }
449
450    /// Execute move operation for file relocation
451    async fn execute_move_operation(
452        &self,
453        source: &Path,
454        target: &Path,
455    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
456        let source = source.to_path_buf();
457        let target = target.to_path_buf();
458        tokio::task::spawn_blocking(
459            move || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
460                // Create target directory if it doesn't exist
461                if let Some(parent) = target.parent() {
462                    std::fs::create_dir_all(parent)?;
463                }
464
465                // Try a fast in-filesystem rename first; fall back to copy+delete otherwise.
466                if !target.exists() {
467                    match std::fs::rename(&source, &target) {
468                        Ok(_) => return Ok(()),
469                        Err(e) if is_cross_device_error(&e) => {}
470                        Err(_) => { /* fall through to copy+delete path */ }
471                    }
472                }
473
474                let (final_target, mut file) = resolve_filename_conflict(target)?;
475
476                if let Some(parent) = final_target.parent() {
477                    validate_write_target(&final_target, parent)?;
478                }
479
480                let mut src = std::fs::File::open(&source)?;
481                std::io::copy(&mut src, &mut file)?;
482                file.sync_all()?;
483                drop(file);
484                std::fs::remove_file(&source)?;
485                Ok(())
486            },
487        )
488        .await?
489    }
490
491    /// Execute a copy with rename operation (local copy) using CIFS-safe copy
492    async fn execute_copy_with_rename_operation(
493        &self,
494        source: &Path,
495        target: &Path,
496    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
497        let source = source.to_path_buf();
498        let target = target.to_path_buf();
499        tokio::task::spawn_blocking(
500            move || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
501                if let Some(parent) = target.parent() {
502                    std::fs::create_dir_all(parent)?;
503                }
504                crate::core::fs_util::copy_file_cifs_safe(&source, &target)?;
505                Ok(())
506            },
507        )
508        .await?
509    }
510
511    /// Execute a create backup operation using an atomically created destination.
512    async fn execute_create_backup_operation(
513        &self,
514        source: &Path,
515        backup: &Path,
516    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
517        let source = source.to_path_buf();
518        let backup = backup.to_path_buf();
519        tokio::task::spawn_blocking(
520            move || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
521                if let Some(parent) = backup.parent() {
522                    std::fs::create_dir_all(parent)?;
523                }
524
525                let (final_target, mut file) = resolve_filename_conflict(backup)?;
526
527                if let Some(parent) = final_target.parent() {
528                    validate_write_target(&final_target, parent)?;
529                }
530
531                let mut src = std::fs::File::open(&source)?;
532                std::io::copy(&mut src, &mut file)?;
533                Ok(())
534            },
535        )
536        .await?
537    }
538
539    /// Execute a file rename operation
540    async fn execute_rename_file_operation(
541        &self,
542        source: &Path,
543        target: &Path,
544    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
545        let source = source.to_path_buf();
546        let target = target.to_path_buf();
547        tokio::task::spawn_blocking(
548            move || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
549                if let Some(parent) = target.parent() {
550                    std::fs::create_dir_all(parent)?;
551                }
552
553                if !target.exists() {
554                    match std::fs::rename(&source, &target) {
555                        Ok(_) => return Ok(()),
556                        Err(e) if is_cross_device_error(&e) => {}
557                        Err(_) => { /* fall through to copy+delete path */ }
558                    }
559                }
560
561                let (final_target, mut file) = resolve_filename_conflict(target)?;
562
563                if let Some(parent) = final_target.parent() {
564                    validate_write_target(&final_target, parent)?;
565                }
566
567                let mut src = std::fs::File::open(&source)?;
568                std::io::copy(&mut src, &mut file)?;
569                file.sync_all()?;
570                drop(file);
571                std::fs::remove_file(&source)?;
572                Ok(())
573            },
574        )
575        .await?
576    }
577
578    async fn convert_format(&self, _from: &str, _to: &str) -> crate::Result<std::path::PathBuf> {
579        // Stub convert: simply return input path
580        Ok(self.input_path.clone())
581    }
582
583    async fn sync_subtitle(
584        &self,
585        _audio_path: &std::path::Path,
586    ) -> crate::Result<crate::core::sync::SyncResult> {
587        // Stub implementation: sync not available
588        Err(crate::error::SubXError::parallel_processing(
589            "sync_subtitle not implemented".to_string(),
590        ))
591    }
592
593    async fn match_files(&self, _recursive: bool) -> crate::Result<Vec<()>> {
594        // Stub implementation: no actual matching
595        Ok(Vec::new())
596    }
597
598    async fn validate_format(&self) -> crate::Result<bool> {
599        // Stub validate: always succeed
600        Ok(true)
601    }
602}
603
604// impl Hash for ProcessingOperation to support task_id generation
605impl std::hash::Hash for ProcessingOperation {
606    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
607        match self {
608            ProcessingOperation::ConvertFormat { from, to } => {
609                "convert".hash(state);
610                from.hash(state);
611                to.hash(state);
612            }
613            ProcessingOperation::SyncSubtitle { audio_path } => {
614                "sync".hash(state);
615                audio_path.hash(state);
616            }
617            ProcessingOperation::MatchFiles { recursive } => {
618                "match".hash(state);
619                recursive.hash(state);
620            }
621            ProcessingOperation::ValidateFormat => {
622                "validate".hash(state);
623            }
624            ProcessingOperation::CopyToVideoFolder { source, target } => {
625                "copy_to_video_folder".hash(state);
626                source.hash(state);
627                target.hash(state);
628            }
629            ProcessingOperation::MoveToVideoFolder { source, target } => {
630                "move_to_video_folder".hash(state);
631                source.hash(state);
632                target.hash(state);
633            }
634            ProcessingOperation::CopyWithRename { source, target } => {
635                "copy_with_rename".hash(state);
636                source.hash(state);
637                target.hash(state);
638            }
639            ProcessingOperation::CreateBackup { source, backup } => {
640                "create_backup".hash(state);
641                source.hash(state);
642                backup.hash(state);
643            }
644            ProcessingOperation::RenameFile { source, target } => {
645                "rename_file".hash(state);
646                source.hash(state);
647                target.hash(state);
648            }
649        }
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use std::time::Duration;
657    use tempfile::TempDir;
658
659    #[tokio::test]
660    async fn test_file_processing_task_validate_format() {
661        let tmp = TempDir::new().unwrap();
662        let test_file = tmp.path().join("test.srt");
663        tokio::fs::write(&test_file, "1\n00:00:01,000 --> 00:00:02,000\nTest\n")
664            .await
665            .unwrap();
666        let task = FileProcessingTask {
667            input_path: test_file.clone(),
668            output_path: None,
669            operation: ProcessingOperation::ValidateFormat,
670        };
671        let result = task.execute().await;
672        assert!(matches!(result, TaskResult::Success(_)));
673    }
674
675    #[tokio::test]
676    async fn test_file_processing_task_copy_with_rename() {
677        let tmp = TempDir::new().unwrap();
678        let src = tmp.path().join("orig.txt");
679        let dst = tmp.path().join("copy.txt");
680        tokio::fs::write(&src, b"hello").await.unwrap();
681        let task = FileProcessingTask::new(
682            src.clone(),
683            Some(dst.clone()),
684            ProcessingOperation::CopyWithRename {
685                source: src.clone(),
686                target: dst.clone(),
687            },
688        );
689        let result = task.execute().await;
690        assert!(matches!(result, TaskResult::Success(_)));
691        let data = tokio::fs::read(&dst).await.unwrap();
692        assert_eq!(data, b"hello");
693    }
694
695    #[tokio::test]
696    async fn test_file_processing_task_create_backup() {
697        let tmp = TempDir::new().unwrap();
698        let src = tmp.path().join("orig.txt");
699        let backup = tmp.path().join("orig.txt.bak");
700        tokio::fs::write(&src, b"backup").await.unwrap();
701        let task = FileProcessingTask::new(
702            src.clone(),
703            Some(backup.clone()),
704            ProcessingOperation::CreateBackup {
705                source: src.clone(),
706                backup: backup.clone(),
707            },
708        );
709        let result = task.execute().await;
710        assert!(matches!(result, TaskResult::Success(_)));
711        let data = tokio::fs::read(&backup).await.unwrap();
712        assert_eq!(data, b"backup");
713    }
714
715    #[tokio::test]
716    async fn test_file_processing_task_rename_file() {
717        let tmp = TempDir::new().unwrap();
718        let src = tmp.path().join("a.txt");
719        let dst = tmp.path().join("b.txt");
720        tokio::fs::write(&src, b"rename").await.unwrap();
721        let task = FileProcessingTask::new(
722            src.clone(),
723            Some(dst.clone()),
724            ProcessingOperation::RenameFile {
725                source: src.clone(),
726                target: dst.clone(),
727            },
728        );
729        let result = task.execute().await;
730        assert!(matches!(result, TaskResult::Success(_)));
731        assert!(tokio::fs::metadata(&src).await.is_err());
732        let data = tokio::fs::read(&dst).await.unwrap();
733        assert_eq!(data, b"rename");
734    }
735
736    /// Test task lifecycle and status transitions
737    #[tokio::test]
738    async fn test_task_lifecycle() {
739        let tmp = TempDir::new().unwrap();
740        let test_file = tmp.path().join("lifecycle.srt");
741        tokio::fs::write(
742            &test_file,
743            "1\n00:00:01,000 --> 00:00:02,000\nLifecycle test\n",
744        )
745        .await
746        .unwrap();
747
748        let task = FileProcessingTask {
749            input_path: test_file.clone(),
750            output_path: None,
751            operation: ProcessingOperation::ValidateFormat,
752        };
753
754        // Test initial task properties
755        assert_eq!(task.task_type(), "validate");
756        assert!(!task.task_id().is_empty());
757        assert!(task.description().contains("Validate format"));
758        assert!(task.description().contains("lifecycle.srt"));
759        assert!(
760            task.estimated_duration().is_some(),
761            "Should estimate duration for existing file"
762        );
763
764        // Test execution
765        let result = task.execute().await;
766        assert!(matches!(result, TaskResult::Success(_)));
767    }
768
769    /// Test task result serialization and display
770    #[test]
771    fn test_task_result_display() {
772        let success = TaskResult::Success("Operation completed".to_string());
773        let failed = TaskResult::Failed("Operation failed".to_string());
774        let cancelled = TaskResult::Cancelled;
775        let partial =
776            TaskResult::PartialSuccess("Mostly worked".to_string(), "Minor issue".to_string());
777
778        assert_eq!(format!("{}", success), "✓ Operation completed");
779        assert_eq!(format!("{}", failed), "✗ Operation failed");
780        assert_eq!(format!("{}", cancelled), "⚠ Task cancelled");
781        assert_eq!(
782            format!("{}", partial),
783            "⚠ Mostly worked (warning: Minor issue)"
784        );
785    }
786
787    /// Test task status display
788    #[test]
789    fn test_task_status_display() {
790        let pending = TaskStatus::Pending;
791        let running = TaskStatus::Running;
792        let completed = TaskStatus::Completed(TaskResult::Success("Done".to_string()));
793        let failed = TaskStatus::Failed("Error occurred".to_string());
794        let cancelled = TaskStatus::Cancelled;
795
796        assert_eq!(format!("{}", pending), "Pending");
797        assert_eq!(format!("{}", running), "Running");
798        assert_eq!(format!("{}", completed), "Completed: ✓ Done");
799        assert_eq!(format!("{}", failed), "Failed: Error occurred");
800        assert_eq!(format!("{}", cancelled), "Cancelled");
801    }
802
803    /// Test format conversion task
804    #[tokio::test]
805    async fn test_format_conversion_task() {
806        let tmp = TempDir::new().unwrap();
807        let input_file = tmp.path().join("input.srt");
808        let output_file = tmp.path().join("output.ass");
809
810        // Create valid SRT content
811        let srt_content = r#"1
81200:00:01,000 --> 00:00:03,000
813First subtitle
814
8152
81600:00:04,000 --> 00:00:06,000
817Second subtitle
818"#;
819
820        tokio::fs::write(&input_file, srt_content).await.unwrap();
821
822        let task = FileProcessingTask {
823            input_path: input_file.clone(),
824            output_path: Some(output_file.clone()),
825            operation: ProcessingOperation::ConvertFormat {
826                from: "srt".to_string(),
827                to: "ass".to_string(),
828            },
829        };
830
831        let result = task.execute().await;
832        assert!(matches!(result, TaskResult::Success(_)));
833
834        // Note: The convert_format method is a stub that returns the input path
835        // In a real implementation, this would create an actual output file
836        assert!(tokio::fs::metadata(&input_file).await.is_ok());
837    }
838
839    /// Test file matching task
840    #[tokio::test]
841    async fn test_file_matching_task() {
842        let tmp = TempDir::new().unwrap();
843        let video_file = tmp.path().join("movie.mkv");
844        let subtitle_file = tmp.path().join("movie.srt");
845
846        // Create test files
847        tokio::fs::write(&video_file, b"fake video content")
848            .await
849            .unwrap();
850        tokio::fs::write(&subtitle_file, "1\n00:00:01,000 --> 00:00:02,000\nTest\n")
851            .await
852            .unwrap();
853
854        let task = FileProcessingTask {
855            input_path: tmp.path().to_path_buf(),
856            output_path: None,
857            operation: ProcessingOperation::MatchFiles { recursive: false },
858        };
859
860        let result = task.execute().await;
861        assert!(matches!(result, TaskResult::Success(_)));
862    }
863
864    /// Test sync subtitle task (expected to fail)
865    #[tokio::test]
866    async fn test_sync_subtitle_task() {
867        let tmp = TempDir::new().unwrap();
868        let audio_file = tmp.path().join("audio.wav");
869        let subtitle_file = tmp.path().join("subtitle.srt");
870
871        tokio::fs::write(&audio_file, b"fake audio content")
872            .await
873            .unwrap();
874        tokio::fs::write(&subtitle_file, "1\n00:00:01,000 --> 00:00:02,000\nTest\n")
875            .await
876            .unwrap();
877
878        let task = FileProcessingTask {
879            input_path: subtitle_file.clone(),
880            output_path: None,
881            operation: ProcessingOperation::SyncSubtitle {
882                audio_path: audio_file,
883            },
884        };
885
886        let result = task.execute().await;
887        // Sync is not implemented, so should fail
888        assert!(matches!(result, TaskResult::Failed(_)));
889    }
890
891    /// Test task error handling
892    #[tokio::test]
893    async fn test_task_error_handling() {
894        // Test with sync operation which always fails in stub implementation
895        let tmp = TempDir::new().unwrap();
896        let test_file = tmp.path().join("test.srt");
897
898        let task = FileProcessingTask {
899            input_path: test_file,
900            output_path: None,
901            operation: ProcessingOperation::SyncSubtitle {
902                audio_path: tmp.path().join("audio.wav"),
903            },
904        };
905
906        let result = task.execute().await;
907        assert!(matches!(result, TaskResult::Failed(_)));
908    }
909
910    /// Test task timeout handling
911    #[tokio::test]
912    async fn test_task_timeout() {
913        use async_trait::async_trait;
914
915        struct SlowTask {
916            duration: Duration,
917        }
918
919        #[async_trait]
920        impl Task for SlowTask {
921            async fn execute(&self) -> TaskResult {
922                tokio::time::sleep(self.duration).await;
923                TaskResult::Success("Slow task completed".to_string())
924            }
925            fn task_type(&self) -> &'static str {
926                "slow"
927            }
928            fn task_id(&self) -> String {
929                "slow_task_1".to_string()
930            }
931            fn estimated_duration(&self) -> Option<Duration> {
932                Some(self.duration)
933            }
934        }
935
936        let slow_task = SlowTask {
937            duration: Duration::from_millis(100),
938        };
939
940        // Test estimated duration
941        assert_eq!(
942            slow_task.estimated_duration(),
943            Some(Duration::from_millis(100))
944        );
945
946        // Test execution
947        let start = std::time::Instant::now();
948        let result = slow_task.execute().await;
949        let elapsed = start.elapsed();
950
951        assert!(matches!(result, TaskResult::Success(_)));
952        assert!(elapsed >= Duration::from_millis(90)); // Allow some variance
953    }
954
955    /// Test processing operation variants
956    #[test]
957    fn test_processing_operation_variants() {
958        let convert_op = ProcessingOperation::ConvertFormat {
959            from: "srt".to_string(),
960            to: "ass".to_string(),
961        };
962
963        let sync_op = ProcessingOperation::SyncSubtitle {
964            audio_path: std::path::PathBuf::from("audio.wav"),
965        };
966
967        let match_op = ProcessingOperation::MatchFiles { recursive: true };
968        let validate_op = ProcessingOperation::ValidateFormat;
969
970        // Test debug formatting
971        assert!(format!("{:?}", convert_op).contains("ConvertFormat"));
972        assert!(format!("{:?}", sync_op).contains("SyncSubtitle"));
973        assert!(format!("{:?}", match_op).contains("MatchFiles"));
974        assert!(format!("{:?}", validate_op).contains("ValidateFormat"));
975
976        // Test cloning
977        let convert_clone = convert_op.clone();
978        assert!(format!("{:?}", convert_clone).contains("ConvertFormat"));
979    }
980
981    /// Test custom task implementation
982    #[tokio::test]
983    async fn test_custom_task_implementation() {
984        use async_trait::async_trait;
985
986        struct CustomTask {
987            id: String,
988            should_succeed: bool,
989        }
990
991        #[async_trait]
992        impl Task for CustomTask {
993            async fn execute(&self) -> TaskResult {
994                if self.should_succeed {
995                    TaskResult::Success(format!("Custom task {} succeeded", self.id))
996                } else {
997                    TaskResult::Failed(format!("Custom task {} failed", self.id))
998                }
999            }
1000
1001            fn task_type(&self) -> &'static str {
1002                "custom"
1003            }
1004
1005            fn task_id(&self) -> String {
1006                self.id.clone()
1007            }
1008
1009            fn description(&self) -> String {
1010                format!("Custom task with ID: {}", self.id)
1011            }
1012
1013            fn estimated_duration(&self) -> Option<Duration> {
1014                Some(Duration::from_millis(1))
1015            }
1016        }
1017
1018        // Test successful custom task
1019        let success_task = CustomTask {
1020            id: "success_1".to_string(),
1021            should_succeed: true,
1022        };
1023
1024        assert_eq!(success_task.task_type(), "custom");
1025        assert_eq!(success_task.task_id(), "success_1");
1026        assert_eq!(success_task.description(), "Custom task with ID: success_1");
1027        assert_eq!(
1028            success_task.estimated_duration(),
1029            Some(Duration::from_millis(1))
1030        );
1031
1032        let result = success_task.execute().await;
1033        assert!(matches!(result, TaskResult::Success(_)));
1034
1035        // Test failing custom task
1036        let fail_task = CustomTask {
1037            id: "fail_1".to_string(),
1038            should_succeed: false,
1039        };
1040
1041        let result = fail_task.execute().await;
1042        assert!(matches!(result, TaskResult::Failed(_)));
1043    }
1044
1045    #[tokio::test]
1046    async fn test_resolve_filename_conflict_sequential_suffixes() {
1047        let tmp = TempDir::new().unwrap();
1048        let base = tmp.path().join("x.txt");
1049        tokio::fs::write(&base, b"first").await.unwrap();
1050
1051        let (p1, f1) = resolve_filename_conflict(base.clone()).unwrap();
1052        assert_eq!(p1.file_name().unwrap(), "x.1.txt");
1053        drop(f1);
1054
1055        let (p2, _f2) = resolve_filename_conflict(base.clone()).unwrap();
1056        assert_eq!(p2.file_name().unwrap(), "x.2.txt");
1057    }
1058
1059    #[tokio::test]
1060    async fn test_execute_copy_operation_atomic() {
1061        let tmp = TempDir::new().unwrap();
1062        let src = tmp.path().join("src.txt");
1063        let dst = tmp.path().join("dst.txt");
1064        tokio::fs::write(&src, b"payload").await.unwrap();
1065
1066        let task = FileProcessingTask {
1067            input_path: src.clone(),
1068            output_path: None,
1069            operation: ProcessingOperation::ValidateFormat,
1070        };
1071        task.execute_copy_operation(&src, &dst).await.unwrap();
1072        assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"payload");
1073    }
1074
1075    #[tokio::test]
1076    async fn test_execute_move_operation_deletes_source() {
1077        let tmp = TempDir::new().unwrap();
1078        let src = tmp.path().join("from.txt");
1079        let dst = tmp.path().join("to.txt");
1080        tokio::fs::write(&src, b"moved").await.unwrap();
1081
1082        let task = FileProcessingTask {
1083            input_path: src.clone(),
1084            output_path: None,
1085            operation: ProcessingOperation::ValidateFormat,
1086        };
1087        task.execute_move_operation(&src, &dst).await.unwrap();
1088        assert!(tokio::fs::metadata(&src).await.is_err());
1089        assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"moved");
1090    }
1091
1092    // ── is_cross_device_error ──────────────────────────────────────────────
1093
1094    #[test]
1095    fn test_is_cross_device_error_unsupported() {
1096        let err = io::Error::new(io::ErrorKind::Unsupported, "unsupported");
1097        assert!(is_cross_device_error(&err));
1098    }
1099
1100    #[test]
1101    fn test_is_cross_device_error_other_kind() {
1102        let err = io::Error::new(io::ErrorKind::NotFound, "not found");
1103        assert!(!is_cross_device_error(&err));
1104    }
1105
1106    #[cfg(unix)]
1107    #[test]
1108    fn test_is_cross_device_error_exdev() {
1109        // EXDEV == 18 on Linux
1110        let err = io::Error::from_raw_os_error(18);
1111        assert!(is_cross_device_error(&err));
1112    }
1113
1114    #[cfg(unix)]
1115    #[test]
1116    fn test_is_cross_device_error_other_os_error() {
1117        let err = io::Error::from_raw_os_error(2); // ENOENT
1118        assert!(!is_cross_device_error(&err));
1119    }
1120
1121    // ── resolve_filename_conflict edge cases ───────────────────────────────
1122
1123    #[test]
1124    fn test_resolve_filename_conflict_new_file() {
1125        let tmp = TempDir::new().unwrap();
1126        let target = tmp.path().join("fresh.txt");
1127        let (path, _file) = resolve_filename_conflict(target.clone()).unwrap();
1128        assert_eq!(path, target);
1129    }
1130
1131    #[test]
1132    fn test_resolve_filename_conflict_no_extension() {
1133        let tmp = TempDir::new().unwrap();
1134        let base = tmp.path().join("noext");
1135        std::fs::write(&base, b"data").unwrap();
1136
1137        let (p1, _f1) = resolve_filename_conflict(base.clone()).unwrap();
1138        assert_eq!(p1.file_name().unwrap(), "noext.1");
1139    }
1140
1141    #[test]
1142    fn test_resolve_filename_conflict_creates_parent_on_demand() {
1143        let tmp = TempDir::new().unwrap();
1144        // Parent directory exists via tempdir; file should not
1145        let target = tmp.path().join("brand_new.srt");
1146        let (path, _file) = resolve_filename_conflict(target.clone()).unwrap();
1147        assert_eq!(path, target);
1148    }
1149
1150    // ── CopyToVideoFolder and MoveToVideoFolder via execute() ──────────────
1151
1152    #[tokio::test]
1153    async fn test_execute_copy_to_video_folder_success() {
1154        let tmp = TempDir::new().unwrap();
1155        let src = tmp.path().join("sub.srt");
1156        let dst = tmp.path().join("video_dir").join("sub.srt");
1157        tokio::fs::write(&src, b"copy content").await.unwrap();
1158
1159        let task = FileProcessingTask::new(
1160            src.clone(),
1161            None,
1162            ProcessingOperation::CopyToVideoFolder {
1163                source: src.clone(),
1164                target: dst.clone(),
1165            },
1166        );
1167        let result = task.execute().await;
1168        assert!(matches!(result, TaskResult::Success(_)));
1169        assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"copy content");
1170    }
1171
1172    #[tokio::test]
1173    async fn test_execute_copy_to_video_folder_failure() {
1174        let tmp = TempDir::new().unwrap();
1175        let src = tmp.path().join("nonexistent.srt");
1176        let dst = tmp.path().join("dst.srt");
1177
1178        let task = FileProcessingTask::new(
1179            src.clone(),
1180            None,
1181            ProcessingOperation::CopyToVideoFolder {
1182                source: src.clone(),
1183                target: dst.clone(),
1184            },
1185        );
1186        let result = task.execute().await;
1187        assert!(matches!(result, TaskResult::Failed(_)));
1188    }
1189
1190    #[tokio::test]
1191    async fn test_execute_move_to_video_folder_success() {
1192        let tmp = TempDir::new().unwrap();
1193        let src = tmp.path().join("move_me.srt");
1194        let dst = tmp.path().join("dest_dir").join("move_me.srt");
1195        tokio::fs::write(&src, b"move content").await.unwrap();
1196
1197        let task = FileProcessingTask::new(
1198            src.clone(),
1199            None,
1200            ProcessingOperation::MoveToVideoFolder {
1201                source: src.clone(),
1202                target: dst.clone(),
1203            },
1204        );
1205        let result = task.execute().await;
1206        assert!(matches!(result, TaskResult::Success(_)));
1207        assert!(tokio::fs::metadata(&src).await.is_err());
1208        assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"move content");
1209    }
1210
1211    #[tokio::test]
1212    async fn test_execute_move_to_video_folder_failure() {
1213        let tmp = TempDir::new().unwrap();
1214        let src = tmp.path().join("missing.srt");
1215        let dst = tmp.path().join("dst.srt");
1216
1217        let task = FileProcessingTask::new(
1218            src.clone(),
1219            None,
1220            ProcessingOperation::MoveToVideoFolder {
1221                source: src.clone(),
1222                target: dst.clone(),
1223            },
1224        );
1225        let result = task.execute().await;
1226        assert!(matches!(result, TaskResult::Failed(_)));
1227    }
1228
1229    // ── Failure cases for other operations ────────────────────────────────
1230
1231    #[tokio::test]
1232    async fn test_execute_copy_with_rename_failure() {
1233        let tmp = TempDir::new().unwrap();
1234        let src = tmp.path().join("ghost.txt");
1235        let dst = tmp.path().join("out.txt");
1236
1237        let task = FileProcessingTask::new(
1238            src.clone(),
1239            None,
1240            ProcessingOperation::CopyWithRename {
1241                source: src.clone(),
1242                target: dst.clone(),
1243            },
1244        );
1245        let result = task.execute().await;
1246        assert!(matches!(result, TaskResult::Failed(_)));
1247    }
1248
1249    #[tokio::test]
1250    async fn test_execute_create_backup_failure() {
1251        let tmp = TempDir::new().unwrap();
1252        let src = tmp.path().join("ghost.txt");
1253        let bak = tmp.path().join("ghost.bak");
1254
1255        let task = FileProcessingTask::new(
1256            src.clone(),
1257            None,
1258            ProcessingOperation::CreateBackup {
1259                source: src.clone(),
1260                backup: bak.clone(),
1261            },
1262        );
1263        let result = task.execute().await;
1264        assert!(matches!(result, TaskResult::Failed(_)));
1265    }
1266
1267    #[tokio::test]
1268    async fn test_execute_rename_file_failure() {
1269        let tmp = TempDir::new().unwrap();
1270        let src = tmp.path().join("ghost.txt");
1271        let dst = tmp.path().join("new_name.txt");
1272
1273        let task = FileProcessingTask::new(
1274            src.clone(),
1275            None,
1276            ProcessingOperation::RenameFile {
1277                source: src.clone(),
1278                target: dst.clone(),
1279            },
1280        );
1281        let result = task.execute().await;
1282        assert!(matches!(result, TaskResult::Failed(_)));
1283    }
1284
1285    // ── Move/rename with existing target (conflict resolution path) ────────
1286
1287    #[tokio::test]
1288    async fn test_execute_move_operation_conflict_resolved() {
1289        let tmp = TempDir::new().unwrap();
1290        let src = tmp.path().join("src_conflict.txt");
1291        let dst = tmp.path().join("dst_conflict.txt");
1292        tokio::fs::write(&src, b"conflict source").await.unwrap();
1293        tokio::fs::write(&dst, b"existing dest").await.unwrap();
1294
1295        let task = FileProcessingTask {
1296            input_path: src.clone(),
1297            output_path: None,
1298            operation: ProcessingOperation::ValidateFormat,
1299        };
1300        task.execute_move_operation(&src, &dst).await.unwrap();
1301        // Source deleted, a renamed copy exists
1302        assert!(tokio::fs::metadata(&src).await.is_err());
1303        assert_eq!(
1304            tokio::fs::read(&dst).await.unwrap(),
1305            b"existing dest",
1306            "Original target must not be overwritten"
1307        );
1308        // The moved file should be at dst_conflict.1.txt
1309        let renamed = tmp.path().join("dst_conflict.1.txt");
1310        assert_eq!(tokio::fs::read(&renamed).await.unwrap(), b"conflict source");
1311    }
1312
1313    #[tokio::test]
1314    async fn test_execute_rename_file_conflict_resolved() {
1315        let tmp = TempDir::new().unwrap();
1316        let src = tmp.path().join("ren_src.txt");
1317        let dst = tmp.path().join("ren_dst.txt");
1318        tokio::fs::write(&src, b"rename conflict").await.unwrap();
1319        tokio::fs::write(&dst, b"existing").await.unwrap();
1320
1321        let task = FileProcessingTask {
1322            input_path: src.clone(),
1323            output_path: None,
1324            operation: ProcessingOperation::ValidateFormat,
1325        };
1326        task.execute_rename_file_operation(&src, &dst)
1327            .await
1328            .unwrap();
1329        assert!(tokio::fs::metadata(&src).await.is_err());
1330        let renamed = tmp.path().join("ren_dst.1.txt");
1331        assert_eq!(tokio::fs::read(&renamed).await.unwrap(), b"rename conflict");
1332    }
1333
1334    // ── task_type() for every operation variant ────────────────────────────
1335
1336    #[test]
1337    fn test_task_type_all_variants() {
1338        let tmp = std::path::PathBuf::from("x");
1339
1340        let cases: &[(&str, ProcessingOperation)] = &[
1341            (
1342                "convert",
1343                ProcessingOperation::ConvertFormat {
1344                    from: "srt".into(),
1345                    to: "ass".into(),
1346                },
1347            ),
1348            (
1349                "sync",
1350                ProcessingOperation::SyncSubtitle {
1351                    audio_path: tmp.clone(),
1352                },
1353            ),
1354            (
1355                "match",
1356                ProcessingOperation::MatchFiles { recursive: false },
1357            ),
1358            ("validate", ProcessingOperation::ValidateFormat),
1359            (
1360                "copy_to_video_folder",
1361                ProcessingOperation::CopyToVideoFolder {
1362                    source: tmp.clone(),
1363                    target: tmp.clone(),
1364                },
1365            ),
1366            (
1367                "move_to_video_folder",
1368                ProcessingOperation::MoveToVideoFolder {
1369                    source: tmp.clone(),
1370                    target: tmp.clone(),
1371                },
1372            ),
1373            (
1374                "copy_with_rename",
1375                ProcessingOperation::CopyWithRename {
1376                    source: tmp.clone(),
1377                    target: tmp.clone(),
1378                },
1379            ),
1380            (
1381                "create_backup",
1382                ProcessingOperation::CreateBackup {
1383                    source: tmp.clone(),
1384                    backup: tmp.clone(),
1385                },
1386            ),
1387            (
1388                "rename_file",
1389                ProcessingOperation::RenameFile {
1390                    source: tmp.clone(),
1391                    target: tmp.clone(),
1392                },
1393            ),
1394        ];
1395
1396        for (expected, op) in cases {
1397            let task = FileProcessingTask::new(tmp.clone(), None, op.clone());
1398            assert_eq!(
1399                task.task_type(),
1400                *expected,
1401                "task_type mismatch for {expected}"
1402            );
1403        }
1404    }
1405
1406    // ── description() for every operation variant ─────────────────────────
1407
1408    #[test]
1409    fn test_description_all_variants() {
1410        let p = std::path::PathBuf::from("a.srt");
1411        let q = std::path::PathBuf::from("b.srt");
1412
1413        let cases: &[(ProcessingOperation, &str)] = &[
1414            (
1415                ProcessingOperation::ConvertFormat {
1416                    from: "srt".into(),
1417                    to: "ass".into(),
1418                },
1419                "Convert",
1420            ),
1421            (
1422                ProcessingOperation::SyncSubtitle {
1423                    audio_path: q.clone(),
1424                },
1425                "Sync subtitle",
1426            ),
1427            (
1428                ProcessingOperation::MatchFiles { recursive: false },
1429                "Match files",
1430            ),
1431            (
1432                ProcessingOperation::MatchFiles { recursive: true },
1433                "(recursive)",
1434            ),
1435            (ProcessingOperation::ValidateFormat, "Validate format"),
1436            (
1437                ProcessingOperation::CopyToVideoFolder {
1438                    source: p.clone(),
1439                    target: q.clone(),
1440                },
1441                "Copy",
1442            ),
1443            (
1444                ProcessingOperation::MoveToVideoFolder {
1445                    source: p.clone(),
1446                    target: q.clone(),
1447                },
1448                "Move",
1449            ),
1450            (
1451                ProcessingOperation::CopyWithRename {
1452                    source: p.clone(),
1453                    target: q.clone(),
1454                },
1455                "CopyWithRename",
1456            ),
1457            (
1458                ProcessingOperation::CreateBackup {
1459                    source: p.clone(),
1460                    backup: q.clone(),
1461                },
1462                "CreateBackup",
1463            ),
1464            (
1465                ProcessingOperation::RenameFile {
1466                    source: p.clone(),
1467                    target: q.clone(),
1468                },
1469                "Rename",
1470            ),
1471        ];
1472
1473        for (op, expected_substr) in cases {
1474            let task = FileProcessingTask::new(p.clone(), None, op.clone());
1475            let desc = task.description();
1476            assert!(
1477                desc.contains(expected_substr),
1478                "description '{desc}' does not contain '{expected_substr}'"
1479            );
1480        }
1481    }
1482
1483    // ── estimated_duration() ──────────────────────────────────────────────
1484
1485    #[test]
1486    fn test_estimated_duration_nonexistent_file() {
1487        let task = FileProcessingTask::new(
1488            std::path::PathBuf::from("/does/not/exist.srt"),
1489            None,
1490            ProcessingOperation::ValidateFormat,
1491        );
1492        assert!(task.estimated_duration().is_none());
1493    }
1494
1495    #[tokio::test]
1496    async fn test_estimated_duration_all_operations() {
1497        let tmp = TempDir::new().unwrap();
1498        let file = tmp.path().join("test.srt");
1499        tokio::fs::write(&file, "1\n00:00:01,000 --> 00:00:02,000\nHi\n")
1500            .await
1501            .unwrap();
1502        let p = file.clone();
1503
1504        let ops = vec![
1505            ProcessingOperation::ConvertFormat {
1506                from: "srt".into(),
1507                to: "ass".into(),
1508            },
1509            ProcessingOperation::SyncSubtitle {
1510                audio_path: p.clone(),
1511            },
1512            ProcessingOperation::MatchFiles { recursive: true },
1513            ProcessingOperation::ValidateFormat,
1514            ProcessingOperation::CopyToVideoFolder {
1515                source: p.clone(),
1516                target: p.clone(),
1517            },
1518            ProcessingOperation::MoveToVideoFolder {
1519                source: p.clone(),
1520                target: p.clone(),
1521            },
1522            ProcessingOperation::CopyWithRename {
1523                source: p.clone(),
1524                target: p.clone(),
1525            },
1526            ProcessingOperation::CreateBackup {
1527                source: p.clone(),
1528                backup: p.clone(),
1529            },
1530            ProcessingOperation::RenameFile {
1531                source: p.clone(),
1532                target: p.clone(),
1533            },
1534        ];
1535
1536        for op in ops {
1537            let task = FileProcessingTask::new(p.clone(), None, op);
1538            // For an existing file estimated_duration must return Some
1539            assert!(
1540                task.estimated_duration().is_some(),
1541                "expected Some for operation when file exists"
1542            );
1543        }
1544    }
1545
1546    // ── task_id uniqueness ─────────────────────────────────────────────────
1547
1548    #[test]
1549    fn test_task_id_uniqueness() {
1550        let p1 = std::path::PathBuf::from("a.srt");
1551        let p2 = std::path::PathBuf::from("b.srt");
1552
1553        let t1 = FileProcessingTask::new(p1.clone(), None, ProcessingOperation::ValidateFormat);
1554        let t2 = FileProcessingTask::new(p2.clone(), None, ProcessingOperation::ValidateFormat);
1555        assert_ne!(
1556            t1.task_id(),
1557            t2.task_id(),
1558            "different paths → different IDs"
1559        );
1560
1561        let t3 = FileProcessingTask::new(
1562            p1.clone(),
1563            None,
1564            ProcessingOperation::ConvertFormat {
1565                from: "srt".into(),
1566                to: "ass".into(),
1567            },
1568        );
1569        assert_ne!(
1570            t1.task_id(),
1571            t3.task_id(),
1572            "different operations → different IDs"
1573        );
1574    }
1575
1576    // ── Debug for TaskResult and TaskStatus ───────────────────────────────
1577
1578    #[test]
1579    fn test_task_result_debug() {
1580        assert!(format!("{:?}", TaskResult::Success("ok".into())).contains("Success"));
1581        assert!(format!("{:?}", TaskResult::Failed("err".into())).contains("Failed"));
1582        assert!(format!("{:?}", TaskResult::Cancelled).contains("Cancelled"));
1583        assert!(
1584            format!("{:?}", TaskResult::PartialSuccess("a".into(), "b".into()))
1585                .contains("PartialSuccess")
1586        );
1587    }
1588
1589    #[test]
1590    fn test_task_status_debug() {
1591        assert!(format!("{:?}", TaskStatus::Pending).contains("Pending"));
1592        assert!(format!("{:?}", TaskStatus::Running).contains("Running"));
1593        assert!(
1594            format!("{:?}", TaskStatus::Completed(TaskResult::Cancelled)).contains("Completed")
1595        );
1596        assert!(format!("{:?}", TaskStatus::Failed("x".into())).contains("Failed"));
1597        assert!(format!("{:?}", TaskStatus::Cancelled).contains("Cancelled"));
1598    }
1599
1600    // ── Clone for TaskResult and TaskStatus ───────────────────────────────
1601
1602    #[test]
1603    fn test_task_result_clone() {
1604        let orig = TaskResult::PartialSuccess("s".into(), "w".into());
1605        let cloned = orig.clone();
1606        assert_eq!(format!("{orig}"), format!("{cloned}"));
1607    }
1608
1609    #[test]
1610    fn test_task_status_clone() {
1611        let orig = TaskStatus::Completed(TaskResult::Success("done".into()));
1612        let cloned = orig.clone();
1613        assert_eq!(format!("{orig}"), format!("{cloned}"));
1614    }
1615
1616    // ── Default Task trait methods via minimal impl ────────────────────────
1617
1618    #[tokio::test]
1619    async fn test_default_task_trait_methods() {
1620        struct MinimalTask;
1621
1622        #[async_trait::async_trait]
1623        impl Task for MinimalTask {
1624            async fn execute(&self) -> TaskResult {
1625                TaskResult::Cancelled
1626            }
1627            fn task_type(&self) -> &'static str {
1628                "minimal"
1629            }
1630            fn task_id(&self) -> String {
1631                "minimal_1".into()
1632            }
1633        }
1634
1635        let t = MinimalTask;
1636        // Default estimated_duration returns None
1637        assert!(t.estimated_duration().is_none());
1638        // Default description uses task_type()
1639        assert_eq!(t.description(), "minimal task");
1640    }
1641
1642    // ── Hash impl covers all ProcessingOperation variants ─────────────────
1643
1644    #[test]
1645    fn test_processing_operation_hash_all_variants() {
1646        use std::collections::hash_map::DefaultHasher;
1647        use std::hash::{Hash, Hasher};
1648
1649        fn compute_hash(op: &ProcessingOperation) -> u64 {
1650            let mut h = DefaultHasher::new();
1651            op.hash(&mut h);
1652            h.finish()
1653        }
1654
1655        let p = std::path::PathBuf::from("x");
1656
1657        let ops = vec![
1658            ProcessingOperation::ConvertFormat {
1659                from: "srt".into(),
1660                to: "ass".into(),
1661            },
1662            ProcessingOperation::SyncSubtitle {
1663                audio_path: p.clone(),
1664            },
1665            ProcessingOperation::MatchFiles { recursive: true },
1666            ProcessingOperation::MatchFiles { recursive: false },
1667            ProcessingOperation::ValidateFormat,
1668            ProcessingOperation::CopyToVideoFolder {
1669                source: p.clone(),
1670                target: p.clone(),
1671            },
1672            ProcessingOperation::MoveToVideoFolder {
1673                source: p.clone(),
1674                target: p.clone(),
1675            },
1676            ProcessingOperation::CopyWithRename {
1677                source: p.clone(),
1678                target: p.clone(),
1679            },
1680            ProcessingOperation::CreateBackup {
1681                source: p.clone(),
1682                backup: p.clone(),
1683            },
1684            ProcessingOperation::RenameFile {
1685                source: p.clone(),
1686                target: p.clone(),
1687            },
1688        ];
1689
1690        let hashes: Vec<u64> = ops.iter().map(compute_hash).collect();
1691        // All hashes must be computable (no panics); non-duplicate ops should differ
1692        assert_ne!(hashes[0], hashes[4], "convert vs validate should differ");
1693        assert_ne!(
1694            hashes[2], hashes[3],
1695            "recursive vs non-recursive should differ"
1696        );
1697    }
1698
1699    // ── MatchFiles recursive variant via execute() ────────────────────────
1700
1701    #[tokio::test]
1702    async fn test_file_matching_task_recursive() {
1703        let tmp = TempDir::new().unwrap();
1704        let task = FileProcessingTask {
1705            input_path: tmp.path().to_path_buf(),
1706            output_path: None,
1707            operation: ProcessingOperation::MatchFiles { recursive: true },
1708        };
1709        let result = task.execute().await;
1710        assert!(matches!(result, TaskResult::Success(_)));
1711        if let TaskResult::Success(msg) = result {
1712            assert!(msg.contains("matches"));
1713        }
1714    }
1715
1716    // ── execute_copy_operation creates parent dirs ────────────────────────
1717
1718    #[tokio::test]
1719    async fn test_execute_copy_operation_creates_parent() {
1720        let tmp = TempDir::new().unwrap();
1721        let src = tmp.path().join("src.txt");
1722        let dst = tmp.path().join("subdir").join("dst.txt");
1723        tokio::fs::write(&src, b"content").await.unwrap();
1724
1725        let task = FileProcessingTask {
1726            input_path: src.clone(),
1727            output_path: None,
1728            operation: ProcessingOperation::ValidateFormat,
1729        };
1730        task.execute_copy_operation(&src, &dst).await.unwrap();
1731        assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"content");
1732    }
1733}