use notify::{
event::{DataChange, ModifyKind},
Error, Event, EventKind, RecommendedWatcher, Watcher,
};
use serde::de::DeserializeOwned;
#[cfg(feature = "xml")]
use serde_xml_rs as serde_xml;
#[cfg(any(
feature = "json",
feature = "yaml",
feature = "toml",
feature = "xml",
feature = "ini"
))]
use std::fs;
use std::{
any::Any,
collections::HashMap,
path::{Path, PathBuf},
sync::{
mpsc::{channel, Receiver},
Arc, Mutex, RwLock,
},
time::{Duration, Instant},
};
#[cfg(feature = "toml")]
use toml as serde_toml;
#[derive(Clone, Debug)]
pub enum Format {
#[cfg(feature = "json")]
Json,
#[cfg(feature = "yaml")]
Yaml,
#[cfg(feature = "toml")]
Toml,
#[cfg(feature = "xml")]
Xml,
#[cfg(feature = "ini")]
Ini,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)]
pub struct ConfigId(String);
impl ConfigId {
pub fn new<S: Into<String>>(id: S) -> Self {
Self(id.into())
}
}
impl From<&str> for ConfigId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<String> for ConfigId {
fn from(s: String) -> Self {
Self(s)
}
}
struct Config {
value: Box<dyn Any + Send + Sync>,
_watcher: RecommendedWatcher,
}
#[derive(Clone)]
pub struct Reloadify(Arc<RwLock<HashMap<ConfigId, Config>>>);
#[derive(Debug, thiserror::Error)]
pub enum ReloadifyError {
#[error("Failed to acquire lock")]
GetLockError,
#[error("Failed to load config: {0}")]
LoadConfigError(#[from] std::io::Error),
#[error("Failed to deserialize config: {0}")]
DeserializeError(String),
#[error("Failed to watch: {0}")]
WatchError(#[from] notify::Error),
#[error("Failed to downcast")]
DowncastError,
#[error("Config does not exist")]
ConfigNotExist,
#[error("Failed to send config")]
SendError,
}
#[derive(Debug, Clone)]
pub struct ReloadableConfig {
pub id: ConfigId,
pub path: PathBuf,
pub format: Format,
pub poll_interval: Duration,
pub debounce_delay: Option<Duration>,
}
impl Reloadify {
pub fn new() -> Self {
Self(Arc::new(RwLock::new(HashMap::new())))
}
#[allow(unreachable_code, unused_variables)]
pub fn add<C>(&self, reloadable_config: ReloadableConfig) -> Result<Receiver<C>, ReloadifyError>
where
C: DeserializeOwned + Send + Sync + Clone + 'static,
{
let (tx, rx) = channel();
let c = reloadable_config.clone();
let s = self.clone();
let debounce_delay = c.debounce_delay;
let last_event = Arc::new(Mutex::new(Instant::now()));
let tx_initial = tx.clone();
let mut watcher = RecommendedWatcher::new(
move |r: Result<Event, Error>| {
if let Ok(event) = r {
if let EventKind::Modify(ModifyKind::Data(DataChange::Content)) = event.kind {
if let Some(delay) = debounce_delay {
if let Ok(mut last) = last_event.lock() {
if last.elapsed() < delay {
return;
}
*last = Instant::now();
}
}
if let Ok(latest_cfg) = s.load::<C>(c.path.as_path(), &c.format) {
if let Ok(mut guard) = s.0.write() {
if let Some(current_cfg) = guard.get_mut(&c.id) {
current_cfg.value = Box::new(latest_cfg.clone());
let _ = tx_initial.send(latest_cfg);
}
}
}
}
}
},
notify::Config::default().with_poll_interval(reloadable_config.poll_interval),
)
.map_err(ReloadifyError::WatchError)?;
watcher
.watch(reloadable_config.path.as_path(), notify::RecursiveMode::NonRecursive)
.map_err(ReloadifyError::WatchError)?;
let initial_cfg =
self.load::<C>(reloadable_config.path.as_path(), &reloadable_config.format)?;
tx.send(initial_cfg.clone()).map_err(|_| ReloadifyError::SendError)?;
let mut guard = self.0.write().map_err(|_| ReloadifyError::GetLockError)?;
guard
.entry(reloadable_config.id)
.or_insert(Config { value: Box::new(initial_cfg), _watcher: watcher });
Ok(rx)
}
pub fn get<C>(&self, config_id: impl Into<ConfigId>) -> Result<C, ReloadifyError>
where
C: DeserializeOwned + Send + Sync + Clone + 'static,
{
let config_id = config_id.into();
match self.0.read() {
Err(_) => Err(ReloadifyError::GetLockError),
Ok(guard) => Ok(guard
.get(&config_id)
.ok_or(ReloadifyError::ConfigNotExist)?
.value
.downcast_ref::<C>()
.cloned()
.ok_or(ReloadifyError::DowncastError)?),
}
}
pub fn remove(&self, config_id: impl Into<ConfigId>) -> Result<(), ReloadifyError> {
let config_id = config_id.into();
let mut guard = self.0.write().map_err(|_| ReloadifyError::GetLockError)?;
guard.remove(&config_id).ok_or(ReloadifyError::ConfigNotExist)?;
Ok(())
}
#[allow(unused_variables)]
fn load<C: DeserializeOwned>(&self, path: &Path, format: &Format) -> Result<C, ReloadifyError> {
#[cfg(any(
feature = "json",
feature = "yaml",
feature = "toml",
feature = "xml",
feature = "ini"
))]
{
let content = fs::read_to_string(path).map_err(ReloadifyError::LoadConfigError)?;
match format {
#[cfg(feature = "json")]
Format::Json => serde_json::from_str::<C>(&content)
.map_err(|err| ReloadifyError::DeserializeError(err.to_string())),
#[cfg(feature = "yaml")]
Format::Yaml => serde_yaml_ng::from_str::<C>(&content)
.map_err(|err| ReloadifyError::DeserializeError(err.to_string())),
#[cfg(feature = "toml")]
Format::Toml => serde_toml::from_str::<C>(&content)
.map_err(|err| ReloadifyError::DeserializeError(err.to_string())),
#[cfg(feature = "xml")]
Format::Xml => serde_xml::from_str::<C>(&content)
.map_err(|err| ReloadifyError::DeserializeError(err.to_string())),
#[cfg(feature = "ini")]
Format::Ini => serde_ini::from_str::<C>(&content)
.map_err(|err| ReloadifyError::DeserializeError(err.to_string())),
}
}
#[cfg(not(any(
feature = "json",
feature = "yaml",
feature = "toml",
feature = "xml",
feature = "ini"
)))]
Err(ReloadifyError::DeserializeError("No format feature enabled".to_string()))
}
}
impl Default for Reloadify {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{collections::HashMap, path::PathBuf, time::Duration};
fn fixture(filename: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples").join("config").join(filename)
}
#[test]
fn new_and_get_nonexistent() {
let r = Reloadify::new();
let result = r.get::<String>("nonexistent");
assert!(matches!(result, Err(ReloadifyError::ConfigNotExist)));
}
#[test]
fn default_constructs() {
let r = Reloadify::default();
assert!(r.get::<String>("any").is_err());
}
#[test]
fn config_id_equality() {
let a = ConfigId::new("foo");
let b = ConfigId::new("foo");
let c = ConfigId::new("bar");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn config_id_from_str() {
let id: ConfigId = "mycfg".into();
assert_eq!(id, ConfigId::new("mycfg"));
}
#[test]
fn config_id_from_string() {
let id = ConfigId::from(String::from("owned"));
assert_eq!(id, ConfigId::new("owned"));
}
#[test]
fn get_with_str_literal() {
let r = Reloadify::new();
let result = r.get::<String>("literal-key");
assert!(matches!(result, Err(ReloadifyError::ConfigNotExist)));
}
#[test]
fn remove_nonexistent() {
let r = Reloadify::new();
let result = r.remove("nonexistent");
assert!(matches!(result, Err(ReloadifyError::ConfigNotExist)));
}
#[test]
#[cfg(feature = "json")]
fn remove_existing() {
let r = Reloadify::new();
let id = ConfigId::new("to-remove");
let _rx = r
.add::<serde_json::Value>(ReloadableConfig {
id: id.clone(),
path: fixture("tsconfig.spec.json"),
format: Format::Json,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add");
assert!(r.get::<serde_json::Value>(id.clone()).is_ok());
r.remove(id.clone()).expect("remove");
assert!(matches!(r.get::<serde_json::Value>(id), Err(ReloadifyError::ConfigNotExist)));
}
#[test]
#[cfg(feature = "json")]
fn remove_disconnects_receiver() {
let r = Reloadify::new();
let id = ConfigId::new("dc-test");
let rx = r
.add::<serde_json::Value>(ReloadableConfig {
id: id.clone(),
path: fixture("tsconfig.spec.json"),
format: Format::Json,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add");
r.remove(id).expect("remove");
let _ = rx.try_recv(); assert!(rx.try_recv().is_err());
}
#[test]
#[cfg(feature = "json")]
fn config_id_serde_roundtrip() {
let id = ConfigId::new("my-config");
let json = serde_json::to_string(&id).unwrap();
let back: ConfigId = serde_json::from_str(&json).unwrap();
assert_eq!(id, back);
}
#[cfg(feature = "json")]
mod json {
use super::*;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct TsConfig {
extends: String,
#[serde(rename = "compilerOptions")]
compiler_options: CompilerOptions,
files: Vec<String>,
include: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct CompilerOptions {
#[serde(rename = "outDir")]
out_dir: String,
types: Vec<String>,
}
#[test]
fn load_json_file() {
let r = Reloadify::new();
let cfg: TsConfig = r
.load(&fixture("tsconfig.spec.json"), &Format::Json)
.expect("should load JSON config");
assert_eq!(cfg.extends, "./tsconfig.json");
assert_eq!(cfg.compiler_options.out_dir, "./out-tsc/spec");
assert_eq!(cfg.compiler_options.types, vec!["jasmine"]);
assert_eq!(cfg.files.len(), 2);
assert_eq!(cfg.include.len(), 2);
}
#[test]
fn load_missing_file() {
let r = Reloadify::new();
let result = r.load::<TsConfig>(
&PathBuf::from("/nonexistent/reloadify_test.json"),
&Format::Json,
);
assert!(matches!(result, Err(ReloadifyError::LoadConfigError(_))));
}
#[test]
fn load_malformed_json() {
let tmp = std::env::temp_dir().join("reloadify_test_malformed.json");
std::fs::write(&tmp, "{ not valid json }").unwrap();
let r = Reloadify::new();
let result = r.load::<serde_json::Value>(&tmp, &Format::Json);
let _ = std::fs::remove_file(&tmp);
assert!(
matches!(result, Err(ReloadifyError::DeserializeError(_))),
"expected DeserializeError, got {result:?}"
);
}
#[test]
fn add_and_get_roundtrip() {
let r = Reloadify::new();
let id = ConfigId::new("tsconfig");
let rx = r
.add::<TsConfig>(ReloadableConfig {
id: id.clone(),
path: fixture("tsconfig.spec.json"),
format: Format::Json,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add should succeed");
let retrieved = r.get::<TsConfig>(id).expect("get should succeed");
assert_eq!(retrieved.extends, "./tsconfig.json");
let from_rx = rx.try_recv().expect("initial value on channel");
assert_eq!(from_rx.extends, "./tsconfig.json");
}
}
#[cfg(feature = "yaml")]
mod yaml {
use super::*;
#[test]
fn load_yaml_file() {
let r = Reloadify::new();
let cfg: serde_yaml_ng::Value = r
.load(&fixture("docker-compose.yaml"), &Format::Yaml)
.expect("should load YAML config");
let services = cfg.get("services").expect("should have services key");
assert!(services.get("minecraft").is_some());
}
#[test]
fn add_and_get_roundtrip() {
let r = Reloadify::new();
let id = ConfigId::new("compose");
let _rx = r
.add::<serde_yaml_ng::Value>(ReloadableConfig {
id: id.clone(),
path: fixture("docker-compose.yaml"),
format: Format::Yaml,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add should succeed");
let retrieved = r.get::<serde_yaml_ng::Value>(id).expect("get should succeed");
assert!(retrieved.get("services").is_some());
}
#[test]
fn load_missing_file() {
let r = Reloadify::new();
let result = r.load::<serde_yaml_ng::Value>(
&PathBuf::from("/nonexistent/reloadify_test.yaml"),
&Format::Yaml,
);
assert!(matches!(result, Err(ReloadifyError::LoadConfigError(_))));
}
}
#[cfg(feature = "toml")]
mod toml_format {
use super::*;
#[test]
fn load_toml_file() {
let r = Reloadify::new();
let cfg: toml::Value =
r.load(&fixture("netlify.toml"), &Format::Toml).expect("should load TOML config");
let build = cfg.get("build").expect("should have [build] section");
assert_eq!(build.get("publish").and_then(|v| v.as_str()), Some("public"));
}
#[test]
fn add_and_get_roundtrip() {
let r = Reloadify::new();
let id = ConfigId::new("netlify");
let _rx = r
.add::<toml::Value>(ReloadableConfig {
id: id.clone(),
path: fixture("netlify.toml"),
format: Format::Toml,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add should succeed");
let retrieved = r.get::<toml::Value>(id).expect("get should succeed");
assert!(retrieved.get("build").is_some());
}
#[test]
fn load_missing_file() {
let r = Reloadify::new();
let result = r.load::<toml::Value>(
&PathBuf::from("/nonexistent/reloadify_test.toml"),
&Format::Toml,
);
assert!(matches!(result, Err(ReloadifyError::LoadConfigError(_))));
}
}
#[cfg(feature = "ini")]
mod ini {
use super::*;
type IniMap = HashMap<String, HashMap<String, Option<String>>>;
#[test]
fn load_ini_file() {
let r = Reloadify::new();
let cfg: IniMap =
r.load(&fixture("pytest.ini"), &Format::Ini).expect("should load INI config");
let pytest = cfg.get("pytest").expect("should have [pytest] section");
assert_eq!(pytest.get("addopts").and_then(|v| v.as_deref()), Some("--tb=short -rxs"));
assert_eq!(
pytest.get("junit_suite_name").and_then(|v| v.as_deref()),
Some("docker-py")
);
}
#[test]
fn add_and_get_roundtrip() {
let r = Reloadify::new();
let id = ConfigId::new("pytest");
let _rx = r
.add::<IniMap>(ReloadableConfig {
id: id.clone(),
path: fixture("pytest.ini"),
format: Format::Ini,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add should succeed");
let retrieved = r.get::<IniMap>(id).expect("get should succeed");
assert!(retrieved.contains_key("pytest"));
}
#[test]
fn load_missing_file() {
let r = Reloadify::new();
let result =
r.load::<IniMap>(&PathBuf::from("/nonexistent/reloadify_test.ini"), &Format::Ini);
assert!(matches!(result, Err(ReloadifyError::LoadConfigError(_))));
}
}
#[cfg(feature = "xml")]
mod xml {
use super::*;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct TomcatUsers {
user: Vec<User>,
role: Vec<Role>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct User {
username: String,
password: String,
roles: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct Role {
rolename: String,
}
#[test]
fn load_xml_file() {
let r = Reloadify::new();
let cfg: TomcatUsers =
r.load(&fixture("tomcat-users.xml"), &Format::Xml).expect("should load XML config");
assert_eq!(cfg.user.len(), 2);
assert_eq!(cfg.user[0].username, "system");
assert_eq!(cfg.user[0].password, "manager");
assert_eq!(cfg.user[0].roles, "admin-gui,manager-gui");
assert_eq!(cfg.role.len(), 1);
assert_eq!(cfg.role[0].rolename, "manager-script");
}
#[test]
fn add_and_get_roundtrip() {
let r = Reloadify::new();
let id = ConfigId::new("tomcat");
let _rx = r
.add::<TomcatUsers>(ReloadableConfig {
id: id.clone(),
path: fixture("tomcat-users.xml"),
format: Format::Xml,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add should succeed");
let retrieved = r.get::<TomcatUsers>(id).expect("get should succeed");
assert_eq!(retrieved.user.len(), 2);
assert_eq!(retrieved.role.len(), 1);
}
#[test]
fn load_missing_file() {
let r = Reloadify::new();
let result = r.load::<TomcatUsers>(
&PathBuf::from("/nonexistent/reloadify_test.xml"),
&Format::Xml,
);
assert!(matches!(result, Err(ReloadifyError::LoadConfigError(_))));
}
}
#[test]
#[cfg(all(feature = "json", feature = "yaml"))]
fn multiple_configs_in_one_reloadify() {
let r = Reloadify::new();
let json_id = ConfigId::new("multi-json");
let yaml_id = ConfigId::new("multi-yaml");
r.add::<serde_json::Value>(ReloadableConfig {
id: json_id.clone(),
path: fixture("tsconfig.spec.json"),
format: Format::Json,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add json");
r.add::<serde_yaml_ng::Value>(ReloadableConfig {
id: yaml_id.clone(),
path: fixture("docker-compose.yaml"),
format: Format::Yaml,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add yaml");
let json_cfg = r.get::<serde_json::Value>(json_id).expect("get json");
let yaml_cfg = r.get::<serde_yaml_ng::Value>(yaml_id).expect("get yaml");
assert!(json_cfg.get("extends").is_some());
assert!(yaml_cfg.get("services").is_some());
}
#[test]
fn downcast_error_on_wrong_type() {
let r = Reloadify::new();
let id = ConfigId::new("typed");
#[cfg(feature = "json")]
{
let _rx = r
.add::<serde_json::Value>(ReloadableConfig {
id: id.clone(),
path: fixture("tsconfig.spec.json"),
format: Format::Json,
poll_interval: Duration::from_secs(10),
debounce_delay: None,
})
.expect("add should succeed");
let result = r.get::<HashMap<String, String>>(id);
assert!(matches!(result, Err(ReloadifyError::DowncastError)));
}
}
}