Skip to main content

kotonoha_core/
config.rs

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/// How `kotonoha` keeps itself up to date in the background.
30///
31/// On every `kotonoha serve` launch the binary can quietly check GitHub
32/// for a newer release and — depending on this mode — install it in the
33/// background (the running process keeps the old binary; the new version
34/// applies on the next launch). Mirrors the auto-update modes used across
35/// the yukimemi/* CLIs.
36///
37/// - `off` — do nothing.
38/// - `notify` — only print a banner when a newer release exists; never
39///   install.
40/// - `install` (default) — silently download + swap the binary in the
41///   background, then print a one-line "restart to apply" notice.
42///
43/// The `KOTONOHA_NO_AUTOUPDATE` env var (non-empty and not `"0"`/`"false"`)
44/// overrides this to `off`, regardless of config.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum AutoUpdateMode {
48    /// No background update activity at all.
49    Off,
50    /// Print a banner when a newer release exists, but never install.
51    Notify,
52    /// Silently install a newer release in the background (default).
53    #[default]
54    Install,
55}
56
57/// `[update]` section of `kotonoha.toml`.
58#[derive(Debug, Clone, Default, Deserialize)]
59pub struct UpdateConfig {
60    /// Background auto-update behaviour. Defaults to [`AutoUpdateMode::Install`].
61    #[serde(default)]
62    pub auto_update: AutoUpdateMode,
63    /// Minimum interval between consecutive background update checks
64    /// (humantime format: `"24h"`, `"6h"`, `"1d"`). Unset → `"24h"`;
65    /// invalid values fall back to the default with a warning.
66    #[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    /// Optional kokoro section — only required when `tts = "kokoro"`.
100    pub kokoro: Option<KokoroConfig>,
101    /// Optional voicevox section — used when a request asks for
102    /// Japanese synthesis (lang=ja or auto-detected).
103    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    /// Numeric VOICEVOX speaker id (e.g. 8 = 春日部つむぎ ノーマル).
131    /// See `kotonoha-tts::voicevox` docs for the curated subset and
132    /// each character's license requirements.
133    #[serde(default = "default_voicevox_speaker")]
134    pub default_speaker_id: u32,
135    /// Speaker ids to pre-load on engine init. On-demand loading
136    /// still works for everything else; this just hides the
137    /// first-call latency for the speakers you expect to use.
138    #[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] // 四国めたん, ずんだもん, 春日部つむぎ
148}
149
150/// Backend configuration — either a local CLI subprocess (claude /
151/// gemini / codex shipped binaries) or a direct HTTP API call.
152///
153/// Discriminated by which fields are present (untagged), so existing
154/// CLI entries like `[backend.claude] cmd = "..." args = [...]` still
155/// parse without adding a `type = "cli"` line.
156#[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    /// Provider key — `"google"` (Gemini), or any OpenAI-compatible
173    /// Chat Completions provider: `"openrouter"`, `"openai"`,
174    /// `"deepseek"`.  Future: `"anthropic"`.
175    pub provider: String,
176    /// Model identifier passed to the provider.  Examples:
177    ///   - google:     `"gemini-2.5-flash"`, `"gemini-2.5-pro"`
178    ///   - openrouter: `"deepseek/deepseek-v4-flash"` (any OpenRouter slug)
179    ///   - openai:     `"gpt-4o-mini"`
180    ///   - deepseek:   `"deepseek-chat"`
181    pub model: String,
182    /// Name of the env var holding the API key (e.g. `"GEMINI_API_KEY"`).
183    /// Resolving at request time means a missing key only breaks API
184    /// backends — CLI backends keep working zero-config.
185    pub api_key_env: String,
186    /// Optional per-call temperature (provider default if omitted).
187    #[serde(default)]
188    pub temperature: Option<f32>,
189    /// Optional API base URL override.  Mainly for OpenAI-compatible
190    /// servers the provider key doesn't already know about (Ollama,
191    /// LM Studio, a corporate proxy, ...).  Known providers fill in
192    /// their default, so this is rarely needed.
193    #[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    /// The avatars directory.  Resolved relative to the current
237    /// working directory (where the server was launched), or used
238    /// as-is if absolute.  Intentionally NOT relative to the config
239    /// file — users expect `./avatars` in the TOML to mean
240    /// `<project_root>/avatars` when running from the project root.
241    pub fn avatars_dir(&self) -> PathBuf {
242        PathBuf::from(&self.avatars.dir)
243    }
244
245    /// Resolve the effective background auto-update mode.
246    ///
247    /// The `KOTONOHA_NO_AUTOUPDATE` env kill-switch takes precedence over
248    /// config and forces [`AutoUpdateMode::Off`]; otherwise the configured
249    /// `[update] auto_update` value is used. Folding the kill-switch in
250    /// here keeps the resolution in one place so call sites can simply use
251    /// `config.update_mode()`.
252    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
261/// Pure truthiness of the kill-switch value: disabled when the value is
262/// present, non-empty (after trim), and not `"0"` / `"false"`
263/// (case-insensitive).
264///
265/// Split out from [`auto_update_disabled_by_env`] so the decision logic
266/// can be unit-tested **by value**, without mutating the global process
267/// environment (which would race under the default parallel test runner).
268fn 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
278/// True when `KOTONOHA_NO_AUTOUPDATE` is set to a "truthy" value
279/// (non-empty and not `"0"` / `"false"`). Takes precedence over config.
280///
281/// Uses [`std::env::var_os`] so a non-Unicode value still counts as set
282/// rather than being silently treated as absent.
283fn 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
289/// Read a TOML file, run teravars's `[vars]` self-resolve, then
290/// render Tera placeholders against the resolved vars + system
291/// context. The result is plain TOML ready for `toml::from_str`.
292pub 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    // Best-effort: if cross-refs don't converge, keep going with
298    // partially-resolved values rather than failing the whole load.
299    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    /// Serializes access to `KOTONOHA_NO_AUTOUPDATE` across tests. Cargo
313    /// runs tests in parallel by default; since `update_mode()` reads this
314    /// env var, every test that touches it (setting it, or asserting the
315    /// resolved mode) must hold this lock so the reads/writes don't race.
316    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
317
318    /// Minimal valid config with an optional trailing `[update]` block.
319    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    /// Pure by-value test of the kill-switch decision. Deliberately does
379    /// NOT touch the process environment, so it is safe to run in parallel
380    /// with every other test (no `ENV_MUTEX`, no data race).
381    #[test]
382    fn env_value_disables_truthy_and_falsy() {
383        // Absent / unset → not disabled.
384        assert!(!env_value_disables(None));
385
386        // Truthy.
387        assert!(env_value_disables(Some("1")));
388        assert!(env_value_disables(Some("true")));
389        assert!(env_value_disables(Some("  yes  "))); // arbitrary non-empty, trimmed
390
391        // Falsy.
392        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        // Config wants `install`, but the env kill-switch wins.
406        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}