Skip to main content

subx_cli/
lib.rs

1//! SubX: Intelligent Subtitle Processing Library
2//!
3//! SubX is a comprehensive Rust library for intelligent subtitle file processing,
4//! featuring AI-powered matching, format conversion, audio synchronization,
5//! and advanced encoding detection capabilities.
6//!
7//! # Key Features
8//!
9//! - **AI-Powered Matching**: Intelligent subtitle file matching and renaming
10//! - **Format Conversion**: Support for multiple subtitle formats (SRT, ASS, VTT, etc.)
11//! - **Audio Synchronization**: Advanced audio-subtitle timing adjustment
12//! - **Encoding Detection**: Automatic character encoding detection and conversion
13//! - **Parallel Processing**: High-performance batch operations
14//! - **Configuration Management**: Flexible multi-source configuration system
15//!
16//! # Architecture Overview
17//!
18//! The library is organized into several key modules:
19//!
20//! - [`cli`] - Command-line interface and argument parsing
21//! - [`commands`] - Implementation of all SubX commands
22//!
23//! The [`config`], [`core`], [`error`] and [`services`] modules are owned by
24//! the `subx_core` crate — the library half of the SubX two-crate split, a
25//! git submodule mounted at `subx-core/` and a member of this Cargo
26//! workspace. They are re-exported below under their pre-split paths so
27//! existing consumers keep compiling; `subx_core::` is the canonical path.
28//!
29//! # Quick Start
30//!
31//! ```rust,no_run
32//! use subx_cli::config::{TestConfigService, ConfigService};
33//!
34//! // Create a configuration service
35//! let config_service = TestConfigService::with_defaults();
36//! let config = config_service.config();
37//!
38//! // Use the configuration for processing...
39//! ```
40//!
41//! # Error Handling
42//!
43//! All operations return a [`Result<T>`] type that wraps [`error::SubXError`]:
44//!
45//! ```rust
46//! use subx_cli::{Result, error::SubXError};
47//!
48//! fn example_operation() -> Result<String> {
49//!     // This could fail with various error types
50//!     Err(SubXError::config("Missing configuration"))
51//! }
52//! ```
53//!
54//! # Configuration
55//!
56//! SubX supports dependency injection-based configuration:
57//!
58//! ```rust,no_run
59//! use subx_cli::config::{TestConfigService, Config};
60//!
61//! // Create configuration service with AI settings
62//! let config_service = TestConfigService::with_ai_settings("openai", "gpt-4.1");
63//! let config = config_service.config();
64//!
65//! // Access configuration values
66//! println!("AI Provider: {}", config.ai.provider);
67//! println!("AI Model: {}", config.ai.model);
68//! ```
69//!
70//! # Performance Considerations
71//!
72//! - Use [`core::parallel`] for batch operations on large file sets
73//! - Configure appropriate cache settings for repeated operations
74//! - Consider memory usage when processing large audio files
75//!
76//! # Thread Safety
77//!
78//! The library is designed to be thread-safe where appropriate:
79//! - Configuration manager uses `Arc<RwLock<T>>` for shared state
80//! - File operations include rollback capabilities for atomicity
81//! - Parallel processing uses safe concurrency patterns
82//!
83//! # Feature Flags
84//!
85//! Both optional features of this crate are pass-throughs that enable the
86//! matching gate in the `subx-core` crate (where the gated sources live) and
87//! nothing else:
88//! ```text
89//! - archive-rar - enable RAR archive extraction (optional unrar dependency)
90//! - slow-tests  - compile long-running tests in both crates
91//! ```
92
93#![allow(
94    clippy::new_without_default,
95    clippy::manual_clamp,
96    clippy::useless_vec,
97    clippy::items_after_test_module,
98    clippy::needless_borrow,
99    clippy::uninlined_format_args,
100    clippy::collapsible_if
101)]
102#![warn(missing_docs)]
103#![warn(rustdoc::missing_crate_level_docs)]
104
105/// Library version string.
106///
107/// This constant provides the current version of the SubX library,
108/// automatically populated from `Cargo.toml` at compile time.
109///
110/// # Examples
111///
112/// ```rust
113/// use subx_cli::VERSION;
114///
115/// println!("SubX version: {}", VERSION);
116/// ```
117pub const VERSION: &str = env!("CARGO_PKG_VERSION");
118
119/// Version of the `subx-core` library this build is linked against.
120///
121/// This constant reports the version of the [`subx_core`] crate resolved at
122/// build time (the git submodule mounted at `subx-core/`), and is distinct
123/// from [`VERSION`], which reports this CLI crate's own version. The two
124/// crates carry independent version lines related only by the caret
125/// requirement in `Cargo.toml`.
126///
127/// # Examples
128///
129/// ```rust
130/// use subx_cli::CORE_VERSION;
131///
132/// assert!(!CORE_VERSION.is_empty());
133/// println!("Linked subx-core version: {}", CORE_VERSION);
134/// ```
135pub const CORE_VERSION: &str = subx_core::VERSION;
136
137pub mod cli;
138pub mod commands;
139
140// ── Legacy back-compat re-exports of the subx-core crate ──────────────────
141//
142// `config`, `core`, `error` and `services` moved to the `subx-core` crate
143// (the library half of the SubX two-crate split). Their canonical paths are
144// now `subx_core::{config, core, error, services}`; the re-exports below
145// exist only so consumers written against the pre-split `subx_cli::` paths
146// keep compiling, and they will be deleted once every out-of-tree consumer
147// (the Tauri GUI first) has migrated. Code inside this crate names
148// `subx_core::…` directly and never resolves through them.
149//
150// Documentation: `docs.rs/subx-core` is the canonical rustdoc for these
151// four modules and everything under them. The re-exported pages that
152// appear on `docs.rs/subx-cli` are generated by the `--workspace` doc
153// build and merely forward there; readers of the library API should go to
154// the `subx-core` documentation directly.
155
156/// Configuration management and validation.
157///
158/// Legacy re-export: the module now lives in the `subx-core` crate; the
159/// canonical path is [`subx_core::config`]. This alias exists for consumers
160/// that have not yet migrated and will be removed once they have.
161pub use subx_core::config;
162/// Core processing engines (formats, matching, sync).
163///
164/// Legacy re-export: the module now lives in the `subx-core` crate; the
165/// canonical path is [`subx_core::core`]. This alias exists for consumers
166/// that have not yet migrated and will be removed once they have.
167pub use subx_core::core;
168/// Comprehensive error handling system.
169///
170/// Legacy re-export: the module now lives in the `subx-core` crate; the
171/// canonical path is [`subx_core::error`]. This alias exists for consumers
172/// that have not yet migrated and will be removed once they have.
173pub use subx_core::error;
174/// External service integrations (AI, audio processing).
175///
176/// Legacy re-export: the module now lives in the `subx-core` crate; the
177/// canonical path is [`subx_core::services`]. This alias exists for
178/// consumers that have not yet migrated and will be removed once they have.
179pub use subx_core::services;
180
181/// Root configuration types.
182///
183/// Legacy re-exports: these types now live in the `subx-core` crate, where
184/// the canonical paths are `subx_core::Config` and `subx_core::ConfigService`
185/// and so on. They exist for consumers that have not yet migrated and will
186/// be removed once they have.
187pub use subx_core::Config;
188pub use subx_core::{
189    ConfigService, EnvironmentProvider, ProductionConfigService, SystemEnvironmentProvider,
190    TestConfigBuilder, TestConfigService, TestEnvironmentProvider,
191};
192
193/// All twelve `#[macro_export]` configuration test macros, re-exported from
194/// the `subx-core` crate.
195///
196/// `#[macro_export]` places a macro at its defining crate's root, so
197/// `pub use subx_core::config;` above cannot reach them; they are listed
198/// individually. Legacy re-exports for consumers that have not yet migrated
199/// to `subx_core::` paths, and will be removed once they have.
200pub use subx_core::{
201    create_default_test_config_service, create_production_config_service_with_empty_env,
202    create_production_config_service_with_env, create_test_config_service,
203    test_production_config_with_env, test_production_config_with_openai_env, test_with_ai_config,
204    test_with_ai_config_and_key, test_with_config, test_with_default_config,
205    test_with_parallel_config, test_with_sync_config,
206};
207
208/// Convenient type alias for `Result<T, SubXError>`.
209///
210/// Denotes the same type as before the crate split: the error type now lives
211/// in the `subx-core` crate, and this alias is the legacy `subx_cli::Result`
212/// path kept for consumers that have not yet migrated to
213/// [`subx_core::Result`].
214pub type Result<T> = subx_core::error::SubXResult<T>;
215
216/// Main application structure with dependency injection support.
217///
218/// The `App` struct provides a programmatic interface to SubX functionality,
219/// designed for embedding SubX in other Rust applications or for advanced
220/// use cases requiring fine-grained control over configuration and execution.
221///
222/// # Use Cases
223///
224/// - **Embedding**: Use SubX as a library component in larger applications
225/// - **Testing**: Programmatic testing of SubX functionality with custom configurations
226/// - **Automation**: Scripted execution of SubX operations without shell commands
227/// - **Custom Workflows**: Building complex workflows that combine multiple SubX operations
228///
229/// # vs CLI Interface
230///
231/// | Feature | CLI (`subx` command) | App (Library API) |
232/// |---------|---------------------|-------------------|
233/// | Usage | Command line tool | Embedded in Rust code |
234/// | Config | Files + Environment | Programmatic injection |
235/// | Output | Terminal/stdout | Programmatic control |
236/// | Error Handling | Exit codes | Result types |
237///
238/// # Examples
239///
240/// ## Basic Usage
241///
242/// ```rust,no_run
243/// use subx_cli::{App, config::ProductionConfigService};
244/// use std::sync::Arc;
245///
246/// # async fn example() -> subx_cli::Result<()> {
247/// let config_service = Arc::new(ProductionConfigService::new()?);
248/// let app = App::new(config_service);
249///
250/// // Execute operations programmatically
251/// app.match_files("/movies", true).await?; // dry run
252/// app.convert_files("/subs", "srt", Some("/output")).await?;
253/// # Ok(())
254/// # }
255/// ```
256///
257/// ## With Custom Configuration
258///
259/// ```rust,no_run
260/// use subx_cli::{App, config::{TestConfigService, Config}};
261/// use std::sync::Arc;
262///
263/// # async fn example() -> subx_cli::Result<()> {
264/// let mut config_service = TestConfigService::with_ai_settings("openai", "gpt-4");
265///
266/// let app = App::new(Arc::new(config_service));
267/// app.match_files("/path", false).await?;
268/// # Ok(())
269/// # }
270/// ```
271pub struct App {
272    config_service: std::sync::Arc<dyn config::ConfigService>,
273}
274
275impl App {
276    /// Create a new application instance with the provided configuration service.
277    ///
278    /// # Arguments
279    ///
280    /// * `config_service` - The configuration service to use
281    ///
282    /// # Examples
283    ///
284    /// ```rust,no_run
285    /// use subx_cli::{App, config::TestConfigService};
286    /// use std::sync::Arc;
287    ///
288    /// let config_service = Arc::new(TestConfigService::with_defaults());
289    /// let app = App::new(config_service);
290    /// ```
291    pub fn new(config_service: std::sync::Arc<dyn config::ConfigService>) -> Self {
292        Self { config_service }
293    }
294
295    /// Create a new application instance with the production configuration service.
296    ///
297    /// This is the default way to create an application instance for production use.
298    ///
299    /// # Examples
300    ///
301    /// ```rust,no_run
302    /// use subx_cli::App;
303    ///
304    /// # async fn example() -> subx_cli::Result<()> {
305    /// let app = App::new_with_production_config()?;
306    /// // Ready to use with production configuration
307    /// # Ok(())
308    /// # }
309    /// ```
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if the production configuration service cannot be created.
314    pub fn new_with_production_config() -> Result<Self> {
315        let config_service = std::sync::Arc::new(config::ProductionConfigService::new()?);
316        Ok(Self::new(config_service))
317    }
318
319    /// Run the application with command-line argument parsing.
320    ///
321    /// This method provides a programmatic way to run SubX with CLI-style
322    /// arguments, useful for embedding SubX in other Rust applications.
323    ///
324    /// # Examples
325    ///
326    /// ```rust,no_run
327    /// use subx_cli::{App, config::ProductionConfigService};
328    /// use std::sync::Arc;
329    ///
330    /// # async fn example() -> subx_cli::Result<()> {
331    /// let config_service = Arc::new(ProductionConfigService::new()?);
332    /// let app = App::new(config_service);
333    ///
334    /// // This parses std::env::args() just like the CLI
335    /// app.run().await?;
336    /// # Ok(())
337    /// # }
338    /// ```
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if command execution fails.
343    pub async fn run(&self) -> Result<()> {
344        let cli = <cli::Cli as clap::Parser>::parse();
345        self.handle_command(cli.command).await
346    }
347
348    /// Handle a specific command with the current configuration.
349    ///
350    /// This method allows programmatic execution of specific SubX commands
351    /// without parsing command-line arguments.
352    ///
353    /// # Examples
354    ///
355    /// ```rust,no_run
356    /// use subx_cli::{App, cli::{Commands, MatchArgs}, config::TestConfigService};
357    /// use std::sync::Arc;
358    ///
359    /// # async fn example() -> subx_cli::Result<()> {
360    /// let config_service = Arc::new(TestConfigService::with_defaults());
361    /// let app = App::new(config_service);
362    ///
363    /// let match_args = MatchArgs {
364    ///     path: Some("/path/to/files".into()),
365    ///     input_paths: vec![],
366    ///     dry_run: true,
367    ///     confidence: 80,
368    ///     recursive: false,
369    ///     backup: false,
370    ///     copy: false,
371    ///     move_files: false,
372    ///     no_extract: false,
373    /// };
374    ///
375    /// app.handle_command(Commands::Match(match_args)).await?;
376    /// # Ok(())
377    /// # }
378    /// ```
379    ///
380    /// # Arguments
381    ///
382    /// * `command` - The command to execute
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if command execution fails.
387    pub async fn handle_command(&self, command: cli::Commands) -> Result<()> {
388        // Use the centralized dispatcher to eliminate code duplication
389        crate::commands::dispatcher::dispatch_command(command, self.config_service.clone()).await
390    }
391
392    /// Execute a match operation programmatically.
393    ///
394    /// This is a convenience method for programmatic usage without
395    /// needing to construct the Commands enum manually.
396    ///
397    /// # Examples
398    ///
399    /// ```rust,no_run
400    /// use subx_cli::{App, config::TestConfigService};
401    /// use std::sync::Arc;
402    ///
403    /// # async fn example() -> subx_cli::Result<()> {
404    /// let config_service = Arc::new(TestConfigService::with_defaults());
405    /// let app = App::new(config_service);
406    ///
407    /// // Match files programmatically
408    /// app.match_files("/path/to/files", true).await?; // dry_run = true
409    /// # Ok(())
410    /// # }
411    /// ```
412    ///
413    /// # Arguments
414    ///
415    /// * `input_path` - Path to the directory or file to process
416    /// * `dry_run` - Whether to perform a dry run (no actual changes)
417    ///
418    /// # Errors
419    ///
420    /// Returns an error if the match operation fails.
421    pub async fn match_files(&self, input_path: &str, dry_run: bool) -> Result<()> {
422        let args = cli::MatchArgs {
423            path: Some(input_path.into()),
424            input_paths: vec![],
425            dry_run,
426            confidence: 80,
427            recursive: false,
428            backup: false,
429            copy: false,
430            move_files: false,
431            no_extract: false,
432        };
433        self.handle_command(cli::Commands::Match(args)).await
434    }
435
436    /// Convert subtitle files programmatically.
437    ///
438    /// # Examples
439    ///
440    /// ```rust,no_run
441    /// use subx_cli::{App, config::TestConfigService};
442    /// use std::sync::Arc;
443    ///
444    /// # async fn example() -> subx_cli::Result<()> {
445    /// let config_service = Arc::new(TestConfigService::with_defaults());
446    /// let app = App::new(config_service);
447    ///
448    /// // Convert to SRT format
449    /// app.convert_files("/path/to/subtitles", "srt", Some("/output/path")).await?;
450    /// # Ok(())
451    /// # }
452    /// ```
453    ///
454    /// # Arguments
455    ///
456    /// * `input_path` - Path to subtitle files to convert
457    /// * `output_format` - Target format ("srt", "ass", "vtt", etc.)
458    /// * `output_path` - Optional output directory path
459    ///
460    /// # Errors
461    ///
462    /// Returns an error if the conversion fails.
463    pub async fn convert_files(
464        &self,
465        input_path: &str,
466        output_format: &str,
467        output_path: Option<&str>,
468    ) -> Result<()> {
469        let format = match output_format.to_lowercase().as_str() {
470            "srt" => cli::OutputSubtitleFormat::Srt,
471            "ass" => cli::OutputSubtitleFormat::Ass,
472            "vtt" => cli::OutputSubtitleFormat::Vtt,
473            "sub" => cli::OutputSubtitleFormat::Sub,
474            _ => {
475                return Err(error::SubXError::CommandExecution(format!(
476                    "Unsupported output format: {output_format}. Supported formats: srt, ass, vtt, sub"
477                )));
478            }
479        };
480
481        let args = cli::ConvertArgs {
482            input: Some(input_path.into()),
483            input_paths: vec![],
484            recursive: false,
485            format: Some(format),
486            output: output_path.map(Into::into),
487            keep_original: false,
488            encoding: "utf-8".to_string(),
489            no_extract: false,
490        };
491        self.handle_command(cli::Commands::Convert(args)).await
492    }
493
494    /// Synchronize subtitle files programmatically.
495    ///
496    /// # Examples
497    ///
498    /// ```rust,no_run
499    /// use subx_cli::{App, config::TestConfigService};
500    /// use std::sync::Arc;
501    ///
502    /// # async fn example() -> subx_cli::Result<()> {
503    /// let config_service = Arc::new(TestConfigService::with_defaults());
504    /// let app = App::new(config_service);
505    ///
506    /// // Synchronize using VAD method
507    /// app.sync_files("/path/to/video.mp4", "/path/to/subtitle.srt", "vad").await?;
508    /// # Ok(())
509    /// # }
510    /// ```
511    ///
512    /// # Arguments
513    ///
514    /// * `video_path` - Path to video file for audio analysis
515    /// * `subtitle_path` - Path to subtitle file to synchronize
516    /// * `method` - Synchronization method ("vad", "manual")
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if synchronization fails.
521    pub async fn sync_files(
522        &self,
523        video_path: &str,
524        subtitle_path: &str,
525        method: &str,
526    ) -> Result<()> {
527        let sync_method = match method.to_lowercase().as_str() {
528            "vad" => Some(cli::SyncMethodArg::Vad),
529            "manual" => Some(cli::SyncMethodArg::Manual),
530            _ => {
531                return Err(error::SubXError::CommandExecution(format!(
532                    "Unsupported sync method: {method}. Supported methods: vad, manual"
533                )));
534            }
535        };
536
537        let args = cli::SyncArgs {
538            positional_paths: Vec::new(),
539            video: Some(video_path.into()),
540            subtitle: Some(subtitle_path.into()),
541            input_paths: vec![],
542            recursive: false,
543            offset: None,
544            method: sync_method,
545            window: 30,
546            vad_sensitivity: None,
547            output: None,
548            verbose: false,
549            dry_run: false,
550            force: false,
551            batch: None,
552            no_extract: false,
553        };
554        self.handle_command(cli::Commands::Sync(args)).await
555    }
556
557    /// Synchronize subtitle files with manual offset.
558    ///
559    /// # Examples
560    ///
561    /// ```rust,no_run
562    /// use subx_cli::{App, config::TestConfigService};
563    /// use std::sync::Arc;
564    ///
565    /// # async fn example() -> subx_cli::Result<()> {
566    /// let config_service = Arc::new(TestConfigService::with_defaults());
567    /// let app = App::new(config_service);
568    ///
569    /// // Apply +2.5 second offset to subtitles
570    /// app.sync_files_with_offset("/path/to/subtitle.srt", 2.5).await?;
571    /// # Ok(())
572    /// # }
573    /// ```
574    ///
575    /// # Arguments
576    ///
577    /// * `subtitle_path` - Path to subtitle file to synchronize
578    /// * `offset` - Time offset in seconds (positive delays, negative advances)
579    ///
580    /// # Errors
581    ///
582    /// Returns an error if synchronization fails.
583    pub async fn sync_files_with_offset(&self, subtitle_path: &str, offset: f32) -> Result<()> {
584        let args = cli::SyncArgs {
585            positional_paths: Vec::new(),
586            video: None,
587            subtitle: Some(subtitle_path.into()),
588            input_paths: vec![],
589            recursive: false,
590            offset: Some(offset),
591            method: None,
592            window: 30,
593            vad_sensitivity: None,
594            output: None,
595            verbose: false,
596            dry_run: false,
597            force: false,
598            batch: None,
599            no_extract: false,
600        };
601        self.handle_command(cli::Commands::Sync(args)).await
602    }
603
604    /// Get a reference to the configuration service.
605    ///
606    /// This allows access to the configuration service for testing or
607    /// advanced use cases.
608    pub fn config_service(&self) -> &std::sync::Arc<dyn config::ConfigService> {
609        &self.config_service
610    }
611
612    /// Get the current configuration.
613    ///
614    /// This is a convenience method that retrieves the configuration
615    /// from the configured service.
616    ///
617    /// # Errors
618    ///
619    /// Returns an error if configuration loading fails.
620    pub fn get_config(&self) -> Result<config::Config> {
621        self.config_service.get_config()
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use std::sync::Arc;
629
630    fn is_expected_test_error(e: &error::SubXError) -> bool {
631        let msg = format!("{e:?}");
632        msg.contains("NotFound")
633            || msg.contains("No subtitle files found")
634            || msg.contains("No video files found")
635            || msg.contains("Config")
636            || msg.contains("no such file")
637            || msg.contains("cannot find")
638            || msg.contains("No input")
639            || msg.contains("No files")
640            || msg.contains("FileNotFound")
641            || msg.contains("IoError")
642            || msg.contains("PathNotFound")
643            || msg.contains("InvalidInput")
644            || msg.contains("CommandExecution")
645            || msg.contains("NoInputSpecified")
646    }
647
648    #[test]
649    fn test_version_is_not_empty() {
650        assert!(!VERSION.is_empty());
651    }
652
653    #[test]
654    fn test_version_matches_cargo_pkg_version() {
655        assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
656    }
657
658    #[test]
659    fn core_version_matches_declared_requirement() {
660        // Inside the workspace, `path` resolution wins outright and the
661        // `version = "1.0"` key in the subx-core dependency declaration is
662        // never consulted. This is the only local guard against the pinned
663        // submodule drifting outside the declared caret requirement.
664        assert!(!CORE_VERSION.is_empty());
665        assert_eq!(CORE_VERSION.split('.').next(), Some("1"));
666    }
667
668    #[test]
669    fn test_app_new_stores_config_service() {
670        let config_service = Arc::new(TestConfigService::with_defaults());
671        let app = App::new(config_service.clone());
672        // Verify config_service() returns a valid Arc
673        let _ = app.config_service();
674    }
675
676    #[test]
677    fn test_app_get_config_returns_config() {
678        let config_service = Arc::new(TestConfigService::with_defaults());
679        let app = App::new(config_service);
680        let config = app.get_config().expect("get_config should succeed");
681        // Just verify we got a config object with a valid AI provider field
682        let _ = config.ai.provider;
683    }
684
685    #[test]
686    fn test_app_get_config_with_ai_settings() {
687        let config_service = Arc::new(TestConfigService::with_ai_settings("openai", "gpt-4.1"));
688        let app = App::new(config_service);
689        let config = app.get_config().expect("get_config should succeed");
690        assert_eq!(config.ai.provider, "openai");
691        assert_eq!(config.ai.model, "gpt-4.1");
692    }
693
694    #[test]
695    fn test_app_config_service_getter() {
696        let config_service = Arc::new(TestConfigService::with_defaults());
697        let app = App::new(config_service);
698        // The returned Arc should be usable (get_config succeeds)
699        let svc = app.config_service();
700        assert!(svc.get_config().is_ok());
701    }
702
703    #[tokio::test]
704    async fn test_convert_files_unknown_format_returns_error() {
705        let config_service = Arc::new(TestConfigService::with_defaults());
706        let app = App::new(config_service);
707        let result = app.convert_files("/nonexistent", "xyz_unknown", None).await;
708        assert!(result.is_err());
709        let err_msg = format!("{:?}", result.unwrap_err());
710        assert!(
711            err_msg.contains("Unsupported output format"),
712            "Error should mention unsupported format, got: {err_msg}"
713        );
714    }
715
716    #[tokio::test]
717    async fn test_convert_files_srt_format_accepted() {
718        let config_service = Arc::new(TestConfigService::with_defaults());
719        let app = App::new(config_service);
720        let result = app.convert_files("/nonexistent_path", "srt", None).await;
721        match result {
722            Ok(_) => {}
723            Err(e) => assert!(
724                is_expected_test_error(&e),
725                "Unexpected error for srt format: {e:?}"
726            ),
727        }
728    }
729
730    #[tokio::test]
731    async fn test_convert_files_ass_format_accepted() {
732        let config_service = Arc::new(TestConfigService::with_defaults());
733        let app = App::new(config_service);
734        let result = app.convert_files("/nonexistent_path", "ass", None).await;
735        match result {
736            Ok(_) => {}
737            Err(e) => assert!(
738                is_expected_test_error(&e),
739                "Unexpected error for ass format: {e:?}"
740            ),
741        }
742    }
743
744    #[tokio::test]
745    async fn test_convert_files_vtt_format_accepted() {
746        let config_service = Arc::new(TestConfigService::with_defaults());
747        let app = App::new(config_service);
748        let result = app.convert_files("/nonexistent_path", "vtt", None).await;
749        match result {
750            Ok(_) => {}
751            Err(e) => assert!(
752                is_expected_test_error(&e),
753                "Unexpected error for vtt format: {e:?}"
754            ),
755        }
756    }
757
758    #[tokio::test]
759    async fn test_convert_files_sub_format_accepted() {
760        let config_service = Arc::new(TestConfigService::with_defaults());
761        let app = App::new(config_service);
762        let result = app.convert_files("/nonexistent_path", "sub", None).await;
763        match result {
764            Ok(_) => {}
765            Err(e) => assert!(
766                is_expected_test_error(&e),
767                "Unexpected error for sub format: {e:?}"
768            ),
769        }
770    }
771
772    #[tokio::test]
773    async fn test_convert_files_format_case_insensitive() {
774        let config_service = Arc::new(TestConfigService::with_defaults());
775        let app = App::new(config_service);
776        // Uppercase format should be treated the same as lowercase
777        let result = app.convert_files("/nonexistent_path", "SRT", None).await;
778        match result {
779            Ok(_) => {}
780            Err(e) => assert!(
781                is_expected_test_error(&e),
782                "Unexpected error for uppercase SRT: {e:?}"
783            ),
784        }
785    }
786
787    #[tokio::test]
788    async fn test_sync_files_unknown_method_returns_error() {
789        let config_service = Arc::new(TestConfigService::with_defaults());
790        let app = App::new(config_service);
791        let result = app
792            .sync_files("/video.mp4", "/subtitle.srt", "unknown_method")
793            .await;
794        assert!(result.is_err());
795        let err_msg = format!("{:?}", result.unwrap_err());
796        assert!(
797            err_msg.contains("Unsupported sync method"),
798            "Error should mention unsupported method, got: {err_msg}"
799        );
800    }
801
802    #[tokio::test]
803    async fn test_sync_files_vad_method_accepted() {
804        let config_service = Arc::new(TestConfigService::with_defaults());
805        let app = App::new(config_service);
806        let result = app
807            .sync_files("/nonexistent_video.mp4", "/nonexistent_sub.srt", "vad")
808            .await;
809        match result {
810            Ok(_) => {}
811            Err(e) => assert!(
812                is_expected_test_error(&e),
813                "Unexpected error for vad method: {e:?}"
814            ),
815        }
816    }
817
818    #[tokio::test]
819    async fn test_sync_files_manual_method_accepted() {
820        let config_service = Arc::new(TestConfigService::with_defaults());
821        let app = App::new(config_service);
822        let result = app
823            .sync_files("/nonexistent_video.mp4", "/nonexistent_sub.srt", "manual")
824            .await;
825        match result {
826            Ok(_) => {}
827            Err(e) => assert!(
828                is_expected_test_error(&e),
829                "Unexpected error for manual method: {e:?}"
830            ),
831        }
832    }
833
834    #[tokio::test]
835    async fn test_sync_files_method_case_insensitive() {
836        let config_service = Arc::new(TestConfigService::with_defaults());
837        let app = App::new(config_service);
838        let result = app
839            .sync_files("/nonexistent_video.mp4", "/nonexistent_sub.srt", "VAD")
840            .await;
841        match result {
842            Ok(_) => {}
843            Err(e) => assert!(
844                is_expected_test_error(&e),
845                "Uppercase VAD should not give format error, got: {e:?}"
846            ),
847        }
848    }
849
850    #[tokio::test]
851    async fn test_sync_files_with_offset_accepted() {
852        let config_service = Arc::new(TestConfigService::with_defaults());
853        let app = App::new(config_service);
854        let result = app
855            .sync_files_with_offset("/nonexistent_sub.srt", 2.5)
856            .await;
857        match result {
858            Ok(_) => {}
859            Err(e) => assert!(
860                is_expected_test_error(&e),
861                "Unexpected error for sync with offset: {e:?}"
862            ),
863        }
864    }
865
866    #[tokio::test]
867    async fn test_sync_files_with_negative_offset() {
868        let config_service = Arc::new(TestConfigService::with_defaults());
869        let app = App::new(config_service);
870        let result = app
871            .sync_files_with_offset("/nonexistent_sub.srt", -1.5)
872            .await;
873        match result {
874            Ok(_) => {}
875            Err(e) => assert!(
876                is_expected_test_error(&e),
877                "Unexpected error for sync with negative offset: {e:?}"
878            ),
879        }
880    }
881
882    #[tokio::test]
883    async fn test_match_files_dry_run() {
884        let config_service = Arc::new(TestConfigService::with_ai_settings(
885            "test_provider",
886            "test_model",
887        ));
888        let app = App::new(config_service);
889        let result = app.match_files("/nonexistent_subx_test_path", true).await;
890        match result {
891            Ok(_) => {}
892            Err(e) => assert!(
893                is_expected_test_error(&e),
894                "Unexpected error for match dry run: {e:?}"
895            ),
896        }
897    }
898
899    #[test]
900    fn test_result_type_alias_ok() {
901        let r: Result<i32> = Ok(42);
902        assert_eq!(r.unwrap(), 42);
903    }
904
905    #[test]
906    fn test_result_type_alias_err() {
907        let r: Result<i32> = Err(error::SubXError::config("test error"));
908        assert!(r.is_err());
909    }
910}