use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use teravars::{Context, Engine, extract_vars, resolve, system_context};
use crate::lesson::Lesson;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub vars: BTreeMap<String, toml::Value>,
pub server: ServerConfig,
#[serde(default)]
pub avatars: AvatarsConfig,
#[serde(default)]
pub voice: VoiceConfig,
#[serde(default)]
pub backend: BTreeMap<String, BackendConfig>,
#[serde(default)]
pub lesson: BTreeMap<String, LessonRef>,
#[serde(skip)]
pub root_dir: PathBuf,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
pub bind: String,
#[serde(default = "default_cors")]
pub cors_allow_origins: Vec<String>,
}
fn default_cors() -> Vec<String> {
vec!["*".into()]
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AvatarsConfig {
#[serde(default = "default_avatars_dir")]
pub dir: String,
#[serde(default)]
pub default: String,
}
fn default_avatars_dir() -> String {
"./avatars".into()
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct VoiceConfig {
#[serde(default = "default_browser")]
pub stt: String,
#[serde(default = "default_browser")]
pub tts: String,
pub kokoro: Option<KokoroConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KokoroConfig {
pub model_path: String,
pub voices_dir: String,
#[serde(default = "default_kokoro_voice")]
pub default_voice: String,
#[serde(default = "default_kokoro_speed")]
pub speed: f32,
}
fn default_browser() -> String {
"browser".into()
}
fn default_kokoro_voice() -> String {
"af_heart".into()
}
fn default_kokoro_speed() -> f32 {
1.0
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum BackendConfig {
Api(ApiBackendConfig),
Cli(CliBackendConfig),
}
#[derive(Debug, Clone, Deserialize)]
pub struct CliBackendConfig {
pub cmd: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ApiBackendConfig {
pub provider: String,
pub model: String,
pub api_key_env: String,
#[serde(default)]
pub temperature: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LessonRef {
pub extends: String,
}
impl Config {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let rendered = render_toml(path)?;
let mut cfg: Config = toml::from_str(&rendered)?;
cfg.root_dir = path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
Ok(cfg)
}
pub fn load_lesson(&self, name: &str) -> anyhow::Result<Lesson> {
let lesson_ref = self
.lesson
.get(name)
.ok_or_else(|| anyhow::anyhow!("unknown lesson: {name}"))?;
let lesson_path = self.root_dir.join(&lesson_ref.extends);
Lesson::load(&lesson_path)
}
pub fn default_lesson_name(&self) -> &str {
self.vars
.get("default_lesson")
.and_then(|v| v.as_str())
.unwrap_or("elementary-low")
}
pub fn default_backend_name(&self) -> &str {
self.vars
.get("default_backend")
.and_then(|v| v.as_str())
.unwrap_or("claude")
}
pub fn avatars_dir(&self) -> PathBuf {
PathBuf::from(&self.avatars.dir)
}
}
pub fn render_toml(path: &Path) -> anyhow::Result<String> {
let raw = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
let mut engine = Engine::new();
let mut vars = extract_vars(&raw)?;
let _ = resolve(&mut vars, &mut engine);
let mut ctx: Context = system_context();
ctx.insert("vars", &vars);
let rendered = engine.render(&raw, &ctx)?;
Ok(rendered)
}