1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5pub fn system_config_dir() -> PathBuf {
11 PathBuf::from("/etc/pwr")
12}
13
14pub fn system_data_dir() -> PathBuf {
16 PathBuf::from("/srv/pwr/projects")
17}
18
19pub fn user_config_dir() -> PathBuf {
21 dirs::config_dir()
22 .unwrap_or_else(|| PathBuf::from("~/.config"))
23 .join("pwr")
24}
25
26pub fn user_data_dir() -> PathBuf {
28 dirs::data_dir()
29 .unwrap_or_else(|| PathBuf::from("~/.local/share"))
30 .join("pwr")
31}
32
33pub fn user_runtime_dir() -> PathBuf {
36 dirs::runtime_dir()
37 .unwrap_or_else(|| dirs::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache")))
38 .join("pwr")
39}
40
41pub fn is_path_writable(path: &Path) -> bool {
45 if path.exists() {
47 return fs::metadata(path)
48 .map(|m| !m.permissions().readonly())
49 .unwrap_or(false);
50 }
51
52 let mut ancestor = path.to_path_buf();
54 loop {
55 if !ancestor.pop() {
56 return false; }
58 if ancestor.exists() {
59 return fs::metadata(&ancestor)
60 .map(|m| !m.permissions().readonly())
61 .unwrap_or(false);
62 }
63 }
64}
65
66pub fn resolve_config_base() -> PathBuf {
71 let system = system_config_dir();
72 if is_path_writable(&system) {
73 system
74 } else {
75 user_config_dir()
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ServerConfig {
86 pub version: u32,
88
89 #[serde(default = "default_listen_address")]
92 pub listen_address: String,
93
94 #[serde(default = "default_port")]
97 pub listen_port: u16,
98
99 #[serde(default = "default_storage_path")]
102 pub storage_base_path: PathBuf,
103
104 #[serde(default = "default_max_size_gb")]
107 pub max_project_size_gb: u64,
108
109 pub tls_cert_path: PathBuf,
112
113 pub tls_key_path: PathBuf,
116
117 pub auth_token: String,
120
121 #[serde(default = "default_max_connections")]
125 pub max_connections: usize,
126
127 #[serde(default = "default_idle_timeout")]
130 pub idle_timeout_secs: u64,
131}
132
133fn default_listen_address() -> String {
134 "0.0.0.0".into()
135}
136
137fn default_port() -> u16 {
138 9742
139}
140
141fn default_storage_path() -> PathBuf {
142 PathBuf::from("/srv/pwr/projects")
143}
144
145fn default_max_size_gb() -> u64 {
146 500
147}
148
149fn default_max_connections() -> usize {
150 32
151}
152
153fn default_idle_timeout() -> u64 {
154 300
155}
156
157impl Default for ServerConfig {
158 fn default() -> Self {
159 Self {
160 version: 1,
161 listen_address: default_listen_address(),
162 listen_port: default_port(),
163 storage_base_path: default_storage_path(),
164 max_project_size_gb: default_max_size_gb(),
165 tls_cert_path: PathBuf::from("/etc/pwr/server.crt"),
166 tls_key_path: PathBuf::from("/etc/pwr/server.key"),
167 auth_token: String::new(),
168 max_connections: default_max_connections(),
169 idle_timeout_secs: default_idle_timeout(),
170 }
171 }
172}
173
174impl ServerConfig {
175 pub fn validate(&self) -> Result<(), String> {
181 if self.listen_port == 0 {
182 return Err("listen_port must be non-zero".into());
183 }
184 if self.auth_token.is_empty() {
185 return Err("auth_token must not be empty".into());
186 }
187 if self.max_project_size_gb == 0 {
188 return Err("max_project_size_gb must be positive".into());
189 }
190 if self.max_connections == 0 {
191 return Err("max_connections must be positive".into());
192 }
193 Ok(())
194 }
195
196 pub fn bind_addr(&self) -> String {
198 format!("{}:{}", self.listen_address, self.listen_port)
199 }
200
201 pub fn project_dir(&self, uuid: &uuid::Uuid) -> PathBuf {
203 self.storage_base_path.join(uuid.to_string())
204 }
205
206 pub fn registry_path(&self) -> PathBuf {
208 self.storage_base_path.join("registry.json")
209 }
210}
211
212pub fn find_config(explicit_path: Option<&Path>) -> Option<PathBuf> {
220 if let Some(p) = explicit_path {
221 if p.exists() {
222 return Some(p.to_path_buf());
223 }
224 }
225
226 let candidates = [
227 PathBuf::from("server.toml"),
228 dirs::config_dir()
229 .unwrap_or_else(|| PathBuf::from("~/.config"))
230 .join("pwr")
231 .join("server.toml"),
232 PathBuf::from("/etc/pwr/server.toml"),
233 ];
234
235 for candidate in &candidates {
236 if candidate.exists() {
237 return Some(candidate.clone());
238 }
239 }
240
241 None
242}
243
244pub fn load_config(path: &Path) -> Result<ServerConfig, String> {
246 let contents = fs::read_to_string(path)
247 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
248
249 let config: ServerConfig = toml::from_str(&contents)
250 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
251
252 config.validate()?;
253
254 Ok(config)
255}
256
257pub fn save_config(config: &ServerConfig, path: &Path) -> Result<(), String> {
260 if let Some(parent) = path.parent() {
261 fs::create_dir_all(parent)
262 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
263 }
264
265 let contents = toml::to_string_pretty(config)
266 .map_err(|e| format!("Failed to serialize config: {}", e))?;
267
268 fs::write(path, &contents)
269 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
270
271 Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn test_default_config_passes_validation() {
280 let mut cfg = ServerConfig::default();
281 cfg.auth_token = "test-token-0123456789abcdef".into();
282 assert!(cfg.validate().is_ok());
283 }
284
285 #[test]
286 fn test_empty_auth_token_fails_validation() {
287 let cfg = ServerConfig::default();
288 assert!(cfg.validate().is_err());
289 }
290
291 #[test]
292 fn test_zero_port_fails_validation() {
293 let mut cfg = ServerConfig::default();
294 cfg.listen_port = 0;
295 cfg.auth_token = "test".into();
296 assert!(cfg.validate().is_err());
297 }
298
299 #[test]
300 fn test_bind_addr_format() {
301 let mut cfg = ServerConfig::default();
302 cfg.listen_address = "192.168.1.100".into();
303 cfg.listen_port = 8443;
304 assert_eq!(cfg.bind_addr(), "192.168.1.100:8443");
305 }
306
307 #[test]
308 fn test_project_dir() {
309 let cfg = ServerConfig::default();
310 let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
311 let dir = cfg.project_dir(&uuid);
312 assert!(dir.to_string_lossy().contains("550e8400"));
313 }
314
315 #[test]
316 fn test_registry_path() {
317 let cfg = ServerConfig::default();
318 assert!(cfg
319 .registry_path()
320 .to_string_lossy()
321 .ends_with("registry.json"));
322 }
323
324 #[test]
325 fn test_save_and_load_round_trip() {
326 let tmp = tempfile::TempDir::new().unwrap();
327 let path = tmp.path().join("server.toml");
328
329 let mut cfg = ServerConfig::default();
330 cfg.auth_token = "roundtrip-test-token".into();
331 cfg.listen_port = 12345;
332
333 save_config(&cfg, &path).unwrap();
334 let loaded = load_config(&path).unwrap();
335
336 assert_eq!(loaded.listen_port, 12345);
337 assert_eq!(loaded.auth_token, "roundtrip-test-token");
338 assert_eq!(loaded.version, 1);
339 }
340}