1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5use crate::event::AutonomyLevel;
6use crate::permissions::PermissionConfig;
7
8pub mod providers;
9pub mod validate;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Config {
14 #[serde(default)]
15 pub defaults: Defaults,
16 #[serde(default)]
17 pub routing: Routing,
18 #[serde(default)]
19 pub budget: Budget,
20 #[serde(default)]
21 pub providers: HashMap<String, ProviderConfig>,
22 #[serde(default)]
23 pub surfaces: SurfaceConfig,
24 #[serde(default)]
25 pub skills: SkillsConfig,
26 #[serde(default)]
27 pub permissions: PermissionConfig,
28 #[serde(default)]
29 pub hooks: Vec<crate::hooks::Hook>,
30 #[serde(default)]
31 pub theme: String,
32 #[serde(default = "default_config_dir")]
33 pub config_dir: PathBuf,
34 #[serde(default = "default_state_dir")]
35 pub state_dir: PathBuf,
36 #[serde(skip)]
37 pub forced_model: Option<(String, String)>,
38}
39
40fn default_config_dir() -> PathBuf {
41 dirs::config_dir()
42 .unwrap_or_else(|| PathBuf::from("."))
43 .join("sparrow")
44}
45
46fn default_state_dir() -> PathBuf {
47 dirs::state_dir()
48 .unwrap_or_else(|| PathBuf::from("."))
49 .join("sparrow")
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Defaults {
54 #[serde(default = "default_autonomy")]
55 pub autonomy: AutonomyLevel,
56 #[serde(default = "default_sandbox")]
57 pub sandbox: String,
58 #[serde(default = "default_theme")]
59 pub theme: String,
60 #[serde(default)]
63 pub verify_command: Option<String>,
64}
65
66impl Default for Defaults {
67 fn default() -> Self {
68 Self {
69 autonomy: default_autonomy(),
70 sandbox: default_sandbox(),
71 theme: default_theme(),
72 verify_command: None,
73 }
74 }
75}
76
77fn default_autonomy() -> AutonomyLevel {
78 AutonomyLevel::Trusted
79}
80fn default_sandbox() -> String {
81 "local-hardened".into()
82}
83fn default_theme() -> String {
84 "captain".into()
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Routing {
89 #[serde(default = "default_true")]
90 pub free_first: bool,
91 #[serde(default = "default_policy")]
92 pub policy: HashMap<String, String>,
93 #[serde(default = "default_on_budget")]
94 pub on_budget: String,
95 #[serde(default = "default_true")]
98 pub auto_discover: bool,
99 #[serde(default)]
104 pub preferred_provider: Option<String>,
105}
106
107impl Default for Routing {
108 fn default() -> Self {
109 Self {
110 free_first: default_true(),
111 policy: default_policy(),
112 on_budget: default_on_budget(),
113 auto_discover: true,
114 preferred_provider: None,
115 }
116 }
117}
118
119fn default_true() -> bool {
120 true
121}
122fn default_policy() -> HashMap<String, String> {
123 HashMap::from([
124 ("trivial".into(), "local".into()),
125 ("small".into(), "groq".into()),
126 ("medium".into(), "nvidia".into()),
127 ("hard".into(), "anthropic".into()),
128 ("vision".into(), "anthropic".into()),
129 ])
130}
131fn default_on_budget() -> String {
132 "downgrade".into()
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct Budget {
137 #[serde(default = "default_five")]
138 pub daily_usd: f64,
139 #[serde(default = "default_one")]
140 pub session_usd: f64,
141}
142
143impl Default for Budget {
144 fn default() -> Self {
145 Self {
146 daily_usd: default_five(),
147 session_usd: default_one(),
148 }
149 }
150}
151
152fn default_five() -> f64 {
153 5.0
154}
155fn default_one() -> f64 {
156 1.0
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ProviderConfig {
161 pub adapter: String,
162 #[serde(default)]
163 pub base_url: Option<String>,
164 #[serde(default)]
165 pub models: Vec<String>,
166 #[serde(default)]
167 pub api_key_env: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Default)]
171pub struct SurfaceConfig {
172 #[serde(default)]
173 pub telegram: Option<MessagingSurface>,
174 #[serde(default)]
175 pub discord: Option<MessagingSurface>,
176 #[serde(default)]
177 pub slack: Option<MessagingSurface>,
178 #[serde(default)]
179 pub email: Option<EmailSurface>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct EmailSurface {
184 pub enabled: bool,
185 pub from: String,
186 pub smtp_host: String,
187 #[serde(default = "default_smtp_port")]
188 pub smtp_port: u16,
189 pub username_env: String,
190 pub password_env: String,
191 #[serde(default)]
192 pub allowed_to: Vec<String>,
193 #[serde(default)]
195 pub imap_host: Option<String>,
196 #[serde(default = "default_imap_port")]
197 pub imap_port: u16,
198}
199
200fn default_smtp_port() -> u16 {
201 587
202}
203
204fn default_imap_port() -> u16 {
205 993
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct MessagingSurface {
210 pub enabled: bool,
211 #[serde(default)]
212 pub allow_users: Vec<String>,
213 #[serde(default)]
214 pub token_env: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct SkillsConfig {
219 #[serde(default = "default_skills_dir")]
220 pub dir: PathBuf,
221 #[serde(default = "default_curator_cron")]
222 pub curator_cron: String,
223}
224
225impl Default for SkillsConfig {
226 fn default() -> Self {
227 Self {
228 dir: default_skills_dir(),
229 curator_cron: default_curator_cron(),
230 }
231 }
232}
233
234fn default_skills_dir() -> PathBuf {
235 dirs::config_dir()
236 .unwrap_or_else(|| PathBuf::from("."))
237 .join("sparrow")
238 .join("skills")
239}
240
241fn default_curator_cron() -> String {
242 "0 */6 * * *".into()
243}
244
245impl Default for Config {
246 fn default() -> Self {
247 Self {
248 defaults: Defaults::default(),
249 routing: Routing::default(),
250 budget: Budget::default(),
251 providers: std::collections::HashMap::new(),
252 surfaces: SurfaceConfig::default(),
253 skills: SkillsConfig::default(),
254 permissions: PermissionConfig::default(),
255 hooks: Vec::new(),
256 theme: "captain".into(),
257 config_dir: default_config_dir(),
258 state_dir: default_state_dir(),
259 forced_model: None,
260 }
261 }
262}
263
264pub trait ConfigStore: Send + Sync {
268 fn load(&self) -> anyhow::Result<Config>;
269 fn save(&self, c: &Config) -> anyhow::Result<()>;
270}
271
272pub struct FsConfigStore {
274 config_dir: PathBuf,
275}
276
277impl FsConfigStore {
278 pub fn new(config_dir: PathBuf) -> Self {
279 Self { config_dir }
280 }
281
282 fn config_path(&self) -> PathBuf {
283 self.config_dir.join("config.toml")
284 }
285
286 fn apply_env_overrides(cfg: &mut Config) {
288 if let Ok(v) = std::env::var("SPARROW_DEFAULTS_AUTONOMY") {
290 if let Ok(level) = serde_json::from_str::<AutonomyLevel>(&format!("\"{}\"", v)) {
291 cfg.defaults.autonomy = level;
292 }
293 }
294 if let Ok(v) = std::env::var("SPARROW_DEFAULTS_SANDBOX") {
296 cfg.defaults.sandbox = v;
297 }
298 if let Ok(v) = std::env::var("SPARROW_BUDGET_DAILY") {
300 if let Ok(amt) = v.parse::<f64>() {
301 cfg.budget.daily_usd = amt;
302 }
303 }
304 if let Ok(v) = std::env::var("SPARROW_BUDGET_SESSION") {
306 if let Ok(amt) = v.parse::<f64>() {
307 cfg.budget.session_usd = amt;
308 }
309 }
310 if let Ok(v) = std::env::var("SPARROW_THEME") {
312 if !v.trim().is_empty() {
313 cfg.theme = v;
314 }
315 }
316 }
317}
318
319impl ConfigStore for FsConfigStore {
320 fn load(&self) -> anyhow::Result<Config> {
321 let path = self.config_path();
322 let mut cfg = if path.exists() {
323 let content = std::fs::read_to_string(&path)?;
324 toml::from_str::<Config>(&content)?
325 } else {
326 let mut c = Config {
328 defaults: Defaults::default(),
329 routing: Routing::default(),
330 budget: Budget::default(),
331 providers: HashMap::new(),
332 surfaces: SurfaceConfig::default(),
333 skills: SkillsConfig::default(),
334 permissions: PermissionConfig::default(),
335 hooks: Vec::new(),
336 theme: "captain".into(),
337 config_dir: self.config_dir.clone(),
338 state_dir: default_state_dir(),
339 forced_model: None,
340 };
341 if let Ok(v) = std::env::var("OLLAMA_HOST") {
343 c.providers.insert(
344 "ollama".into(),
345 ProviderConfig {
346 adapter: "ollama".into(),
347 base_url: Some(v),
348 models: vec![],
349 api_key_env: None,
350 },
351 );
352 }
353 c
354 };
355 Self::apply_env_overrides(&mut cfg);
356 if cfg.theme.trim().is_empty() {
357 cfg.theme = default_theme();
358 }
359 Ok(cfg)
360 }
361
362 fn save(&self, c: &Config) -> anyhow::Result<()> {
363 let path = self.config_path();
364 if let Some(parent) = path.parent() {
365 std::fs::create_dir_all(parent)?;
366 }
367 let content = toml::to_string_pretty(c)?;
368 std::fs::write(&path, content)?;
369 Ok(())
370 }
371}