Skip to main content

sparrow_config/config/
mod.rs

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