use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
pub fn system_config_dir() -> PathBuf {
PathBuf::from("/etc/pwr")
}
pub fn system_data_dir() -> PathBuf {
PathBuf::from("/srv/pwr/projects")
}
pub fn user_config_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("pwr")
}
pub fn user_data_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("pwr")
}
pub fn user_runtime_dir() -> PathBuf {
dirs::runtime_dir()
.unwrap_or_else(|| dirs::cache_dir().unwrap_or_else(|| PathBuf::from("~/.cache")))
.join("pwr")
}
pub fn is_path_writable(path: &Path) -> bool {
if path.exists() {
return access_w_ok(path);
}
let mut ancestor = path.to_path_buf();
loop {
if !ancestor.pop() {
return false; }
if ancestor.exists() {
return access_w_ok(&ancestor);
}
}
}
fn access_w_ok(path: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let bytes = path.as_os_str().as_bytes();
let c_path = std::ffi::CString::new(bytes).unwrap_or_else(|_| std::ffi::CString::new(".").unwrap());
unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 }
}
pub fn resolve_config_base() -> PathBuf {
let system = system_config_dir();
if is_path_writable(&system) {
system
} else {
user_config_dir()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub version: u32,
#[serde(default = "default_listen_address")]
pub listen_address: String,
#[serde(default = "default_port")]
pub listen_port: u16,
#[serde(default = "default_storage_path")]
pub storage_base_path: PathBuf,
#[serde(default = "default_max_size_gb")]
pub max_project_size_gb: u64,
pub tls_cert_path: PathBuf,
pub tls_key_path: PathBuf,
pub auth_token: String,
#[serde(default = "default_max_connections")]
pub max_connections: usize,
#[serde(default = "default_idle_timeout")]
pub idle_timeout_secs: u64,
}
fn default_listen_address() -> String {
"0.0.0.0".into()
}
fn default_port() -> u16 {
9742
}
fn default_storage_path() -> PathBuf {
PathBuf::from("/srv/pwr/projects")
}
fn default_max_size_gb() -> u64 {
500
}
fn default_max_connections() -> usize {
32
}
fn default_idle_timeout() -> u64 {
300
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
version: 1,
listen_address: default_listen_address(),
listen_port: default_port(),
storage_base_path: default_storage_path(),
max_project_size_gb: default_max_size_gb(),
tls_cert_path: PathBuf::from("/etc/pwr/server.crt"),
tls_key_path: PathBuf::from("/etc/pwr/server.key"),
auth_token: String::new(),
max_connections: default_max_connections(),
idle_timeout_secs: default_idle_timeout(),
}
}
}
impl ServerConfig {
pub fn validate(&self) -> Result<(), String> {
if self.listen_port == 0 {
return Err("listen_port must be non-zero".into());
}
if self.auth_token.is_empty() {
return Err("auth_token must not be empty".into());
}
if self.max_project_size_gb == 0 {
return Err("max_project_size_gb must be positive".into());
}
if self.max_connections == 0 {
return Err("max_connections must be positive".into());
}
Ok(())
}
pub fn bind_addr(&self) -> String {
format!("{}:{}", self.listen_address, self.listen_port)
}
pub fn project_dir(&self, uuid: &uuid::Uuid) -> PathBuf {
self.storage_base_path.join(uuid.to_string())
}
pub fn registry_path(&self) -> PathBuf {
self.storage_base_path.join("registry.json")
}
}
pub fn find_config(explicit_path: Option<&Path>) -> Option<PathBuf> {
if let Some(p) = explicit_path {
if p.exists() {
return Some(p.to_path_buf());
}
}
let candidates = [
PathBuf::from("server.toml"),
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("pwr")
.join("server.toml"),
PathBuf::from("/etc/pwr/server.toml"),
];
for candidate in &candidates {
if candidate.exists() {
return Some(candidate.clone());
}
}
None
}
pub fn load_config(path: &Path) -> Result<ServerConfig, String> {
let contents = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let config: ServerConfig = toml::from_str(&contents)
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
config.validate()?;
Ok(config)
}
pub fn save_config(config: &ServerConfig, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
}
let contents = toml::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(path, &contents)
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_passes_validation() {
let mut cfg = ServerConfig::default();
cfg.auth_token = "test-token-0123456789abcdef".into();
assert!(cfg.validate().is_ok());
}
#[test]
fn test_empty_auth_token_fails_validation() {
let cfg = ServerConfig::default();
assert!(cfg.validate().is_err());
}
#[test]
fn test_zero_port_fails_validation() {
let mut cfg = ServerConfig::default();
cfg.listen_port = 0;
cfg.auth_token = "test".into();
assert!(cfg.validate().is_err());
}
#[test]
fn test_bind_addr_format() {
let mut cfg = ServerConfig::default();
cfg.listen_address = "192.168.1.100".into();
cfg.listen_port = 8443;
assert_eq!(cfg.bind_addr(), "192.168.1.100:8443");
}
#[test]
fn test_project_dir() {
let cfg = ServerConfig::default();
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let dir = cfg.project_dir(&uuid);
assert!(dir.to_string_lossy().contains("550e8400"));
}
#[test]
fn test_registry_path() {
let cfg = ServerConfig::default();
assert!(cfg
.registry_path()
.to_string_lossy()
.ends_with("registry.json"));
}
#[test]
fn test_save_and_load_round_trip() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("server.toml");
let mut cfg = ServerConfig::default();
cfg.auth_token = "roundtrip-test-token".into();
cfg.listen_port = 12345;
save_config(&cfg, &path).unwrap();
let loaded = load_config(&path).unwrap();
assert_eq!(loaded.listen_port, 12345);
assert_eq!(loaded.auth_token, "roundtrip-test-token");
assert_eq!(loaded.version, 1);
}
}