pub mod profiles;
use crate::error::{CliError, Result};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use voirs_sdk::config::AppConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliConfig {
#[serde(flatten)]
pub core: AppConfig,
pub cli: CliSettings,
}
pub type Config = CliConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliSettings {
pub default_output_format: String,
pub default_voice: Option<String>,
pub default_quality: String,
pub colored_output: bool,
pub show_progress: bool,
pub auto_play: bool,
pub output_directory: Option<PathBuf>,
pub ssml_validation: SsmlValidationLevel,
pub history_size: usize,
pub download: DownloadSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SsmlValidationLevel {
None,
Warn,
Strict,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadSettings {
pub parallel_downloads: usize,
pub retry_attempts: usize,
pub verify_checksums: bool,
pub preferred_mirrors: Vec<String>,
}
impl Default for CliConfig {
fn default() -> Self {
Self {
core: AppConfig::default(),
cli: CliSettings::default(),
}
}
}
impl Default for CliSettings {
fn default() -> Self {
Self {
default_output_format: "wav".to_string(),
default_voice: None,
default_quality: "high".to_string(),
colored_output: true,
show_progress: true,
auto_play: false,
output_directory: None,
ssml_validation: SsmlValidationLevel::Warn,
history_size: 100,
download: DownloadSettings::default(),
}
}
}
impl Default for DownloadSettings {
fn default() -> Self {
Self {
parallel_downloads: 3,
retry_attempts: 3,
verify_checksums: true,
preferred_mirrors: vec![
"https://huggingface.co".to_string(),
"https://github.com".to_string(),
],
}
}
}
pub struct ConfigManager {
config_path: PathBuf,
config: CliConfig,
}
impl ConfigManager {
pub fn new() -> Result<Self> {
let config_path = Self::find_config_file().unwrap_or_else(Self::default_config_path);
let config = if config_path.exists() {
Self::load_from_file(&config_path)?
} else {
CliConfig::default()
};
Ok(Self {
config_path,
config,
})
}
pub fn with_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let config_path = path.as_ref().to_path_buf();
let config = if config_path.exists() {
Self::load_from_file(&config_path)?
} else {
CliConfig::default()
};
Ok(Self {
config_path,
config,
})
}
pub fn config(&self) -> &CliConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut CliConfig {
&mut self.config
}
pub fn save(&self) -> Result<()> {
if let Some(parent) = self.config_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
CliError::file_operation("create directory", &parent.display().to_string(), e)
})?;
}
let content = toml::to_string_pretty(&self.config).map_err(CliError::from)?;
fs::write(&self.config_path, content).map_err(|e| {
CliError::file_operation("write", &self.config_path.display().to_string(), e)
})?;
Ok(())
}
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
match key {
"default_output_format" => {
self.config.cli.default_output_format = value.to_string();
}
"default_voice" => {
self.config.cli.default_voice = if value.is_empty() {
None
} else {
Some(value.to_string())
};
}
"default_quality" => {
if ["low", "medium", "high", "ultra"].contains(&value) {
self.config.cli.default_quality = value.to_string();
} else {
return Err(CliError::invalid_parameter(
key,
"must be one of: low, medium, high, ultra",
));
}
}
"colored_output" => {
self.config.cli.colored_output = value
.parse()
.map_err(|_| CliError::invalid_parameter(key, "must be true or false"))?;
}
"show_progress" => {
self.config.cli.show_progress = value
.parse()
.map_err(|_| CliError::invalid_parameter(key, "must be true or false"))?;
}
"auto_play" => {
self.config.cli.auto_play = value
.parse()
.map_err(|_| CliError::invalid_parameter(key, "must be true or false"))?;
}
"output_directory" => {
self.config.cli.output_directory = if value.is_empty() {
None
} else {
Some(PathBuf::from(value))
};
}
_ => {
return Err(CliError::invalid_parameter(
key,
"unknown configuration key",
));
}
}
Ok(())
}
pub fn get_value(&self, key: &str) -> Option<String> {
match key {
"default_output_format" => Some(self.config.cli.default_output_format.clone()),
"default_voice" => self.config.cli.default_voice.clone(),
"default_quality" => Some(self.config.cli.default_quality.clone()),
"colored_output" => Some(self.config.cli.colored_output.to_string()),
"show_progress" => Some(self.config.cli.show_progress.to_string()),
"auto_play" => Some(self.config.cli.auto_play.to_string()),
"output_directory" => self
.config
.cli
.output_directory
.as_ref()
.map(|p| p.display().to_string()),
_ => None,
}
}
pub fn apply_env_overrides(&mut self) {
if let Ok(format) = env::var("VOIRS_OUTPUT_FORMAT") {
self.config.cli.default_output_format = format;
}
if let Ok(voice) = env::var("VOIRS_DEFAULT_VOICE") {
self.config.cli.default_voice = Some(voice);
}
if let Ok(quality) = env::var("VOIRS_QUALITY") {
if ["low", "medium", "high", "ultra"].contains(&quality.as_str()) {
self.config.cli.default_quality = quality;
}
}
if let Ok(colored) = env::var("VOIRS_COLORED_OUTPUT") {
if let Ok(value) = colored.parse() {
self.config.cli.colored_output = value;
}
}
if let Ok(progress) = env::var("VOIRS_SHOW_PROGRESS") {
if let Ok(value) = progress.parse() {
self.config.cli.show_progress = value;
}
}
if let Ok(output_dir) = env::var("VOIRS_OUTPUT_DIR") {
self.config.cli.output_directory = Some(PathBuf::from(output_dir));
}
}
pub fn validate(&self) -> Result<Vec<String>> {
let mut warnings = Vec::new();
if let Some(ref voice) = self.config.cli.default_voice {
match self.validate_voice_exists(voice) {
Ok(true) => {
}
Ok(false) => {
warnings.push(format!(
"Default voice '{}' does not exist. Use 'voirs voices list' to see available voices.",
voice
));
}
Err(_) => {
warnings.push(format!(
"Could not verify existence of default voice '{}'. Voice system may not be initialized.",
voice
));
}
}
}
if let Some(ref output_dir) = self.config.cli.output_directory {
if !output_dir.exists() {
warnings.push(format!(
"Output directory '{}' does not exist",
output_dir.display()
));
} else if !output_dir.is_dir() {
return Err(CliError::config(format!(
"Output directory '{}' is not a directory",
output_dir.display()
)));
}
}
if self.config.cli.download.parallel_downloads == 0 {
return Err(CliError::config(
"parallel_downloads must be greater than 0",
));
}
if self.config.cli.download.parallel_downloads > 10 {
warnings.push("parallel_downloads > 10 may cause server rate limiting".to_string());
}
Ok(warnings)
}
fn validate_voice_exists(&self, voice_id: &str) -> Result<bool> {
let voice_dirs = self.get_voice_directories();
for voice_dir in voice_dirs {
let voice_config_path = voice_dir.join(voice_id).join("voice.json");
if voice_config_path.exists() {
return Ok(true);
}
}
Ok(false)
}
fn get_voice_directories(&self) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".voirs").join("voices"));
}
#[cfg(target_os = "linux")]
{
if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
dirs.push(PathBuf::from(xdg_data_home).join("voirs").join("voices"));
} else if let Some(home) = dirs::home_dir() {
dirs.push(
home.join(".local")
.join("share")
.join("voirs")
.join("voices"),
);
}
}
#[cfg(target_os = "macos")]
{
if let Some(home) = dirs::home_dir() {
dirs.push(
home.join("Library")
.join("Application Support")
.join("voirs")
.join("voices"),
);
}
}
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
dirs.push(PathBuf::from(appdata).join("voirs").join("voices"));
}
}
dirs.push(PathBuf::from("./voices"));
dirs
}
pub fn config_path(&self) -> &Path {
&self.config_path
}
fn load_from_file<P: AsRef<Path>>(path: P) -> Result<CliConfig> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| {
CliError::file_operation("read", &path.as_ref().display().to_string(), e)
})?;
if let Ok(config) = toml::from_str::<CliConfig>(&content) {
Ok(config)
} else {
serde_json::from_str::<CliConfig>(&content)
.map_err(|e| CliError::config(format!("Invalid configuration format: {}", e)))
}
}
fn find_config_file() -> Option<PathBuf> {
let possible_paths = [
env::current_dir().ok().map(|d| d.join("voirs.toml")),
env::current_dir().ok().map(|d| d.join("voirs.json")),
Self::config_dir().map(|d| d.join("voirs.toml")),
Self::config_dir().map(|d| d.join("voirs.json")),
env::var("VOIRS_CONFIG").ok().map(PathBuf::from),
];
possible_paths
.into_iter()
.flatten()
.find(|path| path.exists())
}
fn default_config_path() -> PathBuf {
Self::config_dir()
.unwrap_or_else(|| env::current_dir().expect("current dir should be accessible"))
.join("voirs.toml")
}
fn config_dir() -> Option<PathBuf> {
if let Some(config_dir) = env::var_os("XDG_CONFIG_HOME") {
Some(PathBuf::from(config_dir).join("voirs"))
} else if let Some(home_dir) = env::var_os("HOME") {
Some(PathBuf::from(home_dir).join(".config").join("voirs"))
} else {
env::var_os("APPDATA").map(|app_data| PathBuf::from(app_data).join("voirs"))
}
}
}
pub mod utils {
use super::*;
pub fn create_default_config<P: AsRef<Path>>(path: P) -> Result<()> {
let config = CliConfig::default();
let content = toml::to_string_pretty(&config).map_err(CliError::from)?;
if let Some(parent) = path.as_ref().parent() {
fs::create_dir_all(parent).map_err(|e| {
CliError::file_operation("create directory", &parent.display().to_string(), e)
})?;
}
fs::write(path.as_ref(), content).map_err(|e| {
CliError::file_operation("write", &path.as_ref().display().to_string(), e)
})?;
Ok(())
}
pub fn migrate_config<P: AsRef<Path>>(old_path: P, new_path: P) -> Result<()> {
let old_content = fs::read_to_string(old_path.as_ref()).map_err(|e| {
CliError::file_operation("read", &old_path.as_ref().display().to_string(), e)
})?;
let old_config: serde_json::Value = serde_json::from_str(&old_content)
.map_err(|e| CliError::config(format!("Cannot parse old config: {}", e)))?;
let mut new_config = CliConfig::default();
if let Some(output_format) = old_config.get("output_format") {
if let Some(format_str) = output_format.as_str() {
new_config.cli.default_output_format = format_str.to_string();
}
}
let content = toml::to_string_pretty(&new_config).map_err(CliError::from)?;
fs::write(new_path.as_ref(), content).map_err(|e| {
CliError::file_operation("write", &new_path.as_ref().display().to_string(), e)
})?;
Ok(())
}
pub fn export_config<P: AsRef<Path>>(
config: &CliConfig,
path: P,
format: ConfigFormat,
) -> Result<()> {
let content = match format {
ConfigFormat::Toml => toml::to_string_pretty(config)?,
ConfigFormat::Json => serde_json::to_string_pretty(config)?,
ConfigFormat::Yaml => serde_yaml::to_string(config)
.map_err(|e| CliError::config(format!("YAML serialization error: {}", e)))?,
};
fs::write(path.as_ref(), content).map_err(|e| {
CliError::file_operation("write", &path.as_ref().display().to_string(), e)
})?;
Ok(())
}
}
pub struct EnhancedConfigLoader {
cache: Option<(PathBuf, std::time::SystemTime, CliConfig)>,
}
impl EnhancedConfigLoader {
pub fn new() -> Self {
Self { cache: None }
}
pub fn load_config<P: AsRef<Path>>(&mut self, path: P) -> Result<CliConfig> {
let path = path.as_ref();
let start_time = Instant::now();
if let Some((cached_path, cached_time, ref cached_config)) = &self.cache {
if cached_path == path {
if let Ok(metadata) = fs::metadata(path) {
if let Ok(modified) = metadata.modified() {
if modified <= *cached_time {
return Ok(cached_config.clone());
}
}
}
}
}
let content = fs::read_to_string(path)
.map_err(|e| CliError::file_operation("read", &path.display().to_string(), e))?;
let config = self.parse_config_content(&content, path)?;
if let Ok(metadata) = fs::metadata(path) {
if let Ok(modified) = metadata.modified() {
self.cache = Some((path.to_path_buf(), modified, config.clone()));
}
}
let load_time = start_time.elapsed();
if load_time > Duration::from_millis(100) {
eprintln!("Warning: Configuration loading took {:?}", load_time);
}
Ok(config)
}
fn parse_config_content<P: AsRef<Path>>(&self, content: &str, path: P) -> Result<CliConfig> {
let extension = path.as_ref().extension().and_then(|ext| ext.to_str());
match extension {
Some("toml") => {
match toml::from_str::<CliConfig>(content) {
Ok(config) => return Ok(config),
Err(e) => {
eprintln!("TOML parsing failed: {}, trying fallback formats", e);
}
}
}
Some("json") => match serde_json::from_str::<CliConfig>(content) {
Ok(config) => return Ok(config),
Err(e) => {
eprintln!("JSON parsing failed: {}, trying fallback formats", e);
}
},
Some("yaml") | Some("yml") => match serde_yaml::from_str::<CliConfig>(content) {
Ok(config) => return Ok(config),
Err(e) => {
eprintln!("YAML parsing failed: {}, trying fallback formats", e);
}
},
_ => {
}
}
let trimmed = content.trim();
if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
if let Ok(config) = toml::from_str::<CliConfig>(content) {
return Ok(config);
}
}
if trimmed.starts_with('{') && trimmed.ends_with('}') {
if let Ok(config) = serde_json::from_str::<CliConfig>(content) {
return Ok(config);
}
}
if let Ok(config) = serde_yaml::from_str::<CliConfig>(content) {
return Ok(config);
}
Err(CliError::config(format!(
"Unable to parse configuration file '{}' - tried TOML, JSON, and YAML formats",
path.as_ref().display()
)))
}
pub fn clear_cache(&mut self) {
self.cache = None;
}
pub fn is_cached<P: AsRef<Path>>(&self, path: P) -> bool {
if let Some((cached_path, _, _)) = &self.cache {
cached_path == path.as_ref()
} else {
false
}
}
pub fn cache_stats(&self) -> Option<(PathBuf, std::time::SystemTime)> {
self.cache
.as_ref()
.map(|(path, time, _)| (path.clone(), *time))
}
}
impl Default for EnhancedConfigLoader {
fn default() -> Self {
Self::new()
}
}
pub mod validation {
use super::*;
use std::time::{Duration, Instant};
pub fn validate_config_detailed(config: &CliConfig) -> Result<ValidationReport> {
let mut report = ValidationReport::new();
let start_time = Instant::now();
validate_cli_settings(&config.cli, &mut report)?;
validate_core_config(&config.core, &mut report)?;
let validation_time = start_time.elapsed();
if validation_time > Duration::from_millis(50) {
report.add_warning(format!(
"Configuration validation took {:?} - consider optimizing",
validation_time
));
}
Ok(report)
}
fn validate_cli_settings(settings: &CliSettings, report: &mut ValidationReport) -> Result<()> {
let valid_formats = ["wav", "mp3", "flac", "ogg", "m4a"];
if !valid_formats.contains(&settings.default_output_format.as_str()) {
report.add_error(format!(
"Invalid default output format '{}'. Valid formats: {}",
settings.default_output_format,
valid_formats.join(", ")
));
}
let valid_qualities = ["low", "medium", "high", "ultra"];
if !valid_qualities.contains(&settings.default_quality.as_str()) {
report.add_error(format!(
"Invalid default quality '{}'. Valid qualities: {}",
settings.default_quality,
valid_qualities.join(", ")
));
}
if let Some(ref output_dir) = settings.output_directory {
if !output_dir.exists() {
report.add_warning(format!(
"Output directory '{}' does not exist",
output_dir.display()
));
} else if !output_dir.is_dir() {
report.add_error(format!(
"Output directory '{}' is not a directory",
output_dir.display()
));
}
}
if settings.download.parallel_downloads == 0 {
report.add_error("parallel_downloads must be greater than 0".to_string());
} else if settings.download.parallel_downloads > 20 {
report.add_warning(format!(
"parallel_downloads ({}) is very high and may cause issues",
settings.download.parallel_downloads
));
}
if settings.download.retry_attempts > 10 {
report.add_warning(format!(
"retry_attempts ({}) is very high and may cause long delays",
settings.download.retry_attempts
));
}
Ok(())
}
fn validate_core_config(config: &AppConfig, report: &mut ValidationReport) -> Result<()> {
match config.pipeline.device.as_str() {
"cpu" => {
report.add_info("Using CPU device - synthesis will be slower than GPU".to_string());
}
"gpu" | "cuda" => {
report.add_info("GPU acceleration enabled - ensure CUDA is available".to_string());
#[cfg(not(feature = "cuda"))]
report.add_warning(
"GPU device specified but CUDA feature not enabled in build".to_string(),
);
}
"metal" => {
report.add_info("Metal acceleration enabled - macOS only".to_string());
#[cfg(not(target_os = "macos"))]
report.add_error("Metal device is only available on macOS".to_string());
#[cfg(not(feature = "metal"))]
report.add_warning(
"Metal device specified but metal feature not enabled in build".to_string(),
);
}
other => {
report.add_error(format!(
"Invalid device '{}' - must be 'cpu', 'gpu', 'cuda', or 'metal'",
other
));
}
}
if let Some(threads) = config.pipeline.num_threads {
if threads == 0 {
report.add_error("num_threads must be greater than 0".to_string());
} else if threads > num_cpus::get() * 2 {
report.add_warning(format!(
"num_threads ({}) exceeds 2x CPU count ({}) - may cause overhead",
threads,
num_cpus::get()
));
}
}
let sample_rate = config.pipeline.default_synthesis.sample_rate;
match sample_rate {
8000 | 16000 | 22050 | 24000 | 32000 | 44100 | 48000 => {
}
rate if rate < 8000 => {
report.add_error(format!("sample_rate {} is too low - minimum 8000 Hz", rate));
}
rate if rate > 48000 => {
report.add_warning(format!(
"sample_rate {} is very high - may increase processing time",
rate
));
}
rate => {
report.add_warning(format!(
"non-standard sample_rate {} - common rates: 16000, 22050, 44100, 48000",
rate
));
}
}
if let Some(cache_dir) = &config.pipeline.cache_dir {
if !cache_dir.exists() {
report.add_warning(format!(
"cache directory does not exist: {}",
cache_dir.display()
));
} else if !cache_dir.is_dir() {
report.add_error(format!(
"cache path exists but is not a directory: {}",
cache_dir.display()
));
}
}
let max_cache_size_mb = config.pipeline.max_cache_size_mb;
if max_cache_size_mb == 0 {
report.add_warning("cache disabled (max_cache_size_mb = 0)".to_string());
} else if max_cache_size_mb > 10240 {
report.add_warning(format!(
"very large cache size ({} MB) may consume excessive memory",
max_cache_size_mb
));
}
if config.pipeline.use_gpu && config.pipeline.device == "cpu" {
report.add_warning(
"use_gpu is true but device is set to 'cpu' - inconsistent configuration"
.to_string(),
);
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ValidationReport {
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub info: Vec<String>,
}
impl ValidationReport {
pub fn new() -> Self {
Self {
errors: Vec::new(),
warnings: Vec::new(),
info: Vec::new(),
}
}
pub fn add_error(&mut self, error: String) {
self.errors.push(error);
}
pub fn add_warning(&mut self, warning: String) {
self.warnings.push(warning);
}
pub fn add_info(&mut self, info: String) {
self.info.push(info);
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
pub fn summary(&self) -> String {
format!(
"Validation complete: {} errors, {} warnings, {} info messages",
self.errors.len(),
self.warnings.len(),
self.info.len()
)
}
}
impl Default for ValidationReport {
fn default() -> Self {
Self::new()
}
}
}
pub enum ConfigFormat {
Toml,
Json,
Yaml,
}