Replace user configuration with KDL and improve Rust source spacing

This commit is contained in:
Samuel Onoja committed 2026-09-27 05:46:51 +01:00
1 parent dbdfc5688e
commit 9e90de4e74
41 files changed
+1668 -559

No files matched your search

+72 -103
View File
@@ -1,3 +1,5 @@
use ferese_config::Document;
use serde_json::Value;
use std::{
fs,
io::{Read, Write},
@@ -5,23 +7,24 @@ use std::{
process::{Command, Stdio},
time::{Duration, Instant},
};
use toml_edit::{DocumentMut, Item, Value};
pub const LIMIT: usize = 60 * 1024;
#[derive(Clone, Debug)]
pub struct Snapshot {
pub source: String,
pub doc: DocumentMut,
pub doc: Document,
}
impl Snapshot {
pub fn parse(source: String) -> Result<Self, String> {
if source.len() > LIMIT {
return Err("Configuration exceeds the 60 KiB limit.".into());
}
let doc = source.parse::<DocumentMut>().map_err(|e| e.to_string())?;
let doc = Document::parse(&source).map_err(|e| e.to_string())?;
Ok(Self { source, doc })
}
pub fn read(path: &Path) -> Result<Self, String> {
let source = match fs::File::open(path) {
Ok(file) => {
@@ -36,39 +39,35 @@ impl Snapshot {
};
Self::parse(source)
}
pub fn item(&self, path: &str) -> Option<&Item> {
let mut item = self.doc.as_item();
for key in path.split('.') {
item = if let Ok(index) = key.parse::<usize>() {
item.get(index)?
} else {
item.get(key)?
};
}
Some(item)
pub fn item(&self, path: &str) -> Option<&Value> {
self.doc.get(path)
}
pub fn string(&self, path: &str, fallback: &str) -> String {
self.item(path)
.and_then(Item::as_str)
.and_then(Value::as_str)
.unwrap_or(fallback)
.to_owned()
}
pub fn number(&self, path: &str, fallback: f64) -> f64 {
self.item(path)
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|v| v as f64)))
.unwrap_or(fallback)
self.item(path).and_then(|v| v.as_f64()).unwrap_or(fallback)
}
pub fn boolean(&self, path: &str, fallback: bool) -> bool {
self.item(path).and_then(Item::as_bool).unwrap_or(fallback)
self.item(path).and_then(Value::as_bool).unwrap_or(fallback)
}
pub fn records(&self, path: &str) -> usize {
self.item(path)
.and_then(Item::as_array_of_tables)
.and_then(Value::as_array)
.map_or(0, |a| a.len())
}
pub fn argv(&self, path: &str) -> String {
self.item(path)
.and_then(Item::as_array)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
@@ -82,79 +81,26 @@ impl Snapshot {
})
.unwrap_or_default()
}
pub fn edit(&mut self, edit: &Edit) -> Result<(), String> {
match edit {
Edit::Set(path, value) => {
let mut item = self.doc.as_item_mut();
for key in path.split('.') {
if let Ok(index) = key.parse::<usize>() {
item = item
.get_mut(index)
.ok_or("This item no longer exists. Reload settings.")?;
} else {
if item.is_none() {
*item = Item::Table(toml_edit::Table::new());
}
let table = item
.as_table_like_mut()
.ok_or("This setting is not a table.")?;
item = table.entry(key).or_insert(Item::None);
}
}
let mut value = value.clone();
if let Some(previous) = item.as_value() {
*value.decor_mut() = previous.decor().clone();
}
*item = Item::Value(value);
}
Edit::Add(table, fields) => {
let item = nested_item_mut(self.doc.as_item_mut(), table)?;
if item.is_none() {
*item = Item::ArrayOfTables(Default::default());
}
let array = item
.as_array_of_tables_mut()
.ok_or("Expected a list of settings.")?;
let mut record = toml_edit::Table::new();
for (key, value) in fields {
record[key] = Item::Value(value.clone());
}
array.push(record);
}
Edit::Remove(table, index) => {
let array = nested_item_mut(self.doc.as_item_mut(), table)?
.as_array_of_tables_mut()
.ok_or("This list no longer exists.")?;
if *index >= array.len() {
return Err("This item no longer exists. Reload settings.".into());
}
array.remove(*index);
}
Edit::Set(path, value) => self.doc.set(path, value.clone()),
Edit::Add(path, fields) => self.doc.add(path, fields.clone()),
Edit::Remove(path, index) => self.doc.remove(path, *index),
}
.map_err(|e| e.to_string())?;
self.source = self.doc.to_string();
Ok(())
}
}
fn nested_item_mut<'a>(mut item: &'a mut Item, path: &str) -> Result<&'a mut Item, String> {
for key in path.split('.') {
if item.is_none() {
*item = Item::Table(toml_edit::Table::new());
}
let table = item
.as_table_like_mut()
.ok_or("Expected a settings table.")?;
item = table.entry(key).or_insert(Item::None);
}
Ok(item)
}
#[derive(Clone, Debug)]
pub enum Edit {
Set(String, Value),
Add(String, Vec<(String, Value)>),
Remove(String, usize),
}
pub fn set(path: &str, value: impl Into<Value>) -> Edit {
Edit::Set(path.to_owned(), value.into())
}
@@ -164,7 +110,7 @@ pub fn config_path() -> PathBuf {
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
let path = directory.join("ferese/config.toml");
let path = directory.join("ferese/config.kdl");
path.canonicalize().unwrap_or(path)
}
@@ -187,6 +133,7 @@ fn validate(path: &Path) -> Result<(), String> {
.spawn()
.map_err(|e| format!("Cannot validate configuration: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return if status.success() {
@@ -199,11 +146,13 @@ fn validate(path: &Path) -> Result<(), String> {
.collect())
};
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err("Configuration validation timed out.".into());
}
std::thread::sleep(Duration::from_millis(20));
}
}
@@ -213,6 +162,7 @@ fn validate(path: &Path) -> Result<(), String> {
pub fn save(path: &Path, expected: &str, desired: &str) -> Result<Snapshot, String> {
save_with(path, expected, desired, validate)
}
fn save_with(
path: &Path,
expected: &str,
@@ -251,7 +201,7 @@ fn save_with(
.map_err(|e| e.to_string())?;
backup.as_file().sync_all().map_err(|e| e.to_string())?;
backup
.persist(path.with_extension("toml.settings-backup"))
.persist(path.with_extension("kdl.settings-backup"))
.map_err(|e| e.to_string())?;
staged.persist(path).map_err(|e| e.to_string())?;
Ok(snapshot)
@@ -285,53 +235,68 @@ mod tests {
use super::*;
#[test]
fn edits_preserve_comments_custom_keys_and_other_rules() {
let source = "# personal theme\n[animations]\nspeed = 0.75 # keep this\n[custom]\nfuture = true\n[[window_rules]]\napp_id = 'mine'\nfloating = true\n";
let source = r#"// personal theme
// keep this
animations {
speed 0.75
}
custom {
future #true
}
window-rule app-id="mine" floating=#true
"#;
let mut snapshot = Snapshot::parse(source.into()).unwrap();
snapshot.edit(&set("animations.speed", 0.8)).unwrap();
assert!(snapshot.source.contains("# keep this"));
assert!(snapshot.source.contains("# personal theme"));
assert!(snapshot.source.contains("// keep this"));
assert!(snapshot.source.contains("// personal theme"));
assert_eq!(snapshot.string("window_rules.0.app_id", ""), "mine");
assert!(snapshot.boolean("custom.future", false));
}
#[test]
fn rejects_conflicts_and_failed_validation_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "# external edit\n").unwrap();
assert!(save_with(&path, "", "[animations]\nspeed=1.0", |_| Ok(())).is_err());
let path = dir.path().join("config.kdl");
fs::write(&path, "// external edit\n").unwrap();
assert!(save_with(&path, "", "animations {\n speed 1.0\n}\n", |_| Ok(())).is_err());
assert!(
save_with(
&path,
"# external edit\n",
"[animations]\nspeed=1.0",
"// external edit\n",
"animations {\n speed 1.0\n}\n",
|_| Err("invalid".into())
)
.is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), "# external edit\n");
assert_eq!(fs::read_to_string(&path).unwrap(), "// external edit\n");
}
#[test]
fn catches_edits_during_validation_and_keeps_a_backup_on_success() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "# initial\n").unwrap();
let path = dir.path().join("config.kdl");
fs::write(&path, "// initial\n").unwrap();
assert!(
save_with(&path, "# initial\n", "# desired\n", |_| {
fs::write(&path, "# another editor\n").unwrap();
save_with(&path, "// initial\n", "// desired\n", |_| {
fs::write(&path, "// another editor\n").unwrap();
Ok(())
})
.is_err()
);
save_with(&path, "# another editor\n", "# desired\n", |_| Ok(())).unwrap();
save_with(&path, "// another editor\n", "// desired\n", |_| Ok(())).unwrap();
assert_eq!(
fs::read_to_string(path.with_extension("toml.settings-backup")).unwrap(),
"# another editor\n"
fs::read_to_string(path.with_extension("kdl.settings-backup")).unwrap(),
"// another editor\n"
);
assert_eq!(fs::read_to_string(path).unwrap(), "# desired\n");
assert_eq!(fs::read_to_string(path).unwrap(), "// desired\n");
}
#[test]
fn nested_array_edits_and_login_items_roundtrip() {
let mut snapshot = Snapshot::parse("[[output_profiles]]\nname='desk'\n[[output_profiles.outputs]]\nmatch='HDMI-A-1'\nscale=1.5\n".into()).unwrap();
let mut snapshot = Snapshot::parse(
"output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into(),
)
.unwrap();
snapshot
.edit(&set("output_profiles.0.outputs.0.scale", 1.75))
.unwrap();
@@ -347,7 +312,8 @@ mod tests {
"command".into(),
["program", "argument with space"]
.into_iter()
.collect::<toml_edit::Array>()
.map(Value::from)
.collect::<Vec<_>>()
.into(),
),
("enabled".into(), false.into()),
@@ -362,10 +328,13 @@ mod tests {
snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap();
assert_eq!(snapshot.records("autostart"), 0);
}
#[test]
fn nested_note_lists_keep_existing_clock_settings() {
let mut snapshot =
Snapshot::parse("[desktop_widgets.clock]\nenabled = true\n".into()).unwrap();
let mut snapshot = Snapshot::parse(
"desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into(),
)
.unwrap();
snapshot
.edit(&Edit::Add(
"desktop_widgets.notes".into(),
@@ -392,7 +361,7 @@ mod tests {
fn all_gui_controls_and_presets_pass_compositor_validation() {
let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY");
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
let path = directory.path().join("config.kdl");
let validate = |snapshot: &Snapshot| {
fs::write(&path, &snapshot.source).unwrap();
let result = Command::new(&binary)