Single-lib cleanup: the per-crate src/bridge/ and src/ffi.rs layers are gone (oakundo/oakcommon/oaknode/oaktimeline/oakcodec/oakaudio/ oakrender/oaktask/oakplugin/oakstorage); cross-crate calls are plain Rust, CHandle marshalling shrinks to the oakengine boundary, and tests call the Rust APIs directly (pure C-ABI wrapper tests removed where the domain layer already covers the behavior). exporter.h family implemented: oakengine_export_render (CLI contract), oakengine_export_render_with_params (was a stub), last_error and progress callback; synchronous path reuses task_create_export + start_sync. Fixes on the way: oaktask video ticket self-deadlock, audio params dropped on the export path, codec encoder AAC slicing and H.264 time base. Real-mp4 tests cover both entry points, progress and the illegal-argument matrix. Also: oakstorage session maps null project handles to None (version- info path), configstore test double literal 3.14 -> 3.15 (clippy PI lint), oakaudio output callback scratch buffer + env-aware P1 test, cli media round-trip test uses a generated 16-frame clip (no more minute-long debug runs).
1559 lines
50 KiB
Rust
1559 lines
50 KiB
Rust
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
//! 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_location>/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<unsafe extern "C" fn(*const c_char, *const c_char, *mut c_void)>;
|
|
|
|
/// 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<c_void>,
|
|
/// Userdata passed to the error handler.
|
|
error_userdata: AtomicPtr<c_void>,
|
|
/// `group/key` -> typed value.
|
|
entries: Mutex<HashMap<String, ConfigValue>>,
|
|
}
|
|
|
|
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<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
|
|
})
|
|
}
|
|
|
|
/// 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<String, BTreeMap<String, String>> = 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<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)),
|
|
}
|
|
}
|
|
|
|
/// 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<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)),
|
|
}
|
|
}
|
|
|
|
/// 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<ConfigValue> {
|
|
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<ConfigValue> {
|
|
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::<i64>().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::<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,
|
|
}
|
|
}
|
|
|
|
/// 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 `<root>/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_location>/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::<i32>().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<T>(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<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| {
|
|
// 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
|
|
// "<dir>/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<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]);
|
|
});
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
}
|