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
23    #[serde(skip)]
24    pub root_dir: PathBuf,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28pub struct ServerConfig {
29    pub bind: String,
30    #[serde(default = "default_cors")]
31    pub cors_allow_origins: Vec<String>,
32}
33
34fn default_cors() -> Vec<String> {
35    vec!["*".into()]
36}
37
38#[derive(Debug, Clone, Default, Deserialize)]
39pub struct AvatarsConfig {
40    #[serde(default = "default_avatars_dir")]
41    pub dir: String,
42    #[serde(default)]
43    pub default: String,
44}
45
46fn default_avatars_dir() -> String {
47    "./avatars".into()
48}
49
50#[derive(Debug, Clone, Default, Deserialize)]
51pub struct VoiceConfig {
52    #[serde(default = "default_browser")]
53    pub stt: String,
54    #[serde(default = "default_browser")]
55    pub tts: String,
56    /// Optional kokoro section — only required when `tts = "kokoro"`.
57    pub kokoro: Option<KokoroConfig>,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61pub struct KokoroConfig {
62    pub model_path: String,
63    pub voices_dir: String,
64    #[serde(default = "default_kokoro_voice")]
65    pub default_voice: String,
66    #[serde(default = "default_kokoro_speed")]
67    pub speed: f32,
68}
69
70fn default_browser() -> String {
71    "browser".into()
72}
73
74fn default_kokoro_voice() -> String {
75    "af_heart".into()
76}
77
78fn default_kokoro_speed() -> f32 {
79    1.0
80}
81
82/// Backend configuration — either a local CLI subprocess (claude /
83/// gemini / codex shipped binaries) or a direct HTTP API call.
84///
85/// Discriminated by which fields are present (untagged), so existing
86/// CLI entries like `[backend.claude] cmd = "..." args = [...]` still
87/// parse without adding a `type = "cli"` line.
88#[derive(Debug, Clone, Deserialize)]
89#[serde(untagged)]
90pub enum BackendConfig {
91    Api(ApiBackendConfig),
92    Cli(CliBackendConfig),
93}
94
95#[derive(Debug, Clone, Deserialize)]
96pub struct CliBackendConfig {
97    pub cmd: String,
98    #[serde(default)]
99    pub args: Vec<String>,
100}
101
102#[derive(Debug, Clone, Deserialize)]
103pub struct ApiBackendConfig {
104    /// Provider key — currently `"google"` (Gemini).  Future:
105    /// `"anthropic"` / `"openai"`.
106    pub provider: String,
107    /// Model identifier passed to the provider.  Examples:
108    ///   - google:    `"gemini-2.5-flash"`, `"gemini-2.5-pro"`
109    ///   - anthropic: `"claude-sonnet-4-6"`
110    ///   - openai:    `"gpt-4o-mini"`
111    pub model: String,
112    /// Name of the env var holding the API key (e.g. `"GEMINI_API_KEY"`).
113    /// Resolving at request time means a missing key only breaks API
114    /// backends — CLI backends keep working zero-config.
115    pub api_key_env: String,
116    /// Optional per-call temperature (provider default if omitted).
117    #[serde(default)]
118    pub temperature: Option<f32>,
119}
120
121#[derive(Debug, Clone, Deserialize)]
122pub struct LessonRef {
123    pub extends: String,
124}
125
126impl Config {
127    pub fn load(path: &Path) -> anyhow::Result<Self> {
128        let rendered = render_toml(path)?;
129        let mut cfg: Config = toml::from_str(&rendered)?;
130        cfg.root_dir = path
131            .parent()
132            .map(|p| p.to_path_buf())
133            .unwrap_or_else(|| PathBuf::from("."));
134        Ok(cfg)
135    }
136
137    pub fn load_lesson(&self, name: &str) -> anyhow::Result<Lesson> {
138        let lesson_ref = self
139            .lesson
140            .get(name)
141            .ok_or_else(|| anyhow::anyhow!("unknown lesson: {name}"))?;
142        let lesson_path = self.root_dir.join(&lesson_ref.extends);
143        Lesson::load(&lesson_path)
144    }
145
146    pub fn default_lesson_name(&self) -> &str {
147        self.vars
148            .get("default_lesson")
149            .and_then(|v| v.as_str())
150            .unwrap_or("elementary-low")
151    }
152
153    pub fn default_backend_name(&self) -> &str {
154        self.vars
155            .get("default_backend")
156            .and_then(|v| v.as_str())
157            .unwrap_or("claude")
158    }
159
160    /// The avatars directory.  Resolved relative to the current
161    /// working directory (where the server was launched), or used
162    /// as-is if absolute.  Intentionally NOT relative to the config
163    /// file — users expect `./avatars` in the TOML to mean
164    /// `<project_root>/avatars` when running from the project root.
165    pub fn avatars_dir(&self) -> PathBuf {
166        PathBuf::from(&self.avatars.dir)
167    }
168}
169
170/// Read a TOML file, run teravars's `[vars]` self-resolve, then
171/// render Tera placeholders against the resolved vars + system
172/// context. The result is plain TOML ready for `toml::from_str`.
173pub fn render_toml(path: &Path) -> anyhow::Result<String> {
174    let raw = std::fs::read_to_string(path)
175        .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
176    let mut engine = Engine::new();
177    let mut vars = extract_vars(&raw)?;
178    // Best-effort: if cross-refs don't converge, keep going with
179    // partially-resolved values rather than failing the whole load.
180    let _ = resolve(&mut vars, &mut engine);
181
182    let mut ctx: Context = system_context();
183    ctx.insert("vars", &vars);
184
185    let rendered = engine.render(&raw, &ctx)?;
186    Ok(rendered)
187}