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 experience: ExperienceConfig,
26    #[serde(default)]
27    pub skills: SkillsConfig,
28    #[serde(default)]
29    pub permissions: PermissionConfig,
30    #[serde(default)]
31    pub hooks: Vec<crate::hooks::Hook>,
32    #[serde(default)]
33    pub theme: String,
34    #[serde(default = "default_config_dir")]
35    pub config_dir: PathBuf,
36    #[serde(default = "default_state_dir")]
37    pub state_dir: PathBuf,
38    #[serde(skip)]
39    pub forced_model: Option<(String, String)>,
40}
41
42fn default_config_dir() -> PathBuf {
43    dirs::config_dir()
44        .unwrap_or_else(|| PathBuf::from("."))
45        .join("sparrow")
46}
47
48fn default_state_dir() -> PathBuf {
49    dirs::state_dir()
50        .unwrap_or_else(|| PathBuf::from("."))
51        .join("sparrow")
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Defaults {
56    #[serde(default = "default_autonomy")]
57    pub autonomy: AutonomyLevel,
58    #[serde(default = "default_sandbox")]
59    pub sandbox: String,
60    #[serde(default = "default_theme")]
61    pub theme: String,
62    /// Optional verification command run after mutating batches (e.g. "cargo build").
63    /// On non-zero exit, the failure is re-injected so the agent fixes it.
64    #[serde(default)]
65    pub verify_command: Option<String>,
66    /// Whether to create Git-backed checkpoints before mutating tools. Default
67    /// true. Disabled by the `--no-checkpoint` CLI flag (which was parsed but
68    /// never enforced).
69    #[serde(default = "default_true")]
70    pub checkpointing: bool,
71}
72
73impl Default for Defaults {
74    fn default() -> Self {
75        Self {
76            autonomy: default_autonomy(),
77            sandbox: default_sandbox(),
78            theme: default_theme(),
79            verify_command: None,
80            checkpointing: true,
81        }
82    }
83}
84
85fn default_autonomy() -> AutonomyLevel {
86    AutonomyLevel::Trusted
87}
88fn default_sandbox() -> String {
89    "local-hardened".into()
90}
91fn default_theme() -> String {
92    "captain".into()
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Routing {
97    #[serde(default = "default_true")]
98    pub free_first: bool,
99    #[serde(default = "default_policy")]
100    pub policy: HashMap<String, String>,
101    #[serde(default = "default_on_budget")]
102    pub on_budget: String,
103    /// When true, automatically scan /v1/models on every provider as soon as an
104    /// API key is stored, and cache the results for 24h. Defaults to true.
105    #[serde(default = "default_true")]
106    pub auto_discover: bool,
107    /// Pin ALL routing tiers to a single provider. When set, this overrides
108    /// every entry in `policy` (but still respects capability hard constraints
109    /// like vision/tools). Set via `sparrow route set <provider>` or directly
110    /// in config.yaml under `routing.preferred_provider`.
111    #[serde(default)]
112    pub preferred_provider: Option<String>,
113    /// Pin ALL routing tiers to a single MODEL. When set with routing_mode=manual,
114    /// Sparrow uses exactly this model (e.g. \"deepseek-v4-pro\") and never falls
115    /// back. Set via `sparrow route model <model>`.
116    #[serde(default)]
117    pub preferred_model: Option<String>,
118    /// Routing mode: \"auto\" (tier-based policy + free_first) or \"manual\"
119    /// (always use preferred_provider or the model the user picked, never
120    /// auto-fallback). Set via `sparrow route manual`.
121    #[serde(default = "default_routing_mode")]
122    pub routing_mode: String,
123}
124
125impl Default for Routing {
126    fn default() -> Self {
127        Self {
128            free_first: default_true(),
129            policy: default_policy(),
130            on_budget: default_on_budget(),
131            auto_discover: true,
132            preferred_provider: None,
133            preferred_model: None,
134            routing_mode: default_routing_mode(),
135        }
136    }
137}
138
139fn default_routing_mode() -> String {
140    "auto".into()
141}
142
143fn default_true() -> bool {
144    true
145}
146fn default_policy() -> HashMap<String, String> {
147    HashMap::from([
148        ("trivial".into(), "local".into()),
149        ("small".into(), "groq".into()),
150        ("medium".into(), "nvidia".into()),
151        ("hard".into(), "anthropic".into()),
152        ("vision".into(), "anthropic".into()),
153    ])
154}
155fn default_on_budget() -> String {
156    "downgrade".into()
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct Budget {
161    #[serde(default = "default_five")]
162    pub daily_usd: f64,
163    #[serde(default = "default_one")]
164    pub session_usd: f64,
165    /// Hard wall-clock cap for a single run, in seconds. `None` = no time cap.
166    /// Set by the `--max-wall-secs` CLI flag (was parsed but never enforced).
167    #[serde(default)]
168    pub max_wall_secs: Option<u64>,
169    /// Hard cap on total tokens (input + output) for a single run. `None` =
170    /// no token cap. Set by `--max-tokens`.
171    #[serde(default)]
172    pub max_tokens: Option<u64>,
173}
174
175impl Default for Budget {
176    fn default() -> Self {
177        Self {
178            daily_usd: default_five(),
179            session_usd: default_one(),
180            max_wall_secs: None,
181            max_tokens: None,
182        }
183    }
184}
185
186fn default_five() -> f64 {
187    5.0
188}
189fn default_one() -> f64 {
190    1.0
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct ProviderConfig {
195    pub adapter: String,
196    #[serde(default)]
197    pub base_url: Option<String>,
198    #[serde(default)]
199    pub models: Vec<String>,
200    #[serde(default)]
201    pub api_key_env: Option<String>,
202}
203
204/// v0.9 Pilier 2 — how Sparrow talks to the user. Stored separately from the
205/// messaging `surfaces` (telegram/discord/…) which are a different concept.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ExperienceConfig {
208    /// "simple" (plain language, no jargon) · "pro" (full technical output) ·
209    /// "auto" (start simple, the user can switch). Default: auto.
210    #[serde(default = "default_experience_mode")]
211    pub mode: String,
212    /// "fr" · "en" · "auto" (follow the system locale, fall back to French).
213    #[serde(default = "default_experience_language")]
214    pub language: String,
215}
216
217fn default_experience_mode() -> String {
218    "auto".into()
219}
220
221fn default_experience_language() -> String {
222    "auto".into()
223}
224
225impl Default for ExperienceConfig {
226    fn default() -> Self {
227        Self {
228            mode: default_experience_mode(),
229            language: default_experience_language(),
230        }
231    }
232}
233
234impl ExperienceConfig {
235    /// True when output should use the plain-language (simple) layer. `auto`
236    /// resolves to simple — v0.9's default is human-first; pro users opt in
237    /// with `mode = "pro"` (or `sparrow mode pro`).
238    pub fn is_simple(&self) -> bool {
239        !self.mode.eq_ignore_ascii_case("pro")
240    }
241
242    /// Resolved display language for the human layer.
243    pub fn lang(&self) -> crate::humanize::Lang {
244        let code = self.language.trim().to_lowercase();
245        if code == "auto" || code.is_empty() {
246            crate::humanize::Lang::from_code(&detect_locale())
247        } else {
248            crate::humanize::Lang::from_code(&code)
249        }
250    }
251}
252
253/// Best-effort system locale, used only when language = "auto".
254fn detect_locale() -> String {
255    for key in ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] {
256        if let Ok(val) = std::env::var(key) {
257            if !val.trim().is_empty() {
258                return val;
259            }
260        }
261    }
262    // Windows: no standard env locale; default to French (Sparrow's primary).
263    String::new()
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267pub struct SurfaceConfig {
268    #[serde(default)]
269    pub telegram: Option<MessagingSurface>,
270    #[serde(default)]
271    pub discord: Option<MessagingSurface>,
272    #[serde(default)]
273    pub slack: Option<MessagingSurface>,
274    #[serde(default)]
275    pub email: Option<EmailSurface>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct EmailSurface {
280    pub enabled: bool,
281    pub from: String,
282    pub smtp_host: String,
283    #[serde(default = "default_smtp_port")]
284    pub smtp_port: u16,
285    pub username_env: String,
286    pub password_env: String,
287    #[serde(default)]
288    pub allowed_to: Vec<String>,
289    /// Optional IMAP server for inbound polling.
290    #[serde(default)]
291    pub imap_host: Option<String>,
292    #[serde(default = "default_imap_port")]
293    pub imap_port: u16,
294}
295
296fn default_smtp_port() -> u16 {
297    587
298}
299
300fn default_imap_port() -> u16 {
301    993
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct MessagingSurface {
306    pub enabled: bool,
307    #[serde(default)]
308    pub allow_users: Vec<String>,
309    #[serde(default)]
310    pub token_env: Option<String>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct SkillsConfig {
315    #[serde(default = "default_skills_dir")]
316    pub dir: PathBuf,
317    #[serde(default = "default_curator_cron")]
318    pub curator_cron: String,
319}
320
321impl Default for SkillsConfig {
322    fn default() -> Self {
323        Self {
324            dir: default_skills_dir(),
325            curator_cron: default_curator_cron(),
326        }
327    }
328}
329
330fn default_skills_dir() -> PathBuf {
331    dirs::config_dir()
332        .unwrap_or_else(|| PathBuf::from("."))
333        .join("sparrow")
334        .join("skills")
335}
336
337fn default_curator_cron() -> String {
338    "0 */6 * * *".into()
339}
340
341impl Default for Config {
342    fn default() -> Self {
343        Self {
344            defaults: Defaults::default(),
345            routing: Routing::default(),
346            budget: Budget::default(),
347            providers: std::collections::HashMap::new(),
348            surfaces: SurfaceConfig::default(),
349            experience: ExperienceConfig::default(),
350            skills: SkillsConfig::default(),
351            permissions: PermissionConfig::default(),
352            hooks: Vec::new(),
353            theme: "captain".into(),
354            config_dir: default_config_dir(),
355            state_dir: default_state_dir(),
356            forced_model: None,
357        }
358    }
359}
360
361// ─── ConfigStore trait ──────────────────────────────────────────────────────────
362
363/// Loads/merges config from defaults → config.toml → env (SPARROW_*) → CLI flags.
364pub trait ConfigStore: Send + Sync {
365    fn load(&self) -> anyhow::Result<Config>;
366    fn save(&self, c: &Config) -> anyhow::Result<()>;
367}
368
369/// Filesystem-backed config store.
370pub struct FsConfigStore {
371    config_dir: PathBuf,
372}
373
374impl FsConfigStore {
375    pub fn new(config_dir: PathBuf) -> Self {
376        Self { config_dir }
377    }
378
379    fn config_path(&self) -> PathBuf {
380        self.config_dir.join("config.toml")
381    }
382
383    /// Merge environment variables (SPARROW_*) into config.
384    fn apply_env_overrides(cfg: &mut Config) {
385        // SPARROW_DEFAULTS_AUTONOMY
386        if let Ok(v) = std::env::var("SPARROW_DEFAULTS_AUTONOMY") {
387            if let Ok(level) = serde_json::from_str::<AutonomyLevel>(&format!("\"{}\"", v)) {
388                cfg.defaults.autonomy = level;
389            }
390        }
391        // SPARROW_DEFAULTS_SANDBOX
392        if let Ok(v) = std::env::var("SPARROW_DEFAULTS_SANDBOX") {
393            cfg.defaults.sandbox = v;
394        }
395        // SPARROW_BUDGET_DAILY
396        if let Ok(v) = std::env::var("SPARROW_BUDGET_DAILY") {
397            if let Ok(amt) = v.parse::<f64>() {
398                cfg.budget.daily_usd = amt;
399            }
400        }
401        // SPARROW_BUDGET_SESSION
402        if let Ok(v) = std::env::var("SPARROW_BUDGET_SESSION") {
403            if let Ok(amt) = v.parse::<f64>() {
404                cfg.budget.session_usd = amt;
405            }
406        }
407        // SPARROW_THEME
408        if let Ok(v) = std::env::var("SPARROW_THEME") {
409            if !v.trim().is_empty() {
410                cfg.theme = v;
411            }
412        }
413    }
414}
415
416impl ConfigStore for FsConfigStore {
417    fn load(&self) -> anyhow::Result<Config> {
418        let path = self.config_path();
419        let mut cfg = if path.exists() {
420            let content = std::fs::read_to_string(&path)?;
421            toml::from_str::<Config>(&content)?
422        } else {
423            // Default config when no file exists
424            let mut c = Config {
425                defaults: Defaults::default(),
426                routing: Routing::default(),
427                budget: Budget::default(),
428                providers: HashMap::new(),
429                surfaces: SurfaceConfig::default(),
430                experience: ExperienceConfig::default(),
431                skills: SkillsConfig::default(),
432                permissions: PermissionConfig::default(),
433                hooks: Vec::new(),
434                theme: "captain".into(),
435                config_dir: self.config_dir.clone(),
436                state_dir: default_state_dir(),
437                forced_model: None,
438            };
439            // Auto-detect local ollama if available
440            if let Ok(v) = std::env::var("OLLAMA_HOST") {
441                c.providers.insert(
442                    "ollama".into(),
443                    ProviderConfig {
444                        adapter: "ollama".into(),
445                        base_url: Some(v),
446                        models: vec![],
447                        api_key_env: None,
448                    },
449                );
450            }
451            c
452        };
453        Self::apply_env_overrides(&mut cfg);
454        if cfg.theme.trim().is_empty() {
455            cfg.theme = default_theme();
456        }
457        Ok(cfg)
458    }
459
460    fn save(&self, c: &Config) -> anyhow::Result<()> {
461        let path = self.config_path();
462        if let Some(parent) = path.parent() {
463            std::fs::create_dir_all(parent)?;
464        }
465        let content = toml::to_string_pretty(c)?;
466        std::fs::write(&path, human_config_header().to_string() + &content)?;
467        Ok(())
468    }
469}
470
471pub fn human_config_header() -> &'static str {
472    "# Configuration Sparrow\n\
473     # Mode simple par défaut : tu peux changer ce fichier à la main, puis relancer Sparrow.\n\
474     # Les clés API restent dans tes variables d'environnement ou le coffre Sparrow, pas ici.\n\
475     # Pour revenir au mode expert : `sparrow mode pro` ou `sparrow launch --pro`.\n\n"
476}
477
478/// Merge configured providers with auto-detected ones (env vars, stored credentials).
479/// Used by setup and routing to show what's actually available.
480pub fn effective_provider_configs(config: &Config) -> HashMap<String, ProviderConfig> {
481    use crate::auth::AuthStore; // bring the .get(...) trait method into scope
482    let mut effective = config.providers.clone();
483    let auth = crate::auth::store::ChainedAuthStore::new(config.config_dir.clone());
484
485    for (name, pconfig) in effective.iter_mut() {
486        if pconfig.models.is_empty() {
487            pconfig.models = providers::default_models(name);
488        }
489    }
490
491    for def in providers::provider_registry() {
492        if effective.contains_key(&def.id) {
493            continue;
494        }
495
496        let has_env_credential = def
497            .api_key_env
498            .as_ref()
499            .map(|env| {
500                if def.adapter == "ollama" {
501                    true
502                } else {
503                    std::env::var(env)
504                        .map(|value| !value.trim().is_empty())
505                        .unwrap_or(false)
506                }
507            })
508            .unwrap_or(def.adapter == "ollama");
509        let has_stored_credential = auth.get(&def.id).is_some();
510
511        if !has_env_credential && !has_stored_credential {
512            continue;
513        }
514
515        let base_url = if def.adapter == "ollama" {
516            std::env::var("OLLAMA_HOST")
517                .ok()
518                .or(Some(def.base_url.clone()))
519        } else {
520            Some(def.base_url.clone())
521        };
522
523        effective.insert(
524            def.id.clone(),
525            ProviderConfig {
526                adapter: def.adapter,
527                base_url,
528                models: providers::default_models(&def.id),
529                api_key_env: def.api_key_env,
530            },
531        );
532    }
533
534    effective
535}