Skip to main content

folk_core/
config.rs

1//! Configuration loading.
2//!
3//! Folk reads `folk.toml` from the working directory by default, with
4//! environment-variable overrides via the `FOLK_` prefix and double-underscore
5//! section separator (e.g., `FOLK_WORKERS__COUNT=8`, `FOLK_HTTP__LISTEN=0.0.0.0:9000`).
6
7use std::collections::HashMap;
8use std::path::Path;
9use std::time::Duration;
10
11use anyhow::{Context, Result};
12use figment::Figment;
13use figment::providers::{Env, Format, Toml};
14use serde::{Deserialize, Serialize};
15
16/// Top-level Folk configuration.
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18#[serde(default)]
19pub struct FolkConfig {
20    pub server: ServerConfig,
21    pub workers: WorkersConfig,
22    pub log: LogConfig,
23    pub dev: DevConfig,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(default)]
28pub struct ServerConfig {
29    /// Path to the admin RPC Unix socket. Default: `/tmp/folk.sock`.
30    pub rpc_socket: String,
31    /// Maximum time to wait for graceful shutdown after SIGTERM.
32    #[serde(with = "humantime_serde")]
33    pub shutdown_timeout: Duration,
34}
35
36impl Default for ServerConfig {
37    fn default() -> Self {
38        Self {
39            rpc_socket: "/tmp/folk.sock".into(),
40            shutdown_timeout: Duration::from_secs(30),
41        }
42    }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47pub struct WorkersConfig {
48    /// Path to the PHP worker script (e.g., `vendor/bin/folk-worker`).
49    pub script: String,
50    /// PHP binary path (default: `php`).
51    pub php: String,
52    /// Number of worker processes.
53    pub count: usize,
54    /// Maximum number of requests handled concurrently by a single worker.
55    ///
56    /// Currently only `1` is supported (synchronous frameworks). Values `> 1`
57    /// are reserved for a future async runtime (PHP Fibers) and are clamped to
58    /// `1` with a warning by [`WorkersConfig::normalize`].
59    pub max_concurrent_per_worker: usize,
60    /// Recycle a worker after this many requests.
61    pub max_jobs: u64,
62    /// Recycle a worker that has been alive longer than this.
63    #[serde(with = "humantime_serde")]
64    pub ttl: Duration,
65    /// Per-request execution timeout.
66    #[serde(with = "humantime_serde")]
67    pub exec_timeout: Duration,
68    /// Per-worker boot timeout (waits for `control.ready`).
69    #[serde(with = "humantime_serde")]
70    pub boot_timeout: Duration,
71    /// Warm up opcache before spawning workers (default: true).
72    /// Loads all files from Composer classmap into shared opcache.
73    pub warmup: bool,
74}
75
76impl Default for WorkersConfig {
77    fn default() -> Self {
78        Self {
79            script: "vendor/bin/folk-worker".into(),
80            php: "php".into(),
81            count: 4,
82            max_concurrent_per_worker: 1,
83            max_jobs: 1000,
84            ttl: Duration::from_secs(3600),
85            exec_timeout: Duration::from_secs(30),
86            boot_timeout: Duration::from_secs(30),
87            warmup: true,
88        }
89    }
90}
91
92impl WorkersConfig {
93    /// Clamp out-of-range values to supported ones, warning where a requested
94    /// value cannot be honored. Call once at startup before building the pool.
95    pub fn normalize(&mut self) {
96        if self.max_concurrent_per_worker > 1 {
97            tracing::warn!(
98                requested = self.max_concurrent_per_worker,
99                "max_concurrent_per_worker > 1 is not yet supported; clamping to 1 \
100                 (per-worker concurrency requires a future async runtime)"
101            );
102            self.max_concurrent_per_worker = 1;
103        } else if self.max_concurrent_per_worker == 0 {
104            self.max_concurrent_per_worker = 1;
105        }
106    }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(default)]
111pub struct LogConfig {
112    /// Log level filter (e.g., `info`, `debug`, `folk_core=trace`).
113    pub filter: String,
114    /// Output format: `text`, `json`, or `pretty`.
115    pub format: LogFormat,
116    /// Per-plugin log level overrides.
117    /// Keys: `http`, `jobs`, `grpc`, `metrics`, `process`, `core`, `ext`.
118    /// Values: `trace`, `debug`, `info`, `warn`, `error`.
119    #[serde(default)]
120    pub plugins: HashMap<String, String>,
121}
122
123impl Default for LogConfig {
124    fn default() -> Self {
125        Self {
126            filter: "info".into(),
127            format: LogFormat::Text,
128            plugins: HashMap::new(),
129        }
130    }
131}
132
133impl LogConfig {
134    /// Build the effective `EnvFilter` string by combining `filter` with
135    /// per-plugin overrides. Friendly plugin names are mapped to Rust crate
136    /// targets automatically.
137    pub fn effective_filter(&self) -> String {
138        if self.plugins.is_empty() {
139            return self.filter.clone();
140        }
141
142        let mut parts = vec![self.filter.clone()];
143        for (plugin, level) in &self.plugins {
144            let target = plugin_name_to_target(plugin);
145            parts.push(format!("{target}={level}"));
146        }
147        parts.join(",")
148    }
149}
150
151fn plugin_name_to_target(name: &str) -> &str {
152    match name {
153        "http" => "folk_plugin_http",
154        "jobs" => "folk_plugin_jobs",
155        "grpc" => "folk_plugin_grpc",
156        "metrics" => "folk_plugin_metrics",
157        "process" => "folk_plugin_process",
158        "core" => "folk_core",
159        "ext" => "folk_ext",
160        _ => name,
161    }
162}
163
164#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
165#[serde(rename_all = "snake_case")]
166pub enum LogFormat {
167    Text,
168    Json,
169    Pretty,
170}
171
172/// Development-mode configuration (hot reload / watch mode).
173///
174/// Disabled by default — production runs must not pay the watcher cost.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(default)]
177pub struct DevConfig {
178    /// Watch PHP files and reload workers on change (default: false).
179    pub watch: bool,
180    /// Directories to watch (recursively). Relative to the project root.
181    pub watch_paths: Vec<String>,
182    /// File extensions that trigger a reload (without the leading dot).
183    pub watch_extensions: Vec<String>,
184    /// Debounce window: collapse a burst of file events into one reload.
185    #[serde(with = "humantime_serde")]
186    pub debounce: Duration,
187}
188
189impl Default for DevConfig {
190    fn default() -> Self {
191        Self {
192            watch: false,
193            watch_paths: vec!["app".into(), "src".into(), "routes".into(), "config".into()],
194            watch_extensions: vec!["php".into()],
195            debounce: Duration::from_millis(300),
196        }
197    }
198}
199
200impl FolkConfig {
201    /// Load config from `folk.toml` in the current directory plus environment
202    /// variables prefixed with `FOLK_`. Missing file is OK; defaults are used.
203    pub fn load() -> Result<Self> {
204        Self::load_from(Path::new("folk.toml"))
205    }
206
207    /// Load config from a specific path. Missing file is OK; defaults are used.
208    pub fn load_from(path: impl AsRef<Path>) -> Result<Self> {
209        let path = path.as_ref();
210        let mut fig = Figment::from(figment::providers::Serialized::defaults(Self::default()));
211        if path.exists() {
212            fig = fig.merge(Toml::file(path));
213        }
214        fig = fig.merge(Env::prefixed("FOLK_").split("__"));
215        fig.extract().context("failed to parse Folk configuration")
216    }
217}