Replace user configuration with KDL and improve Rust source spacing
This commit is contained in:
1 parent
dbdfc5688e
commit
9e90de4e74
41 files changed
+1668
-559
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user