1use async_trait::async_trait;
3use std::fmt;
4use std::path::Path;
5
6#[async_trait]
11pub trait Task: Send + Sync {
12 async fn execute(&self) -> TaskResult;
14 fn task_type(&self) -> &'static str;
16 fn task_id(&self) -> String;
18 fn estimated_duration(&self) -> Option<std::time::Duration> {
20 None
21 }
22 fn description(&self) -> String {
24 format!("{} task", self.task_type())
25 }
26}
27
28#[derive(Debug, Clone)]
33pub enum TaskResult {
34 Success(String),
36 Failed(String),
38 Cancelled,
40 PartialSuccess(String, String),
42}
43
44#[derive(Debug, Clone)]
49pub enum TaskStatus {
50 Pending,
52 Running,
54 Completed(TaskResult),
56 Failed(String),
58 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
87pub struct FileProcessingTask {
92 pub input_path: std::path::PathBuf,
94 pub output_path: Option<std::path::PathBuf>,
96 pub operation: ProcessingOperation,
98}
99
100#[derive(Debug, Clone)]
105pub enum ProcessingOperation {
106 ConvertFormat {
108 from: String,
110 to: String,
112 },
113 SyncSubtitle {
115 audio_path: std::path::PathBuf,
117 },
118 MatchFiles {
120 recursive: bool,
122 },
123 ValidateFormat,
125 CopyToVideoFolder {
127 source: std::path::PathBuf,
129 target: std::path::PathBuf,
131 },
132 MoveToVideoFolder {
134 source: std::path::PathBuf,
136 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 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, ProcessingOperation::MoveToVideoFolder { .. } => size_mb * 0.005, };
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 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 async fn execute_copy_operation(
293 &self,
294 source: &Path,
295 target: &Path,
296 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
297 if let Some(parent) = target.parent() {
299 std::fs::create_dir_all(parent)?;
300 }
301
302 let final_target = self.resolve_filename_conflict(target.to_path_buf()).await?;
304
305 std::fs::copy(source, &final_target)?;
307 Ok(())
308 }
309
310 async fn execute_move_operation(
312 &self,
313 source: &Path,
314 target: &Path,
315 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
316 if let Some(parent) = target.parent() {
318 std::fs::create_dir_all(parent)?;
319 }
320
321 let final_target = self.resolve_filename_conflict(target.to_path_buf()).await?;
323
324 std::fs::rename(source, &final_target)?;
326 Ok(())
327 }
328
329 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 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 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 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 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 Ok(Vec::new())
381 }
382
383 async fn validate_format(&self) -> crate::Result<bool> {
384 Ok(true)
386 }
387}
388
389impl 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 #[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 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 let result = task.execute().await;
475 assert!(matches!(result, TaskResult::Success(_)));
476 }
477
478 #[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]
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 #[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 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 assert!(tokio::fs::metadata(&input_file).await.is_ok());
546 }
547
548 #[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 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 #[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 assert!(matches!(result, TaskResult::Failed(_)));
598 }
599
600 #[tokio::test]
602 async fn test_task_error_handling() {
603 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 #[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 assert_eq!(
651 slow_task.estimated_duration(),
652 Some(Duration::from_millis(100))
653 );
654
655 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)); }
663
664 #[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 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 let convert_clone = convert_op.clone();
687 assert!(format!("{:?}", convert_clone).contains("ConvertFormat"));
688 }
689
690 #[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 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 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}