Skip to main content

sparrow/config/
mod.rs

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/// The full configuration tree (§11).
12#[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    /// Optional verification command run after mutating batches (e.g. "cargo build").
61    /// On non-zero exit, the failure is re-injected so the agent fixes it.
62    #[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    /// When true, automatically scan /v1/models on every provider as soon as an
96    /// API key is stored, and cache the results for 24h. Defaults to true.
97    #[serde(default = "default_true")]
98    pub auto_discover: bool,
99    /// Pin ALL routing tiers to a single provider. When set, this overrides
100    /// every entry in `policy` (but still respects capability hard constraints
101    /// like vision/tools). Set via `sparrow route set <provider>` or directly
102    /// in config.yaml under `routing.preferred_provider`.
103    #[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    /// Optional IMAP server for inbound polling.
194    #[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
264// ─── ConfigStore trait ──────────────────────────────────────────────────────────
265
266/// Loads/merges config from defaults → config.toml → env (SPARROW_*) → CLI flags.
267pub trait ConfigStore: Send + Sync {
268    fn load(&self) -> anyhow::Result<Config>;
269    fn save(&self, c: &Config) -> anyhow::Result<()>;
270}
271
272/// Filesystem-backed config store.
273pub 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    /// Merge environment variables (SPARROW_*) into config.
287    fn apply_env_overrides(cfg: &mut Config) {
288        // SPARROW_DEFAULTS_AUTONOMY
289        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        // SPARROW_DEFAULTS_SANDBOX
295        if let Ok(v) = std::env::var("SPARROW_DEFAULTS_SANDBOX") {
296            cfg.defaults.sandbox = v;
297        }
298        // SPARROW_BUDGET_DAILY
299        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        // SPARROW_BUDGET_SESSION
305        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        // SPARROW_THEME
311        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            // Default config when no file exists
327            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            // Auto-detect local ollama if available
342            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}