1use std::net::SocketAddr;
2
3use anyhow::{Context, Result};
4
5const DEFAULT_ADDR: &str = "0.0.0.0:8080";
7
8const DEFAULT_ADMIN_EMAIL: &str = "admin@kasl.local";
12
13const DEFAULT_MAX_BATCH_DAYS: usize = 31;
16
17const DEFAULT_MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
20
21#[derive(Debug, Clone)]
23pub struct Config {
24 pub addr: SocketAddr,
26 pub database_url: String,
28 pub agents: String,
31 pub max_batch_days: usize,
33 pub max_body_bytes: usize,
35 pub admin: String,
37 pub admin_email: String,
44 pub secure_cookies: bool,
51 pub demo: bool,
56}
57
58impl Config {
59 pub fn from_env() -> Result<Self> {
60 Self::from_lookup(|key| std::env::var(key).ok())
61 }
62
63 pub fn defaults_for_database(database_url: String) -> Self {
67 Self {
68 addr: DEFAULT_ADDR.parse().expect("the default address is valid"),
69 database_url,
70 agents: String::new(),
71 max_batch_days: DEFAULT_MAX_BATCH_DAYS,
72 max_body_bytes: DEFAULT_MAX_BODY_BYTES,
73 admin: String::new(),
74 admin_email: DEFAULT_ADMIN_EMAIL.to_string(),
75 secure_cookies: true,
76 demo: false,
77 }
78 }
79
80 fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
82 let addr = lookup("KASL_SERVER_ADDR").unwrap_or_else(|| DEFAULT_ADDR.to_string());
83 let addr = addr
84 .parse()
85 .with_context(|| format!("KASL_SERVER_ADDR is not a valid socket address: {addr}"))?;
86 let database_url = lookup("DATABASE_URL").context("DATABASE_URL is not set (e.g. postgres://kasl:kasl@localhost:5432/kasl)")?;
87 let agents = lookup("KASL_AGENTS").unwrap_or_default();
88 let max_batch_days = positive("KASL_MAX_BATCH_DAYS", &lookup, DEFAULT_MAX_BATCH_DAYS)?;
89 let max_body_bytes = positive("KASL_MAX_BODY_BYTES", &lookup, DEFAULT_MAX_BODY_BYTES)?;
90 let admin = lookup("KASL_ADMIN").unwrap_or_default();
91 let admin_email = lookup("KASL_ADMIN_EMAIL").unwrap_or_else(|| DEFAULT_ADMIN_EMAIL.to_string());
92 let secure_cookies = boolean("KASL_SECURE_COOKIES", &lookup, true)?;
93 let demo = boolean("KASL_DEMO", &lookup, false)?;
94 Ok(Self {
95 addr,
96 database_url,
97 agents,
98 max_batch_days,
99 max_body_bytes,
100 admin,
101 admin_email,
102 secure_cookies,
103 demo,
104 })
105 }
106}
107
108fn boolean(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: bool) -> Result<bool> {
113 let Some(raw) = lookup(key) else { return Ok(default) };
114 match raw.trim().to_ascii_lowercase().as_str() {
115 "1" | "true" | "yes" | "on" => Ok(true),
116 "0" | "false" | "no" | "off" => Ok(false),
117 other => anyhow::bail!("{key} is not a yes/no value: {other}"),
118 }
119}
120
121fn positive(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: usize) -> Result<usize> {
125 let Some(raw) = lookup(key) else { return Ok(default) };
126 let value: usize = raw.parse().with_context(|| format!("{key} is not a positive whole number: {raw}"))?;
127 if value == 0 {
128 anyhow::bail!("{key} must be greater than zero");
129 }
130 Ok(value)
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
138 move |key| pairs.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string())
139 }
140
141 #[test]
142 fn defaults_the_bind_address() {
143 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).expect("config should build with only DATABASE_URL set");
144 assert_eq!(config.addr, DEFAULT_ADDR.parse().unwrap());
145 assert_eq!(config.database_url, "postgres://localhost/kasl");
146 }
147
148 #[test]
149 fn reads_the_bind_address_override() {
150 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "127.0.0.1:9090")]))
151 .expect("config should accept a valid override");
152 assert_eq!(config.addr, "127.0.0.1:9090".parse().unwrap());
153 }
154
155 #[test]
156 fn requires_database_url() {
157 let error = Config::from_lookup(env(&[])).unwrap_err();
158 assert!(error.to_string().contains("DATABASE_URL"));
159 }
160
161 #[test]
162 fn limits_have_defaults_and_can_be_overridden() {
163 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
164 assert_eq!(config.max_batch_days, DEFAULT_MAX_BATCH_DAYS);
165 assert_eq!(config.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
166
167 let config = Config::from_lookup(env(&[
168 ("DATABASE_URL", "postgres://localhost/kasl"),
169 ("KASL_MAX_BATCH_DAYS", "7"),
170 ("KASL_MAX_BODY_BYTES", "1048576"),
171 ]))
172 .unwrap();
173 assert_eq!(config.max_batch_days, 7);
174 assert_eq!(config.max_body_bytes, 1048576);
175 }
176
177 #[test]
178 fn a_limit_of_zero_is_refused() {
179 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BATCH_DAYS", "0")])).unwrap_err();
182 assert!(error.to_string().contains("KASL_MAX_BATCH_DAYS"), "{error}");
183
184 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BODY_BYTES", "nope")])).unwrap_err();
185 assert!(error.to_string().contains("KASL_MAX_BODY_BYTES"), "{error}");
186 }
187
188 #[test]
189 fn secure_cookies_default_on_and_refuse_a_typo() {
190 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
191 assert!(config.secure_cookies, "TLS is the assumption; opting out has to be deliberate");
192
193 for value in ["0", "false", "no", "off", "OFF"] {
194 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SECURE_COOKIES", value)])).unwrap();
195 assert!(!config.secure_cookies, "`{value}` should turn it off");
196 }
197
198 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SECURE_COOKIES", "nope")])).unwrap_err();
202 assert!(error.to_string().contains("KASL_SECURE_COOKIES"), "{error}");
203 }
204
205 #[test]
206 fn the_demo_is_off_unless_asked_for() {
207 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
210 assert!(!config.demo);
211
212 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_DEMO", "true")])).unwrap();
213 assert!(config.demo);
214
215 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_DEMO", "maybe")])).unwrap_err();
216 assert!(error.to_string().contains("KASL_DEMO"), "{error}");
217 }
218
219 #[test]
220 fn rejects_a_malformed_bind_address() {
221 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "not-an-address")])).unwrap_err();
222 assert!(error.to_string().contains("KASL_SERVER_ADDR"));
223 }
224}