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 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#[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 pub provider: String,
107 pub model: String,
112 pub api_key_env: String,
116 #[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 pub fn avatars_dir(&self) -> PathBuf {
166 PathBuf::from(&self.avatars.dir)
167 }
168}
169
170pub 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 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}