#![allow(missing_docs)]
#![allow(unsafe_code)]
#![allow(clippy::needless_pass_by_value, clippy::used_underscore_items, clippy::match_wild_err_arm)]
use std::io::Write;
use std::sync::Arc;
use rtb_config::{Config, ConfigBuilder, ConfigError};
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
struct Sample {
#[serde(default)]
host: String,
#[serde(default)]
port: u16,
#[serde(default)]
http: HttpSection,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
struct HttpSection {
#[serde(default)]
port: u16,
}
#[test]
fn t1_config_unit_is_default() {
let cfg = Config::<()>::default();
let snapshot: Arc<()> = cfg.get();
let () = *snapshot;
}
#[test]
fn t1b_config_with_value_holds_value() {
let cfg = Config::<Sample>::with_value(Sample {
host: "h".into(),
port: 4242,
http: HttpSection::default(),
});
let cfg2 = cfg.clone();
let s = cfg.get();
assert_eq!(s.host, "h");
assert_eq!(s.port, 4242);
let s2 = cfg2.get();
assert_eq!(s2.host, "h");
assert_eq!(s2.port, 4242);
assert!(Arc::ptr_eq(&s, &s2), "clone shares the backing allocation");
}
#[test]
fn t2_config_bounds() {
fn assert_bounds<T: Send + Sync + Clone + 'static>() {}
assert_bounds::<Config<Sample>>();
assert_bounds::<Config<()>>();
}
#[test]
fn t3_default_generic_elides() {
fn _requires_unit(c: Config) -> Arc<()> {
c.get()
}
let c = Config::<()>::default();
let _ = _requires_unit(c);
}
#[test]
fn t4_embedded_default_populates() {
let cfg = Config::<Sample>::builder()
.embedded_default("host: localhost\nport: 8080\n")
.build()
.expect("build");
let s = cfg.get();
assert_eq!(s.host, "localhost");
assert_eq!(s.port, 8080);
}
#[test]
fn t5_file_overrides_embedded() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
std::fs::write(&path, "port: 9090\n").expect("write");
let cfg = Config::<Sample>::builder()
.embedded_default("host: localhost\nport: 8080\n")
.user_file(&path)
.build()
.expect("build");
let s = cfg.get();
assert_eq!(s.host, "localhost", "host from embedded default preserved");
assert_eq!(s.port, 9090, "port overridden by file");
}
#[test]
fn t6_env_overrides_file_and_embedded() {
let prefix = "RTBCFG_T6_";
unsafe {
std::env::set_var("RTBCFG_T6_PORT", "9999");
}
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
std::fs::write(&path, "port: 9090\n").expect("write");
let cfg = Config::<Sample>::builder()
.embedded_default("host: localhost\nport: 8080\n")
.user_file(&path)
.env_prefixed(prefix)
.build()
.expect("build");
let s = cfg.get();
assert_eq!(s.port, 9999, "env must win over file");
unsafe {
std::env::remove_var("RTBCFG_T6_PORT");
}
let _ = prefix;
}
#[test]
fn t7_env_prefix_nested() {
unsafe {
std::env::set_var("RTBCFG_T7_HTTP_PORT", "4242");
}
let cfg = Config::<Sample>::builder()
.embedded_default("host: x\nport: 1\nhttp:\n port: 1\n")
.env_prefixed("RTBCFG_T7_")
.build()
.expect("build");
let s = cfg.get();
assert_eq!(s.http.port, 4242, "nested env key populated http.port");
unsafe {
std::env::remove_var("RTBCFG_T7_HTTP_PORT");
}
}
#[derive(Debug, Deserialize)]
struct Strict {
#[allow(dead_code)]
must_be_present: String,
}
#[test]
fn t8_missing_required_field_parse_error() {
let result = Config::<Strict>::builder().embedded_default("other: value\n").build();
match result {
Err(ConfigError::Parse(msg)) => {
assert!(
msg.contains("must_be_present"),
"expected message to mention field, got: {msg}"
);
}
Err(other) => panic!("expected Parse, got {other:?}"),
Ok(_) => panic!("expected error"),
}
}
#[test]
fn t9_reload_reads_file_changes() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
std::fs::write(&path, "port: 8080\n").expect("write");
let cfg = Config::<Sample>::builder().user_file(&path).build().expect("build");
assert_eq!(cfg.get().port, 8080);
let mut f = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).expect("open");
f.write_all(b"port: 8181\n").expect("write");
drop(f);
cfg.reload().expect("reload");
assert_eq!(cfg.get().port, 8181);
}
#[test]
fn t10_snapshot_survives_reload() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
std::fs::write(&path, "port: 1000\n").expect("write");
let cfg = Config::<Sample>::builder().user_file(&path).build().expect("build");
let old_snapshot = cfg.get();
assert_eq!(old_snapshot.port, 1000);
std::fs::write(&path, "port: 2000\n").expect("rewrite");
cfg.reload().expect("reload");
assert_eq!(old_snapshot.port, 1000);
assert_eq!(cfg.get().port, 2000);
}
#[test]
fn t11_io_error_for_non_file_path() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().to_path_buf();
let result = Config::<Sample>::builder().user_file(&path).build();
match result {
Err(ConfigError::Io { path: reported, .. }) => {
assert_eq!(reported, path, "Io variant should carry the offending path");
}
Err(other) => panic!("expected Io, got {other:?}"),
Ok(_) => panic!("expected error — directory cannot be parsed as YAML"),
}
}
#[test]
fn t12_missing_file_is_ok() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("does_not_exist.yaml");
let cfg = Config::<Sample>::builder()
.embedded_default("port: 5555\n")
.user_file(&path)
.build()
.expect("build must succeed despite missing user file");
assert_eq!(cfg.get().port, 5555);
}
#[test]
fn builder_type_is_public() {
let _b: ConfigBuilder<Sample> = ConfigBuilder::new();
}
#[tokio::test]
async fn t13_subscribe_initial_value() {
let cfg = Config::<Sample>::builder().embedded_default("port: 4200\n").build().expect("build");
let rx = cfg.subscribe();
assert_eq!(rx.borrow().port, 4200);
}
#[tokio::test]
async fn t14_subscribe_observes_reload() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cfg.yaml");
std::fs::write(&path, "port: 100\n").unwrap();
let cfg = Config::<Sample>::builder()
.embedded_default("port: 0\n")
.user_file(&path)
.build()
.expect("build");
let mut rx = cfg.subscribe();
assert_eq!(rx.borrow().port, 100);
std::fs::write(&path, "port: 200\n").unwrap();
cfg.reload().expect("reload");
rx.changed().await.expect("channel open");
assert_eq!(rx.borrow().port, 200);
}
#[tokio::test]
async fn t15_failing_reload_keeps_old_value() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cfg.yaml");
std::fs::write(&path, "port: 100\n").unwrap();
let cfg = Config::<Sample>::builder()
.embedded_default("port: 0\n")
.user_file(&path)
.build()
.expect("build");
let rx = cfg.subscribe();
assert_eq!(rx.borrow().port, 100);
std::fs::write(&path, "port: not-a-number\n").unwrap();
let err = cfg.reload().expect_err("malformed YAML");
assert!(matches!(err, ConfigError::Parse(_)), "got {err:?}");
assert_eq!(cfg.get().port, 100);
assert_eq!(rx.borrow().port, 100);
}
#[tokio::test]
async fn t16_late_subscriber_sees_current() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cfg.yaml");
std::fs::write(&path, "port: 10\n").unwrap();
let cfg = Config::<Sample>::builder()
.embedded_default("port: 0\n")
.user_file(&path)
.build()
.expect("build");
std::fs::write(&path, "port: 999\n").unwrap();
cfg.reload().expect("reload");
let rx = cfg.subscribe();
assert_eq!(rx.borrow().port, 999);
}
#[tokio::test]
async fn t17_reload_after_all_subscribers_dropped() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cfg.yaml");
std::fs::write(&path, "port: 1\n").unwrap();
let cfg = Config::<Sample>::builder()
.embedded_default("port: 0\n")
.user_file(&path)
.build()
.expect("build");
{
let _rx = cfg.subscribe();
}
std::fs::write(&path, "port: 2\n").unwrap();
cfg.reload().expect("reload after all subscribers dropped");
assert_eq!(cfg.get().port, 2);
}
#[cfg(feature = "hot-reload")]
mod hot_reload_tests {
use std::time::Duration;
use rtb_config::{Config, ConfigError};
use super::Sample;
#[test]
fn t18_watch_files_rejects_no_paths() {
let cfg = Config::<Sample>::builder().embedded_default("port: 0\n").build().expect("build");
let err = cfg.watch_files().expect_err("no user files");
assert!(matches!(err, ConfigError::Watch(_)), "got {err:?}");
}
#[tokio::test]
async fn t19_file_change_triggers_reload() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cfg.yaml");
std::fs::write(&path, "port: 10\n").unwrap();
let cfg = Config::<Sample>::builder()
.embedded_default("port: 0\n")
.user_file(&path)
.build()
.expect("build");
let rx = cfg.subscribe();
assert_eq!(rx.borrow().port, 10);
let _handle = cfg.watch_files().expect("watch starts");
tokio::time::sleep(Duration::from_millis(50)).await;
std::fs::write(&path, "port: 20\n").unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
if rx.borrow().port == 20 {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"watcher did not reload within 2s; current port={}",
rx.borrow().port,
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn t20_dropping_handle_stops_watcher() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cfg.yaml");
std::fs::write(&path, "port: 1\n").unwrap();
let cfg = Config::<Sample>::builder()
.embedded_default("port: 0\n")
.user_file(&path)
.build()
.expect("build");
let handle = cfg.watch_files().expect("watch starts");
tokio::time::sleep(Duration::from_millis(50)).await;
drop(handle);
tokio::time::sleep(Duration::from_millis(100)).await;
std::fs::write(&path, "port: 99\n").unwrap();
tokio::time::sleep(Duration::from_millis(600)).await;
assert_eq!(cfg.get().port, 1);
}
}