1use crate::app::App;
28use crate::error::{Error, Result};
29use crate::human::{parse_bytes, parse_duration};
30use serde::Deserialize;
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36#[derive(Debug, Clone)]
38pub struct ConfigDoc {
39 pub root: toml::Value,
41 pub profile: String,
43 pub source_dir: Option<PathBuf>,
45}
46
47impl ConfigDoc {
48 pub fn section(&self, section: &str) -> Option<toml::map::Map<String, toml::Value>> {
50 let mut out = toml::map::Map::new();
51 if let Some(toml::Value::Table(t)) = self.root.get(section) {
52 if section != "http" {
57 out.extend(t.clone());
58 }
59 }
60 if let Some(toml::Value::Table(t)) = self
61 .root
62 .get("default")
63 .and_then(|d| d.get(section))
64 {
65 out.extend(t.clone());
66 }
67 if let Some(toml::Value::Table(t)) = self
68 .root
69 .get(&self.profile)
70 .and_then(|d| d.get(section))
71 {
72 out.extend(t.clone());
73 }
74 for alias in profile_aliases_for_merge(&self.profile) {
77 if let Some(toml::Value::Table(t)) =
78 self.root.get(alias).and_then(|d| d.get(section))
79 {
80 out.extend(t.clone());
81 }
82 }
83 if out.is_empty() {
84 None
85 } else {
86 Some(out)
87 }
88 }
89
90 pub fn http_clients(&self) -> HashMap<String, toml::map::Map<String, toml::Value>> {
92 let mut out = HashMap::new();
93 let merge_into = |dest: &mut HashMap<String, toml::map::Map<String, toml::Value>>,
94 table: &toml::map::Map<String, toml::Value>| {
95 for (name, val) in table {
96 if let toml::Value::Table(t) = val {
97 dest.entry(name.clone())
98 .and_modify(|e| e.extend(t.clone()))
99 .or_insert_with(|| t.clone());
100 }
101 }
102 };
103 if let Some(toml::Value::Table(http)) = self.root.get("http") {
104 merge_into(&mut out, http);
105 }
106 if let Some(toml::Value::Table(http)) = self.root.get("default").and_then(|d| d.get("http"))
107 {
108 merge_into(&mut out, http);
109 }
110 if let Some(toml::Value::Table(http)) =
111 self.root.get(&self.profile).and_then(|d| d.get("http"))
112 {
113 merge_into(&mut out, http);
114 }
115 for alias in profile_aliases_for_merge(&self.profile) {
116 if let Some(toml::Value::Table(http)) =
117 self.root.get(alias).and_then(|d| d.get("http"))
118 {
119 merge_into(&mut out, http);
120 }
121 }
122 out
123 }
124}
125
126fn profile_aliases_for_merge(profile: &str) -> &'static [&'static str] {
128 match profile {
129 "development" => &["debug"],
130 "production" => &["release"],
131 _ => &[],
132 }
133}
134
135#[derive(Debug, Default, Deserialize, Clone)]
136#[serde(default)]
137struct ServerProfile {
138 max_body: Option<String>,
139 max_connections: Option<usize>,
140 max_upgraded_connections: Option<usize>,
141 max_concurrent_streams: Option<usize>,
142 max_headers: Option<usize>,
143 max_buf_size: Option<String>,
144 request_timeout: Option<String>,
145 header_read_timeout: Option<String>,
146 idle_timeout: Option<String>,
147 drain_timeout: Option<String>,
148 keep_alive: Option<bool>,
149 trust_proxy: Option<bool>,
150}
151
152fn normalize_profile_name(raw: &str) -> String {
153 match raw.trim() {
154 "debug" => "development".into(),
155 "release" => "production".into(),
156 other => other.to_string(),
157 }
158}
159
160fn active_profile() -> String {
161 if let Ok(p) = std::env::var("SOVA_PROFILE") {
162 return normalize_profile_name(&p);
163 }
164 if let Ok(p) = std::env::var("SOVA_ENV") {
165 return normalize_profile_name(&p);
166 }
167 if cfg!(debug_assertions) {
168 "development".into()
169 } else {
170 "production".into()
171 }
172}
173
174fn merge_server(base: ServerProfile, over: ServerProfile) -> ServerProfile {
175 ServerProfile {
176 max_body: over.max_body.or(base.max_body),
177 max_connections: over.max_connections.or(base.max_connections),
178 max_upgraded_connections: over
179 .max_upgraded_connections
180 .or(base.max_upgraded_connections),
181 max_concurrent_streams: over
182 .max_concurrent_streams
183 .or(base.max_concurrent_streams),
184 max_headers: over.max_headers.or(base.max_headers),
185 max_buf_size: over.max_buf_size.or(base.max_buf_size),
186 request_timeout: over.request_timeout.or(base.request_timeout),
187 header_read_timeout: over.header_read_timeout.or(base.header_read_timeout),
188 idle_timeout: over.idle_timeout.or(base.idle_timeout),
189 drain_timeout: over.drain_timeout.or(base.drain_timeout),
190 keep_alive: over.keep_alive.or(base.keep_alive),
191 trust_proxy: over.trust_proxy.or(base.trust_proxy),
192 }
193}
194
195fn table_to_server(table: &toml::map::Map<String, toml::Value>) -> ServerProfile {
196 let mut filtered = toml::map::Map::new();
198 for key in [
199 "max_body",
200 "max_connections",
201 "max_upgraded_connections",
202 "max_concurrent_streams",
203 "max_headers",
204 "max_buf_size",
205 "request_timeout",
206 "header_read_timeout",
207 "idle_timeout",
208 "drain_timeout",
209 "keep_alive",
210 "trust_proxy",
211 ] {
212 if let Some(v) = table.get(key) {
213 filtered.insert(key.to_string(), v.clone());
214 }
215 }
216 toml::Value::Table(filtered)
217 .try_into()
218 .unwrap_or_default()
219}
220
221fn resolve_server(root: &toml::Value, profile: &str) -> ServerProfile {
223 let mut merged = ServerProfile::default();
224
225 if let Some(toml::Value::Table(t)) = root.get("server") {
227 merged = merge_server(merged, table_to_server(t));
228 }
229
230 if let Some(toml::Value::Table(t)) = root.get("default") {
232 if let Some(toml::Value::Table(server)) = t.get("server") {
234 merged = merge_server(merged, table_to_server(server));
235 } else {
236 merged = merge_server(merged, table_to_server(t));
237 }
238 }
239
240 let mut names = vec![profile];
242 names.extend(profile_aliases_for_merge(profile).iter().copied());
243 for name in names {
244 if let Some(toml::Value::Table(prof)) = root.get(name) {
245 if let Some(toml::Value::Table(server)) = prof.get("server") {
246 merged = merge_server(merged, table_to_server(server));
247 } else {
248 merged = merge_server(merged, table_to_server(prof));
250 }
251 }
252 }
253
254 merged
255}
256
257fn apply_server(app: &mut App, p: &ServerProfile) -> Result<()> {
258 if let Some(ref s) = p.max_body {
259 app.max_body_size(parse_bytes(s).map_err(Error::Internal)?);
260 }
261 if let Some(n) = p.max_connections {
262 app.max_connections(n);
263 }
264 if let Some(n) = p.max_upgraded_connections {
265 app.max_upgraded_connections(n);
266 }
267 if let Some(n) = p.max_concurrent_streams {
268 app.max_concurrent_streams(n);
269 }
270 if let Some(n) = p.max_headers {
271 app.max_headers(n);
272 }
273 if let Some(ref s) = p.max_buf_size {
274 app.max_buf_size(parse_bytes(s).map_err(Error::Internal)?);
275 }
276 if let Some(ref s) = p.request_timeout {
277 if s.eq_ignore_ascii_case("off") || s.eq_ignore_ascii_case("none") {
278 app.request_timeout(None);
279 } else {
280 app.request_timeout(Some(parse_duration(s).map_err(Error::Internal)?));
281 }
282 }
283 if let Some(ref s) = p.header_read_timeout {
284 app.header_read_timeout(parse_duration(s).map_err(Error::Internal)?);
285 }
286 if let Some(ref s) = p.idle_timeout {
287 app.idle_timeout(parse_duration(s).map_err(Error::Internal)?);
288 }
289 if let Some(ref s) = p.drain_timeout {
290 app.drain_timeout(parse_duration(s).map_err(Error::Internal)?);
291 }
292 if let Some(v) = p.keep_alive {
293 app.keep_alive(v);
294 }
295 if let Some(v) = p.trust_proxy {
296 app.trust_proxy(v);
297 }
298 Ok(())
299}
300
301fn parse_timeout_env(s: &str) -> Result<Option<Duration>> {
302 if s.eq_ignore_ascii_case("off") || s.eq_ignore_ascii_case("none") {
303 Ok(None)
304 } else {
305 Ok(Some(parse_duration(s).map_err(Error::Internal)?))
306 }
307}
308
309fn env_override(app: &mut App) -> Result<()> {
310 if let Ok(s) = std::env::var("SOVA_MAX_BODY") {
311 app.max_body_size(parse_bytes(&s).map_err(Error::Internal)?);
312 }
313 if let Ok(s) = std::env::var("SOVA_MAX_CONNECTIONS") {
314 let n: usize = s
315 .parse()
316 .map_err(|_| Error::Internal(format!("SOVA_MAX_CONNECTIONS: {s}")))?;
317 app.max_connections(n);
318 }
319 if let Ok(s) = std::env::var("SOVA_MAX_UPGRADED_CONNECTIONS") {
320 let n: usize = s.parse().map_err(|_| {
321 Error::Internal(format!("SOVA_MAX_UPGRADED_CONNECTIONS: {s}"))
322 })?;
323 app.max_upgraded_connections(n);
324 }
325 if let Ok(s) = std::env::var("SOVA_MAX_CONCURRENT_STREAMS") {
326 let n: usize = s.parse().map_err(|_| {
327 Error::Internal(format!("SOVA_MAX_CONCURRENT_STREAMS: {s}"))
328 })?;
329 app.max_concurrent_streams(n);
330 }
331 if let Ok(s) = std::env::var("SOVA_MAX_HEADERS") {
332 let n: usize = s
333 .parse()
334 .map_err(|_| Error::Internal(format!("SOVA_MAX_HEADERS: {s}")))?;
335 app.max_headers(n);
336 }
337 if let Ok(s) = std::env::var("SOVA_MAX_BUF_SIZE") {
338 app.max_buf_size(parse_bytes(&s).map_err(Error::Internal)?);
339 }
340 if let Ok(s) = std::env::var("SOVA_REQUEST_TIMEOUT") {
341 app.request_timeout(parse_timeout_env(&s)?);
342 }
343 if let Ok(s) = std::env::var("SOVA_HEADER_READ_TIMEOUT") {
344 app.header_read_timeout(parse_duration(&s).map_err(Error::Internal)?);
345 }
346 if let Ok(s) = std::env::var("SOVA_IDLE_TIMEOUT") {
347 app.idle_timeout(parse_duration(&s).map_err(Error::Internal)?);
348 }
349 if let Ok(s) = std::env::var("SOVA_DRAIN_TIMEOUT") {
350 app.drain_timeout(parse_duration(&s).map_err(Error::Internal)?);
351 }
352 if let Ok(s) = std::env::var("SOVA_KEEP_ALIVE") {
353 let v = matches!(s.as_str(), "1" | "true" | "TRUE" | "yes");
354 app.keep_alive(v);
355 }
356 if let Ok(s) = std::env::var("SOVA_TRUST_PROXY") {
357 let v = matches!(s.as_str(), "1" | "true" | "TRUE" | "yes");
358 app.trust_proxy(v);
359 }
360 Ok(())
361}
362
363impl App {
364 pub fn configure(&mut self) -> Result<&mut Self> {
368 for name in ["sova.toml", "Sova.toml"] {
369 let path = Path::new(name);
370 if path.is_file() {
371 return self.configure_from_path(path);
372 }
373 }
374 env_override(self)?;
375 Ok(self)
376 }
377
378 pub fn configure_from_path(&mut self, path: impl AsRef<Path>) -> Result<&mut Self> {
380 let path = path.as_ref();
381 let text = std::fs::read_to_string(path).map_err(Error::Io)?;
382 let source_dir = path
383 .parent()
384 .filter(|p| !p.as_os_str().is_empty())
385 .map(|p| p.to_path_buf());
386 self.configure_from_str_with_dir(&text, source_dir)?;
387 Ok(self)
388 }
389
390 pub fn configure_from_str(&mut self, text: &str) -> Result<&mut Self> {
392 self.configure_from_str_with_dir(text, None)
393 }
394
395 fn configure_from_str_with_dir(
396 &mut self,
397 text: &str,
398 source_dir: Option<PathBuf>,
399 ) -> Result<&mut Self> {
400 let root: toml::Value =
401 toml::from_str(text).map_err(|e| Error::Internal(format!("sova.toml: {e}")))?;
402 let profile_name = active_profile();
403 self.state(ConfigDoc {
404 root: root.clone(),
405 profile: profile_name.clone(),
406 source_dir,
407 });
408
409 let server = resolve_server(&root, &profile_name);
410 apply_server(self, &server)?;
411 env_override(self)?;
412 Ok(self)
413 }
414
415 pub fn from_toml(path: impl AsRef<Path>) -> Result<Self> {
417 let mut app = App::new();
418 app.configure_from_path(path)?;
419 Ok(app)
420 }
421
422 pub fn config_doc(&self) -> Option<Arc<ConfigDoc>> {
424 self.state.get::<ConfigDoc>()
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use std::sync::Mutex;
432 use std::time::Duration;
433
434 static ENV_LOCK: Mutex<()> = Mutex::new(());
435
436 fn with_profile(profile: &str, f: impl FnOnce()) {
437 let _guard = ENV_LOCK.lock().unwrap();
438 let prev_profile = std::env::var("SOVA_PROFILE").ok();
439 let prev_env = std::env::var("SOVA_ENV").ok();
440 std::env::set_var("SOVA_PROFILE", profile);
441 std::env::remove_var("SOVA_ENV");
442 f();
443 match prev_profile {
444 Some(v) => std::env::set_var("SOVA_PROFILE", v),
445 None => std::env::remove_var("SOVA_PROFILE"),
446 }
447 match prev_env {
448 Some(v) => std::env::set_var("SOVA_ENV", v),
449 None => std::env::remove_var("SOVA_ENV"),
450 }
451 }
452
453 #[test]
454 fn parses_canon_server_and_development_overlay() {
455 with_profile("development", || {
456 let mut app = App::new();
457 app.configure_from_str(
458 r#"
459[server]
460max_body = "2 MiB"
461request_timeout = "30s"
462max_connections = 100
463
464[development.server]
465max_connections = 10
466"#,
467 )
468 .unwrap();
469 assert_eq!(app.max_connections, 10);
470 assert_eq!(app.max_body_size, 2 * 1024 * 1024);
471 assert_eq!(app.request_timeout, Some(Duration::from_secs(30)));
472 assert_eq!(app.config_doc().unwrap().profile, "development");
473 });
474 }
475
476 #[test]
477 fn legacy_default_debug_still_works() {
478 with_profile("development", || {
479 let mut app = App::new();
480 app.configure_from_str(
481 r#"
482[default]
483max_body = "2 MiB"
484request_timeout = "30s"
485max_connections = 100
486
487[debug]
488max_connections = 10
489"#,
490 )
491 .unwrap();
492 assert_eq!(app.max_connections, 10);
493 assert_eq!(app.max_body_size, 2 * 1024 * 1024);
494 });
495 }
496
497 #[test]
498 fn section_merges_top_level_and_profile() {
499 with_profile("production", || {
500 let mut app = App::new();
501 app.configure_from_str(
502 r#"
503[mail]
504from = "base@example.com"
505
506[production.mail]
507from = "prod@example.com"
508"#,
509 )
510 .unwrap();
511 let doc = app.config_doc().unwrap();
512 assert_eq!(doc.profile, "production");
513 let mail = doc.section("mail").unwrap();
514 assert_eq!(
515 mail.get("from").and_then(|v| v.as_str()),
516 Some("prod@example.com")
517 );
518 });
519 }
520
521 #[test]
522 fn stores_http_named_clients_in_config_doc() {
523 with_profile("development", || {
524 let mut app = App::new();
525 app.configure_from_str(
526 r#"
527[server]
528request_timeout = "30s"
529
530[http.payments]
531base_url = "https://api.stripe.com"
532timeout = "10s"
533
534[development.http.payments]
535timeout = "5s"
536"#,
537 )
538 .unwrap();
539 let doc = app.config_doc().unwrap();
540 let clients = doc.http_clients();
541 let p = clients.get("payments").unwrap();
542 assert_eq!(
543 p.get("base_url").and_then(|v| v.as_str()),
544 Some("https://api.stripe.com")
545 );
546 assert_eq!(p.get("timeout").and_then(|v| v.as_str()), Some("5s"));
547 });
548 }
549
550 #[test]
551 fn debug_alias_normalizes_to_development() {
552 assert_eq!(normalize_profile_name("debug"), "development");
553 assert_eq!(normalize_profile_name("release"), "production");
554 }
555}