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