use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
const CONFIG_FILE_NAME: &str = "ipc.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSource {
PerApp,
Shared,
}
impl ConfigSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PerApp => "per-app",
Self::Shared => "shared",
}
}
}
impl std::fmt::Display for ConfigSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn resolve_config_path(
per_app: Option<PathBuf>,
shared: Option<PathBuf>,
) -> Option<(PathBuf, ConfigSource)> {
per_app
.map(|path| (path, ConfigSource::PerApp))
.or_else(|| shared.map(|path| (path, ConfigSource::Shared)))
}
fn existing_file(path: PathBuf) -> Option<PathBuf> {
path.is_file().then_some(path)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct IpcConfig {
pub socket: SocketConfig,
pub limits: IpcLimitsConfig,
pub rate_limit: RateLimitConfig,
pub timeouts: IpcTimeoutsConfig,
pub shutdown: ShutdownConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SocketConfig {
pub path: Option<PathBuf>,
#[cfg(unix)]
pub mode: u32,
pub app_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct IpcLimitsConfig {
pub max_connections: usize,
pub max_message_size: usize,
pub push_buffer_size: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct IpcTimeoutsConfig {
#[serde(rename = "request_timeout_ms")]
pub request: u64,
#[serde(rename = "read_timeout_ms")]
pub read: u64,
#[serde(rename = "write_timeout_ms")]
pub write: u64,
#[serde(rename = "subscription_read_timeout_ms")]
pub subscription_read: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RateLimitConfig {
pub enabled: bool,
pub requests_per_second: u32,
pub burst_size: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ShutdownConfig {
#[serde(rename = "drain_timeout_ms")]
pub drain_timeout: u64,
}
impl Default for SocketConfig {
fn default() -> Self {
Self {
path: None,
#[cfg(unix)]
mode: 0o660,
app_name: None,
}
}
}
impl Default for IpcLimitsConfig {
fn default() -> Self {
Self {
max_connections: 1024,
max_message_size: 1_048_576, push_buffer_size: 100, }
}
}
impl Default for IpcTimeoutsConfig {
fn default() -> Self {
Self {
request: 30_000,
read: 60_000,
write: 30_000,
subscription_read: 0, }
}
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: true,
requests_per_second: 100,
burst_size: 50,
}
}
}
impl Default for ShutdownConfig {
fn default() -> Self {
Self {
drain_timeout: 5_000, }
}
}
impl IpcConfig {
#[must_use]
pub fn load() -> Self {
let xdg_dirs = match xdg::BaseDirectories::with_prefix("acton") {
Ok(dirs) => dirs,
Err(e) => {
warn!("Failed to initialize XDG directories for IPC config: {}", e);
return Self::default();
}
};
let app_name = Self::default_app_name();
let per_app = xdg_dirs.find_config_file(PathBuf::from(&app_name).join(CONFIG_FILE_NAME));
let shared = xdg_dirs.find_config_file(CONFIG_FILE_NAME);
resolve_config_path(per_app, shared).map_or_else(
|| {
info!(
app_name = %app_name,
"No IPC configuration file found in either the per-application or shared \
location, using defaults"
);
Self::default()
},
|(path, source)| Self::load_from_path(&path, source),
)
}
fn load_from_path(path: &Path, source: ConfigSource) -> Self {
info!(
source = source.as_str(),
"Loading IPC configuration from: {}",
path.display()
);
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) => {
warn!(
"Failed to read IPC configuration file {}: {}",
path.display(),
e
);
return Self::default();
}
};
match toml::from_str::<Self>(&contents) {
Ok(config) => {
info!(source = source.as_str(), "Successfully loaded IPC configuration");
config
}
Err(e) => {
warn!(
"Failed to parse IPC configuration file {}: {}",
path.display(),
e
);
Self::default()
}
}
}
#[must_use]
pub fn load_from_root(config_root: &Path, app_name: &str) -> Self {
let per_app = existing_file(config_root.join(app_name).join(CONFIG_FILE_NAME));
let shared = existing_file(config_root.join(CONFIG_FILE_NAME));
resolve_config_path(per_app, shared).map_or_else(
|| {
info!("No IPC configuration file found, using defaults");
Self::default()
},
|(path, source)| Self::load_from_path(&path, source),
)
}
#[must_use]
pub fn app_name(&self) -> String {
self.socket
.app_name
.clone()
.unwrap_or_else(Self::default_app_name)
}
fn default_app_name() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "acton".to_string())
}
#[must_use]
pub fn socket_path(&self) -> PathBuf {
self.socket.path.clone().unwrap_or_else(|| {
let app_name = self.app_name();
let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
.map_or_else(|_| PathBuf::from("/tmp"), PathBuf::from);
runtime_dir.join("acton").join(&app_name).join("ipc.sock")
})
}
#[must_use]
pub fn socket_dir(&self) -> PathBuf {
self.socket_path()
.parent()
.map_or_else(|| PathBuf::from("/tmp/acton"), PathBuf::from)
}
#[must_use]
pub const fn request_timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.timeouts.request)
}
#[must_use]
pub const fn read_timeout(&self) -> Option<std::time::Duration> {
if self.timeouts.read == 0 {
None
} else {
Some(std::time::Duration::from_millis(self.timeouts.read))
}
}
#[must_use]
pub const fn write_timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.timeouts.write)
}
#[must_use]
pub const fn subscription_read_timeout(&self) -> Option<std::time::Duration> {
if self.timeouts.subscription_read == 0 {
None
} else {
Some(std::time::Duration::from_millis(
self.timeouts.subscription_read,
))
}
}
#[must_use]
pub const fn drain_timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.shutdown.drain_timeout)
}
#[must_use]
pub const fn is_rate_limited(&self) -> bool {
self.rate_limit.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = IpcConfig::default();
assert_eq!(config.limits.max_connections, 1024);
assert_eq!(config.limits.max_message_size, 1_048_576);
assert_eq!(config.timeouts.request, 30_000);
}
#[test]
fn per_app_candidate_wins_over_shared() {
let resolved = resolve_config_path(
Some(PathBuf::from("/cfg/my_app/ipc.toml")),
Some(PathBuf::from("/cfg/ipc.toml")),
);
assert_eq!(
resolved,
Some((PathBuf::from("/cfg/my_app/ipc.toml"), ConfigSource::PerApp))
);
}
#[test]
fn shared_candidate_is_used_when_there_is_no_per_app_file() {
let resolved = resolve_config_path(None, Some(PathBuf::from("/cfg/ipc.toml")));
assert_eq!(
resolved,
Some((PathBuf::from("/cfg/ipc.toml"), ConfigSource::Shared))
);
}
#[test]
fn no_candidates_resolve_to_nothing() {
assert_eq!(resolve_config_path(None, None), None);
}
#[test]
fn config_source_labels_are_stable() {
assert_eq!(ConfigSource::PerApp.as_str(), "per-app");
assert_eq!(ConfigSource::Shared.as_str(), "shared");
assert_eq!(ConfigSource::PerApp.to_string(), "per-app");
}
#[test]
fn test_socket_path_default() {
let config = IpcConfig::default();
let path = config.socket_path();
assert!(path.to_string_lossy().contains("acton"));
assert!(path.to_string_lossy().ends_with("ipc.sock"));
}
#[test]
fn test_socket_path_override() {
let mut config = IpcConfig::default();
config.socket.path = Some(PathBuf::from("/custom/path/socket.sock"));
assert_eq!(
config.socket_path(),
PathBuf::from("/custom/path/socket.sock")
);
}
#[derive(Clone, Default)]
struct LogCapture {
buffer: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
}
impl LogCapture {
fn contents(&self) -> String {
let buffer = self.buffer.lock().expect("log buffer poisoned");
String::from_utf8_lossy(&buffer).into_owned()
}
}
impl std::io::Write for LogCapture {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.buffer
.lock()
.expect("log buffer poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl tracing_subscriber::fmt::MakeWriter<'_> for LogCapture {
type Writer = Self;
fn make_writer(&self) -> Self::Writer {
self.clone()
}
}
fn capture_logs<T>(f: impl FnOnce() -> T) -> (T, String) {
let capture = LogCapture::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(capture.clone())
.with_max_level(tracing::Level::INFO)
.with_ansi(false)
.finish();
let value = tracing::subscriber::with_default(subscriber, f);
let contents = capture.contents();
(value, contents)
}
fn write_limits_config(path: &Path, max_connections: usize) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create config dir");
}
std::fs::write(
path,
format!("[limits]\nmax_connections = {max_connections}\n"),
)
.expect("write config");
}
#[test]
fn loading_a_per_app_config_logs_that_it_was_the_per_app_one() {
let dir = tempfile::tempdir().expect("tempdir");
write_limits_config(&dir.path().join("my_app").join(CONFIG_FILE_NAME), 7);
let (config, logs) =
capture_logs(|| IpcConfig::load_from_root(dir.path(), "my_app"));
assert_eq!(config.limits.max_connections, 7);
assert!(
logs.contains("per-app"),
"log should name the per-app source, got: {logs}"
);
assert!(
logs.contains("my_app"),
"log should name the file that was loaded, got: {logs}"
);
}
#[test]
fn loading_a_shared_config_logs_that_it_was_the_shared_one() {
let dir = tempfile::tempdir().expect("tempdir");
write_limits_config(&dir.path().join(CONFIG_FILE_NAME), 11);
let (config, logs) =
capture_logs(|| IpcConfig::load_from_root(dir.path(), "my_app"));
assert_eq!(config.limits.max_connections, 11);
assert!(
logs.contains("shared"),
"log should name the shared source, got: {logs}"
);
}
#[test]
fn finding_no_config_file_is_reported() {
let dir = tempfile::tempdir().expect("tempdir");
let (config, logs) =
capture_logs(|| IpcConfig::load_from_root(dir.path(), "my_app"));
assert_eq!(
config.limits.max_connections,
IpcLimitsConfig::default().max_connections
);
assert!(
logs.contains("No IPC configuration file found"),
"log should say defaults were used, got: {logs}"
);
}
#[test]
fn test_app_name_override() {
let mut config = IpcConfig::default();
config.socket.app_name = Some("my_custom_app".to_string());
assert_eq!(config.app_name(), "my_custom_app");
}
#[test]
fn test_timeout_duration() {
let config = IpcConfig::default();
assert_eq!(
config.request_timeout(),
std::time::Duration::from_secs(30)
);
}
#[test]
fn test_socket_dir() {
let config = IpcConfig::default();
let dir = config.socket_dir();
let path = config.socket_path();
assert_eq!(dir, path.parent().unwrap());
}
#[cfg(unix)]
#[test]
fn test_socket_mode() {
let config = IpcConfig::default();
assert_eq!(config.socket.mode, 0o660);
}
#[test]
fn test_config_serialization() {
let config = IpcConfig::default();
let toml_str = toml::to_string(&config).unwrap();
let parsed: IpcConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.limits.max_connections, config.limits.max_connections);
}
#[test]
fn test_rate_limit_defaults() {
let config = IpcConfig::default();
assert!(config.is_rate_limited());
assert_eq!(config.rate_limit.requests_per_second, 100);
assert_eq!(config.rate_limit.burst_size, 50);
}
#[test]
fn test_shutdown_defaults() {
let config = IpcConfig::default();
assert_eq!(config.shutdown.drain_timeout, 5_000);
assert_eq!(
config.drain_timeout(),
std::time::Duration::from_secs(5)
);
}
#[test]
fn test_rate_limit_disabled() {
let mut config = IpcConfig::default();
config.rate_limit.enabled = false;
assert!(!config.is_rate_limited());
}
}