GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git
chore: clippy-clean
The fixes clippy suggests (is_none_or, is_err, .ok(), io::Error::other, no
needless return) and the test module moved to the end of config.rs. cce-ui's
CI now runs clippy with warnings as errors; this crate is linted with it in
the workspace.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/config.rs | 857 +++++++++++++++++++++++++++++-----------------------------
src/ipc.rs | 2 +-
src/motion.rs | 2 +-
3 files changed, 429 insertions(+), 432 deletions(-)
diff --git a/src/config.rs b/src/config.rs
index 5743b59..de3ac09 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -484,7 +484,7 @@ pub fn update_kdl_in_memory_typed(doc: &mut kdl::KdlDocument, key: &str, value:
// A bare number written over a unit-annotated slot keeps the unit:
// typing 3 into a `(mm)` field means 3 mm, not a silent fall back
// to logical px.
- if crate::units::Unit::parse(ext_ty).is_some() && matches!(kdl_val, kdl::KdlValue::Base10Float(_) | kdl::KdlValue::Base10(_)) && kdl_ty.as_deref().map_or(true, |t| t == "f64" || t == "i64") {
+ if crate::units::Unit::parse(ext_ty).is_some() && matches!(kdl_val, kdl::KdlValue::Base10Float(_) | kdl::KdlValue::Base10(_)) && kdl_ty.as_deref().is_none_or(|t| t == "f64" || t == "i64") {
kdl_ty = Some(ext_ty.clone());
}
}
@@ -815,7 +815,7 @@ fn perform_rolling_backup(path: &str) {
return;
}
let backup_dir = config_path.parent().unwrap().join("backups");
- if let Err(_) = fs::create_dir_all(&backup_dir) {
+ if fs::create_dir_all(&backup_dir).is_err() {
return;
}
for i in (1..=4).rev() {
@@ -907,10 +907,7 @@ pub fn get_kdl_type_annotation(kdl_content: &str, key_path: &str) -> Option<Stri
}
pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<Option<String>> {
- let doc = match kdl_content.parse::<kdl::KdlDocument>() {
- Ok(d) => Some(d),
- Err(_) => None,
- };
+ let doc = kdl_content.parse::<kdl::KdlDocument>().ok();
key_paths.iter().map(|key_path| {
let doc = doc.as_ref()?;
let parts: Vec<&str> = key_path.split('.').collect();
@@ -938,436 +935,71 @@ pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<
}).collect()
}
-
-#[cfg(test)]
-mod tests {
- /// `$XDG_STATE_HOME` when it is absolute; `~/.local/state` when it is
- /// unset, empty or relative. Nothing else in the crate reads the
- /// variable, so setting it here cannot race another test.
- #[test]
- fn state_home_follows_xdg_and_falls_back() {
- let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
- let fallback = home.join(".local").join("state");
- std::env::set_var("XDG_STATE_HOME", "/srv/state");
- assert_eq!(super::state_home(), std::path::PathBuf::from("/srv/state"));
- assert_eq!(super::cce_state_dir(), std::path::PathBuf::from("/srv/state/cce"));
- for invalid in ["", "relative/state"] {
- std::env::set_var("XDG_STATE_HOME", invalid);
- assert_eq!(super::state_home(), fallback, "{invalid:?}");
+/// `s` as a quoted KDL string, escaped as KDL v1 (the `kdl` 4 parser this
+/// reads back with) escapes: a quote, a backslash and the control
+/// characters. Every string the writer emits goes through here — until
+/// 2026-10-01 values were written as `"{s}"` with nothing escaped, so one
+/// quote inside a value made a line no parser reads, and an app whose
+/// settings file fails to parse loads its DEFAULTS.
+pub fn kdl_quote(s: &str) -> String {
+ let mut out = String::with_capacity(s.len() + 2);
+ out.push('"');
+ for c in s.chars() {
+ match c {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
+ '\u{08}' => out.push_str("\\b"),
+ '\u{0C}' => out.push_str("\\f"),
+ c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
+ c => out.push(c),
}
- std::env::remove_var("XDG_STATE_HOME");
- assert_eq!(super::state_home(), fallback);
- assert_eq!(super::cce_state_dir(), fallback.join("cce"));
- }
-
- /// A string the writer emits comes back as it went in, quotes,
- /// backslashes and control characters included — as a section value,
- /// a top-level value, a keybind (written with a type annotation), a
- /// list item and a key that is not an identifier. Until 2026-10-01
- /// none of it was escaped, a quote inside a value made a line the
- /// parser refused, and an app whose settings file fails to parse loads
- /// its defaults.
- #[test]
- fn strings_round_trip_through_the_kdl_writer() {
- let odd = "a \"quoted\" \\path\\ with\nnewline,\ttab and \u{1} control";
- let val = serde_json::json!({
- "section": {
- "plain": odd,
- "shortcut": "ctrl+\"",
- "items": [odd, "x"],
- "not an ident": odd,
- },
- "top": odd,
- });
- let text = super::json_to_kdl_string(&val);
- let doc: kdl::KdlDocument = text.parse().unwrap_or_else(|e| panic!("{e}\n{text}"));
- let back = super::kdl_to_json(&doc);
- assert_eq!(back["section"]["plain"], odd, "{text}");
- assert_eq!(back["section"]["shortcut"], "ctrl+\"", "{text}");
- assert_eq!(back["section"]["items"][0], odd, "{text}");
- assert_eq!(back["section"]["not an ident"], odd, "{text}");
- assert_eq!(back["top"], odd, "{text}");
- assert_eq!(super::kdl_quote("plain"), "\"plain\"", "an ordinary string is unchanged");
- }
-
- /// A material node's frost and finish are written as PROPERTIES of a
- /// `frost` / `finish` child (RFC material § 5), created on demand under
- /// `style.surface.material.<name>`, and read back through the same
- /// pointer the loader uses.
- #[test]
- fn material_keys_write_as_frost_and_finish_props() {
- use super::{parse_kdl_to_json, update_kdl_in_memory};
- let mut doc = kdl::KdlDocument::new();
- assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.compression", "0.6", "style"));
- assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.refraction", "0.3", "style"));
- assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.finish.spec", "0.4", "style"));
- assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.color", "#05050840", "style"));
- assert!(update_kdl_in_memory(&mut doc, "style.surface.plate.material", "glass", "style"));
- let text = doc.to_string();
- let val = parse_kdl_to_json(&text);
- assert_eq!(val.pointer("/style/surface/material/glass/frost/compression").and_then(|v| v.as_f64()), Some(0.6), "{text}");
- assert_eq!(val.pointer("/style/surface/material/glass/frost/refraction").and_then(|v| v.as_f64()), Some(0.3));
- assert_eq!(val.pointer("/style/surface/material/glass/finish/spec").and_then(|v| v.as_f64()), Some(0.4));
- assert_eq!(val.pointer("/style/surface/material/glass/color").and_then(|v| v.as_str()), Some("#05050840"));
- assert_eq!(val.pointer("/style/surface/plate/material").and_then(|v| v.as_str()), Some("glass"));
- // One `frost` node with two props, not two `frost` nodes.
- assert_eq!(text.matches("frost").count(), 1, "{text}");
- assert!(text.contains("(rgba)"), "the colour carries its type: {text}");
- }
-
- #[test]
- fn unit_annotations_become_len_strings() {
- let v = parse_kdl_to_json("style {\n relief width=(mm)2.0 depth=(f64)0.15 lip=(px)6\n ruler (in)0.5\n}\n");
- assert_eq!(v["style"]["relief"]["width"], serde_json::json!("2mm"));
- assert_eq!(v["style"]["relief"]["depth"], serde_json::json!(0.15));
- assert_eq!(v["style"]["relief"]["lip"], serde_json::json!("6px"));
- assert_eq!(v["style"]["ruler"], serde_json::json!("0.5in"));
- }
-
- #[test]
- fn unit_strings_write_back_annotated() {
- let v = serde_json::json!({"style": {"relief": {"width": "2mm", "depth": 0.15}}});
- let out = json_to_kdl_string(&v);
- assert!(out.contains("width=(mm)2\n") || out.contains("width=(mm)2 "), "{out}");
- assert!(out.contains("depth=(f64)0.15"), "{out}");
- let back = parse_kdl_to_json(&out);
- assert_eq!(back["style"]["relief"]["width"], serde_json::json!("2mm"));
- }
-
- #[test]
- fn typed_write_keeps_and_sets_units() {
- let mut doc: kdl::KdlDocument = "style {\n relief width=(mm)2.0\n}\n".parse().unwrap();
- // A bare number over a (mm) slot stays mm.
- assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "3", "style", None));
- let v = parse_kdl_to_json(&doc.to_string());
- assert_eq!(v["style"]["relief"]["width"], serde_json::json!("3mm"));
- // A suffixed value sets the unit.
- assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "0.25in", "style", None));
- let v = parse_kdl_to_json(&doc.to_string());
- assert_eq!(v["style"]["relief"]["width"], serde_json::json!("0.25in"));
- }
-
- #[test]
- fn app_name_strips_the_kernels_deleted_marker() {
- use super::app_name_from_exe_basename as name;
- assert_eq!(name("cce-status-interface"), "cce-status-interface");
- assert_eq!(name("cce-status-interface (deleted)"), "cce-status-interface");
- // Only the exact trailing marker: a name that merely contains the
- // word, or an unspaced variant, is left alone.
- assert_eq!(name("cce-deleted-files"), "cce-deleted-files");
- assert_eq!(name("cce-x(deleted)"), "cce-x(deleted)");
- }
-
- use super::*;
-
- #[test]
- fn cce_runtime_dir_sits_under_the_runtime_base_and_is_created() {
- // No env mutation: reading the real base keeps this correct both in a
- // session (XDG_RUNTIME_DIR set) and anywhere it is not (temp dir), and
- // avoids racing every other test in the process.
- let base = runtime_dir();
- assert!(base.is_absolute(), "runtime base must be absolute: {base:?}");
- let dir = cce_runtime_dir();
- assert_eq!(dir, base.join("cce"));
- // The create-on-demand contract callers depend on: they open a file
- // inside this directory without creating it themselves.
- assert!(dir.is_dir(), "cce_runtime_dir must create its directory: {dir:?}");
}
+ out.push('"');
+ out
+}
- #[test]
- fn relief_annotated_string_passes_through() {
- // The (relief) custom value type: an annotated string prop must
- // survive kdl_to_json as a plain JSON string at its pointer.
- let content = "style {\n surface {\n desktop gap_width=(i64)16 line_relief=(relief)\"w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000\"\n }\n}\n";
- let val = parse_kdl_to_json(content);
- assert_eq!(
- val.pointer("/style/surface/desktop/line_relief").and_then(|v| v.as_str()),
- Some("w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000"),
- );
+fn format_kdl_type(ty: &str) -> String {
+ let is_ident = !ty.is_empty()
+ && !ty.chars().next().unwrap().is_ascii_digit()
+ && ty.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
+ if is_ident {
+ ty.to_string()
+ } else {
+ kdl_quote(ty)
}
+}
- #[test]
- fn test_nested_parsing() {
- let content = "style {\n status box_opacity=(f64)0.75\n}\n";
- let val = parse_kdl_to_json(content);
- println!("val = {:?}", val);
- let (sec, node, prop) = parse_config_path("style.status.box_opacity", "layout");
- assert_eq!(sec, "style");
- assert_eq!(node, "status");
- assert_eq!(prop, Some("box_opacity".to_string()));
-
- let sec_val = val.get(&sec).unwrap();
- let node_val = sec_val.get(&node).unwrap();
- let prop_val = node_val.get(prop.as_ref().unwrap()).unwrap();
- assert_eq!(prop_val.as_f64().unwrap(), 0.75);
+fn format_kdl_identifier(name: &str) -> String {
+ let is_ident = !name.is_empty()
+ && !name.chars().next().unwrap().is_ascii_digit()
+ && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
+ if is_ident {
+ name.to_string()
+ } else {
+ kdl_quote(name)
}
+}
- /// The relief's wall and edge keys write as PROPERTIES on `wall` / `edge`
- /// child nodes of the existing `relief` node — the node keeps its own
- /// `depth=` / `width=` properties and grows one child block — and the
- /// reload path reads them back at the nested paths the registry maps.
- /// The legacy flat spellings then come off with `remove_kdl_in_memory`,
- /// which is how cce-relief's Save migrates a file in place.
- #[test]
- fn relief_wall_and_edge_keys_write_as_child_nodes_and_the_flat_ones_come_off() {
- let content = "style {\n surface {\n relief light=(f64)0.15 width=(f64)9.3 profile=\"old\" edge_knobs=(bevel)\"0.500,0.500,0.500\"\n }\n}\n";
- let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
- let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
- assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.wall.profile", spec, "style"));
- assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.wall.height", "0.3", "style", Some("mm")));
- assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.edge.profile", spec, "style"));
- assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.edge.height", "4", "style", Some("px")));
- // Migrate: the flat spellings come off, and a spelling that is not
- // there reports nothing removed.
- assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.profile"));
- assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_knobs"));
- assert!(!remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_height"), "absent: nothing to remove");
- assert!(!remove_kdl_in_memory(&mut doc, "style.surface.nothing.here"), "missing node: nothing to remove");
- let out = doc.to_string();
- assert_eq!(out.matches("relief").count(), 1, "one relief node: {out}");
- assert!(out.contains("light=(f64)0.15"), "the node keeps its properties: {out}");
- assert!(!out.contains("profile=\"old\""), "flat profile migrated: {out}");
- assert!(!out.contains("edge_knobs"), "flat knobs migrated: {out}");
+pub fn value_to_kdl(key: &str, val: &serde_json::Value, indent: usize) -> String {
+ value_to_kdl_with_annotations(key, val, indent, "", &std::collections::HashMap::new())
+}
- let val = parse_kdl_to_json(&out);
- let relief = val.pointer("/style/surface/relief").unwrap();
- assert_eq!(relief.get("light").and_then(|v| v.as_f64()), Some(0.15));
- assert_eq!(relief.pointer("/wall/profile").and_then(|v| v.as_str()), Some(spec));
- assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_str()), Some("0.3mm"), "{relief}");
- assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some(spec));
- assert!(relief.pointer("/edge/height").is_some(), "{relief}");
- assert!(relief.get("profile").is_none() && relief.get("edge_knobs").is_none(), "{relief}");
- // A whole node comes off too.
- assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge"));
- let val = parse_kdl_to_json(&doc.to_string());
- assert!(val.pointer("/style/surface/relief/edge").is_none());
- assert!(val.pointer("/style/surface/relief/wall/profile").is_some());
- }
-
- #[test]
- fn relief_keys_write_as_properties_and_round_trip() {
- // `relief` is a PROP_NODES member: style.surface.relief.* must land as
- // properties on the existing relief node (the config.kdl shape), not
- // as duplicate child nodes shadowing the light=/width= properties.
- let content = "style {\n surface {\n relief light=(f64)0.15 width=(f64)9.3\n }\n}\n";
- let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
- let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
- assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.profile", spec, "style"));
- assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.light", "0.3", "style"));
- let out = doc.to_string();
- // Still one relief node, no child block grown under it.
- assert_eq!(out.matches("relief").count(), 1, "out: {out}");
- assert!(!out.contains("relief {"), "out: {out}");
-
- // The reload path reads through parse_kdl_to_json: the new property
- // must surface at the same dotted path the style registry maps.
- let val = parse_kdl_to_json(&out);
- let relief = val.get("style").unwrap().get("surface").unwrap().get("relief").unwrap();
- assert_eq!(relief.get("profile").unwrap().as_str().unwrap(), spec);
- assert_eq!(relief.get("light").unwrap().as_f64().unwrap(), 0.3);
- assert_eq!(relief.get("width").unwrap().as_f64().unwrap(), 9.3);
- }
-
- #[test]
- fn test_get_kdl_type_annotation() {
- let content = "input {\n accel_profile (\"menu:flat,adaptive,none,custom\")\"flat\"\n touchpad {\n gestures pinch=(bool)true\n }\n}\n";
- let ty1 = get_kdl_type_annotation(content, "input.accel_profile");
- assert_eq!(ty1, Some("menu:flat,adaptive,none,custom".to_string()));
-
- let ty2 = get_kdl_type_annotation(content, "input.touchpad.gestures.pinch");
- assert_eq!(ty2, Some("bool".to_string()));
-
- let keys = vec![
- "input.accel_profile".to_string(),
- "input.touchpad.gestures.pinch".to_string(),
- "input.invalid_key".to_string(),
- ];
- let tys = get_kdl_type_annotations(content, &keys);
- assert_eq!(tys.len(), 3);
- assert_eq!(tys[0], Some("menu:flat,adaptive,none,custom".to_string()));
- assert_eq!(tys[1], Some("bool".to_string()));
- assert_eq!(tys[2], None);
- }
-
- #[test]
- fn test_json_to_kdl_with_special_annotations() {
- let mut annotations = std::collections::HashMap::new();
- annotations.insert("style.surface.desktop.mode".to_string(), "menu:grid,solid".to_string());
-
- let mut desktop_map = serde_json::Map::new();
- desktop_map.insert("mode".to_string(), serde_json::Value::String("grid".to_string()));
-
- let mut surface_map = serde_json::Map::new();
- surface_map.insert("desktop".to_string(), serde_json::Value::Object(desktop_map));
-
- let mut style_map = serde_json::Map::new();
- style_map.insert("surface".to_string(), serde_json::Value::Object(surface_map));
-
- let mut root_map = serde_json::Map::new();
- root_map.insert("style".to_string(), serde_json::Value::Object(style_map));
-
- let root = serde_json::Value::Object(root_map);
- let kdl_str = json_to_kdl_string_with_annotations(&root, &annotations);
- println!("Generated KDL:\n{}", kdl_str);
-
- let doc_parsed = kdl_str.parse::<kdl::KdlDocument>();
- assert!(doc_parsed.is_ok(), "Failed to parse KDL: {:?}", doc_parsed.err());
- }
-
- #[test]
- fn test_brightness_annotations() {
- let mut edp_map = serde_json::Map::new();
- edp_map.insert("scale".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(2.0).unwrap()));
- edp_map.insert("brightness_up".to_string(), serde_json::Value::String("XF86MonBrightnessUp".to_string()));
- edp_map.insert("brightness_down".to_string(), serde_json::Value::String("XF86MonBrightnessDown".to_string()));
- edp_map.insert("brightness_interval".to_string(), serde_json::Value::Number(serde_json::Number::from(10)));
-
- let mut output_map = serde_json::Map::new();
- output_map.insert("eDP-1".to_string(), serde_json::Value::Object(edp_map));
-
- let mut root_map = serde_json::Map::new();
- root_map.insert("output".to_string(), serde_json::Value::Object(output_map));
-
- let root = serde_json::Value::Object(root_map);
- let kdl_str = json_to_kdl_string(&root);
- println!("Generated KDL for brightness:\n{}", kdl_str);
-
- let doc_parsed = kdl_str.parse::<kdl::KdlDocument>().unwrap();
-
- let output_node = doc_parsed.nodes().iter().find(|n| n.name().value() == "output").unwrap();
- let edp_node = output_node.children().unwrap().nodes().iter().find(|n| n.name().value() == "eDP-1").unwrap();
-
- let up_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_up")).unwrap();
- assert_eq!(up_entry.ty().unwrap().value(), "keybind");
-
- let down_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_down")).unwrap();
- assert_eq!(down_entry.ty().unwrap().value(), "keybind");
-
- let interval_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")).unwrap();
- assert_eq!(interval_entry.ty().unwrap().value(), "i64");
- }
-
- /// A string list (`rounded_apps "a" "b"`) must survive the JSON round
- /// trip cce-data-editor saves through — it used to come back as ONE arg,
- /// `rounded_apps "a b"`, and the compositor's allowlist then matched
- /// nothing (Claude Desktop lost its corners after every save).
- #[test]
- fn test_string_list_roundtrip() {
- let content = "window_manager {\n rounded_apps \"claude-desktop\" \"com.anthropic.Claude\"\n corner_shape (f64)4.5\n}\n";
- let val = parse_kdl_to_json(content);
- let list = val.get("window_manager").unwrap().get("rounded_apps").unwrap();
- assert_eq!(
- list.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(),
- vec!["claude-desktop", "com.anthropic.Claude"]
- );
- let kdl_str = json_to_kdl_string(&val);
- assert!(kdl_str.contains("rounded_apps \"claude-desktop\" \"com.anthropic.Claude\""), "{kdl_str}");
- // And it re-parses to the same two args, not one.
- let doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
- let wm = doc.nodes().iter().find(|n| n.name().value() == "window_manager").unwrap();
- let ra = wm.children().unwrap().nodes().iter().find(|n| n.name().value() == "rounded_apps").unwrap();
- assert_eq!(ra.entries().len(), 2);
- // A single-arg string node stays a plain string.
- let single = parse_kdl_to_json("window_manager {\n rounded_apps \"claude-desktop\"\n}\n");
- assert_eq!(single.get("window_manager").unwrap().get("rounded_apps").unwrap().as_str(), Some("claude-desktop"));
- }
-
- #[test]
- fn test_vec2i_lossless_roundtrip() {
- let content = "style {\n surface {\n cloud {\n position_default (vec2i)100 200\n }\n }\n}\n";
- let val = parse_kdl_to_json(content);
- println!("Parsed KDL to JSON: {:?}", val);
-
- let position_default_val = val.get("style").unwrap()
- .get("surface").unwrap()
- .get("cloud").unwrap()
- .get("position_default").unwrap();
- assert_eq!(position_default_val.as_str().unwrap(), "100 200");
-
- let mut annotations = std::collections::HashMap::new();
- annotations.insert("style.surface.cloud.position_default".to_string(), "vec2i".to_string());
-
- let kdl_str = json_to_kdl_string_with_annotations(&val, &annotations);
- println!("Generated KDL:\n{}", kdl_str);
-
- // Assert that (vec2i)100 200 is preserved without quotes
- assert!(kdl_str.contains("position_default (vec2i)100 200"));
-
- // Test update_kdl_in_memory preserves and updates the KDL Document correctly
- let mut doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
- let updated = update_kdl_in_memory(&mut doc, "style.surface.cloud.position_default", "150 250", "layout");
- assert!(updated);
- let updated_kdl = doc.to_string();
- println!("Updated KDL:\n{}", updated_kdl);
- assert!(updated_kdl.contains("position_default (vec2i)150 250"));
- }
-}
-
-/// `s` as a quoted KDL string, escaped as KDL v1 (the `kdl` 4 parser this
-/// reads back with) escapes: a quote, a backslash and the control
-/// characters. Every string the writer emits goes through here — until
-/// 2026-10-01 values were written as `"{s}"` with nothing escaped, so one
-/// quote inside a value made a line no parser reads, and an app whose
-/// settings file fails to parse loads its DEFAULTS.
-pub fn kdl_quote(s: &str) -> String {
- let mut out = String::with_capacity(s.len() + 2);
- out.push('"');
- for c in s.chars() {
- match c {
- '"' => out.push_str("\\\""),
- '\\' => out.push_str("\\\\"),
- '\n' => out.push_str("\\n"),
- '\r' => out.push_str("\\r"),
- '\t' => out.push_str("\\t"),
- '\u{08}' => out.push_str("\\b"),
- '\u{0C}' => out.push_str("\\f"),
- c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
- c => out.push(c),
- }
- }
- out.push('"');
- out
-}
-
-fn format_kdl_type(ty: &str) -> String {
- let is_ident = !ty.is_empty()
- && !ty.chars().next().unwrap().is_ascii_digit()
- && ty.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
- if is_ident {
- ty.to_string()
- } else {
- kdl_quote(ty)
- }
-}
-
-fn format_kdl_identifier(name: &str) -> String {
- let is_ident = !name.is_empty()
- && !name.chars().next().unwrap().is_ascii_digit()
- && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
- if is_ident {
- name.to_string()
- } else {
- kdl_quote(name)
- }
-}
-
-pub fn value_to_kdl(key: &str, val: &serde_json::Value, indent: usize) -> String {
- value_to_kdl_with_annotations(key, val, indent, "", &std::collections::HashMap::new())
-}
-
-pub fn value_to_kdl_with_annotations(
- key: &str,
- val: &serde_json::Value,
- indent: usize,
- parent_path: &str,
- annotations: &std::collections::HashMap<String, String>,
-) -> String {
- let indent_str = " ".repeat(indent);
- let current_path = if parent_path.is_empty() {
- key.to_string()
- } else {
- format!("{}.{}", parent_path, key)
- };
+pub fn value_to_kdl_with_annotations(
+ key: &str,
+ val: &serde_json::Value,
+ indent: usize,
+ parent_path: &str,
+ annotations: &std::collections::HashMap<String, String>,
+) -> String {
+ let indent_str = " ".repeat(indent);
+ let current_path = if parent_path.is_empty() {
+ key.to_string()
+ } else {
+ format!("{}.{}", parent_path, key)
+ };
match val {
serde_json::Value::Object(map) => {
@@ -1384,7 +1016,7 @@ pub fn value_to_kdl_with_annotations(
let mut child_parts = Vec::new();
for (prop_name, prop_val) in map {
let prop_path = format!("{}.{}", current_path, prop_name);
- let is_vec2i = annotations.get(&prop_path).map_or(false, |a| a == "vec2i");
+ let is_vec2i = annotations.get(&prop_path).is_some_and(|a| a == "vec2i");
if is_vec2i {
if let serde_json::Value::String(ref s) = prop_val {
child_parts.push(format!("{}{} (vec2i){}\n", " ".repeat(indent + 1), format_kdl_identifier(prop_name), s));
@@ -1451,7 +1083,7 @@ pub fn value_to_kdl_with_annotations(
if !arr.is_empty() && arr.iter().all(|v| v.is_string()) {
let args: Vec<String> = arr
.iter()
- .filter_map(|v| v.as_str().map(|s| kdl_quote(s)))
+ .filter_map(|v| v.as_str().map(kdl_quote))
.collect();
return format!("{}{} {}\n", indent_str, format_kdl_identifier(key), args.join(" "));
}
@@ -1571,3 +1203,368 @@ pub fn save_recent_files(files: &[String]) {
kdl_str.push_str("}\n");
let _ = std::fs::write(path, kdl_str);
}
+
+
+#[cfg(test)]
+mod tests {
+ /// `$XDG_STATE_HOME` when it is absolute; `~/.local/state` when it is
+ /// unset, empty or relative. Nothing else in the crate reads the
+ /// variable, so setting it here cannot race another test.
+ #[test]
+ fn state_home_follows_xdg_and_falls_back() {
+ let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
+ let fallback = home.join(".local").join("state");
+ std::env::set_var("XDG_STATE_HOME", "/srv/state");
+ assert_eq!(super::state_home(), std::path::PathBuf::from("/srv/state"));
+ assert_eq!(super::cce_state_dir(), std::path::PathBuf::from("/srv/state/cce"));
+ for invalid in ["", "relative/state"] {
+ std::env::set_var("XDG_STATE_HOME", invalid);
+ assert_eq!(super::state_home(), fallback, "{invalid:?}");
+ }
+ std::env::remove_var("XDG_STATE_HOME");
+ assert_eq!(super::state_home(), fallback);
+ assert_eq!(super::cce_state_dir(), fallback.join("cce"));
+ }
+
+ /// A string the writer emits comes back as it went in, quotes,
+ /// backslashes and control characters included — as a section value,
+ /// a top-level value, a keybind (written with a type annotation), a
+ /// list item and a key that is not an identifier. Until 2026-10-01
+ /// none of it was escaped, a quote inside a value made a line the
+ /// parser refused, and an app whose settings file fails to parse loads
+ /// its defaults.
+ #[test]
+ fn strings_round_trip_through_the_kdl_writer() {
+ let odd = "a \"quoted\" \\path\\ with\nnewline,\ttab and \u{1} control";
+ let val = serde_json::json!({
+ "section": {
+ "plain": odd,
+ "shortcut": "ctrl+\"",
+ "items": [odd, "x"],
+ "not an ident": odd,
+ },
+ "top": odd,
+ });
+ let text = super::json_to_kdl_string(&val);
+ let doc: kdl::KdlDocument = text.parse().unwrap_or_else(|e| panic!("{e}\n{text}"));
+ let back = super::kdl_to_json(&doc);
+ assert_eq!(back["section"]["plain"], odd, "{text}");
+ assert_eq!(back["section"]["shortcut"], "ctrl+\"", "{text}");
+ assert_eq!(back["section"]["items"][0], odd, "{text}");
+ assert_eq!(back["section"]["not an ident"], odd, "{text}");
+ assert_eq!(back["top"], odd, "{text}");
+ assert_eq!(super::kdl_quote("plain"), "\"plain\"", "an ordinary string is unchanged");
+ }
+
+ /// A material node's frost and finish are written as PROPERTIES of a
+ /// `frost` / `finish` child (RFC material § 5), created on demand under
+ /// `style.surface.material.<name>`, and read back through the same
+ /// pointer the loader uses.
+ #[test]
+ fn material_keys_write_as_frost_and_finish_props() {
+ use super::{parse_kdl_to_json, update_kdl_in_memory};
+ let mut doc = kdl::KdlDocument::new();
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.compression", "0.6", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.refraction", "0.3", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.finish.spec", "0.4", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.color", "#05050840", "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.plate.material", "glass", "style"));
+ let text = doc.to_string();
+ let val = parse_kdl_to_json(&text);
+ assert_eq!(val.pointer("/style/surface/material/glass/frost/compression").and_then(|v| v.as_f64()), Some(0.6), "{text}");
+ assert_eq!(val.pointer("/style/surface/material/glass/frost/refraction").and_then(|v| v.as_f64()), Some(0.3));
+ assert_eq!(val.pointer("/style/surface/material/glass/finish/spec").and_then(|v| v.as_f64()), Some(0.4));
+ assert_eq!(val.pointer("/style/surface/material/glass/color").and_then(|v| v.as_str()), Some("#05050840"));
+ assert_eq!(val.pointer("/style/surface/plate/material").and_then(|v| v.as_str()), Some("glass"));
+ // One `frost` node with two props, not two `frost` nodes.
+ assert_eq!(text.matches("frost").count(), 1, "{text}");
+ assert!(text.contains("(rgba)"), "the colour carries its type: {text}");
+ }
+
+ #[test]
+ fn unit_annotations_become_len_strings() {
+ let v = parse_kdl_to_json("style {\n relief width=(mm)2.0 depth=(f64)0.15 lip=(px)6\n ruler (in)0.5\n}\n");
+ assert_eq!(v["style"]["relief"]["width"], serde_json::json!("2mm"));
+ assert_eq!(v["style"]["relief"]["depth"], serde_json::json!(0.15));
+ assert_eq!(v["style"]["relief"]["lip"], serde_json::json!("6px"));
+ assert_eq!(v["style"]["ruler"], serde_json::json!("0.5in"));
+ }
+
+ #[test]
+ fn unit_strings_write_back_annotated() {
+ let v = serde_json::json!({"style": {"relief": {"width": "2mm", "depth": 0.15}}});
+ let out = json_to_kdl_string(&v);
+ assert!(out.contains("width=(mm)2\n") || out.contains("width=(mm)2 "), "{out}");
+ assert!(out.contains("depth=(f64)0.15"), "{out}");
+ let back = parse_kdl_to_json(&out);
+ assert_eq!(back["style"]["relief"]["width"], serde_json::json!("2mm"));
+ }
+
+ #[test]
+ fn typed_write_keeps_and_sets_units() {
+ let mut doc: kdl::KdlDocument = "style {\n relief width=(mm)2.0\n}\n".parse().unwrap();
+ // A bare number over a (mm) slot stays mm.
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "3", "style", None));
+ let v = parse_kdl_to_json(&doc.to_string());
+ assert_eq!(v["style"]["relief"]["width"], serde_json::json!("3mm"));
+ // A suffixed value sets the unit.
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "0.25in", "style", None));
+ let v = parse_kdl_to_json(&doc.to_string());
+ assert_eq!(v["style"]["relief"]["width"], serde_json::json!("0.25in"));
+ }
+
+ #[test]
+ fn app_name_strips_the_kernels_deleted_marker() {
+ use super::app_name_from_exe_basename as name;
+ assert_eq!(name("cce-status-interface"), "cce-status-interface");
+ assert_eq!(name("cce-status-interface (deleted)"), "cce-status-interface");
+ // Only the exact trailing marker: a name that merely contains the
+ // word, or an unspaced variant, is left alone.
+ assert_eq!(name("cce-deleted-files"), "cce-deleted-files");
+ assert_eq!(name("cce-x(deleted)"), "cce-x(deleted)");
+ }
+
+ use super::*;
+
+ #[test]
+ fn cce_runtime_dir_sits_under_the_runtime_base_and_is_created() {
+ // No env mutation: reading the real base keeps this correct both in a
+ // session (XDG_RUNTIME_DIR set) and anywhere it is not (temp dir), and
+ // avoids racing every other test in the process.
+ let base = runtime_dir();
+ assert!(base.is_absolute(), "runtime base must be absolute: {base:?}");
+ let dir = cce_runtime_dir();
+ assert_eq!(dir, base.join("cce"));
+ // The create-on-demand contract callers depend on: they open a file
+ // inside this directory without creating it themselves.
+ assert!(dir.is_dir(), "cce_runtime_dir must create its directory: {dir:?}");
+ }
+
+ #[test]
+ fn relief_annotated_string_passes_through() {
+ // The (relief) custom value type: an annotated string prop must
+ // survive kdl_to_json as a plain JSON string at its pointer.
+ let content = "style {\n surface {\n desktop gap_width=(i64)16 line_relief=(relief)\"w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000\"\n }\n}\n";
+ let val = parse_kdl_to_json(content);
+ assert_eq!(
+ val.pointer("/style/surface/desktop/line_relief").and_then(|v| v.as_str()),
+ Some("w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000"),
+ );
+ }
+
+ #[test]
+ fn test_nested_parsing() {
+ let content = "style {\n status box_opacity=(f64)0.75\n}\n";
+ let val = parse_kdl_to_json(content);
+ println!("val = {:?}", val);
+ let (sec, node, prop) = parse_config_path("style.status.box_opacity", "layout");
+ assert_eq!(sec, "style");
+ assert_eq!(node, "status");
+ assert_eq!(prop, Some("box_opacity".to_string()));
+
+ let sec_val = val.get(&sec).unwrap();
+ let node_val = sec_val.get(&node).unwrap();
+ let prop_val = node_val.get(prop.as_ref().unwrap()).unwrap();
+ assert_eq!(prop_val.as_f64().unwrap(), 0.75);
+ }
+
+ /// The relief's wall and edge keys write as PROPERTIES on `wall` / `edge`
+ /// child nodes of the existing `relief` node — the node keeps its own
+ /// `depth=` / `width=` properties and grows one child block — and the
+ /// reload path reads them back at the nested paths the registry maps.
+ /// The legacy flat spellings then come off with `remove_kdl_in_memory`,
+ /// which is how cce-relief's Save migrates a file in place.
+ #[test]
+ fn relief_wall_and_edge_keys_write_as_child_nodes_and_the_flat_ones_come_off() {
+ let content = "style {\n surface {\n relief light=(f64)0.15 width=(f64)9.3 profile=\"old\" edge_knobs=(bevel)\"0.500,0.500,0.500\"\n }\n}\n";
+ let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
+ let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.wall.profile", spec, "style"));
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.wall.height", "0.3", "style", Some("mm")));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.edge.profile", spec, "style"));
+ assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.edge.height", "4", "style", Some("px")));
+ // Migrate: the flat spellings come off, and a spelling that is not
+ // there reports nothing removed.
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.profile"));
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_knobs"));
+ assert!(!remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_height"), "absent: nothing to remove");
+ assert!(!remove_kdl_in_memory(&mut doc, "style.surface.nothing.here"), "missing node: nothing to remove");
+ let out = doc.to_string();
+ assert_eq!(out.matches("relief").count(), 1, "one relief node: {out}");
+ assert!(out.contains("light=(f64)0.15"), "the node keeps its properties: {out}");
+ assert!(!out.contains("profile=\"old\""), "flat profile migrated: {out}");
+ assert!(!out.contains("edge_knobs"), "flat knobs migrated: {out}");
+
+ let val = parse_kdl_to_json(&out);
+ let relief = val.pointer("/style/surface/relief").unwrap();
+ assert_eq!(relief.get("light").and_then(|v| v.as_f64()), Some(0.15));
+ assert_eq!(relief.pointer("/wall/profile").and_then(|v| v.as_str()), Some(spec));
+ assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_str()), Some("0.3mm"), "{relief}");
+ assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some(spec));
+ assert!(relief.pointer("/edge/height").is_some(), "{relief}");
+ assert!(relief.get("profile").is_none() && relief.get("edge_knobs").is_none(), "{relief}");
+ // A whole node comes off too.
+ assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge"));
+ let val = parse_kdl_to_json(&doc.to_string());
+ assert!(val.pointer("/style/surface/relief/edge").is_none());
+ assert!(val.pointer("/style/surface/relief/wall/profile").is_some());
+ }
+
+ #[test]
+ fn relief_keys_write_as_properties_and_round_trip() {
+ // `relief` is a PROP_NODES member: style.surface.relief.* must land as
+ // properties on the existing relief node (the config.kdl shape), not
+ // as duplicate child nodes shadowing the light=/width= properties.
+ let content = "style {\n surface {\n relief light=(f64)0.15 width=(f64)9.3\n }\n}\n";
+ let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
+ let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.profile", spec, "style"));
+ assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.light", "0.3", "style"));
+ let out = doc.to_string();
+ // Still one relief node, no child block grown under it.
+ assert_eq!(out.matches("relief").count(), 1, "out: {out}");
+ assert!(!out.contains("relief {"), "out: {out}");
+
+ // The reload path reads through parse_kdl_to_json: the new property
+ // must surface at the same dotted path the style registry maps.
+ let val = parse_kdl_to_json(&out);
+ let relief = val.get("style").unwrap().get("surface").unwrap().get("relief").unwrap();
+ assert_eq!(relief.get("profile").unwrap().as_str().unwrap(), spec);
+ assert_eq!(relief.get("light").unwrap().as_f64().unwrap(), 0.3);
+ assert_eq!(relief.get("width").unwrap().as_f64().unwrap(), 9.3);
+ }
+
+ #[test]
+ fn test_get_kdl_type_annotation() {
+ let content = "input {\n accel_profile (\"menu:flat,adaptive,none,custom\")\"flat\"\n touchpad {\n gestures pinch=(bool)true\n }\n}\n";
+ let ty1 = get_kdl_type_annotation(content, "input.accel_profile");
+ assert_eq!(ty1, Some("menu:flat,adaptive,none,custom".to_string()));
+
+ let ty2 = get_kdl_type_annotation(content, "input.touchpad.gestures.pinch");
+ assert_eq!(ty2, Some("bool".to_string()));
+
+ let keys = vec![
+ "input.accel_profile".to_string(),
+ "input.touchpad.gestures.pinch".to_string(),
+ "input.invalid_key".to_string(),
+ ];
+ let tys = get_kdl_type_annotations(content, &keys);
+ assert_eq!(tys.len(), 3);
+ assert_eq!(tys[0], Some("menu:flat,adaptive,none,custom".to_string()));
+ assert_eq!(tys[1], Some("bool".to_string()));
+ assert_eq!(tys[2], None);
+ }
+
+ #[test]
+ fn test_json_to_kdl_with_special_annotations() {
+ let mut annotations = std::collections::HashMap::new();
+ annotations.insert("style.surface.desktop.mode".to_string(), "menu:grid,solid".to_string());
+
+ let mut desktop_map = serde_json::Map::new();
+ desktop_map.insert("mode".to_string(), serde_json::Value::String("grid".to_string()));
+
+ let mut surface_map = serde_json::Map::new();
+ surface_map.insert("desktop".to_string(), serde_json::Value::Object(desktop_map));
+
+ let mut style_map = serde_json::Map::new();
+ style_map.insert("surface".to_string(), serde_json::Value::Object(surface_map));
+
+ let mut root_map = serde_json::Map::new();
+ root_map.insert("style".to_string(), serde_json::Value::Object(style_map));
+
+ let root = serde_json::Value::Object(root_map);
+ let kdl_str = json_to_kdl_string_with_annotations(&root, &annotations);
+ println!("Generated KDL:\n{}", kdl_str);
+
+ let doc_parsed = kdl_str.parse::<kdl::KdlDocument>();
+ assert!(doc_parsed.is_ok(), "Failed to parse KDL: {:?}", doc_parsed.err());
+ }
+
+ #[test]
+ fn test_brightness_annotations() {
+ let mut edp_map = serde_json::Map::new();
+ edp_map.insert("scale".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(2.0).unwrap()));
+ edp_map.insert("brightness_up".to_string(), serde_json::Value::String("XF86MonBrightnessUp".to_string()));
+ edp_map.insert("brightness_down".to_string(), serde_json::Value::String("XF86MonBrightnessDown".to_string()));
+ edp_map.insert("brightness_interval".to_string(), serde_json::Value::Number(serde_json::Number::from(10)));
+
+ let mut output_map = serde_json::Map::new();
+ output_map.insert("eDP-1".to_string(), serde_json::Value::Object(edp_map));
+
+ let mut root_map = serde_json::Map::new();
+ root_map.insert("output".to_string(), serde_json::Value::Object(output_map));
+
+ let root = serde_json::Value::Object(root_map);
+ let kdl_str = json_to_kdl_string(&root);
+ println!("Generated KDL for brightness:\n{}", kdl_str);
+
+ let doc_parsed = kdl_str.parse::<kdl::KdlDocument>().unwrap();
+
+ let output_node = doc_parsed.nodes().iter().find(|n| n.name().value() == "output").unwrap();
+ let edp_node = output_node.children().unwrap().nodes().iter().find(|n| n.name().value() == "eDP-1").unwrap();
+
+ let up_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_up")).unwrap();
+ assert_eq!(up_entry.ty().unwrap().value(), "keybind");
+
+ let down_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_down")).unwrap();
+ assert_eq!(down_entry.ty().unwrap().value(), "keybind");
+
+ let interval_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")).unwrap();
+ assert_eq!(interval_entry.ty().unwrap().value(), "i64");
+ }
+
+ /// A string list (`rounded_apps "a" "b"`) must survive the JSON round
+ /// trip cce-data-editor saves through — it used to come back as ONE arg,
+ /// `rounded_apps "a b"`, and the compositor's allowlist then matched
+ /// nothing (Claude Desktop lost its corners after every save).
+ #[test]
+ fn test_string_list_roundtrip() {
+ let content = "window_manager {\n rounded_apps \"claude-desktop\" \"com.anthropic.Claude\"\n corner_shape (f64)4.5\n}\n";
+ let val = parse_kdl_to_json(content);
+ let list = val.get("window_manager").unwrap().get("rounded_apps").unwrap();
+ assert_eq!(
+ list.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(),
+ vec!["claude-desktop", "com.anthropic.Claude"]
+ );
+ let kdl_str = json_to_kdl_string(&val);
+ assert!(kdl_str.contains("rounded_apps \"claude-desktop\" \"com.anthropic.Claude\""), "{kdl_str}");
+ // And it re-parses to the same two args, not one.
+ let doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
+ let wm = doc.nodes().iter().find(|n| n.name().value() == "window_manager").unwrap();
+ let ra = wm.children().unwrap().nodes().iter().find(|n| n.name().value() == "rounded_apps").unwrap();
+ assert_eq!(ra.entries().len(), 2);
+ // A single-arg string node stays a plain string.
+ let single = parse_kdl_to_json("window_manager {\n rounded_apps \"claude-desktop\"\n}\n");
+ assert_eq!(single.get("window_manager").unwrap().get("rounded_apps").unwrap().as_str(), Some("claude-desktop"));
+ }
+
+ #[test]
+ fn test_vec2i_lossless_roundtrip() {
+ let content = "style {\n surface {\n cloud {\n position_default (vec2i)100 200\n }\n }\n}\n";
+ let val = parse_kdl_to_json(content);
+ println!("Parsed KDL to JSON: {:?}", val);
+
+ let position_default_val = val.get("style").unwrap()
+ .get("surface").unwrap()
+ .get("cloud").unwrap()
+ .get("position_default").unwrap();
+ assert_eq!(position_default_val.as_str().unwrap(), "100 200");
+
+ let mut annotations = std::collections::HashMap::new();
+ annotations.insert("style.surface.cloud.position_default".to_string(), "vec2i".to_string());
+
+ let kdl_str = json_to_kdl_string_with_annotations(&val, &annotations);
+ println!("Generated KDL:\n{}", kdl_str);
+
+ // Assert that (vec2i)100 200 is preserved without quotes
+ assert!(kdl_str.contains("position_default (vec2i)100 200"));
+
+ // Test update_kdl_in_memory preserves and updates the KDL Document correctly
+ let mut doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
+ let updated = update_kdl_in_memory(&mut doc, "style.surface.cloud.position_default", "150 250", "layout");
+ assert!(updated);
+ let updated_kdl = doc.to_string();
+ println!("Updated KDL:\n{}", updated_kdl);
+ assert!(updated_kdl.contains("position_default (vec2i)150 250"));
+ }
+}
diff --git a/src/ipc.rs b/src/ipc.rs
index c26a6ef..931dd23 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -112,7 +112,7 @@ fn focus_window_at(path: &str, query: &str) -> std::io::Result<()> {
stream.read_to_string(&mut reply)?;
match reply.trim() {
"ok" => Ok(()),
- other => Err(Error::new(ErrorKind::Other, format!("focus-window {query}: {other}"))),
+ other => Err(Error::other(format!("focus-window {query}: {other}"))),
}
}
diff --git a/src/motion.rs b/src/motion.rs
index 78fcc0b..1c27e7f 100644
--- a/src/motion.rs
+++ b/src/motion.rs
@@ -69,7 +69,7 @@ pub fn force_for_test(value: bool) {
pub fn enabled() -> bool {
#[cfg(any(test, feature = "test-isolation"))]
{
- return FORCED.with(|f| f.get());
+ FORCED.with(|f| f.get())
}
#[cfg(not(any(test, feature = "test-isolation")))]
enabled_on_this_machine()