1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5use crate::permissions::PermissionConfig;
6use sparrow_core::event::AutonomyLevel;
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 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 #[serde(default)]
67 pub verify_command: Option<String>,
68 #[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 #[serde(default = "default_true")]
108 pub auto_discover: bool,
109 #[serde(default)]
114 pub preferred_provider: Option<String>,
115 #[serde(default)]
119 pub preferred_model: Option<String>,
120 #[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 #[serde(default)]
170 pub max_wall_secs: Option<u64>,
171 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ExperienceConfig {
210 #[serde(default = "default_experience_mode")]
214 pub mode: String,
215 #[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 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 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
273fn 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 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 #[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#[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
418pub trait ConfigStore: Send + Sync {
422 fn load(&self) -> anyhow::Result<Config>;
423 fn save(&self, c: &Config) -> anyhow::Result<()>;
424}
425
426pub 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 fn apply_env_overrides(cfg: &mut Config) {
442 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 if let Ok(v) = std::env::var("SPARROW_DEFAULTS_SANDBOX") {
450 cfg.defaults.sandbox = v;
451 }
452 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 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 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 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 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
536pub fn effective_provider_configs(config: &Config) -> HashMap<String, ProviderConfig> {
539 use crate::auth::AuthStore; 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}