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 — currently `"google"` (Gemini).  Future:
173    /// `"anthropic"` / `"openai"`.
174    pub provider: String,
175    /// Model identifier passed to the provider.  Examples:
176    ///   - google:    `"gemini-2.5-flash"`, `"gemini-2.5-pro"`
177    ///   - anthropic: `"claude-sonnet-4-6"`
178    ///   - openai:    `"gpt-4o-mini"`
179    pub model: String,
180    /// Name of the env var holding the API key (e.g. `"GEMINI_API_KEY"`).
181    /// Resolving at request time means a missing key only breaks API
182    /// backends — CLI backends keep working zero-config.
183    pub api_key_env: String,
184    /// Optional per-call temperature (provider default if omitted).
185    #[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    /// The avatars directory.  Resolved relative to the current
229    /// working directory (where the server was launched), or used
230    /// as-is if absolute.  Intentionally NOT relative to the config
231    /// file — users expect `./avatars` in the TOML to mean
232    /// `<project_root>/avatars` when running from the project root.
233    pub fn avatars_dir(&self) -> PathBuf {
234        PathBuf::from(&self.avatars.dir)
235    }
236
237    /// Resolve the effective background auto-update mode.
238    ///
239    /// The `KOTONOHA_NO_AUTOUPDATE` env kill-switch takes precedence over
240    /// config and forces [`AutoUpdateMode::Off`]; otherwise the configured
241    /// `[update] auto_update` value is used. Folding the kill-switch in
242    /// here keeps the resolution in one place so call sites can simply use
243    /// `config.update_mode()`.
244    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
253/// Pure truthiness of the kill-switch value: disabled when the value is
254/// present, non-empty (after trim), and not `"0"` / `"false"`
255/// (case-insensitive).
256///
257/// Split out from [`auto_update_disabled_by_env`] so the decision logic
258/// can be unit-tested **by value**, without mutating the global process
259/// environment (which would race under the default parallel test runner).
260fn 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
270/// True when `KOTONOHA_NO_AUTOUPDATE` is set to a "truthy" value
271/// (non-empty and not `"0"` / `"false"`). Takes precedence over config.
272///
273/// Uses [`std::env::var_os`] so a non-Unicode value still counts as set
274/// rather than being silently treated as absent.
275fn 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
281/// Read a TOML file, run teravars's `[vars]` self-resolve, then
282/// render Tera placeholders against the resolved vars + system
283/// context. The result is plain TOML ready for `toml::from_str`.
284pub 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    // Best-effort: if cross-refs don't converge, keep going with
290    // partially-resolved values rather than failing the whole load.
291    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    /// Serializes access to `KOTONOHA_NO_AUTOUPDATE` across tests. Cargo
305    /// runs tests in parallel by default; since `update_mode()` reads this
306    /// env var, every test that touches it (setting it, or asserting the
307    /// resolved mode) must hold this lock so the reads/writes don't race.
308    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
309
310    /// Minimal valid config with an optional trailing `[update]` block.
311    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    /// Pure by-value test of the kill-switch decision. Deliberately does
371    /// NOT touch the process environment, so it is safe to run in parallel
372    /// with every other test (no `ENV_MUTEX`, no data race).
373    #[test]
374    fn env_value_disables_truthy_and_falsy() {
375        // Absent / unset → not disabled.
376        assert!(!env_value_disables(None));
377
378        // Truthy.
379        assert!(env_value_disables(Some("1")));
380        assert!(env_value_disables(Some("true")));
381        assert!(env_value_disables(Some("  yes  "))); // arbitrary non-empty, trimmed
382
383        // Falsy.
384        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        // Config wants `install`, but the env kill-switch wins.
398        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}