use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const KEYFILE_BACKEND: &str = "keyfile";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GSettingEntry {
pub schema: String,
pub key: String,
pub value: String,
}
impl GSettingEntry {
pub fn new(
schema: impl Into<String>,
key: impl Into<String>,
value: impl Into<String>,
) -> Self {
Self {
schema: schema.into(),
key: key.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct GSettingsConfig {
pub isolated: bool,
pub initial: Vec<GSettingEntry>,
}
impl Default for GSettingsConfig {
fn default() -> Self {
Self {
isolated: true,
initial: Vec::new(),
}
}
}
pub fn config_dir(runtime_dir: &Path) -> PathBuf {
runtime_dir.join("config")
}
fn keyfile_path(runtime_dir: &Path) -> PathBuf {
config_dir(runtime_dir).join("glib-2.0/settings/keyfile")
}
pub fn render_keyfile(entries: &[GSettingEntry]) -> String {
let mut groups: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for e in entries {
let group = e.schema.replace('.', "/");
groups
.entry(group)
.or_default()
.insert(e.key.clone(), e.value.clone());
}
let mut out = String::new();
for (i, (group, kvs)) in groups.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push('[');
out.push_str(group);
out.push_str("]\n");
for (k, v) in kvs {
out.push_str(k);
out.push('=');
out.push_str(v);
out.push('\n');
}
}
out
}
pub fn write_keyfile(runtime_dir: &Path, entries: &[GSettingEntry]) -> std::io::Result<()> {
let path = keyfile_path(runtime_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, render_keyfile(entries))
}
pub fn parse_keyfile(text: &str) -> Vec<GSettingEntry> {
let mut entries = Vec::new();
let mut schema: Option<String> = None;
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(group) = line.strip_prefix('[').and_then(|g| g.strip_suffix(']')) {
schema = Some(group.replace('/', "."));
continue;
}
if let (Some(schema), Some((key, value))) = (&schema, line.split_once('=')) {
entries.push(GSettingEntry::new(schema.clone(), key.trim(), value));
}
}
entries
}
pub fn live_write(runtime_dir: &Path, entry: &GSettingEntry) -> std::io::Result<()> {
let path = keyfile_path(runtime_dir);
let existing = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
let mut entries = parse_keyfile(&existing);
entries.push(entry.clone());
write_keyfile(runtime_dir, &entries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_isolated_with_no_seeds() {
let cfg = GSettingsConfig::default();
assert!(cfg.isolated);
assert!(cfg.initial.is_empty());
}
#[test]
fn config_dir_is_config_subdir_of_runtime() {
let dir = config_dir(Path::new("/run/user/1000/wd-session-abc"));
assert_eq!(dir, PathBuf::from("/run/user/1000/wd-session-abc/config"));
}
#[test]
fn render_groups_by_schema_path_with_dots_to_slashes() {
let out = render_keyfile(&[
GSettingEntry::new("org.gnome.mutter", "experimental-features", "['x']"),
GSettingEntry::new("org.gnome.desktop.interface", "text-scaling-factor", "1.5"),
]);
assert_eq!(
out,
"[org/gnome/desktop/interface]\ntext-scaling-factor=1.5\n\n\
[org/gnome/mutter]\nexperimental-features=['x']\n"
);
}
#[test]
fn render_last_write_wins_for_same_schema_key() {
let out = render_keyfile(&[
GSettingEntry::new("org.gnome.mutter", "experimental-features", "['default']"),
GSettingEntry::new("org.gnome.mutter", "experimental-features", "['override']"),
]);
assert_eq!(
out,
"[org/gnome/mutter]\nexperimental-features=['override']\n"
);
}
#[test]
fn render_empty_is_empty_string() {
assert_eq!(render_keyfile(&[]), "");
}
#[test]
fn write_keyfile_creates_nested_path() {
let dir = tempfile::tempdir().unwrap();
write_keyfile(
dir.path(),
&[GSettingEntry::new(
"org.gnome.mutter",
"experimental-features",
"['x']",
)],
)
.unwrap();
let written = std::fs::read_to_string(keyfile_path(dir.path())).unwrap();
assert_eq!(written, "[org/gnome/mutter]\nexperimental-features=['x']\n");
}
#[test]
fn parse_round_trips_render() {
let entries = [
GSettingEntry::new(
"org.gnome.mutter",
"experimental-features",
"['scale-monitor-framebuffer']",
),
GSettingEntry::new("org.gnome.desktop.interface", "text-scaling-factor", "1.5"),
];
let parsed = parse_keyfile(&render_keyfile(&entries));
assert_eq!(parsed.len(), 2);
assert!(parsed.contains(&entries[0]));
assert!(parsed.contains(&entries[1]));
}
#[test]
fn parse_skips_blanks_and_comments() {
let text = "# leading comment\n\n[org/gnome/mutter]\n# inline comment\n\
experimental-features=['x']\n";
assert_eq!(
parse_keyfile(text),
vec![GSettingEntry::new(
"org.gnome.mutter",
"experimental-features",
"['x']"
)]
);
}
#[test]
fn parse_keeps_value_verbatim_past_first_equals() {
assert_eq!(
parse_keyfile("[a/b]\nkey=a=b=c\n"),
vec![GSettingEntry::new("a.b", "key", "a=b=c")]
);
}
#[test]
fn live_write_upserts_target_key_preserving_others() {
let dir = tempfile::tempdir().unwrap();
write_keyfile(
dir.path(),
&[
GSettingEntry::new(
"org.gnome.mutter",
"experimental-features",
"['scale-monitor-framebuffer']",
),
GSettingEntry::new("org.gnome.desktop.interface", "text-scaling-factor", "1.0"),
],
)
.unwrap();
live_write(
dir.path(),
&GSettingEntry::new("org.gnome.desktop.interface", "text-scaling-factor", "2.0"),
)
.unwrap();
let parsed = parse_keyfile(&std::fs::read_to_string(keyfile_path(dir.path())).unwrap());
assert!(parsed.contains(&GSettingEntry::new(
"org.gnome.mutter",
"experimental-features",
"['scale-monitor-framebuffer']",
)));
let scaling: Vec<_> = parsed
.iter()
.filter(|e| e.key == "text-scaling-factor")
.collect();
assert_eq!(scaling.len(), 1);
assert_eq!(scaling[0].value, "2.0");
}
#[test]
fn live_write_creates_keyfile_when_absent() {
let dir = tempfile::tempdir().unwrap();
live_write(
dir.path(),
&GSettingEntry::new(
"org.gnome.desktop.interface",
"color-scheme",
"'prefer-dark'",
),
)
.unwrap();
assert_eq!(
parse_keyfile(&std::fs::read_to_string(keyfile_path(dir.path())).unwrap()),
vec![GSettingEntry::new(
"org.gnome.desktop.interface",
"color-scheme",
"'prefer-dark'"
)]
);
}
}