Hello friends,
I'm new to watching files and updating global state in Rust, and I'm facing some issues. I’d appreciate your help!
My goal is to read a TOML file from a specific path, store its data in a global state, and then watch the file for changes. Whenever the file updates, I want to modify the state so that the latest configuration is always available.
Since this is a library, and many functions will need access to this state, I decided to store it in a global variable.
Here’s the code I wrote with the help of ChatGPT (since I’m new to Rust):
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use std::{
fs,
path::Path,
sync::mpsc::{self, Receiver, Sender},
sync::{Arc, Mutex, OnceLock},
thread,
};
#[derive(Debug, Deserialize, Clone, Serialize)]
pub struct Config {
pub database_url: String,
pub port: u16,
}
static GLOBAL_CONFIG: OnceLock<Arc<Mutex<Config>>> = OnceLock::new();
pub struct ConfigManager;
impl ConfigManager {
pub fn init(file_path: &str) -> Receiver<()> {
let new_config = Self::load_config(file_path);
let config = Arc::new(Mutex::new(new_config.clone()));
if GLOBAL_CONFIG.get().is_some() {
*GLOBAL_CONFIG.get().unwrap().lock().unwrap() = new_config;
} else {
GLOBAL_CONFIG.set(config).ok().unwrap_or(());
}
let file_path = file_path.to_string();
let (tx, rx) = mpsc::channel();
let tx_clone = tx.clone();
thread::spawn(move || {
Self::watch_file(file_path, tx);
});
let _ = tx_clone.send(());
rx
}
fn load_config(file_path: &str) -> Config {
if !Path::new(file_path).exists() {
println!(
"Config file not found. Creating default config at: {}",
file_path
);
let default_config = Config {
database_url: "sqlite://default.db".to_string(),
port: 8080,
};
let toml_string =
toml::to_string(&default_config).expect("Failed to serialize default config");
fs::write(file_path, toml_string).expect("Failed to write default config file");
return default_config;
}
let content = fs::read_to_string(file_path).expect("Failed to read config file");
toml::from_str(&content).expect("Invalid TOML format")
}
fn watch_file(file_path: String, change_notifier: Sender<()>) {
let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(tx, notify::Config::default()).unwrap();
watcher
.watch(Path::new(&file_path), RecursiveMode::NonRecursive)
.unwrap();
for event in rx {
if let Ok(event) = event {
if matches!(event.kind, EventKind::Modify(_)) {
println!("Config file changed, reloading...");
let new_config = Self::load_config(&file_path);
if let Some(config) = GLOBAL_CONFIG.get() {
let mut config_lock = config.lock().unwrap();
*config_lock = new_config;
}
let _ = change_notifier.send(());
}
}
}
}
pub fn get_config() -> Config {
GLOBAL_CONFIG
.get()
.expect("ConfigManager::init must be called first!")
.lock()
.unwrap()
.clone()
}
}
The problem I'm facing is that my tests fail inconsistently. Sometimes they pass, and sometimes they fail. I suspect that the test might not be detecting file changes correctly.
Has anyone encountered this issue before? What’s the best approach to ensure reliable testing for file change detection?
my tests
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use std::time::Duration;
#[test]
fn test_config_initialization() {
let test_config_path = "test_config.toml";
let mut file = File::create(test_config_path).unwrap();
writeln!(file, "database_url = \"sqlite://test.db\"\nport = 1234").unwrap();
let rx = ConfigManager::init(test_config_path);
rx.recv_timeout(Duration::from_secs(2)).unwrap();
let mut attempts = 5;
let mut config = ConfigManager::get_config();
while config.database_url != "sqlite://test.db" && attempts > 0 {
std::thread::sleep(Duration::from_millis(200));
config = ConfigManager::get_config();
attempts -= 1;
}
assert_eq!(config.database_url, "sqlite://test.db");
assert_eq!(config.port, 1234);
std::fs::remove_file(test_config_path).unwrap();
}
#[test]
fn test_config_update_on_file_change() {
let test_config_path = "test_config_update.toml";
let mut file = File::create(test_config_path).unwrap();
writeln!(file, "database_url = \"sqlite://old.db\"\nport = 1111").unwrap();
let rx = ConfigManager::init(test_config_path);
rx.recv_timeout(Duration::from_secs(2)).unwrap();
let mut file = File::create(test_config_path).unwrap();
writeln!(file, "database_url = \"sqlite://new.db\"\nport = 2222").unwrap();
rx.recv_timeout(Duration::from_secs(2)).unwrap();
let config = ConfigManager::get_config();
assert_eq!(config.database_url, "sqlite://new.db");
assert_eq!(config.port, 2222);
std::fs::remove_file(test_config_path).unwrap();
}
}
All the code: config.rs · GitHub
Thanks in advance!