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 {
48 if path.exists() {
50 return access_w_ok(path);
51 }
52
53 let mut ancestor = path.to_path_buf();
55 loop {
56 if !ancestor.pop() {
57 return false; }
59 if ancestor.exists() {
60 return access_w_ok(&ancestor);
61 }
62 }
63}
64
65fn access_w_ok(path: &Path) -> bool {
67 use std::os::unix::ffi::OsStrExt;
68 let bytes = path.as_os_str().as_bytes();
69 let c_path = std::ffi::CString::new(bytes).unwrap_or_else(|_| std::ffi::CString::new(".").unwrap());
70 unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 }
71}
72
73pub fn resolve_config_base() -> PathBuf {
78 let system = system_config_dir();
79 if is_path_writable(&system) {
80 system
81 } else {
82 user_config_dir()
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ServerConfig {
93 pub version: u32,
95
96 #[serde(default = "default_listen_address")]
99 pub listen_address: String,
100
101 #[serde(default = "default_port")]
104 pub listen_port: u16,
105
106 #[serde(default = "default_storage_path")]
109 pub storage_base_path: PathBuf,
110
111 #[serde(default = "default_max_size_gb")]
114 pub max_project_size_gb: u64,
115
116 pub tls_cert_path: PathBuf,
119
120 pub tls_key_path: PathBuf,
123
124 pub auth_token: String,
127
128 #[serde(default = "default_max_connections")]
132 pub max_connections: usize,
133
134 #[serde(default = "default_idle_timeout")]
137 pub idle_timeout_secs: u64,
138}
139
140fn default_listen_address() -> String {
141 "0.0.0.0".into()
142}
143
144fn default_port() -> u16 {
145 9742
146}
147
148fn default_storage_path() -> PathBuf {
149 PathBuf::from("/srv/pwr/projects")
150}
151
152fn default_max_size_gb() -> u64 {
153 500
154}
155
156fn default_max_connections() -> usize {
157 32
158}
159
160fn default_idle_timeout() -> u64 {
161 300
162}
163
164impl Default for ServerConfig {
165 fn default() -> Self {
166 Self {
167 version: 1,
168 listen_address: default_listen_address(),
169 listen_port: default_port(),
170 storage_base_path: default_storage_path(),
171 max_project_size_gb: default_max_size_gb(),
172 tls_cert_path: PathBuf::from("/etc/pwr/server.crt"),
173 tls_key_path: PathBuf::from("/etc/pwr/server.key"),
174 auth_token: String::new(),
175 max_connections: default_max_connections(),
176 idle_timeout_secs: default_idle_timeout(),
177 }
178 }
179}
180
181impl ServerConfig {
182 pub fn validate(&self) -> Result<(), String> {
188 if self.listen_port == 0 {
189 return Err("listen_port must be non-zero".into());
190 }
191 if self.auth_token.is_empty() {
192 return Err("auth_token must not be empty".into());
193 }
194 if self.max_project_size_gb == 0 {
195 return Err("max_project_size_gb must be positive".into());
196 }
197 if self.max_connections == 0 {
198 return Err("max_connections must be positive".into());
199 }
200 Ok(())
201 }
202
203 pub fn bind_addr(&self) -> String {
205 format!("{}:{}", self.listen_address, self.listen_port)
206 }
207
208 pub fn project_dir(&self, uuid: &uuid::Uuid) -> PathBuf {
210 self.storage_base_path.join(uuid.to_string())
211 }
212
213 pub fn registry_path(&self) -> PathBuf {
215 self.storage_base_path.join("registry.json")
216 }
217}
218
219pub fn find_config(explicit_path: Option<&Path>) -> Option<PathBuf> {
227 if let Some(p) = explicit_path {
228 if p.exists() {
229 return Some(p.to_path_buf());
230 }
231 }
232
233 let candidates = [
234 PathBuf::from("server.toml"),
235 dirs::config_dir()
236 .unwrap_or_else(|| PathBuf::from("~/.config"))
237 .join("pwr")
238 .join("server.toml"),
239 PathBuf::from("/etc/pwr/server.toml"),
240 ];
241
242 for candidate in &candidates {
243 if candidate.exists() {
244 return Some(candidate.clone());
245 }
246 }
247
248 None
249}
250
251pub fn load_config(path: &Path) -> Result<ServerConfig, String> {
253 let contents = fs::read_to_string(path)
254 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
255
256 let config: ServerConfig = toml::from_str(&contents)
257 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
258
259 config.validate()?;
260
261 Ok(config)
262}
263
264pub fn save_config(config: &ServerConfig, path: &Path) -> Result<(), String> {
267 if let Some(parent) = path.parent() {
268 fs::create_dir_all(parent)
269 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
270 }
271
272 let contents = toml::to_string_pretty(config)
273 .map_err(|e| format!("Failed to serialize config: {}", e))?;
274
275 fs::write(path, &contents)
276 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
277
278 Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_default_config_passes_validation() {
287 let mut cfg = ServerConfig::default();
288 cfg.auth_token = "test-token-0123456789abcdef".into();
289 assert!(cfg.validate().is_ok());
290 }
291
292 #[test]
293 fn test_empty_auth_token_fails_validation() {
294 let cfg = ServerConfig::default();
295 assert!(cfg.validate().is_err());
296 }
297
298 #[test]
299 fn test_zero_port_fails_validation() {
300 let mut cfg = ServerConfig::default();
301 cfg.listen_port = 0;
302 cfg.auth_token = "test".into();
303 assert!(cfg.validate().is_err());
304 }
305
306 #[test]
307 fn test_bind_addr_format() {
308 let mut cfg = ServerConfig::default();
309 cfg.listen_address = "192.168.1.100".into();
310 cfg.listen_port = 8443;
311 assert_eq!(cfg.bind_addr(), "192.168.1.100:8443");
312 }
313
314 #[test]
315 fn test_project_dir() {
316 let cfg = ServerConfig::default();
317 let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
318 let dir = cfg.project_dir(&uuid);
319 assert!(dir.to_string_lossy().contains("550e8400"));
320 }
321
322 #[test]
323 fn test_registry_path() {
324 let cfg = ServerConfig::default();
325 assert!(cfg
326 .registry_path()
327 .to_string_lossy()
328 .ends_with("registry.json"));
329 }
330
331 #[test]
332 fn test_save_and_load_round_trip() {
333 let tmp = tempfile::TempDir::new().unwrap();
334 let path = tmp.path().join("server.toml");
335
336 let mut cfg = ServerConfig::default();
337 cfg.auth_token = "roundtrip-test-token".into();
338 cfg.listen_port = 12345;
339
340 save_config(&cfg, &path).unwrap();
341 let loaded = load_config(&path).unwrap();
342
343 assert_eq!(loaded.listen_port, 12345);
344 assert_eq!(loaded.auth_token, "roundtrip-test-token");
345 assert_eq!(loaded.version, 1);
346 }
347}