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