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 pub admin: String,
32 pub secure_cookies: bool,
39}
40
41impl Config {
42 pub fn from_env() -> Result<Self> {
43 Self::from_lookup(|key| std::env::var(key).ok())
44 }
45
46 pub fn defaults_for_database(database_url: String) -> Self {
50 Self {
51 addr: DEFAULT_ADDR.parse().expect("the default address is valid"),
52 database_url,
53 agents: String::new(),
54 max_batch_days: DEFAULT_MAX_BATCH_DAYS,
55 max_body_bytes: DEFAULT_MAX_BODY_BYTES,
56 admin: String::new(),
57 secure_cookies: true,
58 }
59 }
60
61 fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
63 let addr = lookup("KASL_SERVER_ADDR").unwrap_or_else(|| DEFAULT_ADDR.to_string());
64 let addr = addr
65 .parse()
66 .with_context(|| format!("KASL_SERVER_ADDR is not a valid socket address: {addr}"))?;
67 let database_url = lookup("DATABASE_URL").context("DATABASE_URL is not set (e.g. postgres://kasl:kasl@localhost:5432/kasl)")?;
68 let agents = lookup("KASL_AGENTS").unwrap_or_default();
69 let max_batch_days = positive("KASL_MAX_BATCH_DAYS", &lookup, DEFAULT_MAX_BATCH_DAYS)?;
70 let max_body_bytes = positive("KASL_MAX_BODY_BYTES", &lookup, DEFAULT_MAX_BODY_BYTES)?;
71 let admin = lookup("KASL_ADMIN").unwrap_or_default();
72 let secure_cookies = boolean("KASL_SECURE_COOKIES", &lookup, true)?;
73 Ok(Self {
74 addr,
75 database_url,
76 agents,
77 max_batch_days,
78 max_body_bytes,
79 admin,
80 secure_cookies,
81 })
82 }
83}
84
85fn boolean(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: bool) -> Result<bool> {
90 let Some(raw) = lookup(key) else { return Ok(default) };
91 match raw.trim().to_ascii_lowercase().as_str() {
92 "1" | "true" | "yes" | "on" => Ok(true),
93 "0" | "false" | "no" | "off" => Ok(false),
94 other => anyhow::bail!("{key} is not a yes/no value: {other}"),
95 }
96}
97
98fn positive(key: &str, lookup: &impl Fn(&str) -> Option<String>, default: usize) -> Result<usize> {
102 let Some(raw) = lookup(key) else { return Ok(default) };
103 let value: usize = raw.parse().with_context(|| format!("{key} is not a positive whole number: {raw}"))?;
104 if value == 0 {
105 anyhow::bail!("{key} must be greater than zero");
106 }
107 Ok(value)
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
115 move |key| pairs.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string())
116 }
117
118 #[test]
119 fn defaults_the_bind_address() {
120 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).expect("config should build with only DATABASE_URL set");
121 assert_eq!(config.addr, DEFAULT_ADDR.parse().unwrap());
122 assert_eq!(config.database_url, "postgres://localhost/kasl");
123 }
124
125 #[test]
126 fn reads_the_bind_address_override() {
127 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "127.0.0.1:9090")]))
128 .expect("config should accept a valid override");
129 assert_eq!(config.addr, "127.0.0.1:9090".parse().unwrap());
130 }
131
132 #[test]
133 fn requires_database_url() {
134 let error = Config::from_lookup(env(&[])).unwrap_err();
135 assert!(error.to_string().contains("DATABASE_URL"));
136 }
137
138 #[test]
139 fn limits_have_defaults_and_can_be_overridden() {
140 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
141 assert_eq!(config.max_batch_days, DEFAULT_MAX_BATCH_DAYS);
142 assert_eq!(config.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
143
144 let config = Config::from_lookup(env(&[
145 ("DATABASE_URL", "postgres://localhost/kasl"),
146 ("KASL_MAX_BATCH_DAYS", "7"),
147 ("KASL_MAX_BODY_BYTES", "1048576"),
148 ]))
149 .unwrap();
150 assert_eq!(config.max_batch_days, 7);
151 assert_eq!(config.max_body_bytes, 1048576);
152 }
153
154 #[test]
155 fn a_limit_of_zero_is_refused() {
156 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BATCH_DAYS", "0")])).unwrap_err();
159 assert!(error.to_string().contains("KASL_MAX_BATCH_DAYS"), "{error}");
160
161 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_MAX_BODY_BYTES", "nope")])).unwrap_err();
162 assert!(error.to_string().contains("KASL_MAX_BODY_BYTES"), "{error}");
163 }
164
165 #[test]
166 fn secure_cookies_default_on_and_refuse_a_typo() {
167 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl")])).unwrap();
168 assert!(config.secure_cookies, "TLS is the assumption; opting out has to be deliberate");
169
170 for value in ["0", "false", "no", "off", "OFF"] {
171 let config = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SECURE_COOKIES", value)])).unwrap();
172 assert!(!config.secure_cookies, "`{value}` should turn it off");
173 }
174
175 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SECURE_COOKIES", "nope")])).unwrap_err();
179 assert!(error.to_string().contains("KASL_SECURE_COOKIES"), "{error}");
180 }
181
182 #[test]
183 fn rejects_a_malformed_bind_address() {
184 let error = Config::from_lookup(env(&[("DATABASE_URL", "postgres://localhost/kasl"), ("KASL_SERVER_ADDR", "not-an-address")])).unwrap_err();
185 assert!(error.to_string().contains("KASL_SERVER_ADDR"));
186 }
187}