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> =
295 arr.into_iter().filter_map(resolve_env_vars).collect();
296 Some(toml::Value::Array(resolved))
297 }
298 other => Some(other),
299 }
300}
301
302impl Config {
303 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
304 let content = std::fs::read_to_string(path)?;
305
306 let mut raw: toml::Value = toml::from_str(&content)?;
309
310 let dotenv_cfg: DotenvConfig = raw
313 .as_table()
314 .and_then(|t| t.get("dotenv"))
315 .map(|v| v.clone().try_into::<DotenvConfig>())
316 .transpose()
317 .map_err(|e| anyhow::anyhow!("invalid `dotenv` value: {e}"))?
318 .unwrap_or_default();
319
320 match dotenv_cfg {
321 DotenvConfig::Disabled => {}
322 DotenvConfig::Default => {
323 dotenvy::dotenv().ok(); }
325 DotenvConfig::Path(ref p) => {
326 dotenvy::from_path(p)
327 .map_err(|e| anyhow::anyhow!("failed to load .env from `{}`: {e}", p.display()))?;
328 }
329 }
330
331 raw = resolve_env_vars(raw)
333 .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
334
335 let config: Config = raw.try_into()?;
336 config.validate()?;
337 Ok(config)
338 }
339
340 fn validate(&self) -> Result<()> {
341 if self.https_port.is_some() {
342 if self.cert_path.is_none() {
343 bail!("`cert_path` is required when `https_port` is set");
344 }
345 if self.key_path.is_none() {
346 bail!("`key_path` is required when `https_port` is set");
347 }
348 }
349 if self.server.is_empty() {
350 bail!("at least one `[server.NAME]` block is required");
351 }
352 Ok(())
353 }
354}
355
356impl Default for ServerConfig {
357 fn default() -> Self {
358 Self {
359 bind_to: default_bind_to(),
360 proxy_url: default_proxy_url(),
361 include_paths: vec![],
362 exclude_paths: vec![],
363 enable_websocket: default_enable_websocket(),
364 forward_get_only: default_forward_get_only(),
365 cache_404_capacity: default_cache_404_capacity(),
366 use_404_meta: default_use_404_meta(),
367 cache_strategy: CacheStrategy::default(),
368 compress_strategy: CompressStrategy::default(),
369 cache_storage_mode: CacheStorageMode::default(),
370 cache_directory: None,
371 proxy_mode: ProxyModeConfig::default(),
372 pre_generate_paths: vec![],
373 pre_generate_fallthrough: false,
374 execute: None,
375 execute_dir: None,
376 webhooks: vec![],
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 fn single_server_toml(extra: &str) -> String {
386 format!(
387 "[server.default]\nproxy_url = \"http://localhost:8080\"\n{}",
388 extra
389 )
390 }
391
392 #[test]
393 fn test_config_defaults_cache_strategy_to_all() {
394 let config: Config = toml::from_str(&single_server_toml("")).unwrap();
395 let s = config.server.get("default").unwrap();
396 assert_eq!(s.cache_strategy, CacheStrategy::All);
397 assert_eq!(s.compress_strategy, CompressStrategy::Brotli);
398 assert_eq!(s.cache_storage_mode, CacheStorageMode::Memory);
399 assert_eq!(s.cache_directory, None);
400 }
401
402 #[test]
403 fn test_config_parses_cache_strategy() {
404 let config: Config =
405 toml::from_str(&single_server_toml("cache_strategy = \"none\"\n")).unwrap();
406 let s = config.server.get("default").unwrap();
407 assert_eq!(s.cache_strategy, CacheStrategy::None);
408 }
409
410 #[test]
411 fn test_config_parses_compress_strategy() {
412 let config: Config =
413 toml::from_str(&single_server_toml("compress_strategy = \"gzip\"\n")).unwrap();
414 let s = config.server.get("default").unwrap();
415 assert_eq!(s.compress_strategy, CompressStrategy::Gzip);
416 }
417
418 #[test]
419 fn test_config_parses_cache_storage_mode() {
420 let config: Config = toml::from_str(&single_server_toml(
421 "cache_storage_mode = \"filesystem\"\ncache_directory = \"cache-bodies\"\n",
422 ))
423 .unwrap();
424 let s = config.server.get("default").unwrap();
425 assert_eq!(s.cache_storage_mode, CacheStorageMode::Filesystem);
426 assert_eq!(s.cache_directory, Some(PathBuf::from("cache-bodies")));
427 }
428
429 #[test]
430 fn test_config_top_level_ports() {
431 let toml = "http_port = 8080\ncontrol_port = 9000\n".to_string()
432 + &single_server_toml("");
433 let config: Config = toml::from_str(&toml).unwrap();
434 assert_eq!(config.http_port, 8080);
435 assert_eq!(config.control_port, 9000);
436 assert_eq!(config.https_port, None);
437 }
438
439 #[test]
440 fn test_https_validation_requires_cert_and_key() {
441 let toml = "https_port = 443\n".to_string() + &single_server_toml("");
442 let config: Config = toml::from_str(&toml).unwrap();
443 assert!(config.validate().is_err());
444 }
445
446 #[test]
447 fn test_multiple_servers() {
448 let toml = "[server.frontend]\nbind_to = \"*\"\nproxy_url = \"http://localhost:5173\"\n\
449 [server.api]\nbind_to = \"/api\"\nproxy_url = \"http://localhost:8080\"\n";
450 let config: Config = toml::from_str(toml).unwrap();
451 assert_eq!(config.server.len(), 2);
452 assert_eq!(
453 config.server.get("api").unwrap().bind_to,
454 "/api"
455 );
456 assert_eq!(
457 config.server.get("frontend").unwrap().bind_to,
458 "*"
459 );
460 }
461
462 #[test]
465 fn test_env_var_string_field_resolves_when_set() {
466 std::env::set_var("_PF_TEST_CONTROL_AUTH", "secret-token");
467 let toml = format!(
468 "control_auth = \"$env:_PF_TEST_CONTROL_AUTH\"\n{}",
469 single_server_toml("")
470 );
471 let raw: toml::Value = toml::from_str(&toml).unwrap();
472 let resolved = resolve_env_vars(raw).unwrap();
473 let config: Config = resolved.try_into().unwrap();
474 std::env::remove_var("_PF_TEST_CONTROL_AUTH");
475 assert_eq!(config.control_auth, Some("secret-token".to_string()));
476 }
477
478 #[test]
479 fn test_env_var_option_field_becomes_none_when_unset() {
480 std::env::remove_var("_PF_TEST_HTTPS_PORT_MISSING");
481 let toml = format!(
482 "https_port = \"$env:_PF_TEST_HTTPS_PORT_MISSING\"\n{}",
483 single_server_toml("")
484 );
485 let raw: toml::Value = toml::from_str(&toml).unwrap();
486 let resolved = resolve_env_vars(raw).unwrap();
487 let config: Config = resolved.try_into().unwrap();
488 assert_eq!(config.https_port, None);
489 }
490
491 #[test]
492 fn test_env_var_port_field_resolves_as_integer_string() {
493 std::env::set_var("_PF_TEST_HTTP_PORT", "9999");
494 let toml = format!(
495 "http_port = \"$env:_PF_TEST_HTTP_PORT\"\n{}",
496 single_server_toml("")
497 );
498 let raw: toml::Value = toml::from_str(&toml).unwrap();
499 let resolved = resolve_env_vars(raw).unwrap();
500 if let Some(toml::Value::Table(t)) = Some(resolved) {
506 assert_eq!(t.get("http_port"), Some(&toml::Value::String("9999".to_string())));
507 }
508 std::env::remove_var("_PF_TEST_HTTP_PORT");
509 }
510
511 #[test]
514 fn test_dotenv_false_is_disabled() {
515 let toml = format!("dotenv = false\n{}", single_server_toml(""));
516 let config: Config = toml::from_str(&toml).unwrap();
517 assert!(matches!(config.dotenv, DotenvConfig::Disabled));
518 }
519
520 #[test]
521 fn test_dotenv_true_is_default() {
522 let toml = format!("dotenv = true\n{}", single_server_toml(""));
523 let config: Config = toml::from_str(&toml).unwrap();
524 assert!(matches!(config.dotenv, DotenvConfig::Default));
525 }
526
527 #[test]
528 fn test_dotenv_string_path_is_path() {
529 let toml = format!("dotenv = \"./.env.local\"\n{}", single_server_toml(""));
530 let config: Config = toml::from_str(&toml).unwrap();
531 assert!(matches!(config.dotenv, DotenvConfig::Path(ref p) if p == &PathBuf::from("./.env.local")));
532 }
533
534 #[test]
535 fn test_dotenv_absent_is_disabled() {
536 let config: Config = toml::from_str(&single_server_toml("")).unwrap();
537 assert!(matches!(config.dotenv, DotenvConfig::Disabled));
538 }
539
540 #[test]
541 fn test_dotenv_loads_env_file() {
542 let dir = std::env::temp_dir();
543 let env_path = dir.join("_pf_test_dotenv.env");
544 std::fs::write(&env_path, "_PF_DOTENV_VAR=hello_from_dotenv\n").unwrap();
545
546 let cfg_path = dir.join("_pf_test_dotenv.toml");
549 let cfg_content = format!(
550 "dotenv = \"{}\"\ncontrol_auth = \"$env:_PF_DOTENV_VAR\"\n[server.default]\nproxy_url = \"http://localhost:8080\"\n",
551 env_path.to_string_lossy().replace('\\', "/")
552 );
553 std::fs::write(&cfg_path, &cfg_content).unwrap();
554
555 std::env::remove_var("_PF_DOTENV_VAR");
556 let config = Config::from_file(&cfg_path).unwrap();
557
558 std::fs::remove_file(&env_path).ok();
559 std::fs::remove_file(&cfg_path).ok();
560
561 assert_eq!(config.control_auth, Some("hello_from_dotenv".to_string()));
562 }
563}