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(default)]
pub update: UpdateConfig,
#[serde(skip)]
pub root_dir: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AutoUpdateMode {
Off,
Notify,
#[default]
Install,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct UpdateConfig {
#[serde(default)]
pub auto_update: AutoUpdateMode,
#[serde(default)]
pub update_check_interval: Option<String>,
}
#[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>,
pub voicevox: Option<VoicevoxConfig>,
}
#[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)]
pub struct VoicevoxConfig {
#[serde(default = "default_voicevox_speaker")]
pub default_speaker_id: u32,
#[serde(default = "default_voicevox_preload")]
pub preload_speakers: Vec<u32>,
}
fn default_voicevox_speaker() -> u32 {
8
}
fn default_voicevox_preload() -> Vec<u32> {
vec![2, 3, 8] }
#[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>,
#[serde(default)]
pub base_url: Option<String>,
}
#[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 update_mode(&self) -> AutoUpdateMode {
if auto_update_disabled_by_env() {
AutoUpdateMode::Off
} else {
self.update.auto_update
}
}
}
fn env_value_disables(value: Option<&str>) -> bool {
match value {
Some(v) => {
let v = v.trim();
!v.is_empty() && !v.eq_ignore_ascii_case("0") && !v.eq_ignore_ascii_case("false")
}
None => false,
}
}
fn auto_update_disabled_by_env() -> bool {
let raw = std::env::var_os("KOTONOHA_NO_AUTOUPDATE");
let value = raw.as_ref().map(|v| v.to_string_lossy());
env_value_disables(value.as_deref())
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn config_with_update(update_section: &str) -> Config {
let toml = format!(
r#"
[server]
bind = "127.0.0.1:7400"
{update_section}
"#
);
toml::from_str(&toml).expect("config should parse")
}
#[test]
fn auto_update_defaults_to_install_when_section_absent() {
let _guard = ENV_MUTEX.lock().unwrap();
let cfg = config_with_update("");
assert_eq!(cfg.update.auto_update, AutoUpdateMode::Install);
assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
assert_eq!(cfg.update.update_check_interval, None);
}
#[test]
fn auto_update_defaults_to_install_when_section_present_but_field_absent() {
let _guard = ENV_MUTEX.lock().unwrap();
let cfg = config_with_update("[update]\n");
assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
}
#[test]
fn auto_update_parses_off() {
let _guard = ENV_MUTEX.lock().unwrap();
let cfg = config_with_update("[update]\nauto_update = \"off\"\n");
assert_eq!(cfg.update_mode(), AutoUpdateMode::Off);
}
#[test]
fn auto_update_parses_notify() {
let _guard = ENV_MUTEX.lock().unwrap();
let cfg = config_with_update("[update]\nauto_update = \"notify\"\n");
assert_eq!(cfg.update_mode(), AutoUpdateMode::Notify);
}
#[test]
fn auto_update_parses_install() {
let _guard = ENV_MUTEX.lock().unwrap();
let cfg = config_with_update("[update]\nauto_update = \"install\"\n");
assert_eq!(cfg.update_mode(), AutoUpdateMode::Install);
}
#[test]
fn auto_update_parses_check_interval() {
let cfg = config_with_update("[update]\nupdate_check_interval = \"12h\"\n");
assert_eq!(cfg.update.update_check_interval.as_deref(), Some("12h"));
}
#[test]
fn auto_update_mode_default_is_install() {
assert_eq!(AutoUpdateMode::default(), AutoUpdateMode::Install);
}
#[test]
fn env_value_disables_truthy_and_falsy() {
assert!(!env_value_disables(None));
assert!(env_value_disables(Some("1")));
assert!(env_value_disables(Some("true")));
assert!(env_value_disables(Some(" yes ")));
for falsy in ["", " ", "0", "false", "FALSE", " false "] {
assert!(
!env_value_disables(Some(falsy)),
"{falsy:?} should not disable auto-update"
);
}
}
#[test]
fn env_kill_switch_forces_off_in_update_mode() {
let _guard = ENV_MUTEX.lock().unwrap();
let saved = std::env::var("KOTONOHA_NO_AUTOUPDATE").ok();
let cfg = config_with_update("[update]\nauto_update = \"install\"\n");
unsafe { std::env::set_var("KOTONOHA_NO_AUTOUPDATE", "1") };
assert_eq!(cfg.update_mode(), AutoUpdateMode::Off);
match saved {
Some(v) => unsafe { std::env::set_var("KOTONOHA_NO_AUTOUPDATE", v) },
None => unsafe { std::env::remove_var("KOTONOHA_NO_AUTOUPDATE") },
}
}
}