use crate::config::NotificationConfig;
use std::io;
use std::io::Write;
use std::path::Path;
#[derive(Debug)]
pub(crate) enum ConfigFileError {
NotFound,
Parse(serde_json::Error),
Io(io::Error),
}
impl std::fmt::Display for ConfigFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigFileError::NotFound => write!(f, "config file not found"),
ConfigFileError::Parse(e) => write!(f, "config file parse error: {e}"),
ConfigFileError::Io(e) => write!(f, "config file I/O error: {e}"),
}
}
}
impl std::error::Error for ConfigFileError {}
pub(crate) fn load(path: &Path) -> Result<NotificationConfig, ConfigFileError> {
let contents = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(ConfigFileError::NotFound),
Err(e) => return Err(ConfigFileError::Io(e)),
};
serde_json::from_str(&contents).map_err(ConfigFileError::Parse)
}
pub(crate) fn save(path: &Path, config: &NotificationConfig) -> Result<(), ConfigFileError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent).map_err(ConfigFileError::Io)?;
let mut tmp = tempfile::NamedTempFile::new_in(parent).map_err(ConfigFileError::Io)?;
serde_json::to_writer_pretty(&mut tmp, config).map_err(ConfigFileError::Parse)?;
tmp.write_all(b"\n").map_err(ConfigFileError::Io)?;
tmp.as_file().sync_all().map_err(ConfigFileError::Io)?;
tmp.persist(path)
.map_err(|e| ConfigFileError::Io(e.error))?;
std::fs::File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(ConfigFileError::Io)?;
Ok(())
}
pub(crate) fn load_or_create_default(path: &Path) -> NotificationConfig {
match load(path) {
Ok(config) => config,
Err(ConfigFileError::NotFound) => {
log::info!(
"Config file {} does not exist; writing defaults",
path.display()
);
let defaults = NotificationConfig::default();
if let Err(e) = save(path, &defaults) {
log::warn!("Failed to write default config to {}: {e}", path.display());
}
defaults
}
Err(e) => {
log::error!(
"Failed to load config from {}: {e}; falling back to defaults",
path.display()
);
NotificationConfig::default()
}
}
}
pub(crate) fn start_watcher(path: &Path) -> std::sync::mpsc::Receiver<NotificationConfig> {
use notify::{Event, EventKind, RecursiveMode, Watcher};
let (tx, rx) = std::sync::mpsc::channel::<NotificationConfig>();
let watch_path = path.to_path_buf();
std::thread::spawn(move || {
let (notify_tx, notify_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
let mut watcher = match notify::recommended_watcher(notify_tx) {
Ok(w) => w,
Err(e) => {
log::warn!("Failed to construct config-file watcher: {e}");
return;
}
};
let parent = match watch_path.parent() {
Some(p) => p,
None => {
log::warn!(
"Config path {} has no parent directory; cannot watch",
watch_path.display()
);
return;
}
};
if let Err(e) = watcher.watch(parent, RecursiveMode::NonRecursive) {
log::warn!(
"Failed to start config-file watcher on {}: {e}",
parent.display()
);
return;
}
for event in notify_rx {
let event = match event {
Ok(e) => e,
Err(e) => {
log::warn!("Config-file watcher event error: {e}");
continue;
}
};
let touches_our_file = event.paths.iter().any(|p| p == &watch_path);
if !touches_our_file {
continue;
}
let is_modify_or_create =
matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_));
if !is_modify_or_create {
continue;
}
match load(&watch_path) {
Ok(config) => {
if tx.send(config).is_err() {
return;
}
}
Err(ConfigFileError::NotFound) => {
}
Err(e) => {
log::warn!("Config-file reload failed: {e}");
}
}
}
});
rx
}
#[cfg(test)]
mod tests {
use super::*;
fn test_path(suffix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"nwg-config-file-test-{}-{suffix}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("setup test dir");
dir.join("config.json")
}
#[test]
fn load_round_trips_through_save() {
let path = test_path("roundtrip");
let config = NotificationConfig {
popup_timeout: 12345,
max_popups: 7,
..NotificationConfig::default()
};
save(&path, &config).expect("save succeeds");
let loaded = load(&path).expect("load succeeds");
assert_eq!(loaded.popup_timeout, 12345);
assert_eq!(loaded.max_popups, 7);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn load_missing_file_returns_not_found() {
let path = test_path("missing")
.parent()
.unwrap()
.join("does-not-exist.json");
match load(&path) {
Err(ConfigFileError::NotFound) => {}
other => panic!("expected NotFound, got {other:?}"),
}
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn load_malformed_returns_parse_error() {
let path = test_path("malformed");
std::fs::write(&path, b"{not valid json}").expect("seed bad file");
match load(&path) {
Err(ConfigFileError::Parse(_)) => {}
other => panic!("expected Parse, got {other:?}"),
}
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn save_creates_parent_directory_if_missing() {
let nested = test_path("nested-parent")
.parent()
.unwrap()
.join("subdir")
.join("nested.json");
let config = NotificationConfig::default();
save(&nested, &config).expect("save creates parent");
assert!(nested.exists(), "nested file should exist after save");
let _ = std::fs::remove_dir_all(nested.parent().unwrap().parent().unwrap());
}
#[test]
fn load_or_create_default_writes_defaults_when_missing() {
let path = test_path("first-run");
assert!(!path.exists());
let config = load_or_create_default(&path);
assert!(path.exists(), "default file should be created");
let reloaded = load(&path).expect("written file should parse");
assert_eq!(reloaded.popup_timeout, config.popup_timeout);
assert_eq!(reloaded.max_popups, config.max_popups);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn load_or_create_default_returns_defaults_on_parse_error_without_overwriting() {
let path = test_path("parse-error-preserved");
let original = b"{not valid json}";
std::fs::write(&path, original).expect("seed bad file");
let _config = load_or_create_default(&path);
let after = std::fs::read(&path).expect("read should still work");
assert_eq!(
after.as_slice(),
original,
"load_or_create_default must not overwrite a malformed file"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
}