// Oak Video Editor - Non-Linear Video Editor // Copyright (C) 2026 Oak Team // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . //! Process-wide configuration store, mirroring //! `src/common/src/configstore.h` and `include/common/config.h`. //! //! Per the config-wave ruling this is a process-wide singleton, NOT a //! refcounted handle (same precedent as `OakCurrent`). Keys are typed //! (string / int64 / double / bool); typed getters return a caller-supplied //! fallback when the key is absent or of a different type. Persistence is //! an INI file at `/config.ini`. 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}; /// Error-handler callback for user-visible config errors /// (`OakCommonConfigErrorHandler`). Called with title, message, and the /// registered userdata. pub type ErrorHandler = Option; /// Entry types (`OakCommonConfigEntryType`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EntryType { /// No entry / null type. None, /// String. String, /// Integer. Int, /// Double. Double, /// Boolean. Bool, } /// A single typed configuration value (`ConfigStore::Entry` in the C++). #[derive(Clone, Debug, PartialEq)] pub enum ConfigValue { /// String. String(String), /// Integer. Int(i64), /// Double. Double(f64), /// Boolean. Bool(bool), } /// Process-wide singleton store. `AtomicPtr`-based error handler plus a /// `Mutex`-guarded key/value map. pub struct ConfigStore { /// Registered error handler (may be null). error_handler: AtomicPtr, /// Userdata passed to the error handler. error_userdata: AtomicPtr, /// `group/key` -> typed value. entries: Mutex>, } impl ConfigStore { /// The process-wide singleton. /// /// CPP-PARITY: the C++ constructor runs `set_defaults()` on construction /// (`configstore.cpp:41-44`), so the singleton starts pre-loaded with the /// compiled-in defaults, exactly as `ConfigStore::current()` would. pub fn instance() -> &'static ConfigStore { static INSTANCE: OnceLock = 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 }) } /// Reset to compiled-in defaults and load `config.ini` (a missing file /// is not an error). Mirrors `ConfigStore::load()` (`configstore.cpp:239`). pub fn load(&self) -> Result<()> { self.set_defaults(); let path = get_config_file_path(); let metadata = std::fs::metadata(&path); let metadata = match metadata { Ok(m) => m, Err(_) => { // No saved settings yet: defaults are fine, not an error. return Ok(()); } }; // CPP-PARITY: the C++ treats a directory as unreadable (exists() is // checked first, then is_regular_file()), reporting the error and // returning false (`configstore.cpp:245-258`). 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())); } // CPP-PARITY: the C++ reads raw bytes via ifstream. A UTF-8 error in // the file is mapped to the same "unreadable file" failure; config // files are ASCII/UTF-8 in practice. 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()) })?; 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 => { // Malformed line: skip, keep going (matches QSettings' // lax INI parsing). 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) => { // Known key: honor its declared type. An unparseable value // keeps the default. let ty = to_entry_type(&existing); if let Some(parsed) = string_to_value(&value, ty) { self.set_entry(full_key, parsed); } } None => { // Unknown key: stored as a string. self.set_entry(full_key, ConfigValue::String(value)); } } } Ok(()) } /// Write the current store to `config.ini` via temp file + rename. /// Mirrors `ConfigStore::save()` (`configstore.cpp:316`). pub fn save(&self) -> Result<()> { let real_filename = get_config_file_path(); let temp_filename = format!("{}.tmp", real_filename); // Flat keys are written at the top level; keys containing '/' become // [group] sections (group = everything before the last '/'), keeping // the QSettings INI key shape. let mut sections: BTreeMap> = BTreeMap::new(); { let guard = self.entries.lock().unwrap(); for (key, value) in guard.iter() { 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, value_to_string(value)); } None => { sections .entry(String::new()) .or_default() .insert(key.clone(), value_to_string(value)); } } } } let mut out = String::new(); if let Some(flat) = sections.get("") { for (sub, value) in flat { out.push_str(&format!("{}={}\n", sub, value)); } } for (group, subs) in sections.iter() { if group.is_empty() { continue; } out.push('\n'); out.push_str(&format!("[{}]\n", group)); for (sub, value) in subs { out.push_str(&format!("{}={}\n", sub, value)); } } 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(), )); } // CPP-PARITY: rename temp -> real; on POSIX this overwrites // atomically, so the remove+retry fallback only matters on Windows, // mirrored for behavioral parity (`configstore.cpp:378-390`). if let Err(_) = std::fs::rename(&temp_filename, &real_filename) { let _ = std::fs::remove_file(&real_filename); if let Err(_) = std::fs::rename(&temp_filename, &real_filename) { self.report_error( "Error saving settings", "Failed to overwrite the application settings file.", ); return Err(Error::Failed( "config.ini could not be renamed into place".into(), )); } } Ok(()) } /// Reset to compiled-in defaults (drop custom keys). pub fn reset_defaults(&self) -> Result<()> { self.set_defaults(); Ok(()) } /// Set a string entry. Mirrors `oakcommon_config_set` (`config.cpp:102`): /// a new key is created as a string; setting an existing typed entry /// parses the string into its declared type, and an unparseable value /// leaves the entry unchanged. 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); } } } } /// Read an entry as a string (two-stage getter semantics: formatted /// for numeric/bool entries). Mirrors `oakcommon_config_get` /// (`config.cpp:134`). pub fn get(&self, group: Option<&str>, key: &str) -> Result { 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)), } } /// Read an INT entry; `fallback` when absent or a different type. 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, } } /// Read a DOUBLE entry; `fallback` when absent or a different type. 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, } } /// Set an INT entry. pub fn set_int(&self, group: Option<&str>, key: &str, v: i32) { self.set_int64(group, key, v as i64); } /// Read an INT entry as i64; `fallback` when absent or a different type. 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, } } /// Set an INT entry as i64. 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)); } /// Read a BOOL entry as int 0/1; `fallback` when absent or a different /// type. 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, } } /// Set a BOOL entry. 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)); } /// Set a DOUBLE entry. 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)); } /// Entry type of a key, or `NotFound`. Mirrors `oakcommon_config_entry_type` /// (`config.cpp:270`). pub fn entry_type(&self, group: Option<&str>, key: &str) -> Result { 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)), } } /// Register (or clear, with a null handler) the error handler. Mirrors /// the domain half of `oakcommon_config_set_error_handler` (`config.cpp:288`). 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(()) } /// Report a user-visible error through the registered handler, or to /// stderr when none is registered. Mirrors `ConfigStore::report_error` /// (`configstore.cpp:51`). 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(); // The pointer stored by `set_error_handler` is a function pointer // cast to `*mut c_void`; recover it for the call. 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); } } /// Reset to compiled-in defaults (drop custom keys). Mirrors /// `ConfigStore::set_defaults()` (`configstore.cpp:76`). fn set_defaults(&self) { let mut guard = self.entries.lock().unwrap(); guard.clear(); // Only the keys the de-Qt engine modules (oaknode/oakrender/oakcodec) // actually read are registered here; the app-layer keys of the old Qt // config arrive with the app/config wave. Enum-valued ints hardcode // the numeric values of their (still Qt-based) defining headers: // // - Timeline::k_thumbnail_in_out / k_waveforms_enabled = 1 // (engine/timeline/timelinecommon.h) // - PixelFormat::f32 = 4 (core/include/olive/core/render/pixelformat.h) // - VideoParams::k_interlace_none = 0 (src/common/src/videoparams.h) // - k_channel_layout_stereo = 3 // (core/include/olive/core/render/channellayout.h) // - ColorCoding::k_red..k_navy = 0..11, k_lime = 6 // (engine/ui/colorcoding.h) 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)); // Rational settings are stored as strings in oakcore_rational // "num/den" form; this mirrors the old default Rational(1001, 30000). 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)); } } /// Copy the entry for a joined key out of the map, or `None` when absent. fn get_entry(&self, key: &str) -> Option { let guard = self.entries.lock().unwrap(); guard.get(key).cloned() } /// Insert (or replace) an entry. fn set_entry(&self, key: String, value: ConfigValue) { let mut guard = self.entries.lock().unwrap(); guard.insert(key, value); } } /// Joins group and key into the stored "group/key" form. Mirrors /// `ConfigStore::join_key` (`configstore.cpp:68`). 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(), } } /// Serializes a value for the INI file / string getter. Mirrors /// `ConfigStore::value_to_string` (`configstore.cpp:154`). 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() } } } } /// Parses text into a value of the given type; `None` when it cannot be /// parsed as the requested type (strings always parse). Mirrors /// `ConfigStore::string_to_value` (`configstore.cpp:173`). fn string_to_value(text: &str, ty: EntryType) -> Option { match ty { EntryType::String => Some(ConfigValue::String(text.to_string())), EntryType::Int => { // CPP-PARITY: std::stoll skips leading whitespace but requires the // whole remaining string to be consumed; `trim_start().parse()` // reproduces that (trailing whitespace/junk fails the parse). text.trim_start().parse::().ok().map(ConfigValue::Int) } EntryType::Double => { // CPP-PARITY: std::stod additionally accepts inf/infinity/nan // (case-insensitive); handled explicitly since Rust's f64 parse // rejects them. 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::().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, } } /// Maps a value back to its entry type. 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, } } /// Trims `" \t\r\n"` from both ends. Mirrors the anonymous `trim()` helper /// in `configstore.cpp:227`. fn trim(s: &str) -> String { s.trim_matches(|c| c == ' ' || c == '\t' || c == '\r' || c == '\n') .to_string() } /// The configuration directory. Delegates to /// `FileFunctions::get_configuration_location()` exactly like the C++ does /// (`configstore.cpp:61-66` composes the path as /// `FileFunctions::get_configuration_location() / "config.ini"`), so the /// `OAK_CONFIG_DIR` override, portable mode, the macOS Application Support /// default, and the `/oak` suffix all match. fn configuration_location() -> String { crate::filefunctions::FileFunctions::new() .get_configuration_location() .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned()) } /// `/config.ini`. fn get_config_file_path() -> String { format!("{}/config.ini", configuration_location()) } /// Hand-rolled C `%g` formatting with the default precision 6, used by /// `value_to_string` for doubles. Faithful to `snprintf(buf, ..., "%g", v)` /// in `configstore.cpp:163`. /// /// CPP-PARITY: C `%g` rounds to 6 significant digits, drops trailing zeros /// and a trailing decimal point, and switches to scientific notation when the /// decimal exponent is < -4 or >= 6. Rust's `{:.*e}` / `{:.*}` both use /// round-half-to-even like glibc, so results match for ordinary values. The /// fixed-vs-scientific choice here is driven by the exact decimal exponent /// (computed from the value), which also handles carry-over rounding (e.g. /// 999999.5 -> "1000000") the way C does. 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; // Round to PRECISION significant digits via scientific formatting; `%g` // decides between fixed and scientific style using the exponent of the // ROUNDED value (so e.g. 999999.5 -> "1e+06", not "1000000"). let sci = format!("{:.*e}", (PRECISION - 1) as usize, a); // "d.ddddd e±N" let exp = sci.split('e').nth(1).unwrap().parse::().unwrap(); let xr = exp; let body = if xr < -4 || xr >= PRECISION { // Scientific notation: mantissa is already rounded; strip trailing zeros. let mant = sci.split('e').next().unwrap(); let m = trim_mantissa(mant); format!("{}e{}", m, format_exp(&xr.to_string())) } else { // Fixed notation: (PRECISION - 1 - xr) decimals, trailing zeros removed. let decimals = (PRECISION - 1 - xr).max(0) as usize; trim_mantissa(&format!("{:.*}", decimals, a)) }; format!("{}{}", sign, body) } /// Removes trailing zeros after the decimal point and a trailing decimal /// point, but never truncates integer digits (`%g` behavior). 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) } } } } /// Formats a decimal exponent as C does: a sign always followed by at least /// two digits (e.g. "e+06", "e-05", "e+100"). 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; /// Serializes every test that touches the process-global singleton and /// the `OAK_CONFIG_DIR` env override, so parallel tests cannot race. /// /// Uses the crate-wide test lock so `filefunctions` tests (which also /// mutate `OAK_CONFIG_DIR`) serialize on the SAME mutex. fn test_lock() -> &'static StdMutex<()> { crate::test_support::env_lock() } /// Point `OAK_CONFIG_DIR` at an isolated temp dir, run `f`, then clean up. fn with_temp_config(f: impl FnOnce(&Path) -> T) -> T { let _guard = test_lock().lock().unwrap(); let dir = std::env::temp_dir().join(format!("oakcommon_configstore_test_{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); std::env::set_var("OAK_CONFIG_DIR", &dir); let result = f(&dir); std::env::remove_var("OAK_CONFIG_DIR"); let _ = std::fs::remove_dir_all(&dir); result } // ---- Pure logic: %g formatting ------------------------------------- #[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"); } // ---- Defaults ------------------------------------------------------ #[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"); } // ---- Basic getters/setters ----------------------------------------- #[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); // Wrong type -> fallback. 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(); // Create a typed int entry, then set it via the string setter: the // string is parsed into the declared type. 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); // Unparseable string leaves the typed entry unchanged. s.set(None, "Parsed", "abc"); assert_eq!(s.get_int(None, "Parsed", 0), 123); // Bool path. 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); // Double path. 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 ); // Empty group and None are equivalent (flat keys). s.set_int(Some(""), "flatkey", 7); assert_eq!(s.get_int(None, "flatkey", 0), 7); assert_eq!(s.get_int(Some(""), "flatkey", 0), 7); } // ---- INI persistence ------------------------------------------------- #[test] fn test_load_missing_file() { with_temp_config(|_dir| { let s = ConfigStore::instance(); s.reset_defaults().unwrap(); // Missing file is not an error; defaults remain. 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"); // A custom double is persisted but, not being a registered key, is // reloaded as a string (C++ parity: only known keys keep their type). s.set_double(Some("render"), "gain", 1.5); // Modify a KNOWN typed key so its type survives a reload. s.set_int(None, "DefaultSequenceWidth", 640); s.set_bool(None, "SplitClipsCopyNodes", 0); s.save().unwrap(); // Wipe back to defaults, dropping custom keys. s.reset_defaults().unwrap(); assert!(matches!(s.get(None, "CustomString"), Err(Error::NotFound))); s.load().unwrap(); // Flat custom string roundtrips. assert_eq!(s.get(None, "CustomString").unwrap(), "custom value"); // Custom typed keys lose their type on reload -> string form. assert_eq!(s.get(Some("render"), "gain").unwrap(), "1.5"); assert_eq!(s.get_double(Some("render"), "gain", -1.0), -1.0); // Known typed keys roundtrip with their type preserved. assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 640); assert_eq!(s.get_bool(None, "SplitClipsCopyNodes", -1), 0); // Defaults are still present after load. assert_eq!(s.get_int(None, "DefaultSequenceHeight", -1), 1080); }); } #[test] fn test_save_ini_format() { with_temp_config(|dir| { let s = ConfigStore::instance(); s.reset_defaults().unwrap(); s.set_int(None, "FlatKey", 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.ini")).unwrap(); assert!(content.contains("FlatKey=1")); assert!(content.contains("[alpha]\nx=2")); assert!(content.contains("[beta]\ny=3")); // Flat keys are written before any section header. let flat_pos = content.find("FlatKey=1").unwrap(); let alpha_pos = content.find("[alpha]").unwrap(); assert!(flat_pos < alpha_pos); // The temp file is renamed away and does not linger. assert!(!dir.join("config.ini.tmp").exists()); }); } #[test] fn test_load_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" ); }); } #[test] fn test_load_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(); // Unparseable int keeps the compiled-in default. assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 1920); }); } #[test] fn test_load_trims_values() { with_temp_config(|dir| { // load() trims " \t\r\n" around keys and values. 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); }); } // ---- Error paths ---------------------------------------------------- static REPORTED: StdMutex> = 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| { // A directory at config.ini is not a readable config. 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::OAKCOMMON_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(); // With no handler, the error goes to stderr instead of the callback. assert!(res.is_err()); assert!(REPORTED.lock().unwrap().is_empty()); }); } // ---- Pure logic: value conversion ------------------------------------- #[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() { // Mirrors std::stoll: leading whitespace is skipped, the rest must // parse fully (`configstore.cpp:183-194`). 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)) ); // Trailing junk / whitespace fails (std::stoll pos != size). 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)) ); // std::stod accepts inf/infinity/nan (case-insensitive). 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), } // Trailing junk fails. 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)) ); // Case-sensitive in the C++ ("TRUE" does not parse). 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()); // Strings always parse; None never does. 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() { // %g switches to scientific at exponent >= 6 or < -4. 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"); // Trailing zeros dropped, integer digits never truncated. 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"); } // ---- Empty-key / fallback semantics ----------------------------------- #[test] fn test_empty_key_is_rejected_everywhere() { let _g = test_lock().lock().unwrap(); let s = ConfigStore::instance(); s.reset_defaults().unwrap(); // Setters with an empty key are silent no-ops. 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); // Every other typed getter falls back on the int entry. assert_eq!(s.get_double(None, "OnlyInt", 1.5), 1.5); assert_eq!(s.get_bool(None, "OnlyInt", 3), 3); // ...but the string getter formats any type. 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 ); // A group containing '/' nests (join is just string concatenation), // so ("a/b", "c") and ("a", "b/c") address the SAME entry. 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); } // ---- INI byte-level format --------------------------------------------- #[test] fn test_save_exact_bytes() { with_temp_config(|dir| { let s = ConfigStore::instance(); s.reset_defaults().unwrap(); s.set(None, "ZCustom", "v"); s.set_int(Some("grp"), "b", 2); s.set_int(Some("grp"), "a", 1); s.save().unwrap(); // Byte-exact match against the C++ writer in // `ConfigStore::save()` (`configstore.cpp:316-393`): flat keys // first (std::map/BTreeMap lexicographic order), then one blank // line + "[group]" header per sorted section, keys sorted within // each section, '\n' line endings, trailing newline. let expected = concat!( "AutoCacheDelay=1000\n", "CatColor0=0\n", "CatColor1=1\n", "CatColor10=10\n", "CatColor11=11\n", "CatColor2=2\n", "CatColor3=3\n", "CatColor4=4\n", "CatColor5=5\n", "CatColor6=6\n", "CatColor7=7\n", "CatColor8=8\n", "CatColor9=9\n", "DefaultSequenceAudioFrequency=48000\n", "DefaultSequenceAudioLayout=3\n", "DefaultSequenceFrameRate=1001/30000\n", "DefaultSequenceHeight=1080\n", "DefaultSequenceInterlacing=0\n", "DefaultSequencePixelAspect=1/1\n", "DefaultSequenceWidth=1920\n", "DiskCacheAhead=60/1\n", "DiskCacheBehind=0/1\n", "DiskCacheSaveInterval=10000\n", "GraphicsBackend=opengl\n", "LUTLibraryPaths=\n", "MarkerColor=6\n", "OfflinePixelFormat=4\n", "ProxyCRF=23\n", "ProxyDivider=1\n", "ProxyHeight=720\n", "ProxyIncludeAudio=true\n", "ProxyPreset=veryfast\n", "ProxyWidth=1280\n", "ReassocLinToNonLin=false\n", "SplitClipsCopyNodes=true\n", "TimelineThumbnailMode=1\n", "TimelineWaveformMode=1\n", "UseGLFinish=false\n", "UseProxyMedia=true\n", "ZCustom=v\n", "\n", "[grp]\n", "a=1\n", "b=2\n", ); let content = std::fs::read_to_string(dir.join("config.ini")).unwrap(); assert_eq!(content, expected); }); } #[test] fn test_nested_group_roundtrip() { with_temp_config(|dir| { let s = ConfigStore::instance(); s.reset_defaults().unwrap(); // Group = everything before the LAST '/', so "a/b" + "c" lands // in an "[a/b]" section (`configstore.cpp:329-335`). s.set_int(Some("a/b"), "c", 5); s.save().unwrap(); let content = std::fs::read_to_string(dir.join("config.ini")).unwrap(); assert!(content.contains("\n[a/b]\nc=5\n"), "content: {}", content); s.reset_defaults().unwrap(); s.load().unwrap(); // Reloaded as an unknown key -> string, addressed as "a/b/c". assert_eq!(s.get(Some("a/b"), "c").unwrap(), "5"); assert_eq!(s.get_int(Some("a/b"), "c", -1), -1); }); } // ---- Merge order: defaults < loaded file < runtime sets ----------------- #[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(); // A runtime set made BEFORE load() is wiped: load() resets to // defaults first (`configstore.cpp:241`). s.reset_defaults().unwrap(); s.set_int(None, "DefaultSequenceWidth", 640); s.set(None, "RuntimeOnly", "r"); s.load().unwrap(); // File value wins over both defaults and the pre-load runtime set. 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"); // A runtime set AFTER load() wins over the file. s.set_int(None, "DefaultSequenceWidth", 1024); assert_eq!(s.get_int(None, "DefaultSequenceWidth", -1), 1024); // Keys not mentioned in the file keep their defaults. assert_eq!(s.get_int(None, "DefaultSequenceHeight", -1), 1080); }); } // ---- More load parsing edge cases --------------------------------------- #[test] fn test_load_lax_parsing_matrix() { with_temp_config(|dir| { // Hand-built INI exercising the C++ load() branches // (`configstore.cpp:268-312`). 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(); // Malformed lines are skipped, not errors. assert!(matches!( s.get(None, "BareLineWithoutEquals"), Err(Error::NotFound) )); // Value keeps everything after the FIRST '='. assert_eq!(s.get(Some("g"), "KeyWithEquals").unwrap(), "a=b"); // "[]" empties the group, so the key is flat. assert_eq!(s.get(None, "FlatAfterEmptySection").unwrap(), "ok"); assert!(matches!(s.get(Some(""), "FlatAfterEmptySection"), Ok(_))); }); } #[test] fn test_load_invalid_utf8_reports_error() { with_temp_config(|dir| { // Invalid UTF-8: the C++ reads raw bytes and would muddle // through; the Rust port maps this to the same "unreadable file" // failure as a directory (documented CPP-PARITY note in load()). 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::OAKCOMMON_E_FAILED); assert_eq!(REPORTED.lock().unwrap().len(), 1); }); } // ---- Save error path ----------------------------------------------------- #[test] fn test_save_failure_reports_error() { let _g = test_lock().lock().unwrap(); let dir = std::env::temp_dir().join(format!("oakcommon_configstore_test_{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); // Point OAK_CONFIG_DIR at a regular FILE so writing // "/config.ini.tmp" fails (create_dir_all on it is a silent // no-op failure, exactly like the C++ ec-swallowing). let blocker = dir.join("not_a_dir"); std::fs::write(&blocker, b"x").unwrap(); std::env::set_var("OAK_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("OAK_CONFIG_DIR"); let _ = std::fs::remove_dir_all(&dir); assert!(res.is_err()); assert_eq!(res.unwrap_err().code(), crate::error::OAKCOMMON_E_FAILED); let reported = REPORTED.lock().unwrap().clone(); assert_eq!(reported.len(), 1); assert_eq!(reported[0].0, "Error saving settings"); } // ---- Error handler userdata ---------------------------------------------- static USERDATA_HITS: StdMutex> = 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]); }); } // ---- Thread-safety smoke test --------------------------------------------- #[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(); } // Same instance across threads, and the last writer won. assert!(s.get_int(None, "thread3/key4", -1) >= 0); s.reset_defaults().unwrap(); } }