1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ServerConfig {
12 pub version: u32,
14
15 #[serde(default = "default_listen_address")]
18 pub listen_address: String,
19
20 #[serde(default = "default_port")]
23 pub listen_port: u16,
24
25 #[serde(default = "default_storage_path")]
28 pub storage_base_path: PathBuf,
29
30 #[serde(default = "default_max_size_gb")]
33 pub max_project_size_gb: u64,
34
35 pub tls_cert_path: PathBuf,
38
39 pub tls_key_path: PathBuf,
42
43 pub auth_token: String,
46
47 #[serde(default = "default_max_connections")]
51 pub max_connections: usize,
52
53 #[serde(default = "default_idle_timeout")]
56 pub idle_timeout_secs: u64,
57}
58
59fn default_listen_address() -> String {
60 "0.0.0.0".into()
61}
62
63fn default_port() -> u16 {
64 9742
65}
66
67fn default_storage_path() -> PathBuf {
68 PathBuf::from("/srv/pwr/projects")
69}
70
71fn default_max_size_gb() -> u64 {
72 500
73}
74
75fn default_max_connections() -> usize {
76 32
77}
78
79fn default_idle_timeout() -> u64 {
80 300
81}
82
83impl Default for ServerConfig {
84 fn default() -> Self {
85 Self {
86 version: 1,
87 listen_address: default_listen_address(),
88 listen_port: default_port(),
89 storage_base_path: default_storage_path(),
90 max_project_size_gb: default_max_size_gb(),
91 tls_cert_path: PathBuf::from("/etc/pwr/server.crt"),
92 tls_key_path: PathBuf::from("/etc/pwr/server.key"),
93 auth_token: String::new(),
94 max_connections: default_max_connections(),
95 idle_timeout_secs: default_idle_timeout(),
96 }
97 }
98}
99
100impl ServerConfig {
101 pub fn validate(&self) -> Result<(), String> {
107 if self.listen_port == 0 {
108 return Err("listen_port must be non-zero".into());
109 }
110 if self.auth_token.is_empty() {
111 return Err("auth_token must not be empty".into());
112 }
113 if self.max_project_size_gb == 0 {
114 return Err("max_project_size_gb must be positive".into());
115 }
116 if self.max_connections == 0 {
117 return Err("max_connections must be positive".into());
118 }
119 Ok(())
120 }
121
122 pub fn bind_addr(&self) -> String {
124 format!("{}:{}", self.listen_address, self.listen_port)
125 }
126
127 pub fn project_dir(&self, uuid: &uuid::Uuid) -> PathBuf {
129 self.storage_base_path.join(uuid.to_string())
130 }
131
132 pub fn registry_path(&self) -> PathBuf {
134 self.storage_base_path.join("registry.json")
135 }
136}
137
138pub fn find_config(explicit_path: Option<&Path>) -> Option<PathBuf> {
146 if let Some(p) = explicit_path {
147 if p.exists() {
148 return Some(p.to_path_buf());
149 }
150 }
151
152 let candidates = [
153 PathBuf::from("server.toml"),
154 dirs::config_dir()
155 .unwrap_or_else(|| PathBuf::from("~/.config"))
156 .join("pwr")
157 .join("server.toml"),
158 PathBuf::from("/etc/pwr/server.toml"),
159 ];
160
161 for candidate in &candidates {
162 if candidate.exists() {
163 return Some(candidate.clone());
164 }
165 }
166
167 None
168}
169
170pub fn load_config(path: &Path) -> Result<ServerConfig, String> {
172 let contents = fs::read_to_string(path)
173 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
174
175 let config: ServerConfig = toml::from_str(&contents)
176 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
177
178 config.validate()?;
179
180 Ok(config)
181}
182
183pub fn save_config(config: &ServerConfig, path: &Path) -> Result<(), String> {
186 if let Some(parent) = path.parent() {
187 fs::create_dir_all(parent)
188 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
189 }
190
191 let contents = toml::to_string_pretty(config)
192 .map_err(|e| format!("Failed to serialize config: {}", e))?;
193
194 fs::write(path, &contents)
195 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
196
197 Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn test_default_config_passes_validation() {
206 let mut cfg = ServerConfig::default();
207 cfg.auth_token = "test-token-0123456789abcdef".into();
208 assert!(cfg.validate().is_ok());
209 }
210
211 #[test]
212 fn test_empty_auth_token_fails_validation() {
213 let cfg = ServerConfig::default();
214 assert!(cfg.validate().is_err());
215 }
216
217 #[test]
218 fn test_zero_port_fails_validation() {
219 let mut cfg = ServerConfig::default();
220 cfg.listen_port = 0;
221 cfg.auth_token = "test".into();
222 assert!(cfg.validate().is_err());
223 }
224
225 #[test]
226 fn test_bind_addr_format() {
227 let mut cfg = ServerConfig::default();
228 cfg.listen_address = "192.168.1.100".into();
229 cfg.listen_port = 8443;
230 assert_eq!(cfg.bind_addr(), "192.168.1.100:8443");
231 }
232
233 #[test]
234 fn test_project_dir() {
235 let cfg = ServerConfig::default();
236 let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
237 let dir = cfg.project_dir(&uuid);
238 assert!(dir.to_string_lossy().contains("550e8400"));
239 }
240
241 #[test]
242 fn test_registry_path() {
243 let cfg = ServerConfig::default();
244 assert!(cfg
245 .registry_path()
246 .to_string_lossy()
247 .ends_with("registry.json"));
248 }
249
250 #[test]
251 fn test_save_and_load_round_trip() {
252 let tmp = tempfile::TempDir::new().unwrap();
253 let path = tmp.path().join("server.toml");
254
255 let mut cfg = ServerConfig::default();
256 cfg.auth_token = "roundtrip-test-token".into();
257 cfg.listen_port = 12345;
258
259 save_config(&cfg, &path).unwrap();
260 let loaded = load_config(&path).unwrap();
261
262 assert_eq!(loaded.listen_port, 12345);
263 assert_eq!(loaded.auth_token, "roundtrip-test-token");
264 assert_eq!(loaded.version, 1);
265 }
266}