use std::collections::{BTreeMap, HashMap};
use std::ffi::{c_char, c_void, CString};
use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::{Mutex, OnceLock};
use crate::error::{Error, Result};
pub type ErrorHandler = Option<unsafe extern "C" fn(*const c_char, *const c_char, *mut c_void)>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntryType {
None,
String,
Int,
Double,
Bool,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ConfigValue {
String(String),
Int(i64),
Double(f64),
Bool(bool),
}
pub struct ConfigStore {
error_handler: AtomicPtr<c_void>,
error_userdata: AtomicPtr<c_void>,
entries: Mutex<HashMap<String, ConfigValue>>,
}
impl ConfigStore {
pub fn instance() -> &'static ConfigStore {
static INSTANCE: OnceLock<ConfigStore> = OnceLock::new();
INSTANCE.get_or_init(|| {
let store = ConfigStore {
error_handler: AtomicPtr::new(std::ptr::null_mut()),
error_userdata: AtomicPtr::new(std::ptr::null_mut()),
entries: Mutex::new(HashMap::new()),
};
store.set_defaults();
store
})
}
pub fn load(&self) -> Result<()> {
self.set_defaults();
let toml_path = get_config_file_path();
let metadata = match std::fs::metadata(&toml_path) {
Ok(m) => m,
Err(_) => {
return self.load_legacy_ini();
}
};
if !metadata.is_file() {
self.report_error(
"Error loading settings",
"Failed to load application settings. This session will use defaults.",
);
return Err(Error::Failed("config.toml is not a regular file".into()));
}
let content = std::fs::read_to_string(&toml_path).map_err(|_| {
self.report_error(
"Error loading settings",
"Failed to load application settings. This session will use defaults.",
);
Error::Failed("config.toml could not be read".into())
})?;
let parsed: toml::Value = toml::from_str(&content).map_err(|e| {
self.report_error(
"Error loading settings",
"Failed to load application settings. This session will use defaults.",
);
Error::Failed(format!("config.toml could not be parsed: {}", e))
})?;
self.apply_toml(&parsed);
Ok(())
}
fn load_legacy_ini(&self) -> Result<()> {
let path = get_config_ini_path();
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => {
return Ok(());
}
};
if !metadata.is_file() {
self.report_error(
"Error loading settings",
"Failed to load application settings. This session will use defaults.",
);
return Err(Error::Failed("config.ini is not a regular file".into()));
}
let content = std::fs::read_to_string(&path).map_err(|_| {
self.report_error(
"Error loading settings",
"Failed to load application settings. This session will use defaults.",
);
Error::Failed("config.ini could not be read".into())
})?;
self.parse_ini(&content);
let _ = self.save();
Ok(())
}
fn parse_ini(&self, content: &str) {
let mut group = String::new();
for raw_line in content.lines() {
let line = trim(raw_line);
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
group = trim(&line[1..line.len() - 1]);
continue;
}
let eq = match line.find('=') {
Some(e) => e,
None => {
continue;
}
};
let key = trim(&line[..eq]);
let value = trim(&line[eq + 1..]);
if key.is_empty() {
continue;
}
let full_key = if group.is_empty() {
key.to_string()
} else {
format!("{}/{}", group, key)
};
match self.get_entry(&full_key) {
Some(existing) => {
let ty = to_entry_type(&existing);
if let Some(parsed) = string_to_value(&value, ty) {
self.set_entry(full_key, parsed);
}
}
None => {
self.set_entry(full_key, ConfigValue::String(value));
}
}
}
}
fn apply_toml(&self, parsed: &toml::Value) {
if let Some(table) = parsed.as_table() {
self.flatten_toml("", table);
}
}
fn flatten_toml(&self, prefix: &str, table: &toml::map::Map<String, toml::Value>) {
for (key, value) in table {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{}/{}", prefix, key)
};
match value.as_table() {
Some(sub) => self.flatten_toml(&full_key, sub),
None => self.apply_toml_entry(&full_key, value),
}
}
}
fn apply_toml_entry(&self, full_key: &str, value: &toml::Value) {
match self.get_entry(full_key) {
Some(existing) => {
let ty = to_entry_type(&existing);
if let Some(converted) = toml_value_to_typed(value, ty) {
self.set_entry(full_key.to_string(), converted);
}
}
None => {
self.set_entry(
full_key.to_string(),
ConfigValue::String(toml_value_to_string(value)),
);
}
}
}
pub fn save(&self) -> Result<()> {
let real_filename = get_config_file_path();
let temp_filename = format!("{}.tmp", real_filename);
let mut sections: BTreeMap<String, BTreeMap<String, toml::Value>> = BTreeMap::new();
{
let guard = self.entries.lock().unwrap();
for (key, value) in guard.iter() {
let toml_value = config_value_to_toml(value);
match key.rfind('/') {
Some(slash) => {
let group = key[..slash].to_string();
let sub = key[slash + 1..].to_string();
sections.entry(group).or_default().insert(sub, toml_value);
}
None => {
sections
.entry(String::new())
.or_default()
.insert(key.clone(), toml_value);
}
}
}
}
let mut root: toml::map::Map<String, toml::Value> = toml::map::Map::new();
if let Some(flat) = sections.remove("") {
for (sub, value) in flat {
root.insert(sub, value);
}
}
for (group, subs) in sections {
root.insert(group, toml::Value::Table(subs.into_iter().collect()));
}
let out = toml::to_string_pretty(&toml::Value::Table(root)).map_err(|e| {
self.report_error(
"Error saving settings",
"Failed to serialize application settings as TOML.",
);
Error::Failed(format!("config.toml could not be serialized: {}", e))
})?;
if std::fs::write(&temp_filename, out.as_bytes()).is_err() {
self.report_error(
"Error saving settings",
"Failed to save application settings. The application may lack write \
permissions for this location.",
);
return Err(Error::Failed(
"temp config file could not be written".into(),
));
}
if std::fs::rename(&temp_filename, &real_filename).is_err() {
let _ = std::fs::remove_file(&real_filename);
if std::fs::rename(&temp_filename, &real_filename).is_err() {
self.report_error(
"Error saving settings",
"Failed to overwrite the application settings file.",
);
return Err(Error::Failed(
"config.toml could not be renamed into place".into(),
));
}
}
Ok(())
}
pub fn reset_defaults(&self) -> Result<()> {
self.set_defaults();
Ok(())
}
pub fn set(&self, group: Option<&str>, key: &str, value: &str) {
if key.is_empty() {
return;
}
let joined = join_key(group, key);
match self.get_entry(&joined) {
Some(ConfigValue::String(_)) | None => {
self.set_entry(joined, ConfigValue::String(value.to_string()));
}
Some(other) => {
let ty = to_entry_type(&other);
if let Some(parsed) = string_to_value(value, ty) {
self.set_entry(joined, parsed);
}
}
}
}
pub fn get(&self, group: Option<&str>, key: &str) -> Result<String> {
if key.is_empty() {
return Err(Error::Invalid);
}
match self.get_entry(&join_key(group, key)) {
None => Err(Error::NotFound),
Some(v) => Ok(value_to_string(&v)),
}
}
pub fn get_int(&self, group: Option<&str>, key: &str, fallback: i32) -> i32 {
if key.is_empty() {
return fallback;
}
match self.get_entry(&join_key(group, key)) {
Some(ConfigValue::Int(v)) => v as i32,
_ => fallback,
}
}
pub fn get_double(&self, group: Option<&str>, key: &str, fallback: f64) -> f64 {
if key.is_empty() {
return fallback;
}
match self.get_entry(&join_key(group, key)) {
Some(ConfigValue::Double(v)) => v,
_ => fallback,
}
}
pub fn set_int(&self, group: Option<&str>, key: &str, v: i32) {
self.set_int64(group, key, v as i64);
}
pub fn get_int64(&self, group: Option<&str>, key: &str, fallback: i64) -> i64 {
if key.is_empty() {
return fallback;
}
match self.get_entry(&join_key(group, key)) {
Some(ConfigValue::Int(v)) => v,
_ => fallback,
}
}
pub fn set_int64(&self, group: Option<&str>, key: &str, v: i64) {
if key.is_empty() {
return;
}
self.set_entry(join_key(group, key), ConfigValue::Int(v));
}
pub fn get_bool(&self, group: Option<&str>, key: &str, fallback: i32) -> i32 {
if key.is_empty() {
return fallback;
}
match self.get_entry(&join_key(group, key)) {
Some(ConfigValue::Bool(b)) => {
if b {
1
} else {
0
}
}
_ => fallback,
}
}
pub fn set_bool(&self, group: Option<&str>, key: &str, v: i32) {
if key.is_empty() {
return;
}
self.set_entry(join_key(group, key), ConfigValue::Bool(v != 0));
}
pub fn set_double(&self, group: Option<&str>, key: &str, v: f64) {
if key.is_empty() {
return;
}
self.set_entry(join_key(group, key), ConfigValue::Double(v));
}
pub fn entry_type(&self, group: Option<&str>, key: &str) -> Result<EntryType> {
if key.is_empty() {
return Err(Error::Invalid);
}
match self.get_entry(&join_key(group, key)) {
None => Err(Error::NotFound),
Some(v) => Ok(to_entry_type(&v)),
}
}
pub fn set_error_handler(&self, handler: ErrorHandler, userdata: *mut c_void) -> Result<()> {
let ptr = handler.map_or(std::ptr::null_mut(), |h| h as *mut c_void);
self.error_handler.store(ptr, Ordering::Release);
self.error_userdata.store(userdata, Ordering::Release);
Ok(())
}
fn report_error(&self, title: &str, message: &str) {
let handler = self.error_handler.load(Ordering::Acquire);
if handler.is_null() {
eprintln!("{}: {}", title, message);
return;
}
let userdata = self.error_userdata.load(Ordering::Acquire);
let t = CString::new(title).unwrap_or_default();
let m = CString::new(message).unwrap_or_default();
let h: unsafe extern "C" fn(*const c_char, *const c_char, *mut c_void) =
unsafe { std::mem::transmute(handler) };
unsafe {
h(t.as_ptr(), m.as_ptr(), userdata);
}
}
fn set_defaults(&self) {
let mut guard = self.entries.lock().unwrap();
guard.clear();
guard.insert("TimelineThumbnailMode".into(), ConfigValue::Int(1));
guard.insert("TimelineWaveformMode".into(), ConfigValue::Int(1));
guard.insert("DefaultSequenceWidth".into(), ConfigValue::Int(1920));
guard.insert("DefaultSequenceHeight".into(), ConfigValue::Int(1080));
guard.insert(
"DefaultSequenceFrameRate".into(),
ConfigValue::String("1001/30000".into()),
);
guard.insert(
"DefaultSequencePixelAspect".into(),
ConfigValue::String("1/1".into()),
);
guard.insert("DefaultSequenceInterlacing".into(), ConfigValue::Int(0));
guard.insert(
"DefaultSequenceAudioFrequency".into(),
ConfigValue::Int(48000),
);
guard.insert("DefaultSequenceAudioLayout".into(), ConfigValue::Int(3));
guard.insert("OfflinePixelFormat".into(), ConfigValue::Int(4));
guard.insert("SplitClipsCopyNodes".into(), ConfigValue::Bool(true));
guard.insert("UseProxyMedia".into(), ConfigValue::Bool(true));
guard.insert("UseGLFinish".into(), ConfigValue::Bool(false));
guard.insert("ReassocLinToNonLin".into(), ConfigValue::Bool(false));
guard.insert(
"GraphicsBackend".into(),
ConfigValue::String("opengl".into()),
);
guard.insert("LUTLibraryPaths".into(), ConfigValue::String(String::new()));
guard.insert("DiskCacheSaveInterval".into(), ConfigValue::Int(10000));
guard.insert("AutoCacheDelay".into(), ConfigValue::Int(1000));
guard.insert("DiskCacheBehind".into(), ConfigValue::String("0/1".into()));
guard.insert("DiskCacheAhead".into(), ConfigValue::String("60/1".into()));
guard.insert("ProxyWidth".into(), ConfigValue::Int(1280));
guard.insert("ProxyHeight".into(), ConfigValue::Int(720));
guard.insert("ProxyDivider".into(), ConfigValue::Int(1));
guard.insert("ProxyCRF".into(), ConfigValue::Int(23));
guard.insert("ProxyPreset".into(), ConfigValue::String("veryfast".into()));
guard.insert("ProxyIncludeAudio".into(), ConfigValue::Bool(true));
guard.insert("MarkerColor".into(), ConfigValue::Int(6));
for i in 0..=11 {
guard.insert(format!("CatColor{}", i), ConfigValue::Int(i));
}
}
fn get_entry(&self, key: &str) -> Option<ConfigValue> {
let guard = self.entries.lock().unwrap();
guard.get(key).cloned()
}
fn set_entry(&self, key: String, value: ConfigValue) {
let mut guard = self.entries.lock().unwrap();
guard.insert(key, value);
}
}
pub(crate) fn join_key(group: Option<&str>, key: &str) -> String {
match group {
Some(g) if !g.is_empty() => format!("{}/{}", g, key),
_ => key.to_string(),
}
}
fn value_to_string(value: &ConfigValue) -> String {
match value {
ConfigValue::String(s) => s.clone(),
ConfigValue::Int(i) => i.to_string(),
ConfigValue::Double(d) => format_g(*d),
ConfigValue::Bool(b) => {
if *b {
"true".to_string()
} else {
"false".to_string()
}
}
}
}
fn config_value_to_toml(value: &ConfigValue) -> toml::Value {
match value {
ConfigValue::String(s) => toml::Value::String(s.clone()),
ConfigValue::Int(i) => toml::Value::Integer(*i),
ConfigValue::Double(d) if d.is_finite() => toml::Value::Float(*d),
ConfigValue::Double(_) => toml::Value::String(value_to_string(value)),
ConfigValue::Bool(b) => toml::Value::Boolean(*b),
}
}
fn toml_value_to_typed(value: &toml::Value, ty: EntryType) -> Option<ConfigValue> {
match ty {
EntryType::Int => match value {
toml::Value::Integer(i) => Some(ConfigValue::Int(*i)),
_ => string_to_value(&toml_value_to_text(value), EntryType::Int),
},
EntryType::Double => match value {
toml::Value::Float(f) => Some(ConfigValue::Double(*f)),
_ => string_to_value(&toml_value_to_text(value), EntryType::Double),
},
EntryType::Bool => match value {
toml::Value::Boolean(b) => Some(ConfigValue::Bool(*b)),
_ => string_to_value(&toml_value_to_text(value), EntryType::Bool),
},
EntryType::String => Some(ConfigValue::String(toml_value_to_text(value))),
EntryType::None => None,
}
}
fn toml_value_to_text(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
_ => value.to_string(),
}
}
fn toml_value_to_string(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
toml::Value::Integer(i) => i.to_string(),
toml::Value::Boolean(b) => b.to_string(),
toml::Value::Float(f) => format_g(*f),
_ => value.to_string(),
}
}
fn string_to_value(text: &str, ty: EntryType) -> Option<ConfigValue> {
match ty {
EntryType::String => Some(ConfigValue::String(text.to_string())),
EntryType::Int => {
text.trim_start().parse::<i64>().ok().map(ConfigValue::Int)
}
EntryType::Double => {
let t = text.trim_start();
let parsed = if t.eq_ignore_ascii_case("inf")
|| t.eq_ignore_ascii_case("+inf")
|| t.eq_ignore_ascii_case("infinity")
|| t.eq_ignore_ascii_case("+infinity")
{
Some(f64::INFINITY)
} else if t.eq_ignore_ascii_case("-inf") || t.eq_ignore_ascii_case("-infinity") {
Some(f64::NEG_INFINITY)
} else if t.eq_ignore_ascii_case("nan")
|| t.eq_ignore_ascii_case("+nan")
|| t.eq_ignore_ascii_case("-nan")
{
Some(f64::NAN)
} else {
t.parse::<f64>().ok()
};
parsed.map(ConfigValue::Double)
}
EntryType::Bool => {
if text == "true" || text == "1" {
Some(ConfigValue::Bool(true))
} else if text == "false" || text == "0" {
Some(ConfigValue::Bool(false))
} else {
None
}
}
EntryType::None => None,
}
}
fn to_entry_type(value: &ConfigValue) -> EntryType {
match value {
ConfigValue::String(_) => EntryType::String,
ConfigValue::Int(_) => EntryType::Int,
ConfigValue::Double(_) => EntryType::Double,
ConfigValue::Bool(_) => EntryType::Bool,
}
}
fn trim(s: &str) -> String {
s.trim_matches(|c| c == ' ' || c == '\t' || c == '\r' || c == '\n')
.to_string()
}
fn configuration_location() -> String {
crate::filefunctions::FileFunctions::new()
.get_configuration_location()
.unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned())
}
fn get_config_file_path() -> String {
format!("{}/config.toml", configuration_location())
}
fn get_config_ini_path() -> String {
format!("{}/config.ini", configuration_location())
}
fn format_g(v: f64) -> String {
if v.is_nan() {
return "nan".to_string();
}
if v.is_infinite() {
return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0" } else { "0" }.to_string();
}
let sign = if v.is_sign_negative() { "-" } else { "" };
let a = v.abs();
const PRECISION: i32 = 6;
let sci = format!("{:.*e}", (PRECISION - 1) as usize, a); let exp = sci.split('e').nth(1).unwrap().parse::<i32>().unwrap();
let xr = exp;
let body = if !(-4..PRECISION).contains(&xr) {
let mant = sci.split('e').next().unwrap();
let m = trim_mantissa(mant);
format!("{}e{}", m, format_exp(&xr.to_string()))
} else {
let decimals = (PRECISION - 1 - xr).max(0) as usize;
trim_mantissa(&format!("{:.*}", decimals, a))
};
format!("{}{}", sign, body)
}
fn trim_mantissa(m: &str) -> String {
match m.split_once('.') {
None => m.to_string(),
Some((int, frac)) => {
let frac = frac.trim_end_matches('0');
if frac.is_empty() {
int.to_string()
} else {
format!("{}.{}", int, frac)
}
}
}
}
fn format_exp(e: &str) -> String {
let (neg, mag) = match e.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, e.strip_prefix('+').unwrap_or(e)),
};
let sign = if neg { "-" } else { "+" };
if mag.len() < 2 {
format!("{}{:0>2}", sign, mag)
} else {
format!("{}{}", sign, mag)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::c_char;
use std::path::Path;
use std::sync::Mutex as StdMutex;
fn test_lock() -> &'static StdMutex<()> {
crate::test_support::env_lock()
}
fn with_temp_config<T>(f: impl FnOnce(&Path) -> T) -> T {
let _guard = test_lock().lock().unwrap();
let dir =
std::env::temp_dir().join(format!("quercus_core_configstore_test_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::env::set_var("QUERCUS_CONFIG_DIR", &dir);
let result = f(&dir);
std::env::remove_var("QUERCUS_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&dir);
result
}
#[test]
fn test_format_g_matches_c() {
assert_eq!(format_g(0.0), "0");
assert_eq!(format_g(-0.0), "-0");
assert_eq!(format_g(1920.0), "1920");
assert_eq!(format_g(100.0), "100");
assert_eq!(format_g(3.15), "3.15");
assert_eq!(format_g(1.5), "1.5");
assert_eq!(format_g(0.1), "0.1");
assert_eq!(format_g(-3.5), "-3.5");
assert_eq!(format_g(1.23456789), "1.23457");
assert_eq!(format_g(1234567.0), "1.23457e+06");
assert_eq!(format_g(0.00012345), "0.00012345");
assert_eq!(format_g(0.000012345), "1.2345e-05");
assert_eq!(format_g(999999.5), "1e+06");
assert_eq!(format_g(f64::NAN), "nan");
assert_eq!(format_g(f64::INFINITY), "inf");
assert_eq!(format_g(f64::NEG_INFINITY), "-inf");
}
#[test]
fn test_join_key() {
assert_eq!(join_key(None, "k"), "k");
assert_eq!(join_key(Some(""), "k"), "k");
assert_eq!(join_key(Some("g"), "k"), "g/k");
}
#[test]
fn test_defaults() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 1920);
assert_eq!(s.get_int(None, "DefaultSequenceHeight", -1), 1080);
assert_eq!(s.get_int(None, "DefaultSequenceInterlacing", -1), 0);
assert_eq!(s.get_int(None, "DefaultSequenceAudioFrequency", -1), 48000);
assert_eq!(s.get_int(None, "DefaultSequenceAudioLayout", -1), 3);
assert_eq!(s.get_int(None, "OfflinePixelFormat", -1), 4);
assert_eq!(s.get_int(None, "DiskCacheSaveInterval", -1), 10000);
assert_eq!(s.get_int(None, "AutoCacheDelay", -1), 1000);
assert_eq!(s.get_int(None, "ProxyWidth", -1), 1280);
assert_eq!(s.get_int(None, "ProxyHeight", -1), 720);
assert_eq!(s.get_int(None, "ProxyDivider", -1), 1);
assert_eq!(s.get_int(None, "ProxyCRF", -1), 23);
assert_eq!(s.get_int(None, "MarkerColor", -1), 6);
assert_eq!(s.get_int(None, "CatColor0", -1), 0);
assert_eq!(s.get_int(None, "CatColor11", -1), 11);
assert_eq!(s.get_bool(None, "SplitClipsCopyNodes", -1), 1);
assert_eq!(s.get_bool(None, "UseProxyMedia", -1), 1);
assert_eq!(s.get_bool(None, "UseGLFinish", 1), 0);
assert_eq!(s.get_bool(None, "ReassocLinToNonLin", 1), 0);
assert_eq!(s.get_bool(None, "ProxyIncludeAudio", -1), 1);
assert_eq!(s.get(None, "GraphicsBackend").unwrap(), "opengl");
assert_eq!(
s.get(None, "DefaultSequenceFrameRate").unwrap(),
"1001/30000"
);
assert_eq!(s.get(None, "DefaultSequencePixelAspect").unwrap(), "1/1");
assert_eq!(s.get(None, "LUTLibraryPaths").unwrap(), "");
assert_eq!(s.get(None, "DiskCacheBehind").unwrap(), "0/1");
assert_eq!(s.get(None, "DiskCacheAhead").unwrap(), "60/1");
assert_eq!(s.get(None, "ProxyPreset").unwrap(), "veryfast");
}
#[test]
fn test_set_get_string() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set(None, "MyString", "hello world");
assert_eq!(s.get(None, "MyString").unwrap(), "hello world");
assert_eq!(s.entry_type(None, "MyString").unwrap(), EntryType::String);
}
#[test]
fn test_get_missing_and_invalid() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
assert!(matches!(
s.get(None, "DefinitelyMissing"),
Err(Error::NotFound)
));
assert!(matches!(
s.entry_type(None, "DefinitelyMissing"),
Err(Error::NotFound)
));
assert!(matches!(s.get(None, ""), Err(Error::Invalid)));
assert!(matches!(s.entry_type(None, ""), Err(Error::Invalid)));
}
#[test]
fn test_int_get_set_fallback() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_int(None, "MyInt", 42);
assert_eq!(s.get_int(None, "MyInt", 0), 42);
assert_eq!(s.get_int64(None, "MyInt", 0), 42);
assert_eq!(s.entry_type(None, "MyInt").unwrap(), EntryType::Int);
let big = i64::from(i32::MAX) + 1;
s.set_int64(None, "MyBigInt", big);
assert_eq!(s.get_int64(None, "MyBigInt", 0), big);
assert_eq!(s.get_int(None, "MissingInt", 7), 7);
assert_eq!(s.get_int64(None, "MissingInt", 7), 7);
s.set(None, "MyStr", "abc");
assert_eq!(s.get_int(None, "MyStr", 9), 9);
assert_eq!(s.get_int64(None, "MyStr", 9), 9);
}
#[test]
fn test_double_get_set_fallback() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_double(None, "MyD", 3.15);
assert_eq!(s.get_double(None, "MyD", -1.0), 3.15);
assert_eq!(s.get(None, "MyD").unwrap(), "3.15");
assert_eq!(s.entry_type(None, "MyD").unwrap(), EntryType::Double);
assert_eq!(s.get_double(None, "MissingD", 2.5), 2.5);
s.set(None, "MyStr2", "xyz");
assert_eq!(s.get_double(None, "MyStr2", 2.5), 2.5);
}
#[test]
fn test_bool_get_set_fallback() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_bool(None, "MyB", 1);
assert_eq!(s.get_bool(None, "MyB", -1), 1);
assert_eq!(s.get(None, "MyB").unwrap(), "true");
assert_eq!(s.entry_type(None, "MyB").unwrap(), EntryType::Bool);
s.set_bool(None, "MyB2", 0);
assert_eq!(s.get_bool(None, "MyB2", 1), 0);
assert_eq!(s.get(None, "MyB2").unwrap(), "false");
assert_eq!(s.get_bool(None, "MissingB", 5), 5);
s.set(None, "MyStr3", "xyz");
assert_eq!(s.get_bool(None, "MyStr3", 5), 5);
}
#[test]
fn test_set_string_parses_into_typed_entry() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_int(None, "Parsed", 0);
s.set(None, "Parsed", "123");
assert_eq!(s.get_int(None, "Parsed", 0), 123);
assert_eq!(s.entry_type(None, "Parsed").unwrap(), EntryType::Int);
s.set(None, "Parsed", "abc");
assert_eq!(s.get_int(None, "Parsed", 0), 123);
s.set_bool(None, "ParsedB", 0);
s.set(None, "ParsedB", "true");
assert_eq!(s.get_bool(None, "ParsedB", -1), 1);
s.set(None, "ParsedB", "nonsense");
assert_eq!(s.get_bool(None, "ParsedB", -1), 1);
s.set_double(None, "ParsedD", 0.0);
s.set(None, "ParsedD", "2.5");
assert_eq!(s.get_double(None, "ParsedD", -1.0), 2.5);
s.set(None, "ParsedD", "junk");
assert_eq!(s.get_double(None, "ParsedD", -1.0), 2.5);
}
#[test]
fn test_grouped_keys() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_int(Some("audio"), "sample_rate", 44100);
assert_eq!(s.get_int(Some("audio"), "sample_rate", 0), 44100);
assert_eq!(
s.entry_type(Some("audio"), "sample_rate").unwrap(),
EntryType::Int
);
s.set_int(Some(""), "flatkey", 7);
assert_eq!(s.get_int(None, "flatkey", 0), 7);
assert_eq!(s.get_int(Some(""), "flatkey", 0), 7);
}
#[test]
fn test_load_missing_file() {
with_temp_config(|_dir| {
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.load().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 1920);
});
}
#[test]
fn test_save_load_roundtrip() {
with_temp_config(|_dir| {
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set(None, "CustomString", "custom value");
s.set_double(Some("render"), "gain", 1.5);
s.set_int(None, "DefaultSequenceWidth", 640);
s.set_bool(None, "SplitClipsCopyNodes", 0);
s.save().unwrap();
s.reset_defaults().unwrap();
assert!(matches!(s.get(None, "CustomString"), Err(Error::NotFound)));
s.load().unwrap();
assert_eq!(s.get(None, "CustomString").unwrap(), "custom value");
assert_eq!(s.get(Some("render"), "gain").unwrap(), "1.5");
assert_eq!(s.get_double(Some("render"), "gain", -1.0), -1.0);
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 640);
assert_eq!(s.get_bool(None, "SplitClipsCopyNodes", -1), 0);
assert_eq!(s.get_int(None, "DefaultSequenceHeight", -1), 1080);
});
}
#[test]
fn test_save_toml_format() {
with_temp_config(|dir| {
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_int(None, "FlatKey", 1);
s.set(None, "StrKey", "hello");
s.set_double(None, "DblKey", 2.5);
s.set_bool(None, "BoolKey", 1);
s.set_int(Some("alpha"), "x", 2);
s.set_int(Some("beta"), "y", 3);
s.save().unwrap();
let content = std::fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(content.contains("FlatKey = 1"), "content: {}", content);
assert!(
content.contains("StrKey = \"hello\""),
"content: {}",
content
);
assert!(content.contains("DblKey = 2.5"), "content: {}", content);
assert!(content.contains("BoolKey = true"), "content: {}", content);
assert!(content.contains("[alpha]"), "content: {}", content);
assert!(content.contains("x = 2"), "content: {}", content);
assert!(content.contains("[beta]"), "content: {}", content);
assert!(content.contains("y = 3"), "content: {}", content);
let flat_pos = content.find("FlatKey = 1").unwrap();
let alpha_pos = content.find("[alpha]").unwrap();
assert!(flat_pos < alpha_pos);
assert!(!dir.join("config.toml.tmp").exists());
assert!(!dir.join("config.ini").exists());
});
}
#[test]
fn test_migrate_ini_parses_types_and_skips_lines() {
with_temp_config(|dir| {
let ini = "\
# comment
; another comment
DefaultSequenceWidth=640
UseProxyMedia=false
[section]
UnknownTypedThing=hello
";
std::fs::write(dir.join("config.ini"), ini).unwrap();
let s = ConfigStore::instance();
s.load().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 640);
assert_eq!(s.get_bool(None, "UseProxyMedia", -1), 0);
assert_eq!(
s.get(Some("section"), "UnknownTypedThing").unwrap(),
"hello"
);
assert!(dir.join("config.toml").is_file());
});
}
#[test]
fn test_migrate_ini_unparseable_keeps_default() {
with_temp_config(|dir| {
std::fs::write(dir.join("config.ini"), "DefaultSequenceWidth=notanumber\n").unwrap();
let s = ConfigStore::instance();
s.load().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 1920);
});
}
#[test]
fn test_migrate_ini_trims_values() {
with_temp_config(|dir| {
std::fs::write(dir.join("config.ini"), " DefaultSequenceWidth = 320 \n").unwrap();
let s = ConfigStore::instance();
s.load().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 320);
});
}
#[test]
fn test_toml_roundtrip_all_types() {
with_temp_config(|dir| {
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set(None, "CustomString", "hello world");
s.set_int(None, "DefaultSequenceWidth", 640); s.set_bool(None, "SplitClipsCopyNodes", 0); s.set(None, "ProxyPreset", "medium"); s.set_double(Some("render"), "gain", 1.5); s.save().unwrap();
s.reset_defaults().unwrap();
assert!(matches!(s.get(None, "CustomString"), Err(Error::NotFound)));
s.load().unwrap();
assert_eq!(s.get(None, "CustomString").unwrap(), "hello world");
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 640);
assert_eq!(s.get_bool(None, "SplitClipsCopyNodes", -1), 0);
assert_eq!(s.get(None, "ProxyPreset").unwrap(), "medium");
assert_eq!(s.get(Some("render"), "gain").unwrap(), "1.5");
assert_eq!(s.get_double(Some("render"), "gain", -1.0), -1.0);
let content = std::fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(
content.contains("DefaultSequenceWidth = 640"),
"content: {}",
content
);
assert!(
content.contains("SplitClipsCopyNodes = false"),
"content: {}",
content
);
assert!(
content.contains("ProxyPreset = \"medium\""),
"content: {}",
content
);
assert!(content.contains("gain = 1.5"), "content: {}", content);
});
}
#[test]
fn test_migrate_from_ini_generates_toml() {
with_temp_config(|dir| {
let ini = "\
DefaultSequenceWidth=640
UseProxyMedia=false
[section]
CustomKey=hello
";
std::fs::write(dir.join("config.ini"), ini).unwrap();
let s = ConfigStore::instance();
s.load().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 640);
assert_eq!(s.get_bool(None, "UseProxyMedia", -1), 0);
assert_eq!(s.get(Some("section"), "CustomKey").unwrap(), "hello");
assert!(dir.join("config.toml").is_file());
assert!(dir.join("config.ini").is_file());
let content = std::fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(
content.contains("DefaultSequenceWidth = 640"),
"content: {}",
content
);
assert!(
content.contains("UseProxyMedia = false"),
"content: {}",
content
);
assert!(content.contains("[section]"), "content: {}", content);
assert!(
content.contains("CustomKey = \"hello\""),
"content: {}",
content
);
s.reset_defaults().unwrap();
s.load().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 640);
assert_eq!(s.get_bool(None, "UseProxyMedia", -1), 0);
});
}
#[test]
fn test_toml_preferred_over_ini() {
with_temp_config(|dir| {
std::fs::write(
dir.join("config.ini"),
"DefaultSequenceWidth=800\nOnlyInIni=legacy\n",
)
.unwrap();
std::fs::write(
dir.join("config.toml"),
"DefaultSequenceWidth = 640\nOnlyInToml = \"modern\"\n",
)
.unwrap();
let s = ConfigStore::instance();
s.load().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 640);
assert_eq!(s.get(None, "OnlyInToml").unwrap(), "modern");
assert!(matches!(s.get(None, "OnlyInIni"), Err(Error::NotFound)));
});
}
static REPORTED: StdMutex<Vec<(String, String)>> = StdMutex::new(Vec::new());
extern "C" fn record_handler(
title: *const c_char,
message: *const c_char,
_userdata: *mut c_void,
) {
unsafe {
let title = std::ffi::CStr::from_ptr(title)
.to_string_lossy()
.into_owned();
let message = std::ffi::CStr::from_ptr(message)
.to_string_lossy()
.into_owned();
REPORTED.lock().unwrap().push((title, message));
}
}
#[test]
fn test_load_directory_returns_failed_and_reports() {
with_temp_config(|dir| {
std::fs::create_dir(dir.join("config.ini")).unwrap();
REPORTED.lock().unwrap().clear();
let s = ConfigStore::instance();
s.set_error_handler(Some(record_handler), std::ptr::null_mut())
.unwrap();
let res = s.load();
s.set_error_handler(None, std::ptr::null_mut()).unwrap();
assert!(res.is_err());
assert_eq!(res.unwrap_err().code(), crate::error::QUERCUSCORE_E_FAILED);
let reported = REPORTED.lock().unwrap().clone();
assert_eq!(reported.len(), 1);
assert_eq!(reported[0].0, "Error loading settings");
assert!(reported[0]
.1
.contains("Failed to load application settings"));
});
}
#[test]
fn test_error_handler_none_clears() {
with_temp_config(|dir| {
std::fs::create_dir(dir.join("config.ini")).unwrap();
let s = ConfigStore::instance();
s.set_error_handler(Some(record_handler), std::ptr::null_mut())
.unwrap();
s.set_error_handler(None, std::ptr::null_mut()).unwrap();
REPORTED.lock().unwrap().clear();
let res = s.load();
assert!(res.is_err());
assert!(REPORTED.lock().unwrap().is_empty());
});
}
#[test]
fn test_value_to_string_all_types() {
assert_eq!(value_to_string(&ConfigValue::String("hi".into())), "hi");
assert_eq!(value_to_string(&ConfigValue::Int(-42)), "-42");
assert_eq!(
value_to_string(&ConfigValue::Int(i64::MAX)),
"9223372036854775807"
);
assert_eq!(value_to_string(&ConfigValue::Double(2.5)), "2.5");
assert_eq!(value_to_string(&ConfigValue::Double(0.0)), "0");
assert_eq!(value_to_string(&ConfigValue::Bool(true)), "true");
assert_eq!(value_to_string(&ConfigValue::Bool(false)), "false");
}
#[test]
fn test_to_entry_type() {
assert_eq!(
to_entry_type(&ConfigValue::String(String::new())),
EntryType::String
);
assert_eq!(to_entry_type(&ConfigValue::Int(0)), EntryType::Int);
assert_eq!(to_entry_type(&ConfigValue::Double(0.0)), EntryType::Double);
assert_eq!(to_entry_type(&ConfigValue::Bool(false)), EntryType::Bool);
}
#[test]
fn test_string_to_value_int() {
assert_eq!(
string_to_value("42", EntryType::Int),
Some(ConfigValue::Int(42))
);
assert_eq!(
string_to_value(" 42", EntryType::Int),
Some(ConfigValue::Int(42))
);
assert_eq!(
string_to_value("-7", EntryType::Int),
Some(ConfigValue::Int(-7))
);
assert_eq!(
string_to_value("+7", EntryType::Int),
Some(ConfigValue::Int(7))
);
assert!(string_to_value("42 ", EntryType::Int).is_none());
assert!(string_to_value("42x", EntryType::Int).is_none());
assert!(string_to_value("0x10", EntryType::Int).is_none());
assert!(string_to_value("", EntryType::Int).is_none());
assert!(string_to_value("99999999999999999999999", EntryType::Int).is_none());
}
#[test]
fn test_string_to_value_double() {
assert_eq!(
string_to_value("2.5", EntryType::Double),
Some(ConfigValue::Double(2.5))
);
assert_eq!(
string_to_value(" 2.5", EntryType::Double),
Some(ConfigValue::Double(2.5))
);
assert_eq!(
string_to_value("1e3", EntryType::Double),
Some(ConfigValue::Double(1000.0))
);
assert_eq!(
string_to_value("inf", EntryType::Double),
Some(ConfigValue::Double(f64::INFINITY))
);
assert_eq!(
string_to_value("-Infinity", EntryType::Double),
Some(ConfigValue::Double(f64::NEG_INFINITY))
);
match string_to_value("NaN", EntryType::Double) {
Some(ConfigValue::Double(d)) => assert!(d.is_nan()),
other => panic!("expected NaN double, got {:?}", other),
}
assert!(string_to_value("2.5 ", EntryType::Double).is_none());
assert!(string_to_value("2.5x", EntryType::Double).is_none());
assert!(string_to_value("", EntryType::Double).is_none());
}
#[test]
fn test_string_to_value_bool_and_misc() {
assert_eq!(
string_to_value("true", EntryType::Bool),
Some(ConfigValue::Bool(true))
);
assert_eq!(
string_to_value("1", EntryType::Bool),
Some(ConfigValue::Bool(true))
);
assert_eq!(
string_to_value("false", EntryType::Bool),
Some(ConfigValue::Bool(false))
);
assert_eq!(
string_to_value("0", EntryType::Bool),
Some(ConfigValue::Bool(false))
);
assert!(string_to_value("TRUE", EntryType::Bool).is_none());
assert!(string_to_value("yes", EntryType::Bool).is_none());
assert!(string_to_value("2", EntryType::Bool).is_none());
assert_eq!(
string_to_value("anything", EntryType::String),
Some(ConfigValue::String("anything".into()))
);
assert!(string_to_value("anything", EntryType::None).is_none());
}
#[test]
fn test_trim() {
assert_eq!(trim(" hello \t\r\n"), "hello");
assert_eq!(trim("no-space"), "no-space");
assert_eq!(trim(" "), "");
assert_eq!(trim(""), "");
assert_eq!(trim(" a b "), "a b");
}
#[test]
fn test_format_g_edge_cases() {
assert_eq!(format_g(100000.0), "100000");
assert_eq!(format_g(1000000.0), "1e+06");
assert_eq!(format_g(0.0001), "0.0001");
assert_eq!(format_g(0.00001), "1e-05");
assert_eq!(format_g(1.0), "1");
assert_eq!(format_g(1.20), "1.2");
assert_eq!(format_g(-0.00012345), "-0.00012345");
assert_eq!(format_g(6.5e100), "6.5e+100");
}
#[test]
fn test_empty_key_is_rejected_everywhere() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set(None, "", "v");
s.set(Some("g"), "", "v");
s.set_int(None, "", 1);
s.set_int64(None, "", 1);
s.set_double(None, "", 1.0);
s.set_bool(None, "", 1);
assert!(matches!(s.get(None, ""), Err(Error::Invalid)));
assert!(matches!(s.get(Some("g"), ""), Err(Error::Invalid)));
assert_eq!(s.get_int(None, "", 7), 7);
assert_eq!(s.get_int64(None, "", 7), 7);
assert_eq!(s.get_double(None, "", 7.0), 7.0);
assert_eq!(s.get_bool(None, "", 7), 7);
assert!(matches!(s.entry_type(None, ""), Err(Error::Invalid)));
}
#[test]
fn test_typed_getters_cross_type_fallbacks() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_int(None, "OnlyInt", 5);
assert_eq!(s.get_double(None, "OnlyInt", 1.5), 1.5);
assert_eq!(s.get_bool(None, "OnlyInt", 3), 3);
assert_eq!(s.get(None, "OnlyInt").unwrap(), "5");
s.set_bool(None, "OnlyBool", 1);
assert_eq!(s.get_int(None, "OnlyBool", 9), 9);
assert_eq!(s.get_double(None, "OnlyBool", 9.0), 9.0);
assert_eq!(s.get(None, "OnlyBool").unwrap(), "true");
}
#[test]
fn test_unicode_keys_and_values() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set(Some("日本語グループ"), "ã‚ー", "値🎬");
assert_eq!(s.get(Some("日本語グループ"), "ã‚ー").unwrap(), "値🎬");
assert_eq!(
s.entry_type(Some("日本語グループ"), "ã‚ー").unwrap(),
EntryType::String
);
s.set_int(Some("a/b"), "c", 5);
assert_eq!(s.get_int(Some("a/b"), "c", 0), 5);
assert_eq!(s.get_int(Some("a"), "b/c", 0), 5);
}
#[test]
fn test_save_toml_contents_all_defaults() {
with_temp_config(|dir| {
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.save().unwrap();
let content = std::fs::read_to_string(dir.join("config.toml")).unwrap();
let parsed: toml::Value = toml::from_str(&content).expect("saved config.toml parses");
let table = parsed.as_table().unwrap();
assert_eq!(table["DefaultSequenceWidth"].as_integer(), Some(1920));
assert_eq!(table["DefaultSequenceHeight"].as_integer(), Some(1080));
assert_eq!(table["ProxyPreset"].as_str(), Some("veryfast"));
assert_eq!(table["SplitClipsCopyNodes"].as_bool(), Some(true));
assert_eq!(table["UseGLFinish"].as_bool(), Some(false));
assert_eq!(table["CatColor3"].as_integer(), Some(3));
assert!(table.contains_key("DiskCacheSaveInterval"));
assert!(table.contains_key("LUTLibraryPaths"));
assert!(table.values().all(|v| !v.is_table()));
assert!(
!content.contains("DefaultSequenceWidth=1920"),
"content: {}",
content
);
});
}
#[test]
fn test_nested_group_roundtrip() {
with_temp_config(|dir| {
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_int(Some("a/b"), "c", 5);
s.save().unwrap();
let content = std::fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(content.contains("[\"a/b\"]"), "content: {}", content);
assert!(content.contains("c = 5"), "content: {}", content);
s.reset_defaults().unwrap();
s.load().unwrap();
assert_eq!(s.get(Some("a/b"), "c").unwrap(), "5");
assert_eq!(s.get_int(Some("a/b"), "c", -1), -1);
});
}
#[test]
fn test_merge_order_defaults_file_runtime() {
with_temp_config(|dir| {
std::fs::write(
dir.join("config.ini"),
"DefaultSequenceWidth=800\nCustomFromFile=yes\n",
)
.unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_int(None, "DefaultSequenceWidth", 640);
s.set(None, "RuntimeOnly", "r");
s.load().unwrap();
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 800);
assert!(matches!(s.get(None, "RuntimeOnly"), Err(Error::NotFound)));
assert_eq!(s.get(None, "CustomFromFile").unwrap(), "yes");
s.set_int(None, "DefaultSequenceWidth", 1024);
assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 1024);
assert_eq!(s.get_int(None, "DefaultSequenceHeight", -1), 1080);
});
}
#[test]
fn test_load_lax_parsing_matrix() {
with_temp_config(|dir| {
let ini = "\
BareLineWithoutEquals
=EmptyKeySkipped
[g]
KeyWithEquals=a=b
[]
FlatAfterEmptySection=ok
";
std::fs::write(dir.join("config.ini"), ini).unwrap();
let s = ConfigStore::instance();
s.load().unwrap();
assert!(matches!(
s.get(None, "BareLineWithoutEquals"),
Err(Error::NotFound)
));
assert_eq!(s.get(Some("g"), "KeyWithEquals").unwrap(), "a=b");
assert_eq!(s.get(None, "FlatAfterEmptySection").unwrap(), "ok");
assert!(s.get(Some(""), "FlatAfterEmptySection").is_ok());
});
}
#[test]
fn test_load_invalid_utf8_reports_error() {
with_temp_config(|dir| {
std::fs::write(dir.join("config.ini"), b"DefaultSequenceWidth=\xff\xfe\n").unwrap();
REPORTED.lock().unwrap().clear();
let s = ConfigStore::instance();
s.set_error_handler(Some(record_handler), std::ptr::null_mut())
.unwrap();
let res = s.load();
s.set_error_handler(None, std::ptr::null_mut()).unwrap();
assert!(res.is_err());
assert_eq!(res.unwrap_err().code(), crate::error::QUERCUSCORE_E_FAILED);
assert_eq!(REPORTED.lock().unwrap().len(), 1);
});
}
#[test]
fn test_load_invalid_toml_reports_error() {
with_temp_config(|dir| {
std::fs::write(dir.join("config.toml"), "DefaultSequenceWidth = =\n").unwrap();
REPORTED.lock().unwrap().clear();
let s = ConfigStore::instance();
s.set_error_handler(Some(record_handler), std::ptr::null_mut())
.unwrap();
let res = s.load();
s.set_error_handler(None, std::ptr::null_mut()).unwrap();
assert!(res.is_err());
assert_eq!(res.unwrap_err().code(), crate::error::QUERCUSCORE_E_FAILED);
let reported = REPORTED.lock().unwrap().clone();
assert_eq!(reported.len(), 1);
assert_eq!(reported[0].0, "Error loading settings");
assert!(reported[0]
.1
.contains("Failed to load application settings"));
});
}
#[test]
fn test_save_failure_reports_error() {
let _g = test_lock().lock().unwrap();
let dir =
std::env::temp_dir().join(format!("quercus_core_configstore_test_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let blocker = dir.join("not_a_dir");
std::fs::write(&blocker, b"x").unwrap();
std::env::set_var("QUERCUS_CONFIG_DIR", &blocker);
REPORTED.lock().unwrap().clear();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
s.set_error_handler(Some(record_handler), std::ptr::null_mut())
.unwrap();
let res = s.save();
s.set_error_handler(None, std::ptr::null_mut()).unwrap();
std::env::remove_var("QUERCUS_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&dir);
assert!(res.is_err());
assert_eq!(res.unwrap_err().code(), crate::error::QUERCUSCORE_E_FAILED);
let reported = REPORTED.lock().unwrap().clone();
assert_eq!(reported.len(), 1);
assert_eq!(reported[0].0, "Error saving settings");
}
static USERDATA_HITS: StdMutex<Vec<usize>> = StdMutex::new(Vec::new());
extern "C" fn userdata_handler(
_title: *const c_char,
_message: *const c_char,
userdata: *mut c_void,
) {
USERDATA_HITS.lock().unwrap().push(userdata as usize);
}
#[test]
fn test_error_handler_receives_userdata() {
with_temp_config(|dir| {
std::fs::create_dir(dir.join("config.ini")).unwrap();
USERDATA_HITS.lock().unwrap().clear();
let s = ConfigStore::instance();
s.set_error_handler(Some(userdata_handler), 0xDEADusize as *mut c_void)
.unwrap();
let _ = s.load();
s.set_error_handler(None, std::ptr::null_mut()).unwrap();
assert_eq!(USERDATA_HITS.lock().unwrap().as_slice(), &[0xDEADusize]);
});
}
#[test]
fn test_singleton_thread_safety_smoke() {
let _g = test_lock().lock().unwrap();
let s = ConfigStore::instance();
s.reset_defaults().unwrap();
let mut handles = Vec::new();
for t in 0..8 {
handles.push(std::thread::spawn(move || {
let store = ConfigStore::instance();
for i in 0..50 {
let key = format!("thread{}/key{}", t, i % 5);
store.set_int(None, &key, i);
let _ = store.get_int(None, &key, -1);
let _ = store.get(None, &key);
let _ = store.entry_type(None, &key);
}
}));
}
for h in handles {
h.join().unwrap();
}
assert!(s.get_int(None, "thread3/key4", -1) >= 0);
s.reset_defaults().unwrap();
}
}