#![allow(unsafe_code)]
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::endpoint::{Endpoint, DEFAULT_PORT, MAX_ENDPOINT_NAME_LEN};
pub const MIN_REFRESH_SECONDS: u64 = 1;
pub const MAX_REFRESH_SECONDS: u64 = 3600;
pub const MIN_REQUEST_TIMEOUT_MS: u64 = 100;
pub const MAX_CONCURRENT_REQUESTS: u32 = 64;
pub const MIN_PORT: u16 = 1;
pub const MAX_PORT: u16 = 65535;
pub const SUPPORTED_CONFIG_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SystemEntry {
pub id: String,
pub host: String,
pub port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl SystemEntry {
#[must_use]
pub fn to_endpoint(&self) -> Endpoint {
Endpoint {
id: self.id.clone(),
host: self.host.clone(),
port: self.port,
name: self.name.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
#[allow(clippy::struct_field_names)]
pub struct Config {
pub config_version: u32,
pub refresh_seconds: u64,
pub request_timeout_ms: u64,
pub max_concurrent_requests: u32,
pub default_port: u16,
#[serde(default)]
pub systems: Vec<SystemEntry>,
}
impl Default for Config {
fn default() -> Self {
Self {
config_version: SUPPORTED_CONFIG_VERSION,
refresh_seconds: 5,
request_timeout_ms: 1500,
max_concurrent_requests: 16,
default_port: DEFAULT_PORT,
systems: Vec::new(),
}
}
}
impl Config {
#[must_use]
pub fn validate(&self) -> Vec<ConfigViolation> {
let mut violations = Vec::new();
if self.config_version != SUPPORTED_CONFIG_VERSION {
violations.push(ConfigViolation::UnsupportedConfigVersion(
self.config_version,
));
}
if self.refresh_seconds < MIN_REFRESH_SECONDS || self.refresh_seconds > MAX_REFRESH_SECONDS
{
violations.push(ConfigViolation::InvalidRefreshSeconds(self.refresh_seconds));
}
if self.request_timeout_ms < MIN_REQUEST_TIMEOUT_MS {
violations.push(ConfigViolation::InvalidRequestTimeout(
self.request_timeout_ms,
));
}
if self.max_concurrent_requests == 0
|| self.max_concurrent_requests > MAX_CONCURRENT_REQUESTS
{
violations.push(ConfigViolation::InvalidMaxConcurrentRequests(
self.max_concurrent_requests,
));
}
if self.default_port < MIN_PORT {
violations.push(ConfigViolation::InvalidPort(self.default_port));
}
let mut seen_ids = std::collections::HashSet::new();
let mut seen_addresses = std::collections::HashSet::new();
for system in &self.systems {
if !seen_ids.insert(&system.id) {
violations.push(ConfigViolation::DuplicateEndpointId {
id: system.id.clone(),
});
}
let normalized = format!("{}:{}", system.host.to_lowercase(), system.port);
if !seen_addresses.insert(normalized.clone()) {
violations.push(ConfigViolation::DuplicateAddress {
address: normalized,
});
}
let host = system.host.trim();
if host.is_empty() {
violations.push(ConfigViolation::EmptyHost {
id: system.id.clone(),
});
} else if host.contains("://") || host.contains('/') || host.contains('?') {
violations.push(ConfigViolation::InvalidHost {
id: system.id.clone(),
host: host.to_string(),
});
}
if system.port < MIN_PORT {
violations.push(ConfigViolation::InvalidEndpointPort {
id: system.id.clone(),
port: system.port,
});
}
if let Some(name) = &system.name {
let trimmed = name.trim();
if trimmed.is_empty() {
violations.push(ConfigViolation::EmptyName {
id: system.id.clone(),
});
} else if trimmed.len() > MAX_ENDPOINT_NAME_LEN {
violations.push(ConfigViolation::NameTooLong {
id: system.id.clone(),
length: trimmed.len(),
max: MAX_ENDPOINT_NAME_LEN,
});
}
}
}
violations
}
#[must_use]
#[allow(dead_code)]
pub fn is_valid(&self) -> bool {
self.validate().is_empty()
}
#[must_use]
pub fn default_path() -> PathBuf {
#[cfg(target_os = "linux")]
{
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("gregg").join("gregg.toml")
} else {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
.join(".config")
.join("gregg")
.join("gregg.toml")
}
}
#[cfg(target_os = "macos")]
{
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
.join("Library")
.join("Application Support")
.join("gregg")
.join("gregg.toml")
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
PathBuf::from("gregg.toml")
}
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path).map_err(|e| ConfigError::Io {
path: path.to_path_buf(),
source: e,
})?;
Self::parse(&content, Some(path))
}
pub fn parse(content: &str, path: Option<&Path>) -> Result<Self, ConfigError> {
let config: Self = toml::from_str(content).map_err(|e| ConfigError::Parse {
path: path.map(PathBuf::from),
source: e,
})?;
let violations = config.validate();
if violations.is_empty() {
Ok(config)
} else {
Err(ConfigError::Validation(violations))
}
}
#[must_use]
pub fn to_toml(&self) -> String {
toml::to_string_pretty(self).expect("Config serializes to TOML")
}
pub fn write_atomic(&self, path: &Path) -> Result<(), ConfigError> {
let dir = path.parent().ok_or_else(|| ConfigError::AtomicWrite {
path: path.to_path_buf(),
source: AtomicWriteError::NoParentDirectory,
})?;
fs::create_dir_all(dir).map_err(|e| ConfigError::AtomicWrite {
path: path.to_path_buf(),
source: AtomicWriteError::Io(e),
})?;
let content = self.to_toml();
let temp_name = format!(".gregg-{}.toml.tmp", std::process::id());
let temp_path = dir.join(&temp_name);
fs::write(&temp_path, content.as_bytes()).map_err(|e| {
let _ = fs::remove_file(&temp_path);
ConfigError::AtomicWrite {
path: path.to_path_buf(),
source: AtomicWriteError::Io(e),
}
})?;
#[cfg(unix)]
{
let file = fs::OpenOptions::new()
.write(true)
.open(&temp_path)
.map_err(|e| {
let _ = fs::remove_file(&temp_path);
ConfigError::AtomicWrite {
path: path.to_path_buf(),
source: AtomicWriteError::Io(e),
}
})?;
file.sync_all().map_err(|e| {
let _ = fs::remove_file(&temp_path);
ConfigError::AtomicWrite {
path: path.to_path_buf(),
source: AtomicWriteError::Io(e),
}
})?;
}
fs::rename(&temp_path, path).map_err(|e| {
let _ = fs::remove_file(&temp_path);
ConfigError::AtomicWrite {
path: path.to_path_buf(),
source: AtomicWriteError::Io(e),
}
})?;
Ok(())
}
}
pub struct ConfigStore {
path: PathBuf,
lock: Mutex<()>,
}
impl ConfigStore {
#[must_use]
pub fn new(path: PathBuf) -> Self {
Self {
path,
lock: Mutex::new(()),
}
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[allow(dead_code)]
pub fn load_existing(&self) -> Result<Config, ConfigError> {
Config::load(&self.path)
}
pub fn load_or_default(&self) -> Result<Config, ConfigError> {
if self.path.exists() {
Config::load(&self.path)
} else {
Ok(Config::default())
}
}
pub fn write(&self, config: &Config) -> Result<(), ConfigError> {
config.write_atomic(&self.path)
}
pub fn mutate(
&self,
f: impl FnOnce(&mut Config) -> Result<(), ConfigError>,
) -> Result<(), ConfigError> {
let _guard = self.lock.lock().map_err(|_| ConfigError::LockPoisoned)?;
let mut config = self.load_or_default()?;
f(&mut config)?;
let violations = config.validate();
if !violations.is_empty() {
return Err(ConfigError::Validation(violations));
}
self.write(&config)
}
pub fn mutate_with_result<T>(
&self,
f: impl FnOnce(&mut Config) -> Result<T, ConfigError>,
) -> Result<T, ConfigError> {
let _guard = self.lock.lock().map_err(|_| ConfigError::LockPoisoned)?;
let mut config = self.load_or_default()?;
let result = f(&mut config)?;
let violations = config.validate();
if !violations.is_empty() {
return Err(ConfigError::Validation(violations));
}
self.write(&config)?;
Ok(result)
}
}
#[allow(dead_code)]
pub struct AdvisoryLock {
lock_path: PathBuf,
held: AtomicBool,
}
#[allow(dead_code)]
impl AdvisoryLock {
#[must_use]
pub fn new(lock_path: PathBuf) -> Self {
Self {
lock_path,
held: AtomicBool::new(false),
}
}
pub fn try_acquire(&self) -> bool {
match fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&self.lock_path)
{
Ok(file) => {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if result == 0 {
self.held.store(true, Ordering::SeqCst);
let _ = std::io::Write::write_all(
&mut std::io::BufWriter::new(&file),
format!("{}\n", std::process::id()).as_bytes(),
);
true
} else {
false
}
}
#[cfg(not(unix))]
{
self.held.store(true, Ordering::SeqCst);
true
}
}
Err(_) => false,
}
}
pub fn release(&self) {
self.held.store(false, Ordering::SeqCst);
let _ = fs::remove_file(&self.lock_path);
}
#[must_use]
pub fn is_held(&self) -> bool {
self.held.load(Ordering::SeqCst)
}
}
impl Drop for AdvisoryLock {
fn drop(&mut self) {
self.release();
}
}
#[derive(Debug)]
pub enum ConfigError {
Io {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: Option<PathBuf>,
source: toml::de::Error,
},
Validation(Vec<ConfigViolation>),
AtomicWrite {
path: PathBuf,
source: AtomicWriteError,
},
LockPoisoned,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => write!(f, "failed to read {}: {source}", path.display()),
Self::Parse { path, source } => {
if let Some(p) = path {
write!(f, "failed to parse {}: {source}", p.display())
} else {
write!(f, "failed to parse config: {source}")
}
}
Self::Validation(violations) => {
write!(f, "configuration validation failed:")?;
for v in violations {
write!(f, "\n - {v}")?;
}
Ok(())
}
Self::AtomicWrite { path, source } => {
write!(f, "atomic write to {} failed: {source}", path.display())
}
Self::LockPoisoned => write!(f, "config lock was poisoned"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
Self::AtomicWrite { source, .. } => Some(source),
Self::Validation(_) | Self::LockPoisoned => None,
}
}
}
#[derive(Debug)]
pub enum AtomicWriteError {
NoParentDirectory,
Io(std::io::Error),
}
impl fmt::Display for AtomicWriteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoParentDirectory => write!(f, "path has no parent directory"),
Self::Io(e) => write!(f, "I/O error: {e}"),
}
}
}
impl std::error::Error for AtomicWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::NoParentDirectory => None,
Self::Io(e) => Some(e),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigViolation {
UnsupportedConfigVersion(u32),
InvalidRefreshSeconds(u64),
InvalidRequestTimeout(u64),
InvalidMaxConcurrentRequests(u32),
InvalidPort(u16),
DuplicateEndpointId { id: String },
DuplicateAddress { address: String },
EmptyHost { id: String },
InvalidHost { id: String, host: String },
InvalidEndpointPort { id: String, port: u16 },
EmptyName { id: String },
NameTooLong {
id: String,
length: usize,
max: usize,
},
}
impl fmt::Display for ConfigViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedConfigVersion(v) => {
write!(
f,
"unsupported config_version {v}, expected {SUPPORTED_CONFIG_VERSION}"
)
}
Self::InvalidRefreshSeconds(s) => {
write!(f, "refresh_seconds {s} is outside valid range {MIN_REFRESH_SECONDS}..={MAX_REFRESH_SECONDS}")
}
Self::InvalidRequestTimeout(ms) => {
write!(
f,
"request_timeout_ms {ms} is below minimum {MIN_REQUEST_TIMEOUT_MS}"
)
}
Self::InvalidMaxConcurrentRequests(n) => {
write!(f, "max_concurrent_requests {n} is outside valid range 1..={MAX_CONCURRENT_REQUESTS}")
}
Self::InvalidPort(p) => {
write!(
f,
"default_port {p} is outside valid range {MIN_PORT}..={MAX_PORT}"
)
}
Self::DuplicateEndpointId { id } => {
write!(f, "duplicate endpoint id: {id}")
}
Self::DuplicateAddress { address } => {
write!(f, "duplicate endpoint address: {address}")
}
Self::EmptyHost { id } => {
write!(f, "endpoint {id}: host is empty")
}
Self::InvalidHost { id, host } => {
write!(f, "endpoint {id}: host contains invalid characters: {host}")
}
Self::InvalidEndpointPort { id, port } => {
write!(
f,
"endpoint {id}: port {port} is outside valid range {MIN_PORT}..={MAX_PORT}"
)
}
Self::EmptyName { id } => {
write!(f, "endpoint {id}: name is empty")
}
Self::NameTooLong { id, length, max } => {
write!(
f,
"endpoint {id}: name is {length} characters, exceeds maximum of {max}"
)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("gregg_test_{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn default_config_is_valid() {
let config = Config::default();
assert!(config.is_valid());
assert!(config.validate().is_empty());
}
#[test]
fn default_config_has_correct_values() {
let config = Config::default();
assert_eq!(config.config_version, 1);
assert_eq!(config.refresh_seconds, 5);
assert_eq!(config.request_timeout_ms, 1500);
assert_eq!(config.max_concurrent_requests, 16);
assert_eq!(config.default_port, 11310);
assert!(config.systems.is_empty());
}
#[test]
fn config_round_trips_through_toml() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "test-id".into(),
host: "192.168.1.1".into(),
port: 11310,
name: Some("Test".into()),
});
let toml = config.to_toml();
let parsed = Config::parse(&toml, None).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn unsupported_config_version_fails() {
let config = Config {
config_version: 2,
..Config::default()
};
let violations = config.validate();
assert!(violations.contains(&ConfigViolation::UnsupportedConfigVersion(2)));
}
#[test]
fn refresh_seconds_zero_fails() {
let config = Config {
refresh_seconds: 0,
..Config::default()
};
let violations = config.validate();
assert!(violations.contains(&ConfigViolation::InvalidRefreshSeconds(0)));
}
#[test]
fn refresh_seconds_too_high_fails() {
let config = Config {
refresh_seconds: 3601,
..Config::default()
};
let violations = config.validate();
assert!(violations.contains(&ConfigViolation::InvalidRefreshSeconds(3601)));
}
#[test]
fn refresh_seconds_boundary() {
let config = Config {
refresh_seconds: 1,
..Config::default()
};
assert!(config.is_valid());
let config = Config {
refresh_seconds: 3600,
..Config::default()
};
assert!(config.is_valid());
}
#[test]
fn request_timeout_zero_fails() {
let config = Config {
request_timeout_ms: 0,
..Config::default()
};
let violations = config.validate();
assert!(violations.contains(&ConfigViolation::InvalidRequestTimeout(0)));
}
#[test]
fn max_concurrent_zero_fails() {
let config = Config {
max_concurrent_requests: 0,
..Config::default()
};
let violations = config.validate();
assert!(violations.contains(&ConfigViolation::InvalidMaxConcurrentRequests(0)));
}
#[test]
fn default_port_boundary() {
let config = Config {
default_port: 1,
..Config::default()
};
assert!(config.is_valid());
let config = Config {
default_port: 65535,
..Config::default()
};
assert!(config.is_valid());
}
#[test]
fn duplicate_endpoint_id_fails() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "same-id".into(),
host: "host1".into(),
port: 80,
name: None,
});
config.systems.push(SystemEntry {
id: "same-id".into(),
host: "host2".into(),
port: 80,
name: None,
});
let violations = config.validate();
assert!(violations
.iter()
.any(|v| matches!(v, ConfigViolation::DuplicateEndpointId { .. })));
}
#[test]
fn duplicate_address_fails() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "id1".into(),
host: "192.168.1.1".into(),
port: 80,
name: None,
});
config.systems.push(SystemEntry {
id: "id2".into(),
host: "192.168.1.1".into(),
port: 80,
name: None,
});
let violations = config.validate();
assert!(violations
.iter()
.any(|v| matches!(v, ConfigViolation::DuplicateAddress { .. })));
}
#[test]
fn same_host_different_ports_is_valid() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "id1".into(),
host: "192.168.1.1".into(),
port: 80,
name: None,
});
config.systems.push(SystemEntry {
id: "id2".into(),
host: "192.168.1.1".into(),
port: 443,
name: None,
});
assert!(config.is_valid());
}
#[test]
fn empty_host_fails() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "id1".into(),
host: String::new(),
port: 80,
name: None,
});
let violations = config.validate();
assert!(violations
.iter()
.any(|v| matches!(v, ConfigViolation::EmptyHost { .. })));
}
#[test]
fn host_with_scheme_fails() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "id1".into(),
host: "http://server".into(),
port: 80,
name: None,
});
let violations = config.validate();
assert!(violations
.iter()
.any(|v| matches!(v, ConfigViolation::InvalidHost { .. })));
}
#[test]
fn empty_system_name_fails() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "id1".into(),
host: "server".into(),
port: 80,
name: Some(String::new()),
});
let violations = config.validate();
assert!(violations
.iter()
.any(|v| matches!(v, ConfigViolation::EmptyName { .. })));
}
#[test]
fn long_system_name_fails() {
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "id1".into(),
host: "server".into(),
port: 80,
name: Some("x".repeat(MAX_ENDPOINT_NAME_LEN + 1)),
});
let violations = config.validate();
assert!(violations
.iter()
.any(|v| matches!(v, ConfigViolation::NameTooLong { .. })));
}
#[test]
fn write_atomic_creates_file() {
let dir = tmp_dir("atomic_create");
let path = dir.join("config.toml");
let config = Config::default();
config.write_atomic(&path).unwrap();
let loaded = Config::load(&path).unwrap();
assert_eq!(config, loaded);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_overwrites_existing() {
let dir = tmp_dir("atomic_overwrite");
let path = dir.join("config.toml");
let mut config = Config::default();
config.write_atomic(&path).unwrap();
config.refresh_seconds = 10;
config.write_atomic(&path).unwrap();
let loaded = Config::load(&path).unwrap();
assert_eq!(loaded.refresh_seconds, 10);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_preserves_old_on_failure() {
let dir = tmp_dir("atomic_preserve");
let path = dir.join("config.toml");
let config = Config::default();
config.write_atomic(&path).unwrap();
let result = config.write_atomic(Path::new("/nonexistent_dir/config.toml"));
assert!(result.is_err());
let loaded = Config::load(&path).unwrap();
assert_eq!(config, loaded);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn config_store_load_or_default_empty() {
let dir = tmp_dir("store_default");
let path = dir.join("config.toml");
let store = ConfigStore::new(path.clone());
let config = store.load_or_default().unwrap();
assert_eq!(config, Config::default());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn config_store_load_existing_missing_errors() {
let dir = tmp_dir("store_missing");
let path = dir.join("nonexistent.toml");
let store = ConfigStore::new(path);
assert!(store.load_existing().is_err());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn config_store_write_and_load() {
let dir = tmp_dir("store_write");
let path = dir.join("config.toml");
let store = ConfigStore::new(path);
let mut config = Config::default();
config.systems.push(SystemEntry {
id: "id1".into(),
host: "192.168.1.1".into(),
port: 11310,
name: None,
});
store.write(&config).unwrap();
let loaded = store.load_existing().unwrap();
assert_eq!(config, loaded);
let _ = fs::remove_dir_all(store.path().parent().unwrap());
}
#[test]
fn config_store_mutate() {
let dir = tmp_dir("store_mutate");
let path = dir.join("config.toml");
let store = ConfigStore::new(path);
store
.mutate(|config| {
config.refresh_seconds = 10;
Ok(())
})
.unwrap();
let loaded = store.load_existing().unwrap();
assert_eq!(loaded.refresh_seconds, 10);
let _ = fs::remove_dir_all(store.path().parent().unwrap());
}
#[test]
fn parse_rejects_invalid_toml() {
let result = Config::parse("not valid {{{", None);
assert!(result.is_err());
}
#[test]
fn parse_rejects_unknown_fields() {
let toml = r#"
config_version = 1
refresh_seconds = 5
request_timeout_ms = 1500
max_concurrent_requests = 16
default_port = 11310
unknown_field = "oops"
"#;
let result = Config::parse(toml, None);
assert!(result.is_err());
}
#[test]
fn multiple_violations_reported() {
let config = Config {
config_version: 2,
refresh_seconds: 0,
request_timeout_ms: 0,
max_concurrent_requests: 0,
default_port: 0,
systems: Vec::new(),
};
let violations = config.validate();
assert!(violations.len() >= 5);
}
#[test]
fn violation_display_messages_are_human_readable() {
let violations = vec![
ConfigViolation::UnsupportedConfigVersion(2),
ConfigViolation::InvalidRefreshSeconds(0),
ConfigViolation::InvalidRequestTimeout(0),
ConfigViolation::InvalidMaxConcurrentRequests(0),
ConfigViolation::InvalidPort(0),
ConfigViolation::DuplicateEndpointId { id: "x".into() },
ConfigViolation::DuplicateAddress {
address: "x:80".into(),
},
ConfigViolation::EmptyHost { id: "x".into() },
ConfigViolation::InvalidHost {
id: "x".into(),
host: "http://x".into(),
},
ConfigViolation::InvalidEndpointPort {
id: "x".into(),
port: 0,
},
ConfigViolation::EmptyName { id: "x".into() },
ConfigViolation::NameTooLong {
id: "x".into(),
length: 200,
max: 128,
},
];
for v in &violations {
assert!(!format!("{v}").is_empty());
}
}
#[test]
fn default_path_is_not_empty() {
let path = Config::default_path();
assert!(!path.as_os_str().is_empty());
}
#[test]
fn default_path_ends_with_gregg_toml() {
let path = Config::default_path();
assert_eq!(path.file_name().unwrap(), "gregg.toml");
}
#[test]
#[cfg(unix)]
fn write_atomic_to_readonly_directory() {
let dir = tmp_dir("atomic_readonly");
let path = dir.join("config.toml");
let config = Config::default();
config.write_atomic(&path).unwrap();
let mut perms = fs::metadata(&dir).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&dir, perms).unwrap();
let result = config.write_atomic(&path);
assert!(result.is_err());
let loaded = Config::load(&path).unwrap();
assert_eq!(config, loaded);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn write_atomic_no_parent_directory() {
let config = Config::default();
let result = config.write_atomic(Path::new("/"));
match result {
Err(ConfigError::AtomicWrite {
source: AtomicWriteError::NoParentDirectory,
..
}) => {}
other => panic!("expected NoParentDirectory, got {other:?}"),
}
}
#[test]
fn write_atomic_multiple_rapid_writes() {
let dir = tmp_dir("atomic_rapid");
let path = dir.join("config.toml");
for i in 0..10 {
let config = Config {
refresh_seconds: i,
..Default::default()
};
config.write_atomic(&path).unwrap();
}
let loaded = Config::load(&path).unwrap();
assert_eq!(loaded.refresh_seconds, 9);
assert!(loaded.is_valid());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn config_store_concurrent_mutation() {
let dir = tmp_dir("store_concurrent");
let path = dir.join("config.toml");
let store = ConfigStore::new(path);
store
.mutate(|c| {
c.refresh_seconds = 2;
Ok(())
})
.unwrap();
store
.mutate(|c| {
c.refresh_seconds = 3;
Ok(())
})
.unwrap();
store
.mutate(|c| {
c.refresh_seconds = 4;
Ok(())
})
.unwrap();
let loaded = store.load_existing().unwrap();
assert_eq!(loaded.refresh_seconds, 4);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn advisory_lock_acquire_and_release() {
let dir = tmp_dir("advisory_lock");
let lock_path = dir.join("test.lock");
let lock = AdvisoryLock::new(lock_path.clone());
assert!(!lock.is_held());
assert!(lock.try_acquire());
assert!(lock.is_held());
lock.release();
assert!(!lock.is_held());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn advisory_lock_drop_releases() {
let dir = tmp_dir("advisory_lock_drop");
let lock_path = dir.join("test.lock");
{
let lock = AdvisoryLock::new(lock_path.clone());
assert!(lock.try_acquire());
assert!(lock.is_held());
}
let lock2 = AdvisoryLock::new(lock_path.clone());
assert!(lock2.try_acquire());
let _ = fs::remove_dir_all(&dir);
}
}