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#[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 #[serde(default)]
65 pub verify_command: Option<String>,
66 #[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 #[serde(default = "default_true")]
106 pub auto_discover: bool,
107 #[serde(default)]
112 pub preferred_provider: Option<String>,
113 #[serde(default)]
117 pub preferred_model: Option<String>,
118 #[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 #[serde(default)]
168 pub max_wall_secs: Option<u64>,
169 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ExperienceConfig {
208 #[serde(default = "default_experience_mode")]
211 pub mode: String,
212 #[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 pub fn is_simple(&self) -> bool {
239 !self.mode.eq_ignore_ascii_case("pro")
240 }
241
242 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
253fn 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 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 #[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
361pub trait ConfigStore: Send + Sync {
365 fn load(&self) -> anyhow::Result<Config>;
366 fn save(&self, c: &Config) -> anyhow::Result<()>;
367}
368
369pub 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 fn apply_env_overrides(cfg: &mut Config) {
385 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 if let Ok(v) = std::env::var("SPARROW_DEFAULTS_SANDBOX") {
393 cfg.defaults.sandbox = v;
394 }
395 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 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 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 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 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
478pub fn effective_provider_configs(config: &Config) -> HashMap<String, ProviderConfig> {
481 use crate::auth::AuthStore; 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}