use std::path::{Path, PathBuf};
use thiserror::Error;
use super::types::VoicePackage;
#[derive(Debug, Error)]
pub enum VoiceLoaderError {
#[error("voice package '{name}' not found in any search directory")]
NotFound { name: String },
#[error("failed to parse voice.toml at {path}: {source}")]
ParseError {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("I/O error reading voice file at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
const BUNDLED_DUETTO_VOICE: &str = include_str!("../../voices/duetto/voice.toml");
#[derive(Debug, Default)]
pub struct VoiceLoader {
extra_dirs: Vec<PathBuf>,
skip_xdg: bool,
}
impl VoiceLoader {
pub fn new() -> Self {
Self::default()
}
pub fn with_extra_dirs(dirs: Vec<PathBuf>) -> Self {
Self {
extra_dirs: dirs,
skip_xdg: false,
}
}
pub fn bundled_only() -> Self {
Self {
extra_dirs: vec![],
skip_xdg: true,
}
}
pub fn load(&self, name: &str) -> Result<VoicePackage, VoiceLoaderError> {
for base in &self.extra_dirs {
let candidate = base.join(name).join("voice.toml");
if candidate.exists() {
return Self::parse_file(&candidate);
}
}
if !self.skip_xdg
&& let Some(config_dir) = dirs::config_dir()
{
let candidate = config_dir
.join("trusty-review")
.join("voices")
.join(name)
.join("voice.toml");
if candidate.exists() {
return Self::parse_file(&candidate);
}
}
if name == "duetto" {
return toml::from_str::<VoicePackage>(BUNDLED_DUETTO_VOICE).map_err(|source| {
VoiceLoaderError::ParseError {
path: PathBuf::from("<bundled:duetto>"),
source,
}
});
}
Err(VoiceLoaderError::NotFound {
name: name.to_string(),
})
}
pub fn list(&self) -> Vec<String> {
let mut names = std::collections::BTreeSet::new();
for base in &self.extra_dirs {
collect_voice_names(base, &mut names);
}
if let Some(config_dir) = dirs::config_dir() {
let voices_dir = config_dir.join("trusty-review").join("voices");
collect_voice_names(&voices_dir, &mut names);
}
names.insert("duetto".to_string());
names.into_iter().collect()
}
fn parse_file(path: &Path) -> Result<VoicePackage, VoiceLoaderError> {
let content = std::fs::read_to_string(path).map_err(|source| VoiceLoaderError::Io {
path: path.to_path_buf(),
source,
})?;
toml::from_str::<VoicePackage>(&content).map_err(|source| VoiceLoaderError::ParseError {
path: path.to_path_buf(),
source,
})
}
}
fn collect_voice_names(base: &Path, names: &mut std::collections::BTreeSet<String>) {
let Ok(entries) = std::fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir()
&& path.join("voice.toml").exists()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
names.insert(name.to_string());
}
}
}