1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::Deserialize;
5use teravars::{Context, Engine, extract_vars, resolve, system_context};
6
7use crate::lesson::Lesson;
8
9#[derive(Debug, Clone, Deserialize)]
10pub struct Config {
11 #[serde(default)]
12 pub vars: BTreeMap<String, toml::Value>,
13 pub server: ServerConfig,
14 #[serde(default)]
15 pub avatars: AvatarsConfig,
16 #[serde(default)]
17 pub voice: VoiceConfig,
18 #[serde(default)]
19 pub backend: BTreeMap<String, BackendConfig>,
20 #[serde(default)]
21 pub lesson: BTreeMap<String, LessonRef>,
22
23 #[serde(skip)]
24 pub root_dir: PathBuf,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28pub struct ServerConfig {
29 pub bind: String,
30 #[serde(default = "default_cors")]
31 pub cors_allow_origins: Vec<String>,
32}
33
34fn default_cors() -> Vec<String> {
35 vec!["*".into()]
36}
37
38#[derive(Debug, Clone, Default, Deserialize)]
39pub struct AvatarsConfig {
40 #[serde(default = "default_avatars_dir")]
41 pub dir: String,
42 #[serde(default)]
43 pub default: String,
44}
45
46fn default_avatars_dir() -> String {
47 "./avatars".into()
48}
49
50#[derive(Debug, Clone, Default, Deserialize)]
51pub struct VoiceConfig {
52 #[serde(default = "default_browser")]
53 pub stt: String,
54 #[serde(default = "default_browser")]
55 pub tts: String,
56 pub kokoro: Option<KokoroConfig>,
58 pub voicevox: Option<VoicevoxConfig>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub struct KokoroConfig {
65 pub model_path: String,
66 pub voices_dir: String,
67 #[serde(default = "default_kokoro_voice")]
68 pub default_voice: String,
69 #[serde(default = "default_kokoro_speed")]
70 pub speed: f32,
71}
72
73fn default_browser() -> String {
74 "browser".into()
75}
76
77fn default_kokoro_voice() -> String {
78 "af_heart".into()
79}
80
81fn default_kokoro_speed() -> f32 {
82 1.0
83}
84
85#[derive(Debug, Clone, Deserialize)]
86pub struct VoicevoxConfig {
87 #[serde(default = "default_voicevox_speaker")]
91 pub default_speaker_id: u32,
92 #[serde(default = "default_voicevox_preload")]
96 pub preload_speakers: Vec<u32>,
97}
98
99fn default_voicevox_speaker() -> u32 {
100 8
101}
102
103fn default_voicevox_preload() -> Vec<u32> {
104 vec![2, 3, 8] }
106
107#[derive(Debug, Clone, Deserialize)]
114#[serde(untagged)]
115pub enum BackendConfig {
116 Api(ApiBackendConfig),
117 Cli(CliBackendConfig),
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct CliBackendConfig {
122 pub cmd: String,
123 #[serde(default)]
124 pub args: Vec<String>,
125}
126
127#[derive(Debug, Clone, Deserialize)]
128pub struct ApiBackendConfig {
129 pub provider: String,
132 pub model: String,
137 pub api_key_env: String,
141 #[serde(default)]
143 pub temperature: Option<f32>,
144}
145
146#[derive(Debug, Clone, Deserialize)]
147pub struct LessonRef {
148 pub extends: String,
149}
150
151impl Config {
152 pub fn load(path: &Path) -> anyhow::Result<Self> {
153 let rendered = render_toml(path)?;
154 let mut cfg: Config = toml::from_str(&rendered)?;
155 cfg.root_dir = path
156 .parent()
157 .map(|p| p.to_path_buf())
158 .unwrap_or_else(|| PathBuf::from("."));
159 Ok(cfg)
160 }
161
162 pub fn load_lesson(&self, name: &str) -> anyhow::Result<Lesson> {
163 let lesson_ref = self
164 .lesson
165 .get(name)
166 .ok_or_else(|| anyhow::anyhow!("unknown lesson: {name}"))?;
167 let lesson_path = self.root_dir.join(&lesson_ref.extends);
168 Lesson::load(&lesson_path)
169 }
170
171 pub fn default_lesson_name(&self) -> &str {
172 self.vars
173 .get("default_lesson")
174 .and_then(|v| v.as_str())
175 .unwrap_or("elementary-low")
176 }
177
178 pub fn default_backend_name(&self) -> &str {
179 self.vars
180 .get("default_backend")
181 .and_then(|v| v.as_str())
182 .unwrap_or("claude")
183 }
184
185 pub fn avatars_dir(&self) -> PathBuf {
191 PathBuf::from(&self.avatars.dir)
192 }
193}
194
195pub fn render_toml(path: &Path) -> anyhow::Result<String> {
199 let raw = std::fs::read_to_string(path)
200 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
201 let mut engine = Engine::new();
202 let mut vars = extract_vars(&raw)?;
203 let _ = resolve(&mut vars, &mut engine);
206
207 let mut ctx: Context = system_context();
208 ctx.insert("vars", &vars);
209
210 let rendered = engine.render(&raw, &ctx)?;
211 Ok(rendered)
212}