pub mod backend;
pub mod file;
pub mod memory;
pub mod registry;
use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use backend::SettingsBackend;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathCase {
pub appname: String,
pub section: String,
pub key: String,
}
#[derive(Clone, Debug)]
pub struct Entry {
pub path: PathCase,
pub value: String,
}
pub type IndexKey = (String, String, String);
pub(crate) fn normalize(component: &str) -> String {
component.to_ascii_lowercase()
}
pub(crate) fn index_key(appname: &str, section: &str, key: &str) -> IndexKey {
(normalize(appname), normalize(section), normalize(key))
}
fn valid_component(component: &str) -> bool {
!component.is_empty()
&& component != "."
&& component != ".."
&& !component.contains(['/', '\\', '\0', ':', '*', '?', '"', '<', '>', '|'])
}
fn default_store_root() -> Option<PathBuf> {
let base: Option<PathBuf> = if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|home| {
PathBuf::from(home)
.join("Library")
.join("Application Support")
})
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
};
base.map(|base| base.join("vb6").join("settings"))
}
fn default_backend() -> Box<dyn SettingsBackend> {
if cfg!(target_arch = "wasm32") {
Box::new(memory::MemoryBackend::new())
} else if cfg!(windows) {
Box::new(registry::RegistryBackend::new())
} else {
let root = default_store_root().expect("platform has no config location");
Box::new(file::FileBackend::new(root))
}
}
static BACKEND: OnceLock<Mutex<Box<dyn SettingsBackend>>> = OnceLock::new();
static SNAPSHOT: OnceLock<Mutex<Option<Snapshot>>> = OnceLock::new();
struct Snapshot {
values: HashMap<IndexKey, Entry>,
}
fn backend() -> &'static Mutex<Box<dyn SettingsBackend>> {
BACKEND.get_or_init(|| Mutex::new(default_backend()))
}
fn snapshot() -> &'static Mutex<Option<Snapshot>> {
SNAPSHOT.get_or_init(|| Mutex::new(None))
}
fn lock() -> impl std::ops::DerefMut<Target = Snapshot> {
let guard = snapshot().lock().unwrap_or_else(|e| e.into_inner());
drop(guard);
{
let mut snap = snapshot().lock().unwrap_or_else(|e| e.into_inner());
if snap.is_none() {
let backend_guard = backend().lock().unwrap_or_else(|e| e.into_inner());
let values = backend_guard.load_all();
*snap = Some(Snapshot { values });
}
}
SnapshotGuard(snapshot().lock().unwrap_or_else(|e| e.into_inner()))
}
struct SnapshotGuard(std::sync::MutexGuard<'static, Option<Snapshot>>);
impl std::ops::Deref for SnapshotGuard {
type Target = Snapshot;
fn deref(&self) -> &Self::Target {
self.0.as_ref().expect("snapshot must be initialized")
}
}
impl std::ops::DerefMut for SnapshotGuard {
fn deref_mut(&mut self) -> &mut Self::Target {
self.0.as_mut().expect("snapshot must be initialized")
}
}
pub fn reset() {
let backend_guard = backend().lock().unwrap_or_else(|e| e.into_inner());
let values = backend_guard.load_all();
let mut snap = snapshot().lock().unwrap_or_else(|e| e.into_inner());
*snap = Some(Snapshot { values });
}
pub fn set_backend(new_backend: Box<dyn SettingsBackend>) {
let values = {
let mut backend_guard = backend().lock().unwrap_or_else(|e| e.into_inner());
*backend_guard = new_backend;
backend_guard.load_all()
};
let mut snap = snapshot().lock().unwrap_or_else(|e| e.into_inner());
*snap = Some(Snapshot { values });
}
pub fn set_store_root(root: impl Into<PathBuf>) {
set_backend(Box::new(file::FileBackend::new(root.into())));
}
pub fn reset_backend() {
set_backend(default_backend());
}
pub fn reset_store_root() {
reset_backend();
}
pub fn get(appname: &str, section: &str, key: &str) -> Option<String> {
let index = index_key(appname, section, key);
lock().values.get(&index).map(|entry| entry.value.clone())
}
pub fn get_all(appname: &str, section: &str) -> Vec<(String, String)> {
let (app, section) = (normalize(appname), normalize(section));
let mut out: Vec<(String, String)> = lock()
.values
.iter()
.filter(|((a, s, _), _)| *a == app && *s == section)
.map(|(_, entry)| (entry.path.key.clone(), entry.value.clone()))
.collect();
out.sort();
out
}
pub fn entries() -> Vec<(String, String, String, String)> {
let mut out: Vec<(String, String, String, String)> = lock()
.values
.values()
.map(|entry| {
(
entry.path.appname.clone(),
entry.path.section.clone(),
entry.path.key.clone(),
entry.value.clone(),
)
})
.collect();
out.sort();
out
}
pub fn set(appname: &str, section: &str, key: &str, value: &str) -> io::Result<()> {
for component in [appname, section, key] {
if !valid_component(component) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid setting path component: {component:?}"),
));
}
}
backend()
.lock()
.unwrap_or_else(|e| e.into_inner())
.set(appname, section, key, value)?;
let index = index_key(appname, section, key);
let mut state = lock();
let path = match state.values.get(&index) {
Some(existing) => existing.path.clone(),
None => PathCase {
appname: appname.to_string(),
section: section.to_string(),
key: key.to_string(),
},
};
state.values.insert(
index,
Entry {
path,
value: value.to_string(),
},
);
Ok(())
}
pub fn remove_key(appname: &str, section: &str, key: &str) -> io::Result<()> {
backend()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove_key(appname, section, key)?;
let index = index_key(appname, section, key);
lock().values.remove(&index);
Ok(())
}
pub fn remove_section(appname: &str, section: &str) -> io::Result<()> {
backend()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove_section(appname, section)?;
let (app, sec) = (normalize(appname), normalize(section));
lock()
.values
.retain(|(a, s, _), _| !(a == &app && s == &sec));
Ok(())
}
pub fn remove_appname(appname: &str) -> io::Result<()> {
backend()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove_appname(appname)?;
let app = normalize(appname);
lock().values.retain(|(a, _, _), _| a != &app);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::test_support::with_temp_settings_store;
#[test]
fn get_returns_none_for_a_missing_setting() {
with_temp_settings_store(|_| {
assert_eq!(get("MyApp", "Section", "Key"), None);
});
}
#[test]
fn set_then_get_roundtrips() {
with_temp_settings_store(|_| {
set("MyApp", "Startup", "Left", "150").unwrap();
assert_eq!(get("MyApp", "Startup", "Left").as_deref(), Some("150"));
});
}
#[test]
fn lookup_is_case_insensitive() {
with_temp_settings_store(|_| {
set("MyApp", "Window", "Width", "600").unwrap();
assert_eq!(get("myapp", "window", "width").as_deref(), Some("600"));
assert_eq!(get("MYAPP", "WINDOW", "WIDTH").as_deref(), Some("600"));
});
}
#[test]
fn set_reuses_the_original_case_of_an_existing_entry() {
with_temp_settings_store(|_| {
set("MyApp", "Window", "Width", "600").unwrap();
set("myapp", "window", "width", "800").unwrap();
assert_eq!(get("MyApp", "Window", "Width").as_deref(), Some("800"));
});
}
#[test]
fn values_survive_a_reload_from_disk() {
with_temp_settings_store(|_| {
set("MyApp", "Startup", "Left", "150").unwrap();
reset();
assert_eq!(get("MyApp", "Startup", "Left").as_deref(), Some("150"));
});
}
#[test]
fn get_all_returns_section_pairs_sorted_by_key() {
with_temp_settings_store(|_| {
set("MyApp", "Startup", "Left", "150").unwrap();
set("MyApp", "Startup", "Top", "40").unwrap();
set("MyApp", "Other", "Left", "10").unwrap();
set("OtherApp", "Startup", "Left", "1").unwrap();
assert_eq!(
get_all("MyApp", "Startup"),
vec![
("Left".to_string(), "150".to_string()),
("Top".to_string(), "40".to_string()),
]
);
assert_eq!(get_all("myapp", "startup"), get_all("MyApp", "Startup"));
});
}
#[test]
fn entries_returns_every_setting_sorted_and_in_original_case() {
with_temp_settings_store(|_| {
set("MyApp", "Startup", "Top", "40").unwrap();
set("myapp", "Startup", "left", "150").unwrap();
set("OtherApp", "Startup", "Left", "1").unwrap();
assert_eq!(
entries(),
vec![
(
"MyApp".to_string(),
"Startup".to_string(),
"Top".to_string(),
"40".to_string()
),
(
"OtherApp".to_string(),
"Startup".to_string(),
"Left".to_string(),
"1".to_string()
),
(
"myapp".to_string(),
"Startup".to_string(),
"left".to_string(),
"150".to_string()
),
]
);
});
}
#[test]
fn set_rejects_path_traversal() {
with_temp_settings_store(|_| {
for bad in ["../escape", "a/b", "a\\b", "", ".", ".."] {
let err = set("MyApp", bad, "Key", "value").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "for {bad:?}");
}
});
}
#[test]
fn remove_key_deletes_only_that_setting() {
with_temp_settings_store(|_| {
set("MyApp", "Startup", "Left", "150").unwrap();
set("MyApp", "Startup", "Top", "40").unwrap();
remove_key("MyApp", "Startup", "left").unwrap();
assert_eq!(get("MyApp", "Startup", "Left"), None);
assert_eq!(get("MyApp", "Startup", "Top").as_deref(), Some("40"));
});
}
#[test]
fn remove_section_deletes_all_keys_under_it() {
with_temp_settings_store(|_| {
set("MyApp", "Startup", "Left", "150").unwrap();
set("MyApp", "Startup", "Top", "40").unwrap();
remove_section("myapp", "startup").unwrap();
assert!(get_all("MyApp", "Startup").is_empty());
assert_eq!(get("MyApp", "Startup", "Left"), None);
});
}
#[test]
fn remove_appname_deletes_the_whole_application() {
with_temp_settings_store(|_| {
set("MyApp", "Startup", "Left", "150").unwrap();
set("MyApp", "Other", "Top", "40").unwrap();
remove_appname("myapp").unwrap();
assert!(get_all("MyApp", "Startup").is_empty());
assert!(get_all("MyApp", "Other").is_empty());
});
}
#[test]
fn set_backend_switches_to_new_backend() {
use memory::MemoryBackend;
let _guard = crate::state::test_support::TEST_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
set_store_root(dir.path());
set("MyApp", "Section", "Key", "FileValue").unwrap();
assert_eq!(get("MyApp", "Section", "Key").as_deref(), Some("FileValue"));
let mem = MemoryBackend::new();
set_backend(Box::new(mem));
assert_eq!(get("MyApp", "Section", "Key"), None);
set("MyApp", "Section", "Key", "MemValue").unwrap();
assert_eq!(get("MyApp", "Section", "Key").as_deref(), Some("MemValue"));
reset_backend();
}
}