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 (e.g.,
5//! `FOLK_WORKERS_COUNT=8`).
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}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(default)]
27pub struct ServerConfig {
28    /// Path to the admin RPC Unix socket. Default: `/tmp/folk.sock`.
29    pub rpc_socket: String,
30    /// Maximum time to wait for graceful shutdown after SIGTERM.
31    #[serde(with = "humantime_serde")]
32    pub shutdown_timeout: Duration,
33}
34
35impl Default for ServerConfig {
36    fn default() -> Self {
37        Self {
38            rpc_socket: "/tmp/folk.sock".into(),
39            shutdown_timeout: Duration::from_secs(30),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(default)]
46pub struct WorkersConfig {
47    /// Path to the PHP worker script (e.g., `vendor/bin/folk-worker`).
48    pub script: String,
49    /// PHP binary path (default: `php`).
50    pub php: String,
51    /// Number of worker processes.
52    pub count: usize,
53    /// Recycle a worker after this many requests.
54    pub max_jobs: u64,
55    /// Recycle a worker that has been alive longer than this.
56    #[serde(with = "humantime_serde")]
57    pub ttl: Duration,
58    /// Recycle a worker exceeding this RSS in MB.
59    pub max_memory_mb: u64,
60    /// Per-request execution timeout.
61    #[serde(with = "humantime_serde")]
62    pub exec_timeout: Duration,
63    /// Per-worker boot timeout (waits for `control.ready`).
64    #[serde(with = "humantime_serde")]
65    pub boot_timeout: Duration,
66    /// Warm up opcache before spawning workers (default: true).
67    /// Loads all files from Composer classmap into shared opcache.
68    pub warmup: bool,
69}
70
71impl Default for WorkersConfig {
72    fn default() -> Self {
73        Self {
74            script: "vendor/bin/folk-worker".into(),
75            php: "php".into(),
76            count: 4,
77            max_jobs: 1000,
78            ttl: Duration::from_secs(3600),
79            max_memory_mb: 256,
80            exec_timeout: Duration::from_secs(30),
81            boot_timeout: Duration::from_secs(30),
82            warmup: true,
83        }
84    }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(default)]
89pub struct LogConfig {
90    /// Log level filter (e.g., `info`, `debug`, `folk_core=trace`).
91    pub filter: String,
92    /// Output format: `text`, `json`, or `pretty`.
93    pub format: LogFormat,
94    /// Per-plugin log level overrides.
95    /// Keys: `http`, `jobs`, `grpc`, `metrics`, `process`, `core`, `ext`.
96    /// Values: `trace`, `debug`, `info`, `warn`, `error`.
97    #[serde(default)]
98    pub plugins: HashMap<String, String>,
99}
100
101impl Default for LogConfig {
102    fn default() -> Self {
103        Self {
104            filter: "info".into(),
105            format: LogFormat::Text,
106            plugins: HashMap::new(),
107        }
108    }
109}
110
111impl LogConfig {
112    /// Build the effective `EnvFilter` string by combining `filter` with
113    /// per-plugin overrides. Friendly plugin names are mapped to Rust crate
114    /// targets automatically.
115    pub fn effective_filter(&self) -> String {
116        if self.plugins.is_empty() {
117            return self.filter.clone();
118        }
119
120        let mut parts = vec![self.filter.clone()];
121        for (plugin, level) in &self.plugins {
122            let target = plugin_name_to_target(plugin);
123            parts.push(format!("{target}={level}"));
124        }
125        parts.join(",")
126    }
127}
128
129fn plugin_name_to_target(name: &str) -> &str {
130    match name {
131        "http" => "folk_plugin_http",
132        "jobs" => "folk_plugin_jobs",
133        "grpc" => "folk_plugin_grpc",
134        "metrics" => "folk_plugin_metrics",
135        "process" => "folk_plugin_process",
136        "core" => "folk_core",
137        "ext" => "folk_ext",
138        _ => name,
139    }
140}
141
142#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "snake_case")]
144pub enum LogFormat {
145    Text,
146    Json,
147    Pretty,
148}
149
150impl FolkConfig {
151    /// Load config from `folk.toml` in the current directory plus environment
152    /// variables prefixed with `FOLK_`. Missing file is OK; defaults are used.
153    pub fn load() -> Result<Self> {
154        Self::load_from(Path::new("folk.toml"))
155    }
156
157    /// Load config from a specific path. Missing file is OK; defaults are used.
158    pub fn load_from(path: impl AsRef<Path>) -> Result<Self> {
159        let path = path.as_ref();
160        let mut fig = Figment::from(figment::providers::Serialized::defaults(Self::default()));
161        if path.exists() {
162            fig = fig.merge(Toml::file(path));
163        }
164        fig = fig.merge(Env::prefixed("FOLK_").split("_"));
165        fig.extract().context("failed to parse Folk configuration")
166    }
167}