subx_cli/commands/
match_command.rs

1use crate::Result;
2use crate::cli::MatchArgs;
3use crate::cli::display_match_results;
4use crate::config::init_config_manager;
5use crate::config::load_config;
6use crate::core::matcher::{FileDiscovery, MatchConfig, MatchEngine, MediaFileType};
7use crate::core::parallel::{
8    FileProcessingTask, ProcessingOperation, Task, TaskResult, TaskScheduler,
9};
10use crate::services::ai::{AIClientFactory, AIProvider};
11use indicatif::ProgressDrawTarget;
12
13/// 執行 Match 命令,支援 Dry-run 與實際操作,並允許注入 AI 服務以便測試
14pub async fn execute(args: MatchArgs) -> Result<()> {
15    // 載入配置與建立 AI 客戶端
16    let config = load_config()?;
17    // 建立 AI 客戶端,根據配置自動選擇提供商與端點
18    let ai_client = AIClientFactory::create_client(&config.ai)?;
19    execute_with_client(args, ai_client).await
20}
21
22/// 執行 Match 流程,支援 Dry-run 與實際操作,AI 客戶端由外部注入
23pub async fn execute_with_client(args: MatchArgs, ai_client: Box<dyn AIProvider>) -> Result<()> {
24    // 載入配置並初始化匹配引擎
25    let config = load_config()?;
26    let match_config = MatchConfig {
27        confidence_threshold: args.confidence as f32 / 100.0,
28        max_sample_length: config.ai.max_sample_length,
29        // 永遠進行內容分析,以便 Dry-run 時也能產生並快取結果
30        enable_content_analysis: true,
31        backup_enabled: args.backup || config.general.backup_enabled,
32    };
33    let engine = MatchEngine::new(ai_client, match_config);
34
35    // 執行匹配運算
36    let operations = engine.match_files(&args.path, args.recursive).await?;
37
38    // 顯示對映結果表格
39    display_match_results(&operations, args.dry_run);
40
41    if args.dry_run {
42        engine
43            .save_cache(&args.path, args.recursive, &operations)
44            .await?;
45    } else {
46        // 執行檔案操作
47        engine.execute_operations(&operations, args.dry_run).await?;
48    }
49    Ok(())
50}
51
52/// Execute parallel match over multiple files using the parallel processing system
53pub async fn execute_parallel_match(
54    directory: &std::path::Path,
55    recursive: bool,
56    output: Option<&std::path::Path>,
57) -> Result<()> {
58    init_config_manager()?;
59    let scheduler = TaskScheduler::new()?;
60    let discovery = FileDiscovery::new();
61    let files = discovery.scan_directory(directory, recursive)?;
62    let mut tasks: Vec<Box<dyn Task + Send + Sync>> = Vec::new();
63    for f in files
64        .iter()
65        .filter(|f| matches!(f.file_type, MediaFileType::Video))
66    {
67        let task = Box::new(FileProcessingTask {
68            input_path: f.path.clone(),
69            output_path: output.map(|p| p.to_path_buf()),
70            operation: ProcessingOperation::MatchFiles { recursive },
71        });
72        tasks.push(task);
73    }
74    if tasks.is_empty() {
75        println!("未找到需要處理的影片檔案");
76        return Ok(());
77    }
78    println!("準備並行處理 {} 個檔案", tasks.len());
79    println!("最大並行數: {}", scheduler.get_active_workers());
80    let progress_bar = {
81        let pb = create_progress_bar(tasks.len());
82        // 根據配置決定是否顯示進度條
83        if let Ok(cfg) = load_config() {
84            if !cfg.general.enable_progress_bar {
85                pb.set_draw_target(ProgressDrawTarget::hidden());
86            }
87        }
88        pb
89    };
90    let results = monitor_batch_execution(&scheduler, tasks, &progress_bar).await?;
91    let (mut ok, mut failed, mut partial) = (0, 0, 0);
92    for r in &results {
93        match r {
94            TaskResult::Success(_) => ok += 1,
95            TaskResult::Failed(_) | TaskResult::Cancelled => failed += 1,
96            TaskResult::PartialSuccess(_, _) => partial += 1,
97        }
98    }
99    println!("\n處理完成統計:");
100    println!("  ✓ 成功: {} 個檔案", ok);
101    if partial > 0 {
102        println!("  ⚠ 部分成功: {} 個檔案", partial);
103    }
104    if failed > 0 {
105        println!("  ✗ 失敗: {} 個檔案", failed);
106        for (i, r) in results.iter().enumerate() {
107            if matches!(r, TaskResult::Failed(_)) {
108                println!("  失敗詳情 {}: {}", i + 1, r);
109            }
110        }
111    }
112    Ok(())
113}
114
115async fn monitor_batch_execution(
116    scheduler: &TaskScheduler,
117    tasks: Vec<Box<dyn Task + Send + Sync>>,
118    progress_bar: &indicatif::ProgressBar,
119) -> Result<Vec<TaskResult>> {
120    use tokio::time::{Duration, interval};
121    let handles: Vec<_> = tasks
122        .into_iter()
123        .map(|t| {
124            let s = scheduler.clone();
125            tokio::spawn(async move { s.submit_task(t).await })
126        })
127        .collect();
128    let mut ticker = interval(Duration::from_millis(500));
129    let mut completed = 0;
130    let total = handles.len();
131    let mut results = Vec::new();
132    for mut h in handles {
133        loop {
134            tokio::select! {
135                res = &mut h => {
136                    match res {
137                        Ok(Ok(r)) => results.push(r),
138                        Ok(Err(_)) => results.push(TaskResult::Failed("任務執行錯誤".into())),
139                        Err(_) => results.push(TaskResult::Cancelled),
140                    }
141                    completed += 1;
142                    progress_bar.set_position(completed);
143                    break;
144                }
145                _ = ticker.tick() => {
146                    let active = scheduler.list_active_tasks().len();
147                    let queued = scheduler.get_queue_size();
148                    progress_bar.set_message(format!("執行中: {} | 佇列: {} | 已完成: {}/{}", active, queued, completed, total));
149                }
150            }
151        }
152    }
153    progress_bar.finish_with_message("所有任務已完成");
154    Ok(results)
155}
156
157fn create_progress_bar(total: usize) -> indicatif::ProgressBar {
158    use indicatif::ProgressStyle;
159    let pb = indicatif::ProgressBar::new(total as u64);
160    pb.set_style(
161        ProgressStyle::default_bar()
162            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}")
163            .unwrap()
164            .progress_chars("#>-"),
165    );
166    pb
167}
168
169#[cfg(test)]
170mod tests {
171    use super::execute_with_client;
172    use crate::cli::MatchArgs;
173    use crate::config::init_config_manager;
174    use crate::services::ai::{
175        AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
176    };
177    use async_trait::async_trait;
178    use std::fs;
179    use std::path::PathBuf;
180    use tempfile::tempdir;
181
182    struct DummyAI;
183    #[async_trait]
184    impl AIProvider for DummyAI {
185        async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
186            Ok(MatchResult {
187                matches: Vec::new(),
188                confidence: 0.0,
189                reasoning: String::new(),
190            })
191        }
192        async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
193            panic!("verify_match should not be called in dry-run test");
194        }
195    }
196
197    /// Dry-run 模式下應建立快取檔案,且不實際執行任何檔案操作
198    #[tokio::test]
199    async fn dry_run_creates_cache_and_skips_execute_operations() -> crate::Result<()> {
200        // 建立臨時媒體資料夾並放入示意影片與字幕檔
201        let media_dir = tempdir()?;
202        let media_path = media_dir.path().join("media");
203        fs::create_dir_all(&media_path)?;
204        let video = media_path.join("video.mkv");
205        let subtitle = media_path.join("subtitle.ass");
206        fs::write(&video, b"dummy")?;
207        fs::write(&subtitle, b"dummy")?;
208
209        // 指定快取路徑到臨時資料夾
210        unsafe {
211            std::env::set_var("XDG_CONFIG_HOME", media_dir.path());
212        }
213        // 初始化配置管理器,以便使用新系統載入默認配置
214        init_config_manager()?;
215
216        // 確認尚未產生快取檔案
217        let cache_path = dirs::config_dir()
218            .unwrap()
219            .join("subx")
220            .join("match_cache.json");
221        assert!(!cache_path.exists(), "測試開始時不應存在快取檔案");
222
223        // 執行 dry-run
224        let args = MatchArgs {
225            path: PathBuf::from(&media_path),
226            dry_run: true,
227            recursive: false,
228            confidence: 80,
229            backup: false,
230        };
231        execute_with_client(args, Box::new(DummyAI)).await?;
232
233        // 驗證已建立快取檔案,且原始檔案未被移動或刪除
234        assert!(cache_path.exists(), "dry_run 後應建立快取檔案");
235        assert!(
236            video.exists(),
237            "dry_run 不應執行 execute_operations,影片檔仍須存在"
238        );
239        assert!(
240            subtitle.exists(),
241            "dry_run 不應執行 execute_operations,字幕檔仍須存在"
242        );
243        Ok(())
244    }
245}