1use std::net::SocketAddr;
2
3use anyhow::{Context, Result};
4
5const DEFAULT_ADDR: &str = "0.0.0.0:8080";
7
8const DEFAULT_MAX_BATCH_DAYS: usize = 31;
11
12const DEFAULT_MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
15
16#[derive(Debug, Clone)]
18pub struct Config {
19 pub addr: SocketAddr,
21 pub database_url: String,
23 pub agents: String,
26 pub max_batch_days: usize,
28 pub max_body_bytes: usize,
30}
31
32impl Config {
33 pub fn from_env() -> Result<Self> {
34 Self::from_lookup(|key| std::env::var(key).ok())
35 }
36
37 pub fn defaults_for_database(database_url: String) -> Self {
41 Self {
42 addr: DEFAULT_ADDR.parse().expect("the default address is valid"),
43 database_url,
44 agents: String::new(),
45 max_batch_days: DEFAULT_MAX_BATCH_DAYS,
46 max_body_bytes: DEFAULT_MAX_BODY_BYTES,
47 }
48 }
49
50 fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
52 let addr = lookup("KASL_SERVER_ADDR").unwrap_or_else(|| DEFAULT_ADDR.to_string());
53 let addr = addr
54 .parse()
55 .with_context(|| format!("KASL_SERVER_ADDR is not a valid socket address: {addr}"))?;
56 let database_url = lookup("DATABASE_URL").context("DATABASE_URL is not set (e.g. postgres://kasl:kasl@localhost:5432/kasl)")?;
57 let agents = lookup("KASL_AGENTS").unwrap_or_default();
58 let max_batch_days = positive("KASL_MAX_BATCH_DAYS", &lookup, DEFAULT_MAX_BATCH_DAYS)?;
59 let max_body_bytes = positive("KASL_MAX_BODY_BYTES", &lookup, DEFAULT_MAX_BODY_BYTES)?;
60 Ok(Self {
61 addr,
62 database_url,
63 agents,
64 max_batch_days,
65 max_body_bytes,
66 })
67 }
68}
69
70fn positive(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: usize) -> Result<usize> {
74 let Some(raw) = lookup(key) else { return Ok(default) };
75 let value: usize = raw.parse().with_context(|| format!("{key} is not a positive whole number: {raw}"))?;
76 if value == 0 {
77 anyhow::bail!("{key} must be greater than zero");
78 }
79 Ok(value)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
87 move |key| pairs.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string())
88 }
89
90 #[test]
91 fn defaults_the_bind_address() {
92 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).expect("config should build with only DATABASE_URL set");
93 assert_eq!(config.addr, DEFAULT_ADDR.parse().unwrap());
94 assert_eq!(config.database_url, "postgres://localhost/kasl");
95 }
96
97 #[test]
98 fn reads_the_bind_address_override() {
99 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "127.0.0.1:9090")]))
100 .expect("config should accept a valid override");
101 assert_eq!(config.addr, "127.0.0.1:9090".parse().unwrap());
102 }
103
104 #[test]
105 fn requires_database_url() {
106 let error = Config::from_lookup(env(&[])).unwrap_err();
107 assert!(error.to_string().contains("DATABASE_URL"));
108 }
109
110 #[test]
111 fn limits_have_defaults_and_can_be_overridden() {
112 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
113 assert_eq!(config.max_batch_days, DEFAULT_MAX_BATCH_DAYS);
114 assert_eq!(config.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
115
116 let config = Config::from_lookup(env(&[
117 ("DATABASE_URL", "postgres://localhost/kasl"),
118 ("KASL_MAX_BATCH_DAYS", "7"),
119 ("KASL_MAX_BODY_BYTES", "1048576"),
120 ]))
121 .unwrap();
122 assert_eq!(config.max_batch_days, 7);
123 assert_eq!(config.max_body_bytes, 1048576);
124 }
125
126 #[test]
127 fn a_limit_of_zero_is_refused() {
128 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BATCH_DAYS", "0")])).unwrap_err();
131 assert!(error.to_string().contains("KASL_MAX_BATCH_DAYS"), "{error}");
132
133 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BODY_BYTES", "nope")])).unwrap_err();
134 assert!(error.to_string().contains("KASL_MAX_BODY_BYTES"), "{error}");
135 }
136
137 #[test]
138 fn rejects_a_malformed_bind_address() {
139 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "not-an-address")])).unwrap_err();
140 assert!(error.to_string().contains("KASL_SERVER_ADDR"));
141 }
142}