Skip to main content

worldinterface_daemon/
config.rs

1//! Daemon configuration.
2
3use std::num::NonZeroUsize;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8use worldinterface_host::HostConfig;
9
10use crate::error::DaemonError;
11
12/// Configuration for the WorldInterface daemon.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DaemonConfig {
15    /// Socket address to bind the HTTP server.
16    #[serde(default = "default_bind_address")]
17    pub bind_address: String,
18
19    /// Directory for AQ WAL and snapshot files.
20    #[serde(default = "default_aq_data_dir")]
21    pub aq_data_dir: PathBuf,
22
23    /// Path to the SQLite ContextStore database file.
24    #[serde(default = "default_context_store_path")]
25    pub context_store_path: PathBuf,
26
27    /// Tick interval in milliseconds.
28    #[serde(default = "default_tick_interval_ms")]
29    pub tick_interval_ms: u64,
30
31    /// Maximum concurrent handler executions.
32    #[serde(default = "default_dispatch_concurrency")]
33    pub dispatch_concurrency: usize,
34
35    /// AQ lease timeout in seconds.
36    #[serde(default = "default_lease_timeout_secs")]
37    pub lease_timeout_secs: u64,
38
39    /// Graceful shutdown timeout in seconds.
40    #[serde(default = "default_shutdown_timeout_secs")]
41    pub shutdown_timeout_secs: u64,
42}
43
44fn default_bind_address() -> String {
45    "127.0.0.1:7800".to_string()
46}
47fn default_aq_data_dir() -> PathBuf {
48    PathBuf::from("data/aq")
49}
50fn default_context_store_path() -> PathBuf {
51    PathBuf::from("data/context.db")
52}
53fn default_tick_interval_ms() -> u64 {
54    50
55}
56fn default_dispatch_concurrency() -> usize {
57    4
58}
59fn default_lease_timeout_secs() -> u64 {
60    300
61}
62fn default_shutdown_timeout_secs() -> u64 {
63    30
64}
65
66impl Default for DaemonConfig {
67    fn default() -> Self {
68        Self {
69            bind_address: default_bind_address(),
70            aq_data_dir: default_aq_data_dir(),
71            context_store_path: default_context_store_path(),
72            tick_interval_ms: default_tick_interval_ms(),
73            dispatch_concurrency: default_dispatch_concurrency(),
74            lease_timeout_secs: default_lease_timeout_secs(),
75            shutdown_timeout_secs: default_shutdown_timeout_secs(),
76        }
77    }
78}
79
80impl DaemonConfig {
81    /// Load configuration with precedence: env vars > config file > defaults.
82    pub fn load(config_path: Option<&Path>) -> Result<Self, DaemonError> {
83        let mut config = match config_path {
84            Some(path) => {
85                let contents = std::fs::read_to_string(path)
86                    .map_err(|e| DaemonError::Config(format!("reading {}: {e}", path.display())))?;
87                toml::from_str(&contents)?
88            }
89            None => Self::default(),
90        };
91
92        // Environment variable overrides
93        if let Ok(val) = std::env::var("WI_BIND_ADDRESS") {
94            config.bind_address = val;
95        }
96        if let Ok(val) = std::env::var("WI_AQ_DATA_DIR") {
97            config.aq_data_dir = PathBuf::from(val);
98        }
99        if let Ok(val) = std::env::var("WI_CONTEXT_STORE_PATH") {
100            config.context_store_path = PathBuf::from(val);
101        }
102        if let Ok(val) = std::env::var("WI_TICK_INTERVAL_MS") {
103            config.tick_interval_ms = val
104                .parse()
105                .map_err(|e| DaemonError::Config(format!("WI_TICK_INTERVAL_MS: {e}")))?;
106        }
107        if let Ok(val) = std::env::var("WI_DISPATCH_CONCURRENCY") {
108            config.dispatch_concurrency = val
109                .parse()
110                .map_err(|e| DaemonError::Config(format!("WI_DISPATCH_CONCURRENCY: {e}")))?;
111        }
112        if let Ok(val) = std::env::var("WI_LEASE_TIMEOUT_SECS") {
113            config.lease_timeout_secs = val
114                .parse()
115                .map_err(|e| DaemonError::Config(format!("WI_LEASE_TIMEOUT_SECS: {e}")))?;
116        }
117        if let Ok(val) = std::env::var("WI_SHUTDOWN_TIMEOUT_SECS") {
118            config.shutdown_timeout_secs = val
119                .parse()
120                .map_err(|e| DaemonError::Config(format!("WI_SHUTDOWN_TIMEOUT_SECS: {e}")))?;
121        }
122
123        Ok(config)
124    }
125
126    /// Convert to HostConfig for EmbeddedHost::start().
127    pub fn to_host_config(&self) -> HostConfig {
128        HostConfig {
129            aq_data_dir: self.aq_data_dir.clone(),
130            context_store_path: self.context_store_path.clone(),
131            tick_interval: Duration::from_millis(self.tick_interval_ms),
132            dispatch_concurrency: NonZeroUsize::new(self.dispatch_concurrency)
133                .unwrap_or(NonZeroUsize::new(4).unwrap()),
134            lease_timeout_secs: self.lease_timeout_secs,
135            shutdown_timeout: Duration::from_secs(self.shutdown_timeout_secs),
136            ..HostConfig::default()
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use std::sync::Mutex;
144
145    use super::*;
146
147    // Env var tests must not run concurrently since they mutate process-global state.
148    static ENV_LOCK: Mutex<()> = Mutex::new(());
149
150    #[test]
151    fn default_config_is_valid() {
152        let config = DaemonConfig::default();
153        assert_eq!(config.bind_address, "127.0.0.1:7800");
154        assert_eq!(config.tick_interval_ms, 50);
155        assert_eq!(config.dispatch_concurrency, 4);
156    }
157
158    #[test]
159    fn to_host_config_preserves_values() {
160        let config = DaemonConfig {
161            tick_interval_ms: 25,
162            dispatch_concurrency: 8,
163            lease_timeout_secs: 600,
164            ..Default::default()
165        };
166        let host_config = config.to_host_config();
167        assert_eq!(host_config.tick_interval, Duration::from_millis(25));
168        assert_eq!(host_config.dispatch_concurrency.get(), 8);
169        assert_eq!(host_config.lease_timeout_secs, 600);
170    }
171
172    #[test]
173    fn load_without_config_file() {
174        let _lock = ENV_LOCK.lock().unwrap();
175        let config = DaemonConfig::load(None).unwrap();
176        assert_eq!(config.bind_address, "127.0.0.1:7800");
177    }
178
179    #[test]
180    fn load_with_env_overrides() {
181        let _lock = ENV_LOCK.lock().unwrap();
182
183        let key = "WI_BIND_ADDRESS";
184        let original = std::env::var(key).ok();
185
186        std::env::set_var(key, "0.0.0.0:9999");
187        let config = DaemonConfig::load(None).unwrap();
188        assert_eq!(config.bind_address, "0.0.0.0:9999");
189
190        match original {
191            Some(val) => std::env::set_var(key, val),
192            None => std::env::remove_var(key),
193        }
194    }
195
196    #[test]
197    fn load_env_overrides_toml() {
198        let _lock = ENV_LOCK.lock().unwrap();
199
200        let dir = tempfile::tempdir().unwrap();
201        let path = dir.path().join("test.toml");
202        std::fs::write(
203            &path,
204            r#"
205bind_address = "127.0.0.1:1111"
206tick_interval_ms = 10
207"#,
208        )
209        .unwrap();
210
211        let key = "WI_TICK_INTERVAL_MS";
212        let original = std::env::var(key).ok();
213
214        std::env::set_var(key, "99");
215        let config = DaemonConfig::load(Some(&path)).unwrap();
216        // Env var should override TOML value
217        assert_eq!(config.tick_interval_ms, 99);
218        // TOML value should still apply for non-overridden fields
219        assert_eq!(config.bind_address, "127.0.0.1:1111");
220
221        match original {
222            Some(val) => std::env::set_var(key, val),
223            None => std::env::remove_var(key),
224        }
225    }
226
227    #[test]
228    fn load_from_toml_file() {
229        let _lock = ENV_LOCK.lock().unwrap();
230
231        let dir = tempfile::tempdir().unwrap();
232        let path = dir.path().join("test.toml");
233        std::fs::write(
234            &path,
235            r#"
236bind_address = "0.0.0.0:9000"
237tick_interval_ms = 10
238"#,
239        )
240        .unwrap();
241        let config = DaemonConfig::load(Some(&path)).unwrap();
242        assert_eq!(config.bind_address, "0.0.0.0:9000");
243        assert_eq!(config.tick_interval_ms, 10);
244        // Defaults for unspecified fields
245        assert_eq!(config.dispatch_concurrency, 4);
246    }
247}