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    /// Pin ALL routing tiers to a single MODEL. When set with routing_mode=manual,
106    /// Sparrow uses exactly this model (e.g. \"deepseek-v4-pro\") and never falls
107    /// back. Set via `sparrow route model <model>`.
108    #[serde(default)]
109    pub preferred_model: Option<String>,
110    /// Routing mode: \"auto\" (tier-based policy + free_first) or \"manual\"
111    /// (always use preferred_provider or the model the user picked, never
112    /// auto-fallback). Set via `sparrow route manual`.
113    #[serde(default = "default_routing_mode")]
114    pub routing_mode: String,
115}
116
117impl Default for Routing {
118    fn default() -> Self {
119        Self {
120            free_first: default_true(),
121            policy: default_policy(),
122            on_budget: default_on_budget(),
123            auto_discover: true,
124            preferred_provider: None,
125            preferred_model: None,
126            routing_mode: default_routing_mode(),
127        }
128    }
129}
130
131fn default_routing_mode() -> String {
132    "auto".into()
133}
134
135fn default_true() -> bool {
136    true
137}
138fn default_policy() -> HashMap<String, String> {
139    HashMap::from([
140        ("trivial".into(), "local".into()),
141        ("small".into(), "groq".into()),
142        ("medium".into(), "nvidia".into()),
143        ("hard".into(), "anthropic".into()),
144        ("vision".into(), "anthropic".into()),
145    ])
146}
147fn default_on_budget() -> String {
148    "downgrade".into()
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct Budget {
153    #[serde(default = "default_five")]
154    pub daily_usd: f64,
155    #[serde(default = "default_one")]
156    pub session_usd: f64,
157}
158
159impl Default for Budget {
160    fn default() -> Self {
161        Self {
162            daily_usd: default_five(),
163            session_usd: default_one(),
164        }
165    }
166}
167
168fn default_five() -> f64 {
169    5.0
170}
171fn default_one() -> f64 {
172    1.0
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ProviderConfig {
177    pub adapter: String,
178    #[serde(default)]
179    pub base_url: Option<String>,
180    #[serde(default)]
181    pub models: Vec<String>,
182    #[serde(default)]
183    pub api_key_env: Option<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
187pub struct SurfaceConfig {
188    #[serde(default)]
189    pub telegram: Option<MessagingSurface>,
190    #[serde(default)]
191    pub discord: Option<MessagingSurface>,
192    #[serde(default)]
193    pub slack: Option<MessagingSurface>,
194    #[serde(default)]
195    pub email: Option<EmailSurface>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct EmailSurface {
200    pub enabled: bool,
201    pub from: String,
202    pub smtp_host: String,
203    #[serde(default = "default_smtp_port")]
204    pub smtp_port: u16,
205    pub username_env: String,
206    pub password_env: String,
207    #[serde(default)]
208    pub allowed_to: Vec<String>,
209    /// Optional IMAP server for inbound polling.
210    #[serde(default)]
211    pub imap_host: Option<String>,
212    #[serde(default = "default_imap_port")]
213    pub imap_port: u16,
214}
215
216fn default_smtp_port() -> u16 {
217    587
218}
219
220fn default_imap_port() -> u16 {
221    993
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct MessagingSurface {
226    pub enabled: bool,
227    #[serde(default)]
228    pub allow_users: Vec<String>,
229    #[serde(default)]
230    pub token_env: Option<String>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct SkillsConfig {
235    #[serde(default = "default_skills_dir")]
236    pub dir: PathBuf,
237    #[serde(default = "default_curator_cron")]
238    pub curator_cron: String,
239}
240
241impl Default for SkillsConfig {
242    fn default() -> Self {
243        Self {
244            dir: default_skills_dir(),
245            curator_cron: default_curator_cron(),
246        }
247    }
248}
249
250fn default_skills_dir() -> PathBuf {
251    dirs::config_dir()
252        .unwrap_or_else(|| PathBuf::from("."))
253        .join("sparrow")
254        .join("skills")
255}
256
257fn default_curator_cron() -> String {
258    "0 */6 * * *".into()
259}
260
261impl Default for Config {
262    fn default() -> Self {
263        Self {
264            defaults: Defaults::default(),
265            routing: Routing::default(),
266            budget: Budget::default(),
267            providers: std::collections::HashMap::new(),
268            surfaces: SurfaceConfig::default(),
269            skills: SkillsConfig::default(),
270            permissions: PermissionConfig::default(),
271            hooks: Vec::new(),
272            theme: "captain".into(),
273            config_dir: default_config_dir(),
274            state_dir: default_state_dir(),
275            forced_model: None,
276        }
277    }
278}
279
280// ─── ConfigStore trait ──────────────────────────────────────────────────────────
281
282/// Loads/merges config from defaults → config.toml → env (SPARROW_*) → CLI flags.
283pub trait ConfigStore: Send + Sync {
284    fn load(&self) -> anyhow::Result<Config>;
285    fn save(&self, c: &Config) -> anyhow::Result<()>;
286}
287
288/// Filesystem-backed config store.
289pub struct FsConfigStore {
290    config_dir: PathBuf,
291}
292
293impl FsConfigStore {
294    pub fn new(config_dir: PathBuf) -> Self {
295        Self { config_dir }
296    }
297
298    fn config_path(&self) -> PathBuf {
299        self.config_dir.join("config.toml")
300    }
301
302    /// Merge environment variables (SPARROW_*) into config.
303    fn apply_env_overrides(cfg: &mut Config) {
304        // SPARROW_DEFAULTS_AUTONOMY
305        if let Ok(v) = std::env::var("SPARROW_DEFAULTS_AUTONOMY") {
306            if let Ok(level) = serde_json::from_str::<AutonomyLevel>(&format!("\"{}\"", v)) {
307                cfg.defaults.autonomy = level;
308            }
309        }
310        // SPARROW_DEFAULTS_SANDBOX
311        if let Ok(v) = std::env::var("SPARROW_DEFAULTS_SANDBOX") {
312            cfg.defaults.sandbox = v;
313        }
314        // SPARROW_BUDGET_DAILY
315        if let Ok(v) = std::env::var("SPARROW_BUDGET_DAILY") {
316            if let Ok(amt) = v.parse::<f64>() {
317                cfg.budget.daily_usd = amt;
318            }
319        }
320        // SPARROW_BUDGET_SESSION
321        if let Ok(v) = std::env::var("SPARROW_BUDGET_SESSION") {
322            if let Ok(amt) = v.parse::<f64>() {
323                cfg.budget.session_usd = amt;
324            }
325        }
326        // SPARROW_THEME
327        if let Ok(v) = std::env::var("SPARROW_THEME") {
328            if !v.trim().is_empty() {
329                cfg.theme = v;
330            }
331        }
332    }
333}
334
335impl ConfigStore for FsConfigStore {
336    fn load(&self) -> anyhow::Result<Config> {
337        let path = self.config_path();
338        let mut cfg = if path.exists() {
339            let content = std::fs::read_to_string(&path)?;
340            toml::from_str::<Config>(&content)?
341        } else {
342            // Default config when no file exists
343            let mut c = Config {
344                defaults: Defaults::default(),
345                routing: Routing::default(),
346                budget: Budget::default(),
347                providers: HashMap::new(),
348                surfaces: SurfaceConfig::default(),
349                skills: SkillsConfig::default(),
350                permissions: PermissionConfig::default(),
351                hooks: Vec::new(),
352                theme: "captain".into(),
353                config_dir: self.config_dir.clone(),
354                state_dir: default_state_dir(),
355                forced_model: None,
356            };
357            // Auto-detect local ollama if available
358            if let Ok(v) = std::env::var("OLLAMA_HOST") {
359                c.providers.insert(
360                    "ollama".into(),
361                    ProviderConfig {
362                        adapter: "ollama".into(),
363                        base_url: Some(v),
364                        models: vec![],
365                        api_key_env: None,
366                    },
367                );
368            }
369            c
370        };
371        Self::apply_env_overrides(&mut cfg);
372        if cfg.theme.trim().is_empty() {
373            cfg.theme = default_theme();
374        }
375        Ok(cfg)
376    }
377
378    fn save(&self, c: &Config) -> anyhow::Result<()> {
379        let path = self.config_path();
380        if let Some(parent) = path.parent() {
381            std::fs::create_dir_all(parent)?;
382        }
383        let content = toml::to_string_pretty(c)?;
384        std::fs::write(&path, content)?;
385        Ok(())
386    }
387}
388
389/// Merge configured providers with auto-detected ones (env vars, stored credentials).
390/// Used by setup and routing to show what's actually available.
391pub fn effective_provider_configs(config: &Config) -> HashMap<String, ProviderConfig> {
392    use crate::auth::AuthStore; // bring the .get(...) trait method into scope
393    let mut effective = config.providers.clone();
394    let auth = crate::auth::store::ChainedAuthStore::new(config.config_dir.clone());
395
396    for (name, pconfig) in effective.iter_mut() {
397        if pconfig.models.is_empty() {
398            pconfig.models = providers::default_models(name);
399        }
400    }
401
402    for def in providers::provider_registry() {
403        if effective.contains_key(&def.id) {
404            continue;
405        }
406
407        let has_env_credential = def
408            .api_key_env
409            .as_ref()
410            .map(|env| {
411                if def.adapter == "ollama" {
412                    true
413                } else {
414                    std::env::var(env)
415                        .map(|value| !value.trim().is_empty())
416                        .unwrap_or(false)
417                }
418            })
419            .unwrap_or(def.adapter == "ollama");
420        let has_stored_credential = auth.get(&def.id).is_some();
421
422        if !has_env_credential && !has_stored_credential {
423            continue;
424        }
425
426        let base_url = if def.adapter == "ollama" {
427            std::env::var("OLLAMA_HOST")
428                .ok()
429                .or(Some(def.base_url.clone()))
430        } else {
431            Some(def.base_url.clone())
432        };
433
434        effective.insert(
435            def.id.clone(),
436            ProviderConfig {
437                adapter: def.adapter,
438                base_url,
439                models: providers::default_models(&def.id),
440                api_key_env: def.api_key_env,
441            },
442        );
443    }
444
445    effective
446}