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,
176 pub model: String,
182 pub api_key_env: String,
186 #[serde(default)]
188 pub temperature: Option<f32>,
189 #[serde(default)]
194 pub base_url: Option<String>,
195}
196
197#[derive(Debug, Clone, Deserialize)]
198pub struct LessonRef {
199 pub extends: String,
200}
201
202impl Config {
203 pub fn load(path: &Path) -> anyhow::Result<Self> {
204 let rendered = render_toml(path)?;
205 let mut cfg: Config = toml::from_str(&rendered)?;
206 cfg.root_dir = path
207 .parent()
208 .map(|p| p.to_path_buf())
209 .unwrap_or_else(|| PathBuf::from("."));
210 Ok(cfg)
211 }
212
213 pub fn load_lesson(&self, name: &str) -> anyhow::Result<Lesson> {
214 let lesson_ref = self
215 .lesson
216 .get(name)
217 .ok_or_else(|| anyhow::anyhow!("unknown lesson: {name}"))?;
218 let lesson_path = self.root_dir.join(&lesson_ref.extends);
219 Lesson::load(&lesson_path)
220 }
221
222 pub fn default_lesson_name(&self) -> &str {
223 self.vars
224 .get("default_lesson")
225 .and_then(|v| v.as_str())
226 .unwrap_or("elementary-low")
227 }
228
229 pub fn default_backend_name(&self) -> &str {
230 self.vars
231 .get("default_backend")
232 .and_then(|v| v.as_str())
233 .unwrap_or("claude")
234 }
235
236 pub fn avatars_dir(&self) -> PathBuf {
242 PathBuf::from(&self.avatars.dir)
243 }
244
245 pub fn update_mode(&self) -> AutoUpdateMode {
253 if auto_update_disabled_by_env() {
254 AutoUpdateMode::Off
255 } else {
256 self.update.auto_update
257 }
258 }
259}
260
261fn env_value_disables(value: Option<&str>) -> bool {
269 match value {
270 Some(v) => {
271 let v = v.trim();
272 !v.is_empty() && !v.eq_ignore_ascii_case("0") && !v.eq_ignore_ascii_case("false")
273 }
274 None => false,
275 }
276}
277
278fn auto_update_disabled_by_env() -> bool {
284 let raw = std::env::var_os("KOTONOHA_NO_AUTOUPDATE");
285 let value = raw.as_ref().map(|v| v.to_string_lossy());
286 env_value_disables(value.as_deref())
287}
288
289pub fn render_toml(path: &Path) -> anyhow::Result<String> {
293 let raw = std::fs::read_to_string(path)
294 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
295 let mut engine = Engine::new();
296 let mut vars = extract_vars(&raw)?;
297 let _ = resolve(&mut vars, &mut engine);
300
301 let mut ctx: Context = system_context();
302 ctx.insert("vars", &vars);
303
304 let rendered = engine.render(&raw, &ctx)?;
305 Ok(rendered)
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
317
318 fn config_with_update(update_section: &str) -> Config {
320 let toml = format!(
321 r#"
322[server]
323bind = "127.0.0.1:7400"
324{update_section}
325"#
326 );
327 toml::from_str(&toml).expect("config should parse")
328 }
329
330 #[test]
331 fn auto_update_defaults_to_install_when_section_absent() {
332 let _guard = ENV_MUTEX.lock().unwrap();
333 let cfg = config_with_update("");
334 assert_eq!(cfg.update.auto_update, AutoUpdateMode::Install);
335 assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
336 assert_eq!(cfg.update.update_check_interval, None);
337 }
338
339 #[test]
340 fn auto_update_defaults_to_install_when_section_present_but_field_absent() {
341 let _guard = ENV_MUTEX.lock().unwrap();
342 let cfg = config_with_update("[update]\n");
343 assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
344 }
345
346 #[test]
347 fn auto_update_parses_off() {
348 let _guard = ENV_MUTEX.lock().unwrap();
349 let cfg = config_with_update("[update]\nauto_update = \"off\"\n");
350 assert_eq!(cfg.update_mode(), AutoUpdateMode::Off);
351 }
352
353 #[test]
354 fn auto_update_parses_notify() {
355 let _guard = ENV_MUTEX.lock().unwrap();
356 let cfg = config_with_update("[update]\nauto_update = \"notify\"\n");
357 assert_eq!(cfg.update_mode(), AutoUpdateMode::Notify);
358 }
359
360 #[test]
361 fn auto_update_parses_install() {
362 let _guard = ENV_MUTEX.lock().unwrap();
363 let cfg = config_with_update("[update]\nauto_update = \"install\"\n");
364 assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
365 }
366
367 #[test]
368 fn auto_update_parses_check_interval() {
369 let cfg = config_with_update("[update]\nupdate_check_interval = \"12h\"\n");
370 assert_eq!(cfg.update.update_check_interval.as_deref(), Some("12h"));
371 }
372
373 #[test]
374 fn auto_update_mode_default_is_install() {
375 assert_eq!(AutoUpdateMode::default(), AutoUpdateMode::Install);
376 }
377
378 #[test]
382 fn env_value_disables_truthy_and_falsy() {
383 assert!(!env_value_disables(None));
385
386 assert!(env_value_disables(Some("1")));
388 assert!(env_value_disables(Some("true")));
389 assert!(env_value_disables(Some(" yes "))); for falsy in ["", " ", "0", "false", "FALSE", " false "] {
393 assert!(
394 !env_value_disables(Some(falsy)),
395 "{falsy:?} should not disable auto-update"
396 );
397 }
398 }
399
400 #[test]
401 fn env_kill_switch_forces_off_in_update_mode() {
402 let _guard = ENV_MUTEX.lock().unwrap();
403 let saved = std::env::var("KOTONOHA_NO_AUTOUPDATE").ok();
404
405 let cfg = config_with_update("[update]\nauto_update = \"install\"\n");
407 unsafe { std::env::set_var("KOTONOHA_NO_AUTOUPDATE", "1") };
408 assert_eq!(cfg.update_mode(), AutoUpdateMode::Off);
409
410 match saved {
411 Some(v) => unsafe { std::env::set_var("KOTONOHA_NO_AUTOUPDATE", v) },
412 None => unsafe { std::env::remove_var("KOTONOHA_NO_AUTOUPDATE") },
413 }
414 }
415}