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 #[serde(default)]
23 pub update: UpdateConfig,
24
25 #[serde(skip)]
26 pub root_dir: PathBuf,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum AutoUpdateMode {
48 Off,
50 Notify,
52 #[default]
54 Install,
55}
56
57#[derive(Debug, Clone, Default, Deserialize)]
59pub struct UpdateConfig {
60 #[serde(default)]
62 pub auto_update: AutoUpdateMode,
63 #[serde(default)]
67 pub update_check_interval: Option<String>,
68}
69
70#[derive(Debug, Clone, Deserialize)]
71pub struct ServerConfig {
72 pub bind: String,
73 #[serde(default = "default_cors")]
74 pub cors_allow_origins: Vec<String>,
75}
76
77fn default_cors() -> Vec<String> {
78 vec!["*".into()]
79}
80
81#[derive(Debug, Clone, Default, Deserialize)]
82pub struct AvatarsConfig {
83 #[serde(default = "default_avatars_dir")]
84 pub dir: String,
85 #[serde(default)]
86 pub default: String,
87}
88
89fn default_avatars_dir() -> String {
90 "./avatars".into()
91}
92
93#[derive(Debug, Clone, Default, Deserialize)]
94pub struct VoiceConfig {
95 #[serde(default = "default_browser")]
96 pub stt: String,
97 #[serde(default = "default_browser")]
98 pub tts: String,
99 pub kokoro: Option<KokoroConfig>,
101 pub voicevox: Option<VoicevoxConfig>,
104}
105
106#[derive(Debug, Clone, Deserialize)]
107pub struct KokoroConfig {
108 pub model_path: String,
109 pub voices_dir: String,
110 #[serde(default = "default_kokoro_voice")]
111 pub default_voice: String,
112 #[serde(default = "default_kokoro_speed")]
113 pub speed: f32,
114}
115
116fn default_browser() -> String {
117 "browser".into()
118}
119
120fn default_kokoro_voice() -> String {
121 "af_heart".into()
122}
123
124fn default_kokoro_speed() -> f32 {
125 1.0
126}
127
128#[derive(Debug, Clone, Deserialize)]
129pub struct VoicevoxConfig {
130 #[serde(default = "default_voicevox_speaker")]
134 pub default_speaker_id: u32,
135 #[serde(default = "default_voicevox_preload")]
139 pub preload_speakers: Vec<u32>,
140}
141
142fn default_voicevox_speaker() -> u32 {
143 8
144}
145
146fn default_voicevox_preload() -> Vec<u32> {
147 vec![2, 3, 8] }
149
150#[derive(Debug, Clone, Deserialize)]
157#[serde(untagged)]
158pub enum BackendConfig {
159 Api(ApiBackendConfig),
160 Cli(CliBackendConfig),
161}
162
163#[derive(Debug, Clone, Deserialize)]
164pub struct CliBackendConfig {
165 pub cmd: String,
166 #[serde(default)]
167 pub args: Vec<String>,
168}
169
170#[derive(Debug, Clone, Deserialize)]
171pub struct ApiBackendConfig {
172 pub provider: String,
175 pub model: String,
180 pub api_key_env: String,
184 #[serde(default)]
186 pub temperature: Option<f32>,
187}
188
189#[derive(Debug, Clone, Deserialize)]
190pub struct LessonRef {
191 pub extends: String,
192}
193
194impl Config {
195 pub fn load(path: &Path) -> anyhow::Result<Self> {
196 let rendered = render_toml(path)?;
197 let mut cfg: Config = toml::from_str(&rendered)?;
198 cfg.root_dir = path
199 .parent()
200 .map(|p| p.to_path_buf())
201 .unwrap_or_else(|| PathBuf::from("."));
202 Ok(cfg)
203 }
204
205 pub fn load_lesson(&self, name: &str) -> anyhow::Result<Lesson> {
206 let lesson_ref = self
207 .lesson
208 .get(name)
209 .ok_or_else(|| anyhow::anyhow!("unknown lesson: {name}"))?;
210 let lesson_path = self.root_dir.join(&lesson_ref.extends);
211 Lesson::load(&lesson_path)
212 }
213
214 pub fn default_lesson_name(&self) -> &str {
215 self.vars
216 .get("default_lesson")
217 .and_then(|v| v.as_str())
218 .unwrap_or("elementary-low")
219 }
220
221 pub fn default_backend_name(&self) -> &str {
222 self.vars
223 .get("default_backend")
224 .and_then(|v| v.as_str())
225 .unwrap_or("claude")
226 }
227
228 pub fn avatars_dir(&self) -> PathBuf {
234 PathBuf::from(&self.avatars.dir)
235 }
236
237 pub fn update_mode(&self) -> AutoUpdateMode {
245 if auto_update_disabled_by_env() {
246 AutoUpdateMode::Off
247 } else {
248 self.update.auto_update
249 }
250 }
251}
252
253fn env_value_disables(value: Option<&str>) -> bool {
261 match value {
262 Some(v) => {
263 let v = v.trim();
264 !v.is_empty() && !v.eq_ignore_ascii_case("0") && !v.eq_ignore_ascii_case("false")
265 }
266 None => false,
267 }
268}
269
270fn auto_update_disabled_by_env() -> bool {
276 let raw = std::env::var_os("KOTONOHA_NO_AUTOUPDATE");
277 let value = raw.as_ref().map(|v| v.to_string_lossy());
278 env_value_disables(value.as_deref())
279}
280
281pub fn render_toml(path: &Path) -> anyhow::Result<String> {
285 let raw = std::fs::read_to_string(path)
286 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
287 let mut engine = Engine::new();
288 let mut vars = extract_vars(&raw)?;
289 let _ = resolve(&mut vars, &mut engine);
292
293 let mut ctx: Context = system_context();
294 ctx.insert("vars", &vars);
295
296 let rendered = engine.render(&raw, &ctx)?;
297 Ok(rendered)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
309
310 fn config_with_update(update_section: &str) -> Config {
312 let toml = format!(
313 r#"
314[server]
315bind = "127.0.0.1:7400"
316{update_section}
317"#
318 );
319 toml::from_str(&toml).expect("config should parse")
320 }
321
322 #[test]
323 fn auto_update_defaults_to_install_when_section_absent() {
324 let _guard = ENV_MUTEX.lock().unwrap();
325 let cfg = config_with_update("");
326 assert_eq!(cfg.update.auto_update, AutoUpdateMode::Install);
327 assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
328 assert_eq!(cfg.update.update_check_interval, None);
329 }
330
331 #[test]
332 fn auto_update_defaults_to_install_when_section_present_but_field_absent() {
333 let _guard = ENV_MUTEX.lock().unwrap();
334 let cfg = config_with_update("[update]\n");
335 assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
336 }
337
338 #[test]
339 fn auto_update_parses_off() {
340 let _guard = ENV_MUTEX.lock().unwrap();
341 let cfg = config_with_update("[update]\nauto_update = \"off\"\n");
342 assert_eq!(cfg.update_mode(), AutoUpdateMode::Off);
343 }
344
345 #[test]
346 fn auto_update_parses_notify() {
347 let _guard = ENV_MUTEX.lock().unwrap();
348 let cfg = config_with_update("[update]\nauto_update = \"notify\"\n");
349 assert_eq!(cfg.update_mode(), AutoUpdateMode::Notify);
350 }
351
352 #[test]
353 fn auto_update_parses_install() {
354 let _guard = ENV_MUTEX.lock().unwrap();
355 let cfg = config_with_update("[update]\nauto_update = \"install\"\n");
356 assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
357 }
358
359 #[test]
360 fn auto_update_parses_check_interval() {
361 let cfg = config_with_update("[update]\nupdate_check_interval = \"12h\"\n");
362 assert_eq!(cfg.update.update_check_interval.as_deref(), Some("12h"));
363 }
364
365 #[test]
366 fn auto_update_mode_default_is_install() {
367 assert_eq!(AutoUpdateMode::default(), AutoUpdateMode::Install);
368 }
369
370 #[test]
374 fn env_value_disables_truthy_and_falsy() {
375 assert!(!env_value_disables(None));
377
378 assert!(env_value_disables(Some("1")));
380 assert!(env_value_disables(Some("true")));
381 assert!(env_value_disables(Some(" yes "))); for falsy in ["", " ", "0", "false", "FALSE", " false "] {
385 assert!(
386 !env_value_disables(Some(falsy)),
387 "{falsy:?} should not disable auto-update"
388 );
389 }
390 }
391
392 #[test]
393 fn env_kill_switch_forces_off_in_update_mode() {
394 let _guard = ENV_MUTEX.lock().unwrap();
395 let saved = std::env::var("KOTONOHA_NO_AUTOUPDATE").ok();
396
397 let cfg = config_with_update("[update]\nauto_update = \"install\"\n");
399 unsafe { std::env::set_var("KOTONOHA_NO_AUTOUPDATE", "1") };
400 assert_eq!(cfg.update_mode(), AutoUpdateMode::Off);
401
402 match saved {
403 Some(v) => unsafe { std::env::set_var("KOTONOHA_NO_AUTOUPDATE", v) },
404 None => unsafe { std::env::remove_var("KOTONOHA_NO_AUTOUPDATE") },
405 }
406 }
407}