git.lucas.co / cce-core
GUI-free half of the cce toolkit: config, input, IPC, spec parsers
git clone https://git.lucas.co/cce-core.git

src/config.rs (69.4K)

   1 use std::fs;
   2 use serde_json::Value;
   3 
   4 /// A unit-annotated number (`width=(mm)2.0`, `(px)9.3`, `(in)0.5`,
   5 /// `(pt)6`) becomes the JSON string `"2mm"` — the form
   6 /// `crate::units::Len::parse` reads — so the unit survives the JSON hop
   7 /// into the style registry, where it resolves against the live metric at
   8 /// every read. A bare number stays a number: a logical px, as always.
   9 fn unit_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> Option<serde_json::Value> {
  10     let ty = entry.ty()?.value();
  11     let len = crate::units::Len::from_annotated(value as f32, ty)?;
  12     Some(serde_json::Value::String(len.serialize()))
  13 }
  14 
  15 fn int_entry_to_json(value: i64, entry: &kdl::KdlEntry) -> serde_json::Value {
  16     unit_entry_to_json(value as f64, entry)
  17         .unwrap_or_else(|| serde_json::Value::Number(serde_json::Number::from(value)))
  18 }
  19 
  20 fn float_entry_to_json(value: f64, entry: &kdl::KdlEntry) -> serde_json::Value {
  21     if let Some(v) = unit_entry_to_json(value, entry) {
  22         return v;
  23     }
  24     let mut val_f = value;
  25     if let Some(ty) = entry.ty() {
  26         let ty_str = ty.value();
  27         if let Some(range_str) = ty_str.strip_prefix("f64:") {
  28             if let Some(dash_idx) = range_str.find('-') {
  29                 let min_str = range_str[..dash_idx].trim();
  30                 let max_str = range_str[dash_idx + 1..].trim();
  31                 if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
  32                     val_f = val_f.clamp(min_f, max_f);
  33                 }
  34             }
  35         }
  36     }
  37     match serde_json::Number::from_f64(val_f) {
  38         Some(num) => serde_json::Value::Number(num),
  39         None => serde_json::Value::Null,
  40     }
  41 }
  42 
  43 fn kdl_to_json(doc: &kdl::KdlDocument) -> serde_json::Value {
  44     let mut map = serde_json::Map::new();
  45     for node in doc.nodes() {
  46         let name = node.name().value().to_string();
  47         
  48         let mut node_map = serde_json::Map::new();
  49         let mut has_props = false;
  50         for entry in node.entries() {
  51             if let Some(prop_name) = entry.name() {
  52                 has_props = true;
  53                 let j_val = match entry.value() {
  54                     kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
  55                     kdl::KdlValue::Base2(i) |
  56                     kdl::KdlValue::Base8(i) |
  57                     kdl::KdlValue::Base10(i) |
  58                     kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
  59                     kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
  60                     kdl::KdlValue::String(s) |
  61                     kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
  62                     kdl::KdlValue::Null => serde_json::Value::Null,
  63                 };
  64                 node_map.insert(prop_name.value().to_string(), j_val);
  65             }
  66         }
  67 
  68         let val = if name == "key_bindings" {
  69             if let Some(children) = node.children() {
  70                 let mut binds = Vec::new();
  71                 for child in children.nodes() {
  72                     let mut child_map = serde_json::Map::new();
  73                     for entry in child.entries() {
  74                         if let Some(prop_name) = entry.name() {
  75                             let j_val = match entry.value() {
  76                                 kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
  77                                 kdl::KdlValue::Base2(i) |
  78                                 kdl::KdlValue::Base8(i) |
  79                                 kdl::KdlValue::Base10(i) |
  80                                 kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
  81                                 kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
  82                                 kdl::KdlValue::String(s) |
  83                                 kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
  84                                 kdl::KdlValue::Null => serde_json::Value::Null,
  85                             };
  86                             child_map.insert(prop_name.value().to_string(), j_val);
  87                         }
  88                     }
  89                     binds.push(serde_json::Value::Object(child_map));
  90                 }
  91                 serde_json::Value::Array(binds)
  92             } else if has_props {
  93                 serde_json::Value::Object(node_map)
  94             } else {
  95                 serde_json::Value::Null
  96             }
  97         } else {
  98             let mut node_val = serde_json::Value::Null;
  99             if has_props {
 100                 node_val = serde_json::Value::Object(node_map);
 101             } else if node.entries().len() > 1
 102                 && node.entries().iter().all(|e| matches!(e.value(), kdl::KdlValue::String(_) | kdl::KdlValue::RawString(_)))
 103             {
 104                 // A string LIST (`rounded_apps "a" "b"`) is an array, so the
 105                 // writer can put the args back. Joined into one string, as the
 106                 // numeric multi-arg case below still is (`(vec2i)100 200`),
 107                 // it came back as ONE quoted arg — `rounded_apps "a b"` — and
 108                 // a compositor allowlist silently matched nothing after
 109                 // every cce-data-editor save.
 110                 node_val = serde_json::Value::Array(
 111                     node.entries()
 112                         .iter()
 113                         .filter_map(|e| e.value().as_string().map(|s| serde_json::Value::String(s.to_string())))
 114                         .collect(),
 115                 );
 116             } else if node.entries().len() > 1 {
 117                 let parts: Vec<String> = node.entries().iter().map(|entry| {
 118                     match entry.value() {
 119                         kdl::KdlValue::Bool(b) => b.to_string(),
 120                         kdl::KdlValue::Base2(i) |
 121                         kdl::KdlValue::Base8(i) |
 122                         kdl::KdlValue::Base10(i) |
 123                         kdl::KdlValue::Base16(i) => i.to_string(),
 124                         kdl::KdlValue::Base10Float(f) => f.to_string(),
 125                         kdl::KdlValue::String(s) |
 126                         kdl::KdlValue::RawString(s) => s.clone(),
 127                         kdl::KdlValue::Null => "null".to_string(),
 128                     }
 129                 }).collect();
 130                 node_val = serde_json::Value::String(parts.join(" "));
 131             } else if let Some(entry) = node.entries().first() {
 132                 node_val = match entry.value() {
 133                     kdl::KdlValue::Bool(b) => serde_json::Value::Bool(*b),
 134                     kdl::KdlValue::Base2(i) |
 135                     kdl::KdlValue::Base8(i) |
 136                     kdl::KdlValue::Base10(i) |
 137                     kdl::KdlValue::Base16(i) => int_entry_to_json(*i, entry),
 138                     kdl::KdlValue::Base10Float(f) => float_entry_to_json(*f, entry),
 139                     kdl::KdlValue::String(s) |
 140                     kdl::KdlValue::RawString(s) => serde_json::Value::String(s.clone()),
 141                     kdl::KdlValue::Null => serde_json::Value::Null,
 142                 };
 143             }
 144 
 145             if let Some(children) = node.children() {
 146                 let children_val = kdl_to_json(children);
 147                 if let serde_json::Value::Object(children_map) = children_val {
 148                     if let serde_json::Value::Object(mut nm) = node_val {
 149                         for (k, v) in children_map {
 150                             nm.insert(k, v);
 151                         }
 152                         serde_json::Value::Object(nm)
 153                     } else {
 154                         serde_json::Value::Object(children_map)
 155                     }
 156                 } else {
 157                     node_val
 158                 }
 159             } else {
 160                 node_val
 161             }
 162         };
 163 
 164         if let Some(existing) = map.remove(&name) {
 165             match existing {
 166                 serde_json::Value::Array(mut arr) => {
 167                     match val {
 168                         serde_json::Value::Array(new_arr) => {
 169                             arr.extend(new_arr);
 170                         }
 171                         _ => {
 172                             arr.push(val);
 173                         }
 174                     }
 175                     map.insert(name, serde_json::Value::Array(arr));
 176                 }
 177                 other => {
 178                     match val {
 179                         serde_json::Value::Array(new_arr) => {
 180                             let mut combined = vec![other];
 181                             combined.extend(new_arr);
 182                             map.insert(name, serde_json::Value::Array(combined));
 183                         }
 184                         _ => {
 185                             map.insert(name, serde_json::Value::Array(vec![other, val]));
 186                         }
 187                     }
 188                 }
 189             }
 190         } else {
 191             let list_names = ["key_bindings", "pointer_bind", "gesture_bind", "mode_rule", "tag_layout", "startup", "device"];
 192             if list_names.contains(&name.as_str()) {
 193                 match val {
 194                     serde_json::Value::Array(_) => {
 195                         map.insert(name, val);
 196                     }
 197                     _ => {
 198                         map.insert(name, serde_json::Value::Array(vec![val]));
 199                     }
 200                 }
 201             } else {
 202                 map.insert(name, val);
 203             }
 204         }
 205     }
 206     serde_json::Value::Object(map)
 207 }
 208 
 209 /// The calling app's name — the basename of its executable — used to locate
 210 /// its per-app config (`~/.config/cce/<name>/config.kdl`) and its `input.kdl`
 211 /// domain.
 212 ///
 213 /// Derived from `/proc/self/exe` once per process, which the kernel renders as
 214 /// `<path> (deleted)` once the binary on disk has been replaced (`ccebuild
 215 /// install` unlinks before writing). Without the strip, a still-running client
 216 /// would resolve its override to `~/.config/cce/<name> (deleted)/config.kdl`
 217 /// on its next config reload and silently lose the whole file — the status
 218 /// bar's droplet bubbles reverted to square boxes this way on 2026-09-03.
 219 ///
 220 /// Cached because every config getter reaches it (through
 221 /// [`config_files_modified`]), many of them per frame; the stripped basename
 222 /// cannot change for the life of the process.
 223 pub fn get_app_name() -> Option<String> {
 224     app_name().map(str::to_string)
 225 }
 226 
 227 /// [`get_app_name`] without the allocation.
 228 fn app_name() -> Option<&'static str> {
 229     static NAME: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
 230     NAME.get_or_init(|| {
 231         std::env::current_exe()
 232             .ok()
 233             .and_then(|p| p.file_name().and_then(|s| s.to_str().map(app_name_from_exe_basename)))
 234     })
 235     .as_deref()
 236 }
 237 
 238 /// [`get_app_name`]'s normalization: the kernel's ` (deleted)` marker on an
 239 /// unlinked executable is not part of the name.
 240 fn app_name_from_exe_basename(basename: &str) -> String {
 241     basename.strip_suffix(" (deleted)").unwrap_or(basename).to_string()
 242 }
 243 
 244 pub fn get_app_config_path(app_name: &str) -> std::path::PathBuf {
 245     get_config_path().parent().unwrap().join(app_name).join("config.kdl")
 246 }
 247 
 248 fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) {
 249     match (a, b) {
 250         (serde_json::Value::Object(a_map), serde_json::Value::Object(b_map)) => {
 251             for (k, v) in b_map {
 252                 if !v.is_null() {
 253                     merge_json(a_map.entry(k.clone()).or_insert(serde_json::Value::Null), v);
 254                 }
 255             }
 256         }
 257         (a_val, b_val) => {
 258             *a_val = b_val.clone();
 259         }
 260     }
 261 }
 262 
 263 pub fn parse_kdl_to_json(content: &str) -> serde_json::Value {
 264     let mut main_val = if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
 265         kdl_to_json(&doc)
 266     } else {
 267         serde_json::json!({})
 268     };
 269 
 270     if let Some(app_name) = app_name() {
 271         let app_path = get_app_config_path(app_name);
 272         if let Ok(override_content) = std::fs::read_to_string(&app_path) {
 273             if let Ok(override_doc) = override_content.parse::<kdl::KdlDocument>() {
 274                 let override_val = kdl_to_json(&override_doc);
 275                 merge_json(&mut main_val, &override_val);
 276             }
 277         }
 278     }
 279 
 280     main_val
 281 }
 282 
 283 pub fn update_json_in_memory(val_obj: &mut Value, key: &str, value: &str, default_section: &str) -> bool {
 284     let j_val = if let Ok(parsed_val) = serde_json::from_str::<Value>(value) {
 285         parsed_val
 286     } else {
 287         serde_json::json!(value)
 288     };
 289 
 290     let mut updated = false;
 291     if let Some(obj) = val_obj.as_object_mut() {
 292         for (_sec_name, sec_val) in obj.iter_mut() {
 293             if let Some(sec_obj) = sec_val.as_object_mut() {
 294                 if sec_obj.contains_key(key) {
 295                     sec_obj.insert(key.to_string(), j_val.clone());
 296                     updated = true;
 297                     break;
 298                 }
 299             }
 300         }
 301         if !updated {
 302             if let Some(sec_obj) = obj.get_mut(default_section).and_then(|s| s.as_object_mut()) {
 303                 sec_obj.insert(key.to_string(), j_val);
 304                 updated = true;
 305             } else {
 306                 let mut map = serde_json::Map::new();
 307                 map.insert(key.to_string(), j_val);
 308                 obj.insert(default_section.to_string(), Value::Object(map));
 309                 updated = true;
 310             }
 311         }
 312     }
 313     updated
 314 }
 315 
 316 pub fn parse_config_path(key: &str, default_section: &str) -> (String, String, Option<String>) {
 317     let parts: Vec<&str> = key.split('.').collect();
 318     if parts.len() == 3 {
 319         (parts[0].to_string(), parts[1].to_string(), Some(parts[2].to_string()))
 320     } else if parts.len() == 2 {
 321         (parts[0].to_string(), parts[1].to_string(), None)
 322     } else {
 323         (default_section.to_string(), key.to_string(), None)
 324     }
 325 }
 326 
 327 const PROP_NODES: &[&str] = &[
 328     "gestures", "key_bindings", "pointer_bind", "gesture_bind",
 329     "button", "button_strip", "dropdown", "toggle", "spinbox", "slider", "font_selector",
 330     "status", "overlay", "root", "desktop", "list", "section", "textbox", "multiline", "editor", "tree",
 331     "menubar", "statusbar", "node", "relief", "frost", "finish",
 332     // The relief's two shapes (`relief { wall height=… profile=… knobs=… ; edge … }`).
 333     "wall", "edge"
 334 ];
 335 
 336 fn get_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
 337     if path.is_empty() {
 338         return None;
 339     }
 340     let idx = doc.nodes().iter().position(|n| n.name().value() == path[0])?;
 341     let node = &mut doc.nodes_mut()[idx];
 342     if path.len() == 1 {
 343         Some(node)
 344     } else {
 345         get_node_mut(node.children_mut().as_mut()?, &path[1..])
 346     }
 347 }
 348 
 349 /// Remove `key` from the document — a property off its `PROP_NODES` node, or
 350 /// the whole node otherwise — creating nothing on the way. `true` when
 351 /// something was removed; a key that is not there is `false`, so a writer
 352 /// can tell "migrated" from "was already clean".
 353 pub fn remove_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str) -> bool {
 354     let parts: Vec<&str> = key.split('.').collect();
 355     let Some(leaf) = parts.last().copied() else {
 356         return false;
 357     };
 358     // A PROP_NODES parent holds its keys as properties — but it may also
 359     // hold child NODES (`relief { wall … }`), so a leaf that is not among
 360     // the properties is looked for among the children before giving up.
 361     let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
 362     if is_property {
 363         let Some(node) = get_node_mut(doc, &parts[..parts.len() - 1]) else {
 364             return false;
 365         };
 366         let before = node.entries().len();
 367         node.entries_mut().retain(|e| e.name().map(|n| n.value()) != Some(leaf));
 368         if node.entries().len() != before {
 369             return true;
 370         }
 371     }
 372     let parent: &mut kdl::KdlDocument = if parts.len() == 1 {
 373         doc
 374     } else {
 375         match get_node_mut(doc, &parts[..parts.len() - 1]).and_then(|n| n.children_mut().as_mut()) {
 376             Some(children) => children,
 377             None => return false,
 378         }
 379     };
 380     let before = parent.nodes().len();
 381     parent.nodes_mut().retain(|n| n.name().value() != leaf);
 382     parent.nodes().len() != before
 383 }
 384 
 385 fn get_or_create_node_mut<'a>(doc: &'a mut kdl::KdlDocument, path: &[&str]) -> Option<&'a mut kdl::KdlNode> {
 386     if path.is_empty() {
 387         return None;
 388     }
 389     let segment = path[0];
 390     let idx = if let Some(i) = doc.nodes().iter().position(|n| n.name().value() == segment) {
 391         i
 392     } else {
 393         let new_node = format!("{}\n", segment).parse::<kdl::KdlNode>().ok()?;
 394         doc.nodes_mut().push(new_node);
 395         doc.nodes().len() - 1
 396     };
 397     if path.len() == 1 {
 398         Some(&mut doc.nodes_mut()[idx])
 399     } else {
 400         let children = doc.nodes_mut()[idx].ensure_children();
 401         get_or_create_node_mut(children, &path[1..])
 402     }
 403 }
 404 
 405 fn get_node_ref<'a>(doc: &'a kdl::KdlDocument, path: &[&str]) -> Option<&'a kdl::KdlNode> {
 406     if path.is_empty() {
 407         return None;
 408     }
 409     let segment = path[0];
 410     let node = doc.nodes().iter().find(|n| n.name().value() == segment)?;
 411     if path.len() == 1 {
 412         Some(node)
 413     } else {
 414         let children = node.children()?;
 415         get_node_ref(children, &path[1..])
 416     }
 417 }
 418 
 419 pub fn update_kdl_in_memory(doc: &mut kdl::KdlDocument, key: &str, value: &str, default_section: &str) -> bool {
 420     update_kdl_in_memory_typed(doc, key, value, default_section, None)
 421 }
 422 
 423 /// [`update_kdl_in_memory`] with an explicit type annotation for the written
 424 /// entry. `forced_ty` overrides both the value-shape inference and the
 425 /// preserved existing annotation — how a writer ESTABLISHES a custom type
 426 /// (e.g. cce-relief writing `(bevel)` knob keys into a config that never had
 427 /// them; preservation alone can't create the annotation).
 428 pub fn update_kdl_in_memory_typed(doc: &mut kdl::KdlDocument, key: &str, value: &str, _default_section: &str, forced_ty: Option<&str>) -> bool {
 429     let parts: Vec<&str> = key.split('.').collect();
 430     if parts.is_empty() {
 431         return false;
 432     }
 433 
 434     let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
 435 
 436     let (node_path, target_prop) = if is_property {
 437         (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
 438     } else {
 439         (&parts[0..parts.len()], None)
 440     };
 441 
 442     let child_node = if let Some(node) = get_or_create_node_mut(doc, node_path) {
 443         node
 444     } else {
 445         return false;
 446     };
 447 
 448     let existing_ty = if let Some(ref prop_name) = target_prop {
 449         child_node.entries().iter()
 450             .find(|e| e.name().map(|n| n.value()) == Some(prop_name))
 451             .and_then(|e| e.ty().map(|t| t.value().to_string()))
 452     } else {
 453         child_node.entries().first()
 454             .and_then(|e| e.ty().map(|t| t.value().to_string()))
 455     };
 456 
 457     let (kdl_val, mut kdl_ty) = if let Ok(b) = value.parse::<bool>() {
 458         (kdl::KdlValue::Bool(b), Some("bool".to_string()))
 459     } else if let Some(len) = crate::units::Len::parse(value) {
 460         // `2mm` → `(mm)2.0`: the unit rides as the annotation, the value
 461         // stays a number the editor's spinbox can step.
 462         (kdl::KdlValue::Base10Float(len.value as f64), Some(len.unit.suffix().to_string()))
 463     } else if value.starts_with('#') {
 464         let s_clean = value.trim_start_matches('#');
 465         let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
 466         (kdl::KdlValue::String(value.to_string()), Some(ty.to_string()))
 467     } else if value.contains('.') {
 468         if let Ok(f) = value.parse::<f64>() {
 469             (kdl::KdlValue::Base10Float(f), Some("f64".to_string()))
 470         } else {
 471             (kdl::KdlValue::String(value.to_string()), None)
 472         }
 473     } else if let Ok(i) = value.parse::<i64>() {
 474         (kdl::KdlValue::Base10(i), Some("i64".to_string()))
 475     } else {
 476         let s = value.trim_matches('"').to_string();
 477         (kdl::KdlValue::String(s), None)
 478     };
 479 
 480     if let Some(ref ext_ty) = existing_ty {
 481         if ext_ty.starts_with("menu:") || ext_ty == "button" || ext_ty.starts_with("button:") || ext_ty == "vec2i" || ext_ty == "radian" || ext_ty == "bevel" || ext_ty == "keybind" {
 482             kdl_ty = Some(ext_ty.clone());
 483         }
 484         // A bare number written over a unit-annotated slot keeps the unit:
 485         // typing 3 into a `(mm)` field means 3 mm, not a silent fall back
 486         // to logical px.
 487         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") {
 488             kdl_ty = Some(ext_ty.clone());
 489         }
 490     }
 491     if let Some(f) = forced_ty {
 492         kdl_ty = Some(f.to_string());
 493     }
 494 
 495     if let Some(prop_name) = target_prop {
 496         let mut found = false;
 497         for entry in child_node.entries_mut() {
 498             if let Some(id) = entry.name() {
 499                 if id.value() == prop_name {
 500                     *entry = kdl::KdlEntry::new_prop(prop_name.clone(), kdl_val.clone());
 501                     if let Some(ref ty) = kdl_ty {
 502                         entry.set_ty(ty.as_str());
 503                     }
 504                     found = true;
 505                     break;
 506                 }
 507             }
 508         }
 509         if !found {
 510             let mut entry = kdl::KdlEntry::new_prop(prop_name, kdl_val);
 511             if let Some(ref ty) = kdl_ty {
 512                 entry.set_ty(ty.as_str());
 513             }
 514             child_node.entries_mut().push(entry);
 515         }
 516     } else {
 517         child_node.entries_mut().clear();
 518         if kdl_ty.as_deref() == Some("vec2i") {
 519             let parts: Vec<&str> = value.split_whitespace().collect();
 520             for (idx, part) in parts.iter().enumerate() {
 521                 if let Ok(i) = part.parse::<i64>() {
 522                     let mut entry = kdl::KdlEntry::new(kdl::KdlValue::Base10(i));
 523                     if idx == 0 {
 524                         entry.set_ty("vec2i");
 525                     }
 526                     child_node.entries_mut().push(entry);
 527                 }
 528             }
 529         } else {
 530             let mut entry = kdl::KdlEntry::new(kdl_val);
 531             if let Some(ref ty) = kdl_ty {
 532                 entry.set_ty(ty.as_str());
 533             }
 534             child_node.entries_mut().push(entry);
 535         }
 536     }
 537 
 538     true
 539 }
 540 
 541 /// XDG config base directory: `$XDG_CONFIG_HOME`, else `~/.config`.
 542 ///
 543 /// Under this crate's own tests it is a per-process directory nobody
 544 /// creates, so every config read (`config.kdl`, a per-app override,
 545 /// `input.kdl`) finds no file and the toolkit's defaults are what a test
 546 /// sees. Until 2026-10-07 the tests read the machine's `~/.config/cce`:
 547 /// six of them failed on the user's desktop (a `relief edge height` pinned
 548 /// the plate rise the heightfield tests set; the frost and blur keys moved
 549 /// the material ones) and passed on a machine without that config. A test
 550 /// that wants a configuration loads it from a string (`reload_colors`).
 551 /// Dependents' tests link the non-test build and are unaffected.
 552 pub fn config_home() -> std::path::PathBuf {
 553     #[cfg(any(test, feature = "test-isolation"))]
 554     {
 555         return std::env::temp_dir().join(format!("cce-ui-test-config-{}", std::process::id()));
 556     }
 557     #[allow(unreachable_code)]
 558     match std::env::var("XDG_CONFIG_HOME") {
 559         Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
 560         _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config"),
 561     }
 562 }
 563 
 564 /// XDG data base directory: `$XDG_DATA_HOME`, else `~/.local/share`.
 565 pub fn data_home() -> std::path::PathBuf {
 566     match std::env::var("XDG_DATA_HOME") {
 567         Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
 568         _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
 569             .join(".local")
 570             .join("share"),
 571     }
 572 }
 573 
 574 /// XDG state base directory: `$XDG_STATE_HOME`, else `~/.local/state`.
 575 ///
 576 /// State is what should survive a restart but is not worth backing up or
 577 /// syncing like data, and is not the user's to edit like config: history,
 578 /// open tabs, logs, a "never ask again" list. A relative `$XDG_STATE_HOME`
 579 /// counts as unset, as the XDG spec says it must (an empty one too).
 580 pub fn state_home() -> std::path::PathBuf {
 581     match std::env::var_os("XDG_STATE_HOME").map(std::path::PathBuf::from) {
 582         Some(x) if x.is_absolute() => x,
 583         _ => std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
 584             .join(".local")
 585             .join("state"),
 586     }
 587 }
 588 
 589 /// XDG runtime base directory: `$XDG_RUNTIME_DIR`, else the temp dir.
 590 ///
 591 /// Unlike the other bases there is no `~/...` fallback to construct: the
 592 /// runtime dir is `/run/user/UID`, created by pam_systemd at login and mode
 593 /// 0700. An unset variable means we are outside a login session, where the
 594 /// shared temp dir is the honest answer rather than a path we would have to
 595 /// invent (and could not create with the right ownership anyway).
 596 pub fn runtime_dir() -> std::path::PathBuf {
 597     match std::env::var("XDG_RUNTIME_DIR") {
 598         Ok(x) if !x.is_empty() => std::path::PathBuf::from(x),
 599         _ => std::env::temp_dir(),
 600     }
 601 }
 602 
 603 /// The cce config directory (`<config_home>/cce`).
 604 pub fn cce_config_dir() -> std::path::PathBuf {
 605     config_home().join("cce")
 606 }
 607 
 608 /// The cce state directory (`<state_home>/cce`); each app keeps its own
 609 /// subdirectory under it (`cce/browser`, `cce/mail`, …). Not created here:
 610 /// a caller creates the subdirectory it writes, with the mode it needs.
 611 pub fn cce_state_dir() -> std::path::PathBuf {
 612     state_home().join("cce")
 613 }
 614 
 615 /// The cce runtime directory (`<runtime_dir>/cce`), created if absent.
 616 ///
 617 /// Session-scoped files — logs, sockets, pid files — belong here rather than
 618 /// in `/tmp`, which is one namespace shared by every user on the machine: a
 619 /// fixed `/tmp/cce-*.log` is a path the first user to log in owns, and the
 620 /// sticky bit then denies everyone else. Creating on demand keeps call sites
 621 /// to one line; a failure surfaces when the caller opens its file, which it
 622 /// already has to handle.
 623 pub fn cce_runtime_dir() -> std::path::PathBuf {
 624     let dir = runtime_dir().join("cce");
 625     let _ = std::fs::create_dir_all(&dir);
 626     dir
 627 }
 628 
 629 pub fn get_config_path() -> std::path::PathBuf {
 630     cce_config_dir().join("config.kdl")
 631 }
 632 
 633 struct ConfigCache {
 634     last_modified: Option<std::time::SystemTime>,
 635     /// Shared, not cloned per read: the getters below run many times a frame,
 636     /// and a deep copy of the whole tree to read one key was most of their cost.
 637     parsed: Option<std::sync::Arc<serde_json::Value>>,
 638     raw_content: String,
 639 }
 640 
 641 static CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
 642     last_modified: None,
 643     parsed: None,
 644     raw_content: String::new(),
 645 });
 646 
 647 /// The cce config parsed to JSON, cached on the config file's mtime (per process).
 648 /// Re-reads and re-parses only when `get_config_path()`'s modification time changes.
 649 /// The newest mtime across the shared config and the calling app's override
 650 /// file — the cache key for [`cached_config`], and what apps should poll for
 651 /// live-reload triggers. Compared by EQUALITY, so an app-file deletion (max
 652 /// drops back to the shared mtime) also invalidates.
 653 pub fn config_files_modified() -> Option<std::time::SystemTime> {
 654     let shared = std::fs::metadata(get_config_path()).ok().and_then(|m| m.modified().ok());
 655     let app = app_name()
 656         .and_then(|n| std::fs::metadata(get_app_config_path(n)).ok())
 657         .and_then(|m| m.modified().ok());
 658     match (shared, app) {
 659         (Some(a), Some(b)) => Some(a.max(b)),
 660         (a, b) => a.or(b),
 661     }
 662 }
 663 
 664 pub fn cached_config() -> serde_json::Value {
 665     (*cached_config_arc()).clone()
 666 }
 667 
 668 /// [`cached_config`] without the copy — what every getter here reads.
 669 pub fn cached_config_arc() -> std::sync::Arc<serde_json::Value> {
 670     let path = get_config_path();
 671     // Both files participate in the parse (parse_kdl_to_json merges the
 672     // per-app override), so both participate in the cache key.
 673     let current_modified = config_files_modified();
 674 
 675     if let Ok(cache) = CONFIG_CACHE.read() {
 676         if cache.last_modified.is_some() && cache.last_modified == current_modified {
 677             if let Some(ref val) = cache.parsed {
 678                 return val.clone();
 679             }
 680         }
 681     }
 682 
 683     let content = std::fs::read_to_string(&path).unwrap_or_default();
 684     let val = std::sync::Arc::new(parse_kdl_to_json(&content));
 685     if let Ok(mut cache) = CONFIG_CACHE.write() {
 686         cache.last_modified = current_modified;
 687         cache.parsed = Some(val.clone());
 688         cache.raw_content = content;
 689     }
 690     val
 691 }
 692 
 693 /// The raw text of the cce config, cached alongside [`cached_config`].
 694 pub fn cached_config_content() -> String {
 695     let _ = cached_config_arc();
 696     CONFIG_CACHE.read().map(|c| c.raw_content.clone()).unwrap_or_default()
 697 }
 698 
 699 static SHARED_CONFIG_CACHE: std::sync::RwLock<ConfigCache> = std::sync::RwLock::new(ConfigCache {
 700     last_modified: None,
 701     parsed: None,
 702     raw_content: String::new(),
 703 });
 704 
 705 /// The SHARED config alone — the per-app override is deliberately NOT merged.
 706 /// For values that describe something outside the app (the compositor's
 707 /// window silhouette radius), where an app-local override restyles the app
 708 /// but must not desynchronize it from the DE. Mtime-cached like
 709 /// [`cached_config`].
 710 pub fn cached_shared_config() -> serde_json::Value {
 711     (*cached_shared_config_arc()).clone()
 712 }
 713 
 714 /// [`cached_shared_config`] without the copy.
 715 pub fn cached_shared_config_arc() -> std::sync::Arc<serde_json::Value> {
 716     let path = get_config_path();
 717     let current_modified = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok());
 718 
 719     if let Ok(cache) = SHARED_CONFIG_CACHE.read() {
 720         if cache.last_modified.is_some() && cache.last_modified == current_modified {
 721             if let Some(ref val) = cache.parsed {
 722                 return val.clone();
 723             }
 724         }
 725     }
 726 
 727     let content = std::fs::read_to_string(&path).unwrap_or_default();
 728     let val = std::sync::Arc::new(if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
 729         kdl_to_json(&doc)
 730     } else {
 731         serde_json::json!({})
 732     });
 733     if let Ok(mut cache) = SHARED_CONFIG_CACHE.write() {
 734         cache.last_modified = current_modified;
 735         cache.parsed = Some(val.clone());
 736         cache.raw_content = content;
 737     }
 738     val
 739 }
 740 
 741 /// Read an i64 at `pointer` from the SHARED config only (no per-app merge),
 742 /// or `default` — see [`cached_shared_config`].
 743 pub fn get_i64_shared(pointer: &str, default: i64) -> i64 {
 744     cached_shared_config_arc().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
 745 }
 746 
 747 /// [`get_i64_shared`] without a default — for canonical-first alias chains
 748 /// (RFC Phase 7a) where absence must fall through to the next spelling.
 749 pub fn get_i64_shared_opt(pointer: &str) -> Option<i64> {
 750     cached_shared_config_arc().pointer(pointer).and_then(|v| v.as_i64())
 751 }
 752 
 753 // ── Typed accessors over the cached config ──────────────────────────────────
 754 // Each reads the mtime-cached config and extracts a value at a JSON pointer
 755 // (e.g. "/notifications/enable"), returning the default when absent or mistyped.
 756 
 757 /// Read a boolean at `pointer` from the cached config, or `default`.
 758 pub fn get_bool(pointer: &str, default: bool) -> bool {
 759     cached_config_arc().pointer(pointer).and_then(|v| v.as_bool()).unwrap_or(default)
 760 }
 761 
 762 /// Read an f32 at `pointer` from the cached config, or `default`.
 763 pub fn get_f32(pointer: &str, default: f32) -> f32 {
 764     cached_config_arc()
 765         .pointer(pointer)
 766         .and_then(|v| v.as_f64())
 767         .map(|f| f as f32)
 768         .unwrap_or(default)
 769 }
 770 
 771 /// Read an i64 at `pointer` from the cached config, or `default`.
 772 pub fn get_i64(pointer: &str, default: i64) -> i64 {
 773     cached_config_arc().pointer(pointer).and_then(|v| v.as_i64()).unwrap_or(default)
 774 }
 775 
 776 /// Read a string at `pointer` from the cached config.
 777 pub fn get_string(pointer: &str) -> Option<String> {
 778     cached_config_arc().pointer(pointer).and_then(|v| v.as_str()).map(|s| s.to_string())
 779 }
 780 
 781 /// Read a hex color string at `pointer` and parse it to raw sRGB RGBA (`[0,1]`).
 782 /// Apply [`crate::color::srgb_to_linear`] if your render target expects linear.
 783 pub fn get_color(pointer: &str) -> Option<[f32; 4]> {
 784     get_string(pointer).as_deref().and_then(crate::color::parse_hex_rgba)
 785 }
 786 
 787 /// Recursively search a JSON value for the first entry whose object key equals
 788 /// `key`, returning a reference to its value. Depth-first over objects and arrays.
 789 pub fn find_key<'a>(val: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
 790     match val {
 791         serde_json::Value::Object(map) => {
 792             if let Some(found) = map.get(key) {
 793                 return Some(found);
 794             }
 795             for v in map.values() {
 796                 if let Some(found) = find_key(v, key) {
 797                     return Some(found);
 798                 }
 799             }
 800             None
 801         }
 802         serde_json::Value::Array(arr) => arr.iter().find_map(|v| find_key(v, key)),
 803         _ => None,
 804     }
 805 }
 806 
 807 
 808 
 809 fn perform_rolling_backup(path: &str) {
 810     let config_path = get_config_path();
 811     if std::path::Path::new(path) != config_path {
 812         return;
 813     }
 814     if !std::path::Path::new(path).exists() {
 815         return;
 816     }
 817     let backup_dir = config_path.parent().unwrap().join("backups");
 818     if fs::create_dir_all(&backup_dir).is_err() {
 819         return;
 820     }
 821     for i in (1..=4).rev() {
 822         let src = backup_dir.join(format!("config.kdl.{}.bak", i));
 823         let dst = backup_dir.join(format!("config.kdl.{}.bak", i + 1));
 824         if src.exists() {
 825             let _ = fs::rename(src, dst);
 826         }
 827     }
 828     let dst = backup_dir.join("config.kdl.1.bak");
 829     let _ = fs::copy(path, dst);
 830 }
 831 
 832 pub(crate) fn safe_write(path: &str, content: &str) -> bool {
 833     perform_rolling_backup(path);
 834     if let Some(parent) = std::path::Path::new(path).parent() {
 835         let _ = fs::create_dir_all(parent);
 836     }
 837     let temp_path = format!("{}.tmp", path);
 838     if fs::write(&temp_path, content).is_ok() {
 839         if fs::rename(&temp_path, path).is_ok() {
 840             return true;
 841         }
 842         let _ = fs::remove_file(&temp_path);
 843     }
 844     false
 845 }
 846 
 847 pub fn write_config_value(path: &str, key: &str, value: &str, default_section: &str) -> bool {
 848     write_config_value_typed(path, key, value, default_section, None)
 849 }
 850 
 851 /// Remove `key` from the config file at `path` ([`remove_kdl_in_memory`]),
 852 /// writing only when something was removed. `true` when the key is gone —
 853 /// absent to begin with, or removed and written — so a writer migrating a
 854 /// legacy spelling can fold it into its own success.
 855 pub fn remove_config_value(path: &str, key: &str) -> bool {
 856     let content = fs::read_to_string(path).unwrap_or_default();
 857     let mut doc = match content.parse::<kdl::KdlDocument>() {
 858         Ok(d) => d,
 859         Err(_) => return true,
 860     };
 861     if remove_kdl_in_memory(&mut doc, key) {
 862         return safe_write(path, &doc.to_string());
 863     }
 864     true
 865 }
 866 
 867 /// [`write_config_value`] with an explicit type annotation — see
 868 /// [`update_kdl_in_memory_typed`].
 869 pub fn write_config_value_typed(path: &str, key: &str, value: &str, default_section: &str, forced_ty: Option<&str>) -> bool {
 870     let content = fs::read_to_string(path).unwrap_or_default();
 871     let mut doc = match content.parse::<kdl::KdlDocument>() {
 872         Ok(d) => d,
 873         Err(_) => kdl::KdlDocument::new(),
 874     };
 875 
 876     if update_kdl_in_memory_typed(&mut doc, key, value, default_section, forced_ty) {
 877         let updated_str = doc.to_string();
 878         return safe_write(path, &updated_str);
 879     }
 880     false
 881 }
 882 
 883 pub fn get_kdl_type_annotation(kdl_content: &str, key_path: &str) -> Option<String> {
 884     let doc: kdl::KdlDocument = kdl_content.parse().ok()?;
 885     let parts: Vec<&str> = key_path.split('.').collect();
 886     if parts.is_empty() {
 887         return None;
 888     }
 889 
 890     let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
 891 
 892     let (node_path, target_prop) = if is_property {
 893         (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
 894     } else {
 895         (&parts[0..parts.len()], None)
 896     };
 897 
 898     let child_node = get_node_ref(&doc, node_path)?;
 899 
 900     if let Some(prop_name) = target_prop {
 901         let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
 902         entry.ty().map(|t| t.value().to_string())
 903     } else {
 904         let entry = child_node.entries().first()?;
 905         entry.ty().map(|t| t.value().to_string())
 906     }
 907 }
 908 
 909 pub fn get_kdl_type_annotations(kdl_content: &str, key_paths: &[String]) -> Vec<Option<String>> {
 910     let doc = kdl_content.parse::<kdl::KdlDocument>().ok();
 911     key_paths.iter().map(|key_path| {
 912         let doc = doc.as_ref()?;
 913         let parts: Vec<&str> = key_path.split('.').collect();
 914         if parts.is_empty() {
 915             return None;
 916         }
 917 
 918         let is_property = parts.len() >= 2 && PROP_NODES.contains(&parts[parts.len() - 2]);
 919 
 920         let (node_path, target_prop) = if is_property {
 921             (&parts[0..parts.len() - 1], Some(parts[parts.len() - 1].to_string()))
 922         } else {
 923             (&parts[0..parts.len()], None)
 924         };
 925 
 926         let child_node = get_node_ref(doc, node_path)?;
 927 
 928         if let Some(prop_name) = target_prop {
 929             let entry = child_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some(&prop_name))?;
 930             entry.ty().map(|t| t.value().to_string())
 931         } else {
 932             let entry = child_node.entries().first()?;
 933             entry.ty().map(|t| t.value().to_string())
 934         }
 935     }).collect()
 936 }
 937 
 938 /// `s` as a quoted KDL string, escaped as KDL v1 (the `kdl` 4 parser this
 939 /// reads back with) escapes: a quote, a backslash and the control
 940 /// characters. Every string the writer emits goes through here — until
 941 /// 2026-10-01 values were written as `"{s}"` with nothing escaped, so one
 942 /// quote inside a value made a line no parser reads, and an app whose
 943 /// settings file fails to parse loads its DEFAULTS.
 944 pub fn kdl_quote(s: &str) -> String {
 945     let mut out = String::with_capacity(s.len() + 2);
 946     out.push('"');
 947     for c in s.chars() {
 948         match c {
 949             '"' => out.push_str("\\\""),
 950             '\\' => out.push_str("\\\\"),
 951             '\n' => out.push_str("\\n"),
 952             '\r' => out.push_str("\\r"),
 953             '\t' => out.push_str("\\t"),
 954             '\u{08}' => out.push_str("\\b"),
 955             '\u{0C}' => out.push_str("\\f"),
 956             c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
 957             c => out.push(c),
 958         }
 959     }
 960     out.push('"');
 961     out
 962 }
 963 
 964 fn format_kdl_type(ty: &str) -> String {
 965     let is_ident = !ty.is_empty()
 966         && !ty.chars().next().unwrap().is_ascii_digit()
 967         && ty.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
 968     if is_ident {
 969         ty.to_string()
 970     } else {
 971         kdl_quote(ty)
 972     }
 973 }
 974 
 975 fn format_kdl_identifier(name: &str) -> String {
 976     let is_ident = !name.is_empty()
 977         && !name.chars().next().unwrap().is_ascii_digit()
 978         && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '?' | '!' | '@' | '*' | '~' | '|' | '.'));
 979     if is_ident {
 980         name.to_string()
 981     } else {
 982         kdl_quote(name)
 983     }
 984 }
 985 
 986 pub fn value_to_kdl(key: &str, val: &serde_json::Value, indent: usize) -> String {
 987     value_to_kdl_with_annotations(key, val, indent, "", &std::collections::HashMap::new())
 988 }
 989 
 990 pub fn value_to_kdl_with_annotations(
 991     key: &str,
 992     val: &serde_json::Value,
 993     indent: usize,
 994     parent_path: &str,
 995     annotations: &std::collections::HashMap<String, String>,
 996 ) -> String {
 997     let indent_str = "    ".repeat(indent);
 998     let current_path = if parent_path.is_empty() {
 999         key.to_string()
1000     } else {
1001         format!("{}.{}", parent_path, key)
1002     };
1003 
1004     match val {
1005         serde_json::Value::Object(map) => {
1006             let has_objects = map.values().any(|v| v.is_object());
1007             if has_objects {
1008                 let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
1009                 for (k, v) in map {
1010                     out.push_str(&value_to_kdl_with_annotations(k, v, indent + 1, &current_path, annotations));
1011                 }
1012                 out.push_str(&format!("{}}}\n", indent_str));
1013                 out
1014             } else {
1015                 let mut prop_parts = Vec::new();
1016                 let mut child_parts = Vec::new();
1017                 for (prop_name, prop_val) in map {
1018                     let prop_path = format!("{}.{}", current_path, prop_name);
1019                     let is_vec2i = annotations.get(&prop_path).is_some_and(|a| a == "vec2i");
1020                     if is_vec2i {
1021                         if let serde_json::Value::String(ref s) = prop_val {
1022                             child_parts.push(format!("{}{} (vec2i){}\n", "    ".repeat(indent + 1), format_kdl_identifier(prop_name), s));
1023                         }
1024                     } else {
1025                         let (val_str, val_ty) = match prop_val {
1026                             serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
1027                             serde_json::Value::Number(num) => {
1028                                 if prop_name == "light_source_position" {
1029                                     (num.to_string(), Some("radian".to_string()))
1030                                 } else if num.is_f64() {
1031                                     (num.to_string(), Some("f64".to_string()))
1032                                 } else {
1033                                     (num.to_string(), Some("i64".to_string()))
1034                                 }
1035                             }
1036                             serde_json::Value::String(s) => {
1037                                 if let Some(len) = crate::units::Len::parse(s) {
1038                                     (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
1039                                 } else if let Some(anno) = annotations.get(&prop_path) {
1040                                     if anno == "vec2i" {
1041                                         (s.clone(), Some(anno.clone()))
1042                                     } else {
1043                                         (kdl_quote(s), Some(anno.clone()))
1044                                     }
1045                                 } else if s.starts_with('#') {
1046                                     let s_clean = s.trim_start_matches('#');
1047                                     let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
1048                                     (kdl_quote(s), Some(ty.to_string()))
1049                                 } else if prop_name == "key" || prop_name == "keybind" || prop_name == "shortcut" || prop_name == "open_search" || prop_name == "close_search" || prop_name == "delete" || prop_name.ends_with("_key") || prop_name.ends_with(".key") || prop_name.ends_with(".keybind") || prop_name.ends_with(".open_search") || prop_name.ends_with(".close_search") || prop_name == "brightness_up" || prop_name == "brightness_down" || prop_name.ends_with(".brightness_up") || prop_name.ends_with(".brightness_down") {
1050                                     (kdl_quote(s), Some("keybind".to_string()))
1051                                 } else {
1052                                     (kdl_quote(s), None)
1053                                 }
1054                             }
1055                             _ => (prop_val.to_string(), None),
1056                         };
1057                         if let Some(ty) = val_ty {
1058                             prop_parts.push(format!("{}=({}){}", format_kdl_identifier(prop_name), format_kdl_type(&ty), val_str));
1059                         } else {
1060                             prop_parts.push(format!("{}={}", format_kdl_identifier(prop_name), val_str));
1061                         }
1062                     }
1063                 }
1064                 if !child_parts.is_empty() {
1065                     let mut out = format!("{}{} {{\n", indent_str, format_kdl_identifier(key));
1066                     if !prop_parts.is_empty() {
1067                         out.push_str(&format!("{}{}\n", "    ".repeat(indent + 1), prop_parts.join(" ")));
1068                     }
1069                     for child in child_parts {
1070                         out.push_str(&child);
1071                     }
1072                     out.push_str(&format!("{}}}\n", indent_str));
1073                     out
1074                 } else {
1075                     format!("{}{} {}\n", indent_str, format_kdl_identifier(key), prop_parts.join(" "))
1076                 }
1077             }
1078         }
1079         serde_json::Value::Array(arr) => {
1080             // An array of strings is one node with several positional args
1081             // (the shape `kdl_to_json` reads `rounded_apps "a" "b"` into);
1082             // any other array is one node per item (key_bindings' objects).
1083             if !arr.is_empty() && arr.iter().all(|v| v.is_string()) {
1084                 let args: Vec<String> = arr
1085                     .iter()
1086                     .filter_map(|v| v.as_str().map(kdl_quote))
1087                     .collect();
1088                 return format!("{}{} {}\n", indent_str, format_kdl_identifier(key), args.join(" "));
1089             }
1090             let mut out = String::new();
1091             for item in arr {
1092                 out.push_str(&value_to_kdl_with_annotations(key, item, indent, parent_path, annotations));
1093             }
1094             out
1095         }
1096         _ => {
1097             let (val_str, val_ty) = match val {
1098                 serde_json::Value::Bool(b) => (b.to_string(), Some("bool".to_string())),
1099                 serde_json::Value::Number(num) => {
1100                     if key == "light_source_position" {
1101                         (num.to_string(), Some("radian".to_string()))
1102                     } else if num.is_f64() {
1103                         (num.to_string(), Some("f64".to_string()))
1104                     } else {
1105                         (num.to_string(), Some("i64".to_string()))
1106                     }
1107                 }
1108                 serde_json::Value::String(s) => {
1109                     if let Some(len) = crate::units::Len::parse(s) {
1110                         (crate::units::fmt_num(len.value), Some(len.unit.suffix().to_string()))
1111                     } else if let Some(anno) = annotations.get(&current_path) {
1112                         if anno == "vec2i" {
1113                             (s.clone(), Some(anno.clone()))
1114                         } else {
1115                             (kdl_quote(s), Some(anno.clone()))
1116                         }
1117                     } else if s.starts_with('#') {
1118                         let s_clean = s.trim_start_matches('#');
1119                         let ty = if s_clean.len() == 8 { "rgba" } else { "rgb" };
1120                         (kdl_quote(s), Some(ty.to_string()))
1121                     } else if key == "key" || key == "keybind" || key == "shortcut" || key == "open_search" || key == "close_search" || key == "delete" || key.ends_with("_key") || key.ends_with(".key") || key.ends_with(".keybind") || key.ends_with(".open_search") || key.ends_with(".close_search") || key == "brightness_up" || key == "brightness_down" || key.ends_with(".brightness_up") || key.ends_with(".brightness_down") {
1122                         (kdl_quote(s), Some("keybind".to_string()))
1123                     } else {
1124                         (kdl_quote(s), None)
1125                     }
1126                 }
1127                 _ => (val.to_string(), None),
1128             };
1129             if let Some(ty) = val_ty {
1130                 format!("{}{} ({}){}\n", indent_str, format_kdl_identifier(key), format_kdl_type(&ty), val_str)
1131             } else {
1132                 format!("{}{} {}\n", indent_str, format_kdl_identifier(key), val_str)
1133             }
1134         }
1135     }
1136 }
1137 
1138 pub fn json_to_kdl_string(val: &serde_json::Value) -> String {
1139     json_to_kdl_string_with_annotations(val, &std::collections::HashMap::new())
1140 }
1141 
1142 pub fn json_to_kdl_string_with_annotations(
1143     val: &serde_json::Value,
1144     annotations: &std::collections::HashMap<String, String>,
1145 ) -> String {
1146     let mut out = String::new();
1147     if let serde_json::Value::Object(map) = val {
1148         for (sec_name, sec_val) in map {
1149             if let serde_json::Value::Object(sec_map) = sec_val {
1150                 out.push_str(&format!("{} {{\n", format_kdl_identifier(sec_name)));
1151                 for (k, v) in sec_map {
1152                     out.push_str(&value_to_kdl_with_annotations(k, v, 1, sec_name, annotations));
1153                 }
1154                 out.push_str("}\n");
1155             } else {
1156                 out.push_str(&value_to_kdl_with_annotations(sec_name, sec_val, 0, "", annotations));
1157             }
1158         }
1159     }
1160     out
1161 }
1162 
1163 pub fn get_app_recent_files_path() -> std::path::PathBuf {
1164     let app_name = get_app_name().unwrap_or_else(|| "cce-app".to_string());
1165     get_config_path().parent().unwrap().join(app_name).join("recent-files.kdl")
1166 }
1167 
1168 pub fn load_recent_files() -> Vec<String> {
1169     let path = get_app_recent_files_path();
1170     if path.exists() {
1171         if let Ok(content) = std::fs::read_to_string(&path) {
1172             if let Ok(doc) = content.parse::<kdl::KdlDocument>() {
1173                 if let Some(recent_node) = doc.get("recent") {
1174                     if let Some(children) = recent_node.children() {
1175                         let mut files = Vec::new();
1176                         for node in children.nodes() {
1177                             if node.name().value() == "file" {
1178                                 if let Some(entry) = node.entries().first() {
1179                                     if let kdl::KdlValue::String(s) = entry.value() {
1180                                         files.push(s.clone());
1181                                     }
1182                                 }
1183                             }
1184                         }
1185                         return files;
1186                     }
1187                 }
1188             }
1189         }
1190     }
1191     Vec::new()
1192 }
1193 
1194 pub fn save_recent_files(files: &[String]) {
1195     let path = get_app_recent_files_path();
1196     if let Some(parent) = path.parent() {
1197         let _ = std::fs::create_dir_all(parent);
1198     }
1199     let mut kdl_str = "recent {\n".to_string();
1200     for file in files {
1201         kdl_str.push_str(&format!("    file {}\n", kdl_quote(file)));
1202     }
1203     kdl_str.push_str("}\n");
1204     let _ = std::fs::write(path, kdl_str);
1205 }
1206 
1207 
1208 #[cfg(test)]
1209 mod tests {
1210     /// `$XDG_STATE_HOME` when it is absolute; `~/.local/state` when it is
1211     /// unset, empty or relative. Nothing else in the crate reads the
1212     /// variable, so setting it here cannot race another test.
1213     #[test]
1214     fn state_home_follows_xdg_and_falls_back() {
1215         let home = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
1216         let fallback = home.join(".local").join("state");
1217         std::env::set_var("XDG_STATE_HOME", "/srv/state");
1218         assert_eq!(super::state_home(), std::path::PathBuf::from("/srv/state"));
1219         assert_eq!(super::cce_state_dir(), std::path::PathBuf::from("/srv/state/cce"));
1220         for invalid in ["", "relative/state"] {
1221             std::env::set_var("XDG_STATE_HOME", invalid);
1222             assert_eq!(super::state_home(), fallback, "{invalid:?}");
1223         }
1224         std::env::remove_var("XDG_STATE_HOME");
1225         assert_eq!(super::state_home(), fallback);
1226         assert_eq!(super::cce_state_dir(), fallback.join("cce"));
1227     }
1228 
1229     /// A string the writer emits comes back as it went in, quotes,
1230     /// backslashes and control characters included — as a section value,
1231     /// a top-level value, a keybind (written with a type annotation), a
1232     /// list item and a key that is not an identifier. Until 2026-10-01
1233     /// none of it was escaped, a quote inside a value made a line the
1234     /// parser refused, and an app whose settings file fails to parse loads
1235     /// its defaults.
1236     #[test]
1237     fn strings_round_trip_through_the_kdl_writer() {
1238         let odd = "a \"quoted\" \\path\\ with\nnewline,\ttab and \u{1} control";
1239         let val = serde_json::json!({
1240             "section": {
1241                 "plain": odd,
1242                 "shortcut": "ctrl+\"",
1243                 "items": [odd, "x"],
1244                 "not an ident": odd,
1245             },
1246             "top": odd,
1247         });
1248         let text = super::json_to_kdl_string(&val);
1249         let doc: kdl::KdlDocument = text.parse().unwrap_or_else(|e| panic!("{e}\n{text}"));
1250         let back = super::kdl_to_json(&doc);
1251         assert_eq!(back["section"]["plain"], odd, "{text}");
1252         assert_eq!(back["section"]["shortcut"], "ctrl+\"", "{text}");
1253         assert_eq!(back["section"]["items"][0], odd, "{text}");
1254         assert_eq!(back["section"]["not an ident"], odd, "{text}");
1255         assert_eq!(back["top"], odd, "{text}");
1256         assert_eq!(super::kdl_quote("plain"), "\"plain\"", "an ordinary string is unchanged");
1257     }
1258 
1259     /// A material node's frost and finish are written as PROPERTIES of a
1260     /// `frost` / `finish` child (RFC material § 5), created on demand under
1261     /// `style.surface.material.<name>`, and read back through the same
1262     /// pointer the loader uses.
1263     #[test]
1264     fn material_keys_write_as_frost_and_finish_props() {
1265         use super::{parse_kdl_to_json, update_kdl_in_memory};
1266         let mut doc = kdl::KdlDocument::new();
1267         assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.compression", "0.6", "style"));
1268         assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.frost.refraction", "0.3", "style"));
1269         assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.finish.spec", "0.4", "style"));
1270         assert!(update_kdl_in_memory(&mut doc, "style.surface.material.glass.color", "#05050840", "style"));
1271         assert!(update_kdl_in_memory(&mut doc, "style.surface.plate.material", "glass", "style"));
1272         let text = doc.to_string();
1273         let val = parse_kdl_to_json(&text);
1274         assert_eq!(val.pointer("/style/surface/material/glass/frost/compression").and_then(|v| v.as_f64()), Some(0.6), "{text}");
1275         assert_eq!(val.pointer("/style/surface/material/glass/frost/refraction").and_then(|v| v.as_f64()), Some(0.3));
1276         assert_eq!(val.pointer("/style/surface/material/glass/finish/spec").and_then(|v| v.as_f64()), Some(0.4));
1277         assert_eq!(val.pointer("/style/surface/material/glass/color").and_then(|v| v.as_str()), Some("#05050840"));
1278         assert_eq!(val.pointer("/style/surface/plate/material").and_then(|v| v.as_str()), Some("glass"));
1279         // One `frost` node with two props, not two `frost` nodes.
1280         assert_eq!(text.matches("frost").count(), 1, "{text}");
1281         assert!(text.contains("(rgba)"), "the colour carries its type: {text}");
1282     }
1283 
1284     #[test]
1285     fn unit_annotations_become_len_strings() {
1286         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");
1287         assert_eq!(v["style"]["relief"]["width"], serde_json::json!("2mm"));
1288         assert_eq!(v["style"]["relief"]["depth"], serde_json::json!(0.15));
1289         assert_eq!(v["style"]["relief"]["lip"], serde_json::json!("6px"));
1290         assert_eq!(v["style"]["ruler"], serde_json::json!("0.5in"));
1291     }
1292 
1293     #[test]
1294     fn unit_strings_write_back_annotated() {
1295         let v = serde_json::json!({"style": {"relief": {"width": "2mm", "depth": 0.15}}});
1296         let out = json_to_kdl_string(&v);
1297         assert!(out.contains("width=(mm)2\n") || out.contains("width=(mm)2 "), "{out}");
1298         assert!(out.contains("depth=(f64)0.15"), "{out}");
1299         let back = parse_kdl_to_json(&out);
1300         assert_eq!(back["style"]["relief"]["width"], serde_json::json!("2mm"));
1301     }
1302 
1303     #[test]
1304     fn typed_write_keeps_and_sets_units() {
1305         let mut doc: kdl::KdlDocument = "style {\n    relief width=(mm)2.0\n}\n".parse().unwrap();
1306         // A bare number over a (mm) slot stays mm.
1307         assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "3", "style", None));
1308         let v = parse_kdl_to_json(&doc.to_string());
1309         assert_eq!(v["style"]["relief"]["width"], serde_json::json!("3mm"));
1310         // A suffixed value sets the unit.
1311         assert!(update_kdl_in_memory_typed(&mut doc, "style.relief.width", "0.25in", "style", None));
1312         let v = parse_kdl_to_json(&doc.to_string());
1313         assert_eq!(v["style"]["relief"]["width"], serde_json::json!("0.25in"));
1314     }
1315 
1316     #[test]
1317     fn app_name_strips_the_kernels_deleted_marker() {
1318         use super::app_name_from_exe_basename as name;
1319         assert_eq!(name("cce-status-interface"), "cce-status-interface");
1320         assert_eq!(name("cce-status-interface (deleted)"), "cce-status-interface");
1321         // Only the exact trailing marker: a name that merely contains the
1322         // word, or an unspaced variant, is left alone.
1323         assert_eq!(name("cce-deleted-files"), "cce-deleted-files");
1324         assert_eq!(name("cce-x(deleted)"), "cce-x(deleted)");
1325     }
1326 
1327     use super::*;
1328 
1329     #[test]
1330     fn cce_runtime_dir_sits_under_the_runtime_base_and_is_created() {
1331         // No env mutation: reading the real base keeps this correct both in a
1332         // session (XDG_RUNTIME_DIR set) and anywhere it is not (temp dir), and
1333         // avoids racing every other test in the process.
1334         let base = runtime_dir();
1335         assert!(base.is_absolute(), "runtime base must be absolute: {base:?}");
1336         let dir = cce_runtime_dir();
1337         assert_eq!(dir, base.join("cce"));
1338         // The create-on-demand contract callers depend on: they open a file
1339         // inside this directory without creating it themselves.
1340         assert!(dir.is_dir(), "cce_runtime_dir must create its directory: {dir:?}");
1341     }
1342 
1343     #[test]
1344     fn relief_annotated_string_passes_through() {
1345         // The (relief) custom value type: an annotated string prop must
1346         // survive kdl_to_json as a plain JSON string at its pointer.
1347         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";
1348         let val = parse_kdl_to_json(content);
1349         assert_eq!(
1350             val.pointer("/style/surface/desktop/line_relief").and_then(|v| v.as_str()),
1351             Some("w=8 d=0.55 k=0.8,0.2,0.5 p=0.000:0.000,1.000:1.000"),
1352         );
1353     }
1354 
1355     #[test]
1356     fn test_nested_parsing() {
1357         let content = "style {\n    status box_opacity=(f64)0.75\n}\n";
1358         let val = parse_kdl_to_json(content);
1359         println!("val = {:?}", val);
1360         let (sec, node, prop) = parse_config_path("style.status.box_opacity", "layout");
1361         assert_eq!(sec, "style");
1362         assert_eq!(node, "status");
1363         assert_eq!(prop, Some("box_opacity".to_string()));
1364         
1365         let sec_val = val.get(&sec).unwrap();
1366         let node_val = sec_val.get(&node).unwrap();
1367         let prop_val = node_val.get(prop.as_ref().unwrap()).unwrap();
1368         assert_eq!(prop_val.as_f64().unwrap(), 0.75);
1369     }
1370 
1371     /// The relief's wall and edge keys write as PROPERTIES on `wall` / `edge`
1372     /// child nodes of the existing `relief` node — the node keeps its own
1373     /// `depth=` / `width=` properties and grows one child block — and the
1374     /// reload path reads them back at the nested paths the registry maps.
1375     /// The legacy flat spellings then come off with `remove_kdl_in_memory`,
1376     /// which is how cce-relief's Save migrates a file in place.
1377     #[test]
1378     fn relief_wall_and_edge_keys_write_as_child_nodes_and_the_flat_ones_come_off() {
1379         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";
1380         let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
1381         let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
1382         assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.wall.profile", spec, "style"));
1383         assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.wall.height", "0.3", "style", Some("mm")));
1384         assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.edge.profile", spec, "style"));
1385         assert!(update_kdl_in_memory_typed(&mut doc, "style.surface.relief.edge.height", "4", "style", Some("px")));
1386         // Migrate: the flat spellings come off, and a spelling that is not
1387         // there reports nothing removed.
1388         assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.profile"));
1389         assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_knobs"));
1390         assert!(!remove_kdl_in_memory(&mut doc, "style.surface.relief.edge_height"), "absent: nothing to remove");
1391         assert!(!remove_kdl_in_memory(&mut doc, "style.surface.nothing.here"), "missing node: nothing to remove");
1392         let out = doc.to_string();
1393         assert_eq!(out.matches("relief").count(), 1, "one relief node: {out}");
1394         assert!(out.contains("light=(f64)0.15"), "the node keeps its properties: {out}");
1395         assert!(!out.contains("profile=\"old\""), "flat profile migrated: {out}");
1396         assert!(!out.contains("edge_knobs"), "flat knobs migrated: {out}");
1397 
1398         let val = parse_kdl_to_json(&out);
1399         let relief = val.pointer("/style/surface/relief").unwrap();
1400         assert_eq!(relief.get("light").and_then(|v| v.as_f64()), Some(0.15));
1401         assert_eq!(relief.pointer("/wall/profile").and_then(|v| v.as_str()), Some(spec));
1402         assert_eq!(relief.pointer("/wall/height").and_then(|v| v.as_str()), Some("0.3mm"), "{relief}");
1403         assert_eq!(relief.pointer("/edge/profile").and_then(|v| v.as_str()), Some(spec));
1404         assert!(relief.pointer("/edge/height").is_some(), "{relief}");
1405         assert!(relief.get("profile").is_none() && relief.get("edge_knobs").is_none(), "{relief}");
1406         // A whole node comes off too.
1407         assert!(remove_kdl_in_memory(&mut doc, "style.surface.relief.edge"));
1408         let val = parse_kdl_to_json(&doc.to_string());
1409         assert!(val.pointer("/style/surface/relief/edge").is_none());
1410         assert!(val.pointer("/style/surface/relief/wall/profile").is_some());
1411     }
1412 
1413     #[test]
1414     fn relief_keys_write_as_properties_and_round_trip() {
1415         // `relief` is a PROP_NODES member: style.surface.relief.* must land as
1416         // properties on the existing relief node (the config.kdl shape), not
1417         // as duplicate child nodes shadowing the light=/width= properties.
1418         let content = "style {\n    surface {\n        relief light=(f64)0.15 width=(f64)9.3\n    }\n}\n";
1419         let mut doc = content.parse::<kdl::KdlDocument>().unwrap();
1420         let spec = "smooth;0.000:0.500,0.400:1.000,1.000:0.000";
1421         assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.profile", spec, "style"));
1422         assert!(update_kdl_in_memory(&mut doc, "style.surface.relief.light", "0.3", "style"));
1423         let out = doc.to_string();
1424         // Still one relief node, no child block grown under it.
1425         assert_eq!(out.matches("relief").count(), 1, "out: {out}");
1426         assert!(!out.contains("relief {"), "out: {out}");
1427 
1428         // The reload path reads through parse_kdl_to_json: the new property
1429         // must surface at the same dotted path the style registry maps.
1430         let val = parse_kdl_to_json(&out);
1431         let relief = val.get("style").unwrap().get("surface").unwrap().get("relief").unwrap();
1432         assert_eq!(relief.get("profile").unwrap().as_str().unwrap(), spec);
1433         assert_eq!(relief.get("light").unwrap().as_f64().unwrap(), 0.3);
1434         assert_eq!(relief.get("width").unwrap().as_f64().unwrap(), 9.3);
1435     }
1436 
1437     #[test]
1438     fn test_get_kdl_type_annotation() {
1439         let content = "input {\n    accel_profile (\"menu:flat,adaptive,none,custom\")\"flat\"\n    touchpad {\n        gestures pinch=(bool)true\n    }\n}\n";
1440         let ty1 = get_kdl_type_annotation(content, "input.accel_profile");
1441         assert_eq!(ty1, Some("menu:flat,adaptive,none,custom".to_string()));
1442         
1443         let ty2 = get_kdl_type_annotation(content, "input.touchpad.gestures.pinch");
1444         assert_eq!(ty2, Some("bool".to_string()));
1445 
1446         let keys = vec![
1447             "input.accel_profile".to_string(),
1448             "input.touchpad.gestures.pinch".to_string(),
1449             "input.invalid_key".to_string(),
1450         ];
1451         let tys = get_kdl_type_annotations(content, &keys);
1452         assert_eq!(tys.len(), 3);
1453         assert_eq!(tys[0], Some("menu:flat,adaptive,none,custom".to_string()));
1454         assert_eq!(tys[1], Some("bool".to_string()));
1455         assert_eq!(tys[2], None);
1456     }
1457 
1458     #[test]
1459     fn test_json_to_kdl_with_special_annotations() {
1460         let mut annotations = std::collections::HashMap::new();
1461         annotations.insert("style.surface.desktop.mode".to_string(), "menu:grid,solid".to_string());
1462         
1463         let mut desktop_map = serde_json::Map::new();
1464         desktop_map.insert("mode".to_string(), serde_json::Value::String("grid".to_string()));
1465         
1466         let mut surface_map = serde_json::Map::new();
1467         surface_map.insert("desktop".to_string(), serde_json::Value::Object(desktop_map));
1468         
1469         let mut style_map = serde_json::Map::new();
1470         style_map.insert("surface".to_string(), serde_json::Value::Object(surface_map));
1471         
1472         let mut root_map = serde_json::Map::new();
1473         root_map.insert("style".to_string(), serde_json::Value::Object(style_map));
1474         
1475         let root = serde_json::Value::Object(root_map);
1476         let kdl_str = json_to_kdl_string_with_annotations(&root, &annotations);
1477         println!("Generated KDL:\n{}", kdl_str);
1478         
1479         let doc_parsed = kdl_str.parse::<kdl::KdlDocument>();
1480         assert!(doc_parsed.is_ok(), "Failed to parse KDL: {:?}", doc_parsed.err());
1481     }
1482 
1483     #[test]
1484     fn test_brightness_annotations() {
1485         let mut edp_map = serde_json::Map::new();
1486         edp_map.insert("scale".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(2.0).unwrap()));
1487         edp_map.insert("brightness_up".to_string(), serde_json::Value::String("XF86MonBrightnessUp".to_string()));
1488         edp_map.insert("brightness_down".to_string(), serde_json::Value::String("XF86MonBrightnessDown".to_string()));
1489         edp_map.insert("brightness_interval".to_string(), serde_json::Value::Number(serde_json::Number::from(10)));
1490 
1491         let mut output_map = serde_json::Map::new();
1492         output_map.insert("eDP-1".to_string(), serde_json::Value::Object(edp_map));
1493 
1494         let mut root_map = serde_json::Map::new();
1495         root_map.insert("output".to_string(), serde_json::Value::Object(output_map));
1496 
1497         let root = serde_json::Value::Object(root_map);
1498         let kdl_str = json_to_kdl_string(&root);
1499         println!("Generated KDL for brightness:\n{}", kdl_str);
1500 
1501         let doc_parsed = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1502         
1503         let output_node = doc_parsed.nodes().iter().find(|n| n.name().value() == "output").unwrap();
1504         let edp_node = output_node.children().unwrap().nodes().iter().find(|n| n.name().value() == "eDP-1").unwrap();
1505         
1506         let up_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_up")).unwrap();
1507         assert_eq!(up_entry.ty().unwrap().value(), "keybind");
1508 
1509         let down_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_down")).unwrap();
1510         assert_eq!(down_entry.ty().unwrap().value(), "keybind");
1511 
1512         let interval_entry = edp_node.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")).unwrap();
1513         assert_eq!(interval_entry.ty().unwrap().value(), "i64");
1514     }
1515 
1516     /// A string list (`rounded_apps "a" "b"`) must survive the JSON round
1517     /// trip cce-data-editor saves through — it used to come back as ONE arg,
1518     /// `rounded_apps "a b"`, and the compositor's allowlist then matched
1519     /// nothing (Claude Desktop lost its corners after every save).
1520     #[test]
1521     fn test_string_list_roundtrip() {
1522         let content = "window_manager {\n    rounded_apps \"claude-desktop\" \"com.anthropic.Claude\"\n    corner_shape (f64)4.5\n}\n";
1523         let val = parse_kdl_to_json(content);
1524         let list = val.get("window_manager").unwrap().get("rounded_apps").unwrap();
1525         assert_eq!(
1526             list.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect::<Vec<_>>(),
1527             vec!["claude-desktop", "com.anthropic.Claude"]
1528         );
1529         let kdl_str = json_to_kdl_string(&val);
1530         assert!(kdl_str.contains("rounded_apps \"claude-desktop\" \"com.anthropic.Claude\""), "{kdl_str}");
1531         // And it re-parses to the same two args, not one.
1532         let doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1533         let wm = doc.nodes().iter().find(|n| n.name().value() == "window_manager").unwrap();
1534         let ra = wm.children().unwrap().nodes().iter().find(|n| n.name().value() == "rounded_apps").unwrap();
1535         assert_eq!(ra.entries().len(), 2);
1536         // A single-arg string node stays a plain string.
1537         let single = parse_kdl_to_json("window_manager {\n    rounded_apps \"claude-desktop\"\n}\n");
1538         assert_eq!(single.get("window_manager").unwrap().get("rounded_apps").unwrap().as_str(), Some("claude-desktop"));
1539     }
1540 
1541     #[test]
1542     fn test_vec2i_lossless_roundtrip() {
1543         let content = "style {\n    surface {\n        cloud {\n            position_default (vec2i)100 200\n        }\n    }\n}\n";
1544         let val = parse_kdl_to_json(content);
1545         println!("Parsed KDL to JSON: {:?}", val);
1546         
1547         let position_default_val = val.get("style").unwrap()
1548             .get("surface").unwrap()
1549             .get("cloud").unwrap()
1550             .get("position_default").unwrap();
1551         assert_eq!(position_default_val.as_str().unwrap(), "100 200");
1552 
1553         let mut annotations = std::collections::HashMap::new();
1554         annotations.insert("style.surface.cloud.position_default".to_string(), "vec2i".to_string());
1555         
1556         let kdl_str = json_to_kdl_string_with_annotations(&val, &annotations);
1557         println!("Generated KDL:\n{}", kdl_str);
1558         
1559         // Assert that (vec2i)100 200 is preserved without quotes
1560         assert!(kdl_str.contains("position_default (vec2i)100 200"));
1561         
1562         // Test update_kdl_in_memory preserves and updates the KDL Document correctly
1563         let mut doc = kdl_str.parse::<kdl::KdlDocument>().unwrap();
1564         let updated = update_kdl_in_memory(&mut doc, "style.surface.cloud.position_default", "150 250", "layout");
1565         assert!(updated);
1566         let updated_kdl = doc.to_string();
1567         println!("Updated KDL:\n{}", updated_kdl);
1568         assert!(updated_kdl.contains("position_default (vec2i)150 250"));
1569     }
1570 }