1use crate::{CacheStorageMode, CacheStrategy, CompressStrategy, WebhookConfig};
2use anyhow::{bail, Result};
3use serde::{
4 de::{self, Visitor},
5 Deserialize, Serialize,
6};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Default)]
16pub enum DotenvConfig {
17 #[default]
19 Disabled,
20 Default,
22 Path(PathBuf),
24}
25
26impl serde::Serialize for DotenvConfig {
27 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
28 match self {
29 DotenvConfig::Disabled => serializer.serialize_bool(false),
30 DotenvConfig::Default => serializer.serialize_bool(true),
31 DotenvConfig::Path(p) => serializer.serialize_str(&p.to_string_lossy()),
32 }
33 }
34}
35
36impl<'de> Deserialize<'de> for DotenvConfig {
37 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
38 struct DotenvVisitor;
39
40 impl<'de> Visitor<'de> for DotenvVisitor {
41 type Value = DotenvConfig;
42
43 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
44 write!(f, "a boolean or a path string for the .env file")
45 }
46
47 fn visit_bool<E: de::Error>(self, v: bool) -> Result<DotenvConfig, E> {
48 if v {
49 Ok(DotenvConfig::Default)
50 } else {
51 Ok(DotenvConfig::Disabled)
52 }
53 }
54
55 fn visit_str<E: de::Error>(self, v: &str) -> Result<DotenvConfig, E> {
56 Ok(DotenvConfig::Path(PathBuf::from(v)))
57 }
58 }
59
60 deserializer.deserialize_any(DotenvVisitor)
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
66#[serde(rename_all = "snake_case")]
67pub enum ProxyModeConfig {
68 #[default]
70 Dynamic,
71 PreGenerate,
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize)]
95pub struct Config {
96 #[serde(default = "default_http_port")]
98 pub http_port: u16,
99
100 pub https_port: Option<u16>,
103
104 pub cert_path: Option<PathBuf>,
106
107 pub key_path: Option<PathBuf>,
109
110 #[serde(default = "default_control_port")]
112 pub control_port: u16,
113
114 pub control_auth: Option<String>,
116
117 pub server: HashMap<String, ServerConfig>,
119
120 #[serde(default)]
126 pub dotenv: DotenvConfig,
127}
128
129#[derive(Debug, Clone, Deserialize, Serialize)]
131pub struct ServerConfig {
132 #[serde(default = "default_bind_to")]
144 pub bind_to: String,
145
146 #[serde(default = "default_proxy_url")]
148 pub proxy_url: String,
149
150 #[serde(default)]
153 pub include_paths: Vec<String>,
154
155 #[serde(default)]
159 pub exclude_paths: Vec<String>,
160
161 #[serde(default = "default_enable_websocket")]
170 pub enable_websocket: bool,
171
172 #[serde(default = "default_forward_get_only")]
174 pub forward_get_only: bool,
175
176 #[serde(default = "default_cache_404_capacity")]
178 pub cache_404_capacity: usize,
179
180 #[serde(default = "default_use_404_meta")]
182 pub use_404_meta: bool,
183
184 #[serde(default)]
186 pub cache_strategy: CacheStrategy,
187
188 #[serde(default)]
190 pub compress_strategy: CompressStrategy,
191
192 #[serde(default)]
194 pub cache_storage_mode: CacheStorageMode,
195
196 #[serde(default)]
198 pub cache_directory: Option<PathBuf>,
199
200 #[serde(default)]
202 pub proxy_mode: ProxyModeConfig,
203
204 #[serde(default)]
206 pub pre_generate_paths: Vec<String>,
207
208 #[serde(default = "default_pre_generate_fallthrough")]
211 pub pre_generate_fallthrough: bool,
212
213 #[serde(default)]
219 pub execute: Option<String>,
220
221 #[serde(default)]
226 pub execute_dir: Option<String>,
227
228 #[serde(default)]
231 pub webhooks: Vec<WebhookConfig>,
232}
233
234fn default_http_port() -> u16 {
237 3000
238}
239
240fn default_control_port() -> u16 {
241 17809
242}
243
244fn default_bind_to() -> String {
245 "*".to_string()
246}
247
248fn default_proxy_url() -> String {
249 "http://localhost:8080".to_string()
250}
251
252fn default_enable_websocket() -> bool {
253 true
254}
255
256fn default_forward_get_only() -> bool {
257 false
258}
259
260fn default_cache_404_capacity() -> usize {
261 100
262}
263
264fn default_use_404_meta() -> bool {
265 false
266}
267
268fn default_pre_generate_fallthrough() -> bool {
269 false
270}
271
272fn resolve_env_vars(value: toml::Value) -> Option<toml::Value> {
281 match value {
282 toml::Value::String(ref s) if s.starts_with("$env:") => {
283 let var_name = &s[5..];
284 std::env::var(var_name).ok().map(toml::Value::String)
285 }
286 toml::Value::Table(table) => {
287 let resolved: toml::map::Map<String, toml::Value> = table
288 .into_iter()
289 .filter_map(|(k, v)| resolve_env_vars(v).map(|rv| (k, rv)))
290 .collect();
291 Some(toml::Value::Table(resolved))
292 }
293 toml::Value::Array(arr) => {
294 let resolved: Vec<toml::Value> = arr.into_iter().filter_map(resolve_env_vars).collect();
295 Some(toml::Value::Array(resolved))
296 }
297 other => Some(other),
298 }
299}
300
301impl Config {
302 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
303 let content = std::fs::read_to_string(path)?;
304
305 let mut raw: toml::Value = toml::from_str(&content)?;
308
309 let dotenv_cfg: DotenvConfig = raw
312 .as_table()
313 .and_then(|t| t.get("dotenv"))
314 .map(|v| v.clone().try_into::<DotenvConfig>())
315 .transpose()
316 .map_err(|e| anyhow::anyhow!("invalid `dotenv` value: {e}"))?
317 .unwrap_or_default();
318
319 match dotenv_cfg {
320 DotenvConfig::Disabled => {}
321 DotenvConfig::Default => {
322 dotenvy::dotenv().ok(); }
324 DotenvConfig::Path(ref p) => {
325 dotenvy::from_path(p).map_err(|e| {
326 anyhow::anyhow!("failed to load .env from `{}`: {e}", p.display())
327 })?;
328 }
329 }
330
331 raw = resolve_env_vars(raw).unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
333
334 let config: Config = raw.try_into()?;
335 config.validate()?;
336 Ok(config)
337 }
338
339 fn validate(&self) -> Result<()> {
340 if self.https_port.is_some() {
341 if self.cert_path.is_none() {
342 bail!("`cert_path` is required when `https_port` is set");
343 }
344 if self.key_path.is_none() {
345 bail!("`key_path` is required when `https_port` is set");
346 }
347 }
348 if self.server.is_empty() {
349 bail!("at least one `[server.NAME]` block is required");
350 }
351 Ok(())
352 }
353}
354
355impl Default for ServerConfig {
356 fn default() -> Self {
357 Self {
358 bind_to: default_bind_to(),
359 proxy_url: default_proxy_url(),
360 include_paths: vec![],
361 exclude_paths: vec![],
362 enable_websocket: default_enable_websocket(),
363 forward_get_only: default_forward_get_only(),
364 cache_404_capacity: default_cache_404_capacity(),
365 use_404_meta: default_use_404_meta(),
366 cache_strategy: CacheStrategy::default(),
367 compress_strategy: CompressStrategy::default(),
368 cache_storage_mode: CacheStorageMode::default(),
369 cache_directory: None,
370 proxy_mode: ProxyModeConfig::default(),
371 pre_generate_paths: vec![],
372 pre_generate_fallthrough: false,
373 execute: None,
374 execute_dir: None,
375 webhooks: vec![],
376 }
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn single_server_toml(extra: &str) -> String {
385 format!(
386 "[server.default]\nproxy_url = \"http://localhost:8080\"\n{}",
387 extra
388 )
389 }
390
391 #[test]
392 fn test_config_defaults_cache_strategy_to_all() {
393 let config: Config = toml::from_str(&single_server_toml("")).unwrap();
394 let s = config.server.get("default").unwrap();
395 assert_eq!(s.cache_strategy, CacheStrategy::All);
396 assert_eq!(s.compress_strategy, CompressStrategy::Brotli);
397 assert_eq!(s.cache_storage_mode, CacheStorageMode::Memory);
398 assert_eq!(s.cache_directory, None);
399 }
400
401 #[test]
402 fn test_config_parses_cache_strategy() {
403 let config: Config =
404 toml::from_str(&single_server_toml("cache_strategy = \"none\"\n")).unwrap();
405 let s = config.server.get("default").unwrap();
406 assert_eq!(s.cache_strategy, CacheStrategy::None);
407 }
408
409 #[test]
410 fn test_config_parses_compress_strategy() {
411 let config: Config =
412 toml::from_str(&single_server_toml("compress_strategy = \"gzip\"\n")).unwrap();
413 let s = config.server.get("default").unwrap();
414 assert_eq!(s.compress_strategy, CompressStrategy::Gzip);
415 }
416
417 #[test]
418 fn test_config_parses_cache_storage_mode() {
419 let config: Config = toml::from_str(&single_server_toml(
420 "cache_storage_mode = \"filesystem\"\ncache_directory = \"cache-bodies\"\n",
421 ))
422 .unwrap();
423 let s = config.server.get("default").unwrap();
424 assert_eq!(s.cache_storage_mode, CacheStorageMode::Filesystem);
425 assert_eq!(s.cache_directory, Some(PathBuf::from("cache-bodies")));
426 }
427
428 #[test]
429 fn test_config_top_level_ports() {
430 let toml = "http_port = 8080\ncontrol_port = 9000\n".to_string() + &single_server_toml("");
431 let config: Config = toml::from_str(&toml).unwrap();
432 assert_eq!(config.http_port, 8080);
433 assert_eq!(config.control_port, 9000);
434 assert_eq!(config.https_port, None);
435 }
436
437 #[test]
438 fn test_https_validation_requires_cert_and_key() {
439 let toml = "https_port = 443\n".to_string() + &single_server_toml("");
440 let config: Config = toml::from_str(&toml).unwrap();
441 assert!(config.validate().is_err());
442 }
443
444 #[test]
445 fn test_multiple_servers() {
446 let toml = "[server.frontend]\nbind_to = \"*\"\nproxy_url = \"http://localhost:5173\"\n\
447 [server.api]\nbind_to = \"/api\"\nproxy_url = \"http://localhost:8080\"\n";
448 let config: Config = toml::from_str(toml).unwrap();
449 assert_eq!(config.server.len(), 2);
450 assert_eq!(config.server.get("api").unwrap().bind_to, "/api");
451 assert_eq!(config.server.get("frontend").unwrap().bind_to, "*");
452 }
453
454 #[test]
457 fn test_env_var_string_field_resolves_when_set() {
458 std::env::set_var("_PF_TEST_CONTROL_AUTH", "secret-token");
459 let toml = format!(
460 "control_auth = \"$env:_PF_TEST_CONTROL_AUTH\"\n{}",
461 single_server_toml("")
462 );
463 let raw: toml::Value = toml::from_str(&toml).unwrap();
464 let resolved = resolve_env_vars(raw).unwrap();
465 let config: Config = resolved.try_into().unwrap();
466 std::env::remove_var("_PF_TEST_CONTROL_AUTH");
467 assert_eq!(config.control_auth, Some("secret-token".to_string()));
468 }
469
470 #[test]
471 fn test_env_var_option_field_becomes_none_when_unset() {
472 std::env::remove_var("_PF_TEST_HTTPS_PORT_MISSING");
473 let toml = format!(
474 "https_port = \"$env:_PF_TEST_HTTPS_PORT_MISSING\"\n{}",
475 single_server_toml("")
476 );
477 let raw: toml::Value = toml::from_str(&toml).unwrap();
478 let resolved = resolve_env_vars(raw).unwrap();
479 let config: Config = resolved.try_into().unwrap();
480 assert_eq!(config.https_port, None);
481 }
482
483 #[test]
484 fn test_env_var_port_field_resolves_as_integer_string() {
485 std::env::set_var("_PF_TEST_HTTP_PORT", "9999");
486 let toml = format!(
487 "http_port = \"$env:_PF_TEST_HTTP_PORT\"\n{}",
488 single_server_toml("")
489 );
490 let raw: toml::Value = toml::from_str(&toml).unwrap();
491 let resolved = resolve_env_vars(raw).unwrap();
492 if let Some(toml::Value::Table(t)) = Some(resolved) {
498 assert_eq!(
499 t.get("http_port"),
500 Some(&toml::Value::String("9999".to_string()))
501 );
502 }
503 std::env::remove_var("_PF_TEST_HTTP_PORT");
504 }
505
506 #[test]
509 fn test_dotenv_false_is_disabled() {
510 let toml = format!("dotenv = false\n{}", single_server_toml(""));
511 let config: Config = toml::from_str(&toml).unwrap();
512 assert!(matches!(config.dotenv, DotenvConfig::Disabled));
513 }
514
515 #[test]
516 fn test_dotenv_true_is_default() {
517 let toml = format!("dotenv = true\n{}", single_server_toml(""));
518 let config: Config = toml::from_str(&toml).unwrap();
519 assert!(matches!(config.dotenv, DotenvConfig::Default));
520 }
521
522 #[test]
523 fn test_dotenv_string_path_is_path() {
524 let toml = format!("dotenv = \"./.env.local\"\n{}", single_server_toml(""));
525 let config: Config = toml::from_str(&toml).unwrap();
526 assert!(
527 matches!(config.dotenv, DotenvConfig::Path(ref p) if p == &PathBuf::from("./.env.local"))
528 );
529 }
530
531 #[test]
532 fn test_dotenv_absent_is_disabled() {
533 let config: Config = toml::from_str(&single_server_toml("")).unwrap();
534 assert!(matches!(config.dotenv, DotenvConfig::Disabled));
535 }
536
537 #[test]
538 fn test_dotenv_loads_env_file() {
539 let dir = std::env::temp_dir();
540 let env_path = dir.join("_pf_test_dotenv.env");
541 std::fs::write(&env_path, "_PF_DOTENV_VAR=hello_from_dotenv\n").unwrap();
542
543 let cfg_path = dir.join("_pf_test_dotenv.toml");
546 let cfg_content = format!(
547 "dotenv = \"{}\"\ncontrol_auth = \"$env:_PF_DOTENV_VAR\"\n[server.default]\nproxy_url = \"http://localhost:8080\"\n",
548 env_path.to_string_lossy().replace('\\', "/")
549 );
550 std::fs::write(&cfg_path, &cfg_content).unwrap();
551
552 std::env::remove_var("_PF_DOTENV_VAR");
553 let config = Config::from_file(&cfg_path).unwrap();
554
555 std::fs::remove_file(&env_path).ok();
556 std::fs::remove_file(&cfg_path).ok();
557
558 assert_eq!(config.control_auth, Some("hello_from_dotenv".to_string()));
559 }
560}