#![allow(
clippy::new_without_default,
clippy::manual_clamp,
clippy::useless_vec,
clippy::items_after_test_module,
clippy::needless_borrow,
clippy::uninlined_format_args,
clippy::collapsible_if
)]
#![warn(missing_docs)]
#![warn(rustdoc::missing_crate_level_docs)]
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub mod cli;
pub mod commands;
pub mod config;
pub use config::Config;
pub use config::{
ConfigService, EnvironmentProvider, ProductionConfigService, SystemEnvironmentProvider,
TestConfigBuilder, TestConfigService, TestEnvironmentProvider,
};
pub mod core;
pub mod error;
pub type Result<T> = error::SubXResult<T>;
pub mod services;
pub struct App {
config_service: std::sync::Arc<dyn config::ConfigService>,
}
impl App {
pub fn new(config_service: std::sync::Arc<dyn config::ConfigService>) -> Self {
Self { config_service }
}
pub fn new_with_production_config() -> Result<Self> {
let config_service = std::sync::Arc::new(config::ProductionConfigService::new()?);
Ok(Self::new(config_service))
}
pub async fn run(&self) -> Result<()> {
let cli = <cli::Cli as clap::Parser>::parse();
self.handle_command(cli.command).await
}
pub async fn handle_command(&self, command: cli::Commands) -> Result<()> {
crate::commands::dispatcher::dispatch_command(command, self.config_service.clone()).await
}
pub async fn match_files(&self, input_path: &str, dry_run: bool) -> Result<()> {
let args = cli::MatchArgs {
path: Some(input_path.into()),
input_paths: vec![],
dry_run,
confidence: 80,
recursive: false,
backup: false,
copy: false,
move_files: false,
no_extract: false,
};
self.handle_command(cli::Commands::Match(args)).await
}
pub async fn convert_files(
&self,
input_path: &str,
output_format: &str,
output_path: Option<&str>,
) -> Result<()> {
let format = match output_format.to_lowercase().as_str() {
"srt" => cli::OutputSubtitleFormat::Srt,
"ass" => cli::OutputSubtitleFormat::Ass,
"vtt" => cli::OutputSubtitleFormat::Vtt,
"sub" => cli::OutputSubtitleFormat::Sub,
_ => {
return Err(error::SubXError::CommandExecution(format!(
"Unsupported output format: {output_format}. Supported formats: srt, ass, vtt, sub"
)));
}
};
let args = cli::ConvertArgs {
input: Some(input_path.into()),
input_paths: vec![],
recursive: false,
format: Some(format),
output: output_path.map(Into::into),
keep_original: false,
encoding: "utf-8".to_string(),
no_extract: false,
};
self.handle_command(cli::Commands::Convert(args)).await
}
pub async fn sync_files(
&self,
video_path: &str,
subtitle_path: &str,
method: &str,
) -> Result<()> {
let sync_method = match method.to_lowercase().as_str() {
"vad" => Some(cli::SyncMethodArg::Vad),
"manual" => Some(cli::SyncMethodArg::Manual),
_ => {
return Err(error::SubXError::CommandExecution(format!(
"Unsupported sync method: {method}. Supported methods: vad, manual"
)));
}
};
let args = cli::SyncArgs {
positional_paths: Vec::new(),
video: Some(video_path.into()),
subtitle: Some(subtitle_path.into()),
input_paths: vec![],
recursive: false,
offset: None,
method: sync_method,
window: 30,
vad_sensitivity: None,
output: None,
verbose: false,
dry_run: false,
force: false,
batch: None,
no_extract: false,
};
self.handle_command(cli::Commands::Sync(args)).await
}
pub async fn sync_files_with_offset(&self, subtitle_path: &str, offset: f32) -> Result<()> {
let args = cli::SyncArgs {
positional_paths: Vec::new(),
video: None,
subtitle: Some(subtitle_path.into()),
input_paths: vec![],
recursive: false,
offset: Some(offset),
method: None,
window: 30,
vad_sensitivity: None,
output: None,
verbose: false,
dry_run: false,
force: false,
batch: None,
no_extract: false,
};
self.handle_command(cli::Commands::Sync(args)).await
}
pub fn config_service(&self) -> &std::sync::Arc<dyn config::ConfigService> {
&self.config_service
}
pub fn get_config(&self) -> Result<config::Config> {
self.config_service.get_config()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn is_expected_test_error(e: &error::SubXError) -> bool {
let msg = format!("{e:?}");
msg.contains("NotFound")
|| msg.contains("No subtitle files found")
|| msg.contains("No video files found")
|| msg.contains("Config")
|| msg.contains("no such file")
|| msg.contains("cannot find")
|| msg.contains("No input")
|| msg.contains("No files")
|| msg.contains("FileNotFound")
|| msg.contains("IoError")
|| msg.contains("PathNotFound")
|| msg.contains("InvalidInput")
|| msg.contains("CommandExecution")
|| msg.contains("NoInputSpecified")
}
#[test]
fn test_version_is_not_empty() {
assert!(!VERSION.is_empty());
}
#[test]
fn test_version_matches_cargo_pkg_version() {
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
}
#[test]
fn test_app_new_stores_config_service() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service.clone());
let _ = app.config_service();
}
#[test]
fn test_app_get_config_returns_config() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let config = app.get_config().expect("get_config should succeed");
let _ = config.ai.provider;
}
#[test]
fn test_app_get_config_with_ai_settings() {
let config_service = Arc::new(TestConfigService::with_ai_settings("openai", "gpt-4.1"));
let app = App::new(config_service);
let config = app.get_config().expect("get_config should succeed");
assert_eq!(config.ai.provider, "openai");
assert_eq!(config.ai.model, "gpt-4.1");
}
#[test]
fn test_app_config_service_getter() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let svc = app.config_service();
assert!(svc.get_config().is_ok());
}
#[tokio::test]
async fn test_convert_files_unknown_format_returns_error() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app.convert_files("/nonexistent", "xyz_unknown", None).await;
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(
err_msg.contains("Unsupported output format"),
"Error should mention unsupported format, got: {err_msg}"
);
}
#[tokio::test]
async fn test_convert_files_srt_format_accepted() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app.convert_files("/nonexistent_path", "srt", None).await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for srt format: {e:?}"
),
}
}
#[tokio::test]
async fn test_convert_files_ass_format_accepted() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app.convert_files("/nonexistent_path", "ass", None).await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for ass format: {e:?}"
),
}
}
#[tokio::test]
async fn test_convert_files_vtt_format_accepted() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app.convert_files("/nonexistent_path", "vtt", None).await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for vtt format: {e:?}"
),
}
}
#[tokio::test]
async fn test_convert_files_sub_format_accepted() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app.convert_files("/nonexistent_path", "sub", None).await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for sub format: {e:?}"
),
}
}
#[tokio::test]
async fn test_convert_files_format_case_insensitive() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app.convert_files("/nonexistent_path", "SRT", None).await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for uppercase SRT: {e:?}"
),
}
}
#[tokio::test]
async fn test_sync_files_unknown_method_returns_error() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app
.sync_files("/video.mp4", "/subtitle.srt", "unknown_method")
.await;
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(
err_msg.contains("Unsupported sync method"),
"Error should mention unsupported method, got: {err_msg}"
);
}
#[tokio::test]
async fn test_sync_files_vad_method_accepted() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app
.sync_files("/nonexistent_video.mp4", "/nonexistent_sub.srt", "vad")
.await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for vad method: {e:?}"
),
}
}
#[tokio::test]
async fn test_sync_files_manual_method_accepted() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app
.sync_files("/nonexistent_video.mp4", "/nonexistent_sub.srt", "manual")
.await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for manual method: {e:?}"
),
}
}
#[tokio::test]
async fn test_sync_files_method_case_insensitive() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app
.sync_files("/nonexistent_video.mp4", "/nonexistent_sub.srt", "VAD")
.await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Uppercase VAD should not give format error, got: {e:?}"
),
}
}
#[tokio::test]
async fn test_sync_files_with_offset_accepted() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app
.sync_files_with_offset("/nonexistent_sub.srt", 2.5)
.await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for sync with offset: {e:?}"
),
}
}
#[tokio::test]
async fn test_sync_files_with_negative_offset() {
let config_service = Arc::new(TestConfigService::with_defaults());
let app = App::new(config_service);
let result = app
.sync_files_with_offset("/nonexistent_sub.srt", -1.5)
.await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for sync with negative offset: {e:?}"
),
}
}
#[tokio::test]
async fn test_match_files_dry_run() {
let config_service = Arc::new(TestConfigService::with_ai_settings(
"test_provider",
"test_model",
));
let app = App::new(config_service);
let result = app.match_files("/nonexistent_subx_test_path", true).await;
match result {
Ok(_) => {}
Err(e) => assert!(
is_expected_test_error(&e),
"Unexpected error for match dry run: {e:?}"
),
}
}
#[test]
fn test_result_type_alias_ok() {
let r: Result<i32> = Ok(42);
assert_eq!(r.unwrap(), 42);
}
#[test]
fn test_result_type_alias_err() {
let r: Result<i32> = Err(error::SubXError::config("test error"));
assert!(r.is_err());
}
}