subx_cli/core/parallel/
task.rs

1//! Task definition and utilities for parallel processing
2use async_trait::async_trait;
3use std::fmt;
4use std::path::Path;
5
6/// Trait defining a unit of work that can be executed asynchronously.
7///
8/// All tasks in the parallel processing system must implement this trait
9/// to provide execution logic and metadata.
10#[async_trait]
11pub trait Task: Send + Sync {
12    /// Executes the task and returns the result.
13    async fn execute(&self) -> TaskResult;
14    /// Returns the type identifier for this task.
15    fn task_type(&self) -> &'static str;
16    /// Returns a unique identifier for this specific task instance.
17    fn task_id(&self) -> String;
18    /// Returns an estimated duration for the task execution.
19    fn estimated_duration(&self) -> Option<std::time::Duration> {
20        None
21    }
22    /// Returns a human-readable description of the task.
23    fn description(&self) -> String {
24        format!("{} task", self.task_type())
25    }
26}
27
28/// Result of task execution indicating success, failure, or partial completion.
29///
30/// Provides detailed information about the outcome of a task execution,
31/// including success/failure status and descriptive messages.
32#[derive(Debug, Clone)]
33pub enum TaskResult {
34    /// Task completed successfully with a result message
35    Success(String),
36    /// Task failed with an error message
37    Failed(String),
38    /// Task was cancelled before completion
39    Cancelled,
40    /// Task partially completed with success and failure messages
41    PartialSuccess(String, String),
42}
43
44/// Current execution status of a task in the system.
45///
46/// Tracks the lifecycle of a task from initial queuing through completion
47/// or failure, providing detailed status information.
48#[derive(Debug, Clone)]
49pub enum TaskStatus {
50    /// Task is queued and waiting for execution
51    Pending,
52    /// Task is currently being executed
53    Running,
54    /// Task completed successfully or with partial success
55    Completed(TaskResult),
56    /// Task failed during execution
57    Failed(String),
58    /// Task was cancelled before or during execution
59    Cancelled,
60}
61
62impl fmt::Display for TaskResult {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            TaskResult::Success(msg) => write!(f, "✓ {}", msg),
66            TaskResult::Failed(msg) => write!(f, "✗ {}", msg),
67            TaskResult::Cancelled => write!(f, "⚠ Task cancelled"),
68            TaskResult::PartialSuccess(success, warn) => {
69                write!(f, "⚠ {} (warning: {})", success, warn)
70            }
71        }
72    }
73}
74
75impl fmt::Display for TaskStatus {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self {
78            TaskStatus::Pending => write!(f, "Pending"),
79            TaskStatus::Running => write!(f, "Running"),
80            TaskStatus::Completed(result) => write!(f, "Completed: {}", result),
81            TaskStatus::Failed(msg) => write!(f, "Failed: {}", msg),
82            TaskStatus::Cancelled => write!(f, "Cancelled"),
83        }
84    }
85}
86
87/// Task for processing files (convert, sync, match, validate).
88///
89/// Represents a file processing operation that can be executed
90/// asynchronously in the parallel processing system.
91pub struct FileProcessingTask {
92    /// Path to the input file to be processed
93    pub input_path: std::path::PathBuf,
94    /// Optional output path for the processed file
95    pub output_path: Option<std::path::PathBuf>,
96    /// The specific operation to perform on the file
97    pub operation: ProcessingOperation,
98}
99
100/// Supported operations for file processing tasks.
101///
102/// Defines the different types of operations that can be performed
103/// on subtitle and video files in the processing system.
104#[derive(Debug, Clone)]
105pub enum ProcessingOperation {
106    /// Convert subtitle format from one type to another
107    ConvertFormat {
108        /// Source format (e.g., "srt", "ass")
109        from: String,
110        /// Target format (e.g., "srt", "ass")
111        to: String,
112    },
113    /// Synchronize subtitle timing with audio
114    SyncSubtitle {
115        /// Path to the audio file for synchronization
116        audio_path: std::path::PathBuf,
117    },
118    /// Match subtitle files with video files
119    MatchFiles {
120        /// Whether to search recursively in subdirectories
121        recursive: bool,
122    },
123    /// Validate subtitle file format and structure
124    ValidateFormat,
125    /// Copy subtitle file to video folder
126    CopyToVideoFolder {
127        /// Path to the source subtitle file to be copied
128        source: std::path::PathBuf,
129        /// Path to the target video folder where the subtitle will be copied
130        target: std::path::PathBuf,
131    },
132    /// Move subtitle file to video folder
133    MoveToVideoFolder {
134        /// Path to the source subtitle file to be moved
135        source: std::path::PathBuf,
136        /// Path to the target video folder where the subtitle will be moved
137        target: std::path::PathBuf,
138    },
139}
140
141#[async_trait]
142impl Task for FileProcessingTask {
143    async fn execute(&self) -> TaskResult {
144        match &self.operation {
145            ProcessingOperation::ConvertFormat { from, to } => {
146                match self.convert_format(from, to).await {
147                    Ok(path) => TaskResult::Success(format!(
148                        "Successfully converted {} -> {}: {}",
149                        from,
150                        to,
151                        path.display()
152                    )),
153                    Err(e) => TaskResult::Failed(format!(
154                        "Conversion failed {}: {}",
155                        self.input_path.display(),
156                        e
157                    )),
158                }
159            }
160            ProcessingOperation::SyncSubtitle { .. } => {
161                // Sync not supported in parallel tasks
162                TaskResult::Failed("Sync functionality not implemented".to_string())
163            }
164            ProcessingOperation::MatchFiles { recursive } => {
165                match self.match_files(*recursive).await {
166                    Ok(m) => TaskResult::Success(format!(
167                        "File matching completed: found {} matches",
168                        m.len()
169                    )),
170                    Err(e) => TaskResult::Failed(format!("Matching failed: {}", e)),
171                }
172            }
173            ProcessingOperation::ValidateFormat => match self.validate_format().await {
174                Ok(true) => TaskResult::Success(format!(
175                    "Format validation passed: {}",
176                    self.input_path.display()
177                )),
178                Ok(false) => TaskResult::Failed(format!(
179                    "Format validation failed: {}",
180                    self.input_path.display()
181                )),
182                Err(e) => TaskResult::Failed(format!("Validation error: {}", e)),
183            },
184            ProcessingOperation::CopyToVideoFolder { source, target } => {
185                match self.execute_copy_operation(source, target).await {
186                    Ok(_) => TaskResult::Success(format!(
187                        "Copied: {} -> {}",
188                        source.display(),
189                        target.display()
190                    )),
191                    Err(e) => TaskResult::Failed(format!("Copy failed: {}", e)),
192                }
193            }
194            ProcessingOperation::MoveToVideoFolder { source, target } => {
195                match self.execute_move_operation(source, target).await {
196                    Ok(_) => TaskResult::Success(format!(
197                        "Moved: {} -> {}",
198                        source.display(),
199                        target.display()
200                    )),
201                    Err(e) => TaskResult::Failed(format!("Move failed: {}", e)),
202                }
203            }
204        }
205    }
206
207    fn task_type(&self) -> &'static str {
208        match &self.operation {
209            ProcessingOperation::ConvertFormat { .. } => "convert",
210            ProcessingOperation::SyncSubtitle { .. } => "sync",
211            ProcessingOperation::MatchFiles { .. } => "match",
212            ProcessingOperation::ValidateFormat => "validate",
213            ProcessingOperation::CopyToVideoFolder { .. } => "copy_to_video_folder",
214            ProcessingOperation::MoveToVideoFolder { .. } => "move_to_video_folder",
215        }
216    }
217
218    fn task_id(&self) -> String {
219        use std::collections::hash_map::DefaultHasher;
220        use std::hash::{Hash, Hasher};
221        let mut hasher = DefaultHasher::new();
222        self.input_path.hash(&mut hasher);
223        self.operation.hash(&mut hasher);
224        format!("{}_{:x}", self.task_type(), hasher.finish())
225    }
226
227    fn estimated_duration(&self) -> Option<std::time::Duration> {
228        if let Ok(meta) = std::fs::metadata(&self.input_path) {
229            let size_mb = meta.len() as f64 / 1_048_576.0;
230            let secs = match &self.operation {
231                ProcessingOperation::ConvertFormat { .. } => size_mb * 0.1,
232                ProcessingOperation::SyncSubtitle { .. } => size_mb * 0.5,
233                ProcessingOperation::MatchFiles { .. } => 2.0,
234                ProcessingOperation::ValidateFormat => size_mb * 0.05,
235                ProcessingOperation::CopyToVideoFolder { .. } => size_mb * 0.01, // Fast copy
236                ProcessingOperation::MoveToVideoFolder { .. } => size_mb * 0.005, // Even faster move
237            };
238            Some(std::time::Duration::from_secs_f64(secs))
239        } else {
240            None
241        }
242    }
243
244    fn description(&self) -> String {
245        match &self.operation {
246            ProcessingOperation::ConvertFormat { from, to } => {
247                format!(
248                    "Convert {} from {} to {}",
249                    self.input_path.display(),
250                    from,
251                    to
252                )
253            }
254            ProcessingOperation::SyncSubtitle { audio_path } => format!(
255                "Sync subtitle {} with audio {}",
256                self.input_path.display(),
257                audio_path.display()
258            ),
259            ProcessingOperation::MatchFiles { recursive } => format!(
260                "Match files in {}{}",
261                self.input_path.display(),
262                if *recursive { " (recursive)" } else { "" }
263            ),
264            ProcessingOperation::ValidateFormat => {
265                format!("Validate format of {}", self.input_path.display())
266            }
267            ProcessingOperation::CopyToVideoFolder { source, target } => {
268                format!("Copy {} to {}", source.display(), target.display())
269            }
270            ProcessingOperation::MoveToVideoFolder { source, target } => {
271                format!("Move {} to {}", source.display(), target.display())
272            }
273        }
274    }
275}
276
277impl FileProcessingTask {
278    /// Create a new file processing task with operation
279    pub fn new(
280        input_path: std::path::PathBuf,
281        output_path: Option<std::path::PathBuf>,
282        operation: ProcessingOperation,
283    ) -> Self {
284        FileProcessingTask {
285            input_path,
286            output_path,
287            operation,
288        }
289    }
290
291    /// Execute copy operation for file relocation
292    async fn execute_copy_operation(
293        &self,
294        source: &Path,
295        target: &Path,
296    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
297        // Create target directory if it doesn't exist
298        if let Some(parent) = target.parent() {
299            std::fs::create_dir_all(parent)?;
300        }
301
302        // Handle filename conflicts
303        let final_target = self.resolve_filename_conflict(target.to_path_buf()).await?;
304
305        // Execute copy operation
306        std::fs::copy(source, &final_target)?;
307        Ok(())
308    }
309
310    /// Execute move operation for file relocation
311    async fn execute_move_operation(
312        &self,
313        source: &Path,
314        target: &Path,
315    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
316        // Create target directory if it doesn't exist
317        if let Some(parent) = target.parent() {
318            std::fs::create_dir_all(parent)?;
319        }
320
321        // Handle filename conflicts
322        let final_target = self.resolve_filename_conflict(target.to_path_buf()).await?;
323
324        // Execute move operation
325        std::fs::rename(source, &final_target)?;
326        Ok(())
327    }
328
329    /// Resolve filename conflicts by adding numeric suffix
330    async fn resolve_filename_conflict(
331        &self,
332        target: std::path::PathBuf,
333    ) -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
334        if !target.exists() {
335            return Ok(target);
336        }
337
338        // Extract filename components
339        let file_stem = target
340            .file_stem()
341            .and_then(|s| s.to_str())
342            .unwrap_or("file");
343        let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
344
345        let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
346
347        // Try adding numeric suffixes
348        for i in 1..1000 {
349            let new_name = if extension.is_empty() {
350                format!("{}.{}", file_stem, i)
351            } else {
352                format!("{}.{}.{}", file_stem, i, extension)
353            };
354            let new_path = parent.join(new_name);
355            if !new_path.exists() {
356                return Ok(new_path);
357            }
358        }
359
360        Err("Could not resolve filename conflict".into())
361    }
362
363    async fn convert_format(&self, _from: &str, _to: &str) -> crate::Result<std::path::PathBuf> {
364        // Stub convert: simply return input path
365        Ok(self.input_path.clone())
366    }
367
368    async fn sync_subtitle(
369        &self,
370        _audio_path: &std::path::Path,
371    ) -> crate::Result<crate::core::sync::SyncResult> {
372        // Stub implementation: sync not available
373        Err(crate::error::SubXError::parallel_processing(
374            "sync_subtitle not implemented".to_string(),
375        ))
376    }
377
378    async fn match_files(&self, _recursive: bool) -> crate::Result<Vec<()>> {
379        // Stub implementation: no actual matching
380        Ok(Vec::new())
381    }
382
383    async fn validate_format(&self) -> crate::Result<bool> {
384        // Stub validate: always succeed
385        Ok(true)
386    }
387}
388
389// impl Hash for ProcessingOperation to support task_id generation
390impl std::hash::Hash for ProcessingOperation {
391    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
392        match self {
393            ProcessingOperation::ConvertFormat { from, to } => {
394                "convert".hash(state);
395                from.hash(state);
396                to.hash(state);
397            }
398            ProcessingOperation::SyncSubtitle { audio_path } => {
399                "sync".hash(state);
400                audio_path.hash(state);
401            }
402            ProcessingOperation::MatchFiles { recursive } => {
403                "match".hash(state);
404                recursive.hash(state);
405            }
406            ProcessingOperation::ValidateFormat => {
407                "validate".hash(state);
408            }
409            ProcessingOperation::CopyToVideoFolder { source, target } => {
410                "copy_to_video_folder".hash(state);
411                source.hash(state);
412                target.hash(state);
413            }
414            ProcessingOperation::MoveToVideoFolder { source, target } => {
415                "move_to_video_folder".hash(state);
416                source.hash(state);
417                target.hash(state);
418            }
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use std::time::Duration;
427    use tempfile::TempDir;
428
429    #[tokio::test]
430    async fn test_file_processing_task_validate_format() {
431        let tmp = TempDir::new().unwrap();
432        let test_file = tmp.path().join("test.srt");
433        tokio::fs::write(&test_file, "1\n00:00:01,000 --> 00:00:02,000\nTest\n")
434            .await
435            .unwrap();
436        let task = FileProcessingTask {
437            input_path: test_file.clone(),
438            output_path: None,
439            operation: ProcessingOperation::ValidateFormat,
440        };
441        let result = task.execute().await;
442        assert!(matches!(result, TaskResult::Success(_)));
443    }
444
445    /// Test task lifecycle and status transitions
446    #[tokio::test]
447    async fn test_task_lifecycle() {
448        let tmp = TempDir::new().unwrap();
449        let test_file = tmp.path().join("lifecycle.srt");
450        tokio::fs::write(
451            &test_file,
452            "1\n00:00:01,000 --> 00:00:02,000\nLifecycle test\n",
453        )
454        .await
455        .unwrap();
456
457        let task = FileProcessingTask {
458            input_path: test_file.clone(),
459            output_path: None,
460            operation: ProcessingOperation::ValidateFormat,
461        };
462
463        // Test initial task properties
464        assert_eq!(task.task_type(), "validate");
465        assert!(!task.task_id().is_empty());
466        assert!(task.description().contains("Validate format"));
467        assert!(task.description().contains("lifecycle.srt"));
468        assert!(
469            task.estimated_duration().is_some(),
470            "Should estimate duration for existing file"
471        );
472
473        // Test execution
474        let result = task.execute().await;
475        assert!(matches!(result, TaskResult::Success(_)));
476    }
477
478    /// Test task result serialization and display
479    #[test]
480    fn test_task_result_display() {
481        let success = TaskResult::Success("Operation completed".to_string());
482        let failed = TaskResult::Failed("Operation failed".to_string());
483        let cancelled = TaskResult::Cancelled;
484        let partial =
485            TaskResult::PartialSuccess("Mostly worked".to_string(), "Minor issue".to_string());
486
487        assert_eq!(format!("{}", success), "✓ Operation completed");
488        assert_eq!(format!("{}", failed), "✗ Operation failed");
489        assert_eq!(format!("{}", cancelled), "⚠ Task cancelled");
490        assert_eq!(
491            format!("{}", partial),
492            "⚠ Mostly worked (warning: Minor issue)"
493        );
494    }
495
496    /// Test task status display
497    #[test]
498    fn test_task_status_display() {
499        let pending = TaskStatus::Pending;
500        let running = TaskStatus::Running;
501        let completed = TaskStatus::Completed(TaskResult::Success("Done".to_string()));
502        let failed = TaskStatus::Failed("Error occurred".to_string());
503        let cancelled = TaskStatus::Cancelled;
504
505        assert_eq!(format!("{}", pending), "Pending");
506        assert_eq!(format!("{}", running), "Running");
507        assert_eq!(format!("{}", completed), "Completed: ✓ Done");
508        assert_eq!(format!("{}", failed), "Failed: Error occurred");
509        assert_eq!(format!("{}", cancelled), "Cancelled");
510    }
511
512    /// Test format conversion task
513    #[tokio::test]
514    async fn test_format_conversion_task() {
515        let tmp = TempDir::new().unwrap();
516        let input_file = tmp.path().join("input.srt");
517        let output_file = tmp.path().join("output.ass");
518
519        // Create valid SRT content
520        let srt_content = r#"1
52100:00:01,000 --> 00:00:03,000
522First subtitle
523
5242
52500:00:04,000 --> 00:00:06,000
526Second subtitle
527"#;
528
529        tokio::fs::write(&input_file, srt_content).await.unwrap();
530
531        let task = FileProcessingTask {
532            input_path: input_file.clone(),
533            output_path: Some(output_file.clone()),
534            operation: ProcessingOperation::ConvertFormat {
535                from: "srt".to_string(),
536                to: "ass".to_string(),
537            },
538        };
539
540        let result = task.execute().await;
541        assert!(matches!(result, TaskResult::Success(_)));
542
543        // Note: The convert_format method is a stub that returns the input path
544        // In a real implementation, this would create an actual output file
545        assert!(tokio::fs::metadata(&input_file).await.is_ok());
546    }
547
548    /// Test file matching task
549    #[tokio::test]
550    async fn test_file_matching_task() {
551        let tmp = TempDir::new().unwrap();
552        let video_file = tmp.path().join("movie.mkv");
553        let subtitle_file = tmp.path().join("movie.srt");
554
555        // Create test files
556        tokio::fs::write(&video_file, b"fake video content")
557            .await
558            .unwrap();
559        tokio::fs::write(&subtitle_file, "1\n00:00:01,000 --> 00:00:02,000\nTest\n")
560            .await
561            .unwrap();
562
563        let task = FileProcessingTask {
564            input_path: tmp.path().to_path_buf(),
565            output_path: None,
566            operation: ProcessingOperation::MatchFiles { recursive: false },
567        };
568
569        let result = task.execute().await;
570        assert!(matches!(result, TaskResult::Success(_)));
571    }
572
573    /// Test sync subtitle task (expected to fail)
574    #[tokio::test]
575    async fn test_sync_subtitle_task() {
576        let tmp = TempDir::new().unwrap();
577        let audio_file = tmp.path().join("audio.wav");
578        let subtitle_file = tmp.path().join("subtitle.srt");
579
580        tokio::fs::write(&audio_file, b"fake audio content")
581            .await
582            .unwrap();
583        tokio::fs::write(&subtitle_file, "1\n00:00:01,000 --> 00:00:02,000\nTest\n")
584            .await
585            .unwrap();
586
587        let task = FileProcessingTask {
588            input_path: subtitle_file.clone(),
589            output_path: None,
590            operation: ProcessingOperation::SyncSubtitle {
591                audio_path: audio_file,
592            },
593        };
594
595        let result = task.execute().await;
596        // Sync is not implemented, so should fail
597        assert!(matches!(result, TaskResult::Failed(_)));
598    }
599
600    /// Test task error handling
601    #[tokio::test]
602    async fn test_task_error_handling() {
603        // Test with sync operation which always fails in stub implementation
604        let tmp = TempDir::new().unwrap();
605        let test_file = tmp.path().join("test.srt");
606
607        let task = FileProcessingTask {
608            input_path: test_file,
609            output_path: None,
610            operation: ProcessingOperation::SyncSubtitle {
611                audio_path: tmp.path().join("audio.wav"),
612            },
613        };
614
615        let result = task.execute().await;
616        assert!(matches!(result, TaskResult::Failed(_)));
617    }
618
619    /// Test task timeout handling
620    #[tokio::test]
621    async fn test_task_timeout() {
622        use async_trait::async_trait;
623
624        struct SlowTask {
625            duration: Duration,
626        }
627
628        #[async_trait]
629        impl Task for SlowTask {
630            async fn execute(&self) -> TaskResult {
631                tokio::time::sleep(self.duration).await;
632                TaskResult::Success("Slow task completed".to_string())
633            }
634            fn task_type(&self) -> &'static str {
635                "slow"
636            }
637            fn task_id(&self) -> String {
638                "slow_task_1".to_string()
639            }
640            fn estimated_duration(&self) -> Option<Duration> {
641                Some(self.duration)
642            }
643        }
644
645        let slow_task = SlowTask {
646            duration: Duration::from_millis(100),
647        };
648
649        // Test estimated duration
650        assert_eq!(
651            slow_task.estimated_duration(),
652            Some(Duration::from_millis(100))
653        );
654
655        // Test execution
656        let start = std::time::Instant::now();
657        let result = slow_task.execute().await;
658        let elapsed = start.elapsed();
659
660        assert!(matches!(result, TaskResult::Success(_)));
661        assert!(elapsed >= Duration::from_millis(90)); // Allow some variance
662    }
663
664    /// Test processing operation variants
665    #[test]
666    fn test_processing_operation_variants() {
667        let convert_op = ProcessingOperation::ConvertFormat {
668            from: "srt".to_string(),
669            to: "ass".to_string(),
670        };
671
672        let sync_op = ProcessingOperation::SyncSubtitle {
673            audio_path: std::path::PathBuf::from("audio.wav"),
674        };
675
676        let match_op = ProcessingOperation::MatchFiles { recursive: true };
677        let validate_op = ProcessingOperation::ValidateFormat;
678
679        // Test debug formatting
680        assert!(format!("{:?}", convert_op).contains("ConvertFormat"));
681        assert!(format!("{:?}", sync_op).contains("SyncSubtitle"));
682        assert!(format!("{:?}", match_op).contains("MatchFiles"));
683        assert!(format!("{:?}", validate_op).contains("ValidateFormat"));
684
685        // Test cloning
686        let convert_clone = convert_op.clone();
687        assert!(format!("{:?}", convert_clone).contains("ConvertFormat"));
688    }
689
690    /// Test custom task implementation
691    #[tokio::test]
692    async fn test_custom_task_implementation() {
693        use async_trait::async_trait;
694
695        struct CustomTask {
696            id: String,
697            should_succeed: bool,
698        }
699
700        #[async_trait]
701        impl Task for CustomTask {
702            async fn execute(&self) -> TaskResult {
703                if self.should_succeed {
704                    TaskResult::Success(format!("Custom task {} succeeded", self.id))
705                } else {
706                    TaskResult::Failed(format!("Custom task {} failed", self.id))
707                }
708            }
709
710            fn task_type(&self) -> &'static str {
711                "custom"
712            }
713
714            fn task_id(&self) -> String {
715                self.id.clone()
716            }
717
718            fn description(&self) -> String {
719                format!("Custom task with ID: {}", self.id)
720            }
721
722            fn estimated_duration(&self) -> Option<Duration> {
723                Some(Duration::from_millis(1))
724            }
725        }
726
727        // Test successful custom task
728        let success_task = CustomTask {
729            id: "success_1".to_string(),
730            should_succeed: true,
731        };
732
733        assert_eq!(success_task.task_type(), "custom");
734        assert_eq!(success_task.task_id(), "success_1");
735        assert_eq!(success_task.description(), "Custom task with ID: success_1");
736        assert_eq!(
737            success_task.estimated_duration(),
738            Some(Duration::from_millis(1))
739        );
740
741        let result = success_task.execute().await;
742        assert!(matches!(result, TaskResult::Success(_)));
743
744        // Test failing custom task
745        let fail_task = CustomTask {
746            id: "fail_1".to_string(),
747            should_succeed: false,
748        };
749
750        let result = fail_task.execute().await;
751        assert!(matches!(result, TaskResult::Failed(_)));
752    }
753}