Skip to main content

post_cortex_daemon/daemon/
config.rs

1// Copyright (c) 2025 Julius ML
2//
3// Permission is hereby granted, free of charge, to any person obtaining a copy
4// of this software and associated documentation files (the "Software"), to deal
5// in the Software without restriction, including without limitation the rights
6// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7// copies of the Software, and to permit persons to whom the Software is
8// furnished to do so, subject to the following conditions:
9//
10// The above copyright notice and this permission notice shall be included in all
11// copies or substantial portions of the Software.
12//
13// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19// SOFTWARE.
20
21//! Daemon configuration file support
22//!
23//! Loads configuration from TOML file with environment variable overrides.
24//! Priority: Environment variables > Config file > Defaults
25
26use serde::{Deserialize, Serialize};
27use std::fs;
28use std::path::PathBuf;
29
30/// Daemon configuration loaded from file and environment
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DaemonConfig {
33    /// Host to bind to (default: 127.0.0.1)
34    pub host: String,
35
36    /// Port to listen on (default: 3737)
37    pub port: u16,
38
39    /// gRPC port (default: 3738, 0 = disabled)
40    #[serde(default = "default_grpc_port")]
41    pub grpc_port: u16,
42
43    /// Data directory for RocksDB (default: ~/.post-cortex/data)
44    pub data_directory: String,
45
46    /// Storage backend: "rocksdb" or "surrealdb" (default: rocksdb)
47    #[serde(default = "default_storage_backend")]
48    pub storage_backend: String,
49
50    /// SurrealDB endpoint (required if storage_backend = "surrealdb")
51    /// Example: "ws://localhost:8000"
52    #[serde(default)]
53    pub surrealdb_endpoint: Option<String>,
54
55    /// SurrealDB username (optional)
56    #[serde(default)]
57    pub surrealdb_username: Option<String>,
58
59    /// SurrealDB password (optional)
60    #[serde(default)]
61    pub surrealdb_password: Option<String>,
62
63    /// SurrealDB namespace (default: "post_cortex")
64    #[serde(default = "default_surrealdb_namespace")]
65    pub surrealdb_namespace: String,
66
67    /// SurrealDB database (default: "main")
68    #[serde(default = "default_surrealdb_database")]
69    pub surrealdb_database: String,
70}
71
72fn default_grpc_port() -> u16 {
73    3738
74}
75
76fn default_storage_backend() -> String {
77    "rocksdb".to_string()
78}
79
80fn default_surrealdb_namespace() -> String {
81    "post_cortex".to_string()
82}
83
84fn default_surrealdb_database() -> String {
85    "main".to_string()
86}
87
88impl Default for DaemonConfig {
89    fn default() -> Self {
90        Self {
91            host: "127.0.0.1".to_string(),
92            port: 3737,
93            grpc_port: default_grpc_port(),
94            data_directory: default_data_dir(),
95            storage_backend: default_storage_backend(),
96            surrealdb_endpoint: None,
97            surrealdb_username: None,
98            surrealdb_password: None,
99            surrealdb_namespace: default_surrealdb_namespace(),
100            surrealdb_database: default_surrealdb_database(),
101        }
102    }
103}
104
105impl DaemonConfig {
106    /// Load configuration from file with environment variable overrides
107    ///
108    /// Priority order:
109    /// 1. Environment variables (PC_HOST, PC_PORT, PC_DATA_DIR)
110    /// 2. Config file (~/.post-cortex/daemon.toml)
111    /// 3. Default values
112    pub fn load() -> Self {
113        let config_path = default_config_path();
114
115        // Start with defaults
116        let mut config = Self::default();
117
118        // Try to load config file if it exists
119        if config_path.exists() {
120            match fs::read_to_string(&config_path) {
121                Ok(contents) => match toml::from_str::<DaemonConfig>(&contents) {
122                    Ok(file_config) => {
123                        // Merge file config into defaults
124                        config = file_config;
125                        tracing::info!("Loaded configuration from {:?}", config_path);
126                    }
127                    Err(e) => {
128                        tracing::warn!("Failed to parse config file {:?}: {}", config_path, e);
129                        tracing::info!("Using default configuration");
130                    }
131                },
132                Err(e) => {
133                    tracing::warn!("Failed to read config file {:?}: {}", config_path, e);
134                    tracing::info!("Using default configuration");
135                }
136            }
137        } else {
138            tracing::debug!("Config file {:?} not found, using defaults", config_path);
139        }
140
141        // Apply environment variable overrides (highest priority)
142        if let Ok(host) = std::env::var("PC_HOST") {
143            config.host = host;
144            tracing::debug!("Overriding host from PC_HOST environment variable");
145        }
146
147        if let Ok(port_str) = std::env::var("PC_PORT") {
148            if let Ok(port) = port_str.parse::<u16>() {
149                config.port = port;
150                tracing::debug!("Overriding port from PC_PORT environment variable");
151            } else {
152                tracing::warn!("Invalid PC_PORT value: {}", port_str);
153            }
154        }
155
156        if let Ok(grpc_port_str) = std::env::var("PC_GRPC_PORT") {
157            if let Ok(port) = grpc_port_str.parse::<u16>() {
158                config.grpc_port = port;
159                tracing::debug!("Overriding grpc_port from PC_GRPC_PORT environment variable");
160            }
161        }
162
163        if let Ok(data_dir) = std::env::var("PC_DATA_DIR") {
164            config.data_directory = data_dir;
165            tracing::debug!("Overriding data_directory from PC_DATA_DIR environment variable");
166        }
167
168        // Storage backend overrides
169        if let Ok(backend) = std::env::var("PC_STORAGE_BACKEND") {
170            config.storage_backend = backend;
171            tracing::debug!(
172                "Overriding storage_backend from PC_STORAGE_BACKEND environment variable"
173            );
174        }
175
176        if let Ok(endpoint) = std::env::var("PC_SURREALDB_ENDPOINT") {
177            config.surrealdb_endpoint = Some(endpoint);
178            tracing::debug!(
179                "Overriding surrealdb_endpoint from PC_SURREALDB_ENDPOINT environment variable"
180            );
181        }
182
183        if let Ok(username) = std::env::var("PC_SURREALDB_USER") {
184            config.surrealdb_username = Some(username);
185            tracing::debug!(
186                "Overriding surrealdb_username from PC_SURREALDB_USER environment variable"
187            );
188        }
189
190        if let Ok(password) = std::env::var("PC_SURREALDB_PASS") {
191            config.surrealdb_password = Some(password);
192            tracing::debug!(
193                "Overriding surrealdb_password from PC_SURREALDB_PASS environment variable"
194            );
195        }
196
197        config
198    }
199
200    /// Create example config file at the default location
201    ///
202    /// Returns path to created file or error message
203    pub fn create_example_config() -> Result<PathBuf, String> {
204        let config_path = default_config_path();
205
206        if config_path.exists() {
207            return Err(format!("Config file already exists at {:?}", config_path));
208        }
209
210        // Ensure parent directory exists
211        if let Some(parent) = config_path.parent() {
212            fs::create_dir_all(parent)
213                .map_err(|e| format!("Failed to create config directory: {}", e))?;
214        }
215
216        let example_config = DaemonConfig::default();
217        let toml_content = toml::to_string_pretty(&example_config)
218            .map_err(|e| format!("Failed to serialize config: {}", e))?;
219
220        // Add comments to explain each field
221        let commented_toml = format!(
222            "# Post-Cortex Daemon Configuration\n\
223             # \n\
224             # This file configures the HTTP daemon server for multi-client access.\n\
225             # Environment variables override these settings:\n\
226             #   PC_HOST - Override host\n\
227             #   PC_PORT - Override port\n\
228             #   PC_DATA_DIR - Override data directory\n\
229             #   PC_STORAGE_BACKEND - Storage backend: \"rocksdb\" or \"surrealdb\"\n\
230             #   PC_SURREALDB_ENDPOINT - SurrealDB WebSocket endpoint (e.g., \"ws://localhost:8000\")\n\
231             #   PC_SURREALDB_USER - SurrealDB username\n\
232             #   PC_SURREALDB_PASS - SurrealDB password\n\
233             # \n\
234             # Priority: Environment > Config file > Defaults\n\n\
235             {}",
236            toml_content
237        );
238
239        fs::write(&config_path, commented_toml)
240            .map_err(|e| format!("Failed to write config file: {}", e))?;
241
242        Ok(config_path)
243    }
244
245    /// Validate configuration values
246    pub fn validate(&self) -> Result<(), String> {
247        // Validate host
248        if self.host.is_empty() {
249            return Err("Host cannot be empty".to_string());
250        }
251
252        // Validate port range
253        if self.port == 0 {
254            return Err("Port cannot be 0".to_string());
255        }
256
257        // Validate data directory is not empty
258        if self.data_directory.is_empty() {
259            return Err("Data directory cannot be empty".to_string());
260        }
261
262        Ok(())
263    }
264}
265
266/// Get default config file path: ~/.post-cortex/daemon.toml
267fn default_config_path() -> PathBuf {
268    dirs::home_dir()
269        .unwrap_or_else(|| PathBuf::from("."))
270        .join(".post-cortex")
271        .join("daemon.toml")
272}
273
274/// Get default data directory: ~/.post-cortex/data
275fn default_data_dir() -> String {
276    dirs::home_dir()
277        .unwrap_or_else(|| PathBuf::from("."))
278        .join(".post-cortex/data")
279        .to_str()
280        .unwrap()
281        .to_string()
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use std::env;
288
289    #[test]
290    fn test_default_config() {
291        let config = DaemonConfig::default();
292        assert_eq!(config.host, "127.0.0.1");
293        assert_eq!(config.port, 3737);
294        assert!(config.data_directory.contains(".post-cortex/data"));
295    }
296
297    #[test]
298    fn test_config_validation() {
299        let config = DaemonConfig::default();
300        assert!(config.validate().is_ok());
301
302        let invalid = DaemonConfig {
303            host: "".to_string(),
304            ..Default::default()
305        };
306        assert!(invalid.validate().is_err());
307
308        let invalid_port = DaemonConfig {
309            port: 0,
310            ..Default::default()
311        };
312        assert!(invalid_port.validate().is_err());
313    }
314
315    #[test]
316    fn test_env_override() {
317        // SAFETY: This test runs in isolation and only modifies env vars
318        // for the duration of the test. No other tests depend on these env vars.
319        #[allow(unsafe_code)]
320        unsafe {
321            env::set_var("PC_HOST", "0.0.0.0");
322            env::set_var("PC_PORT", "8080");
323            env::set_var("PC_DATA_DIR", "/tmp/test-data");
324        }
325
326        let config = DaemonConfig::load();
327
328        assert_eq!(config.host, "0.0.0.0");
329        assert_eq!(config.port, 8080);
330        assert_eq!(config.data_directory, "/tmp/test-data");
331
332        // SAFETY: Cleanup of env vars set above
333        #[allow(unsafe_code)]
334        unsafe {
335            env::remove_var("PC_HOST");
336            env::remove_var("PC_PORT");
337            env::remove_var("PC_DATA_DIR");
338        }
339    }
340
341    #[test]
342    fn test_toml_serialization() {
343        let config = DaemonConfig::default();
344        let toml_str = toml::to_string(&config).unwrap();
345        let parsed: DaemonConfig = toml::from_str(&toml_str).unwrap();
346
347        assert_eq!(config.host, parsed.host);
348        assert_eq!(config.port, parsed.port);
349        assert_eq!(config.data_directory, parsed.data_directory);
350    }
351}