diff --git a/src/config_edit/write.rs b/src/config_edit/write.rs index 07bef24..10eec63 100644 --- a/src/config_edit/write.rs +++ b/src/config_edit/write.rs @@ -13,7 +13,11 @@ impl ConfigEditor { let config = xmltree::EmitterConfig::new() .perform_indent(true) .indent_string("\t") - .write_document_declaration(false); + .write_document_declaration(false) + // Loxone writes self-closing tags without a leading space (``, not ``). + // xml-rs pads by default, which makes every self-closing element differ on + // round-trip and drowns real changes in formatting noise. + .pad_self_closing(false); self.root .write_with_config(&mut buf, config) .context("Failed to write XML")?; @@ -29,6 +33,23 @@ impl ConfigEditor { buf = s.into_bytes(); } + // Post-process: Loxone writes attribute-less empty elements expanded + // (``, never ``). xml-rs always self-closes, so expand + // the attribute-less self-closing tags to match and keep round-trips byte-clean. + { + let s = String::from_utf8(buf).context("XML is not valid UTF-8")?; + let mut s = Self::expand_attrless_empty_tags(&s); + // Loxone keeps literal newlines inside attribute values (e.g. multi-line PicoC + // code, notification texts); xml-rs escapes them to ` `. Un-escape to match. + // Loxone never emits ` `, so this only reverses xml-rs's own escaping. + s = s.replace(" ", "\n"); + // Loxone terminates the file with a trailing newline; xml-rs does not. + if !s.ends_with('\n') { + s.push('\n'); + } + buf = s.into_bytes(); + } + // Post-process: restore BOM if self.had_bom { let mut result = Vec::with_capacity(3 + buf.len()); @@ -45,4 +66,76 @@ impl ConfigEditor { Ok(buf) } + + /// Expand attribute-less self-closing tags (`` → ``). + /// + /// Loxone writes empty elements that have no attributes in expanded form. xml-rs always + /// self-closes; only tags of the shape `` (name immediately followed by `/>`, i.e. + /// no attributes) are rewritten — attributed empty tags like `` are left + /// self-closed, matching Loxone. + fn expand_attrless_empty_tags(s: &str) -> String { + let bytes = s.as_bytes(); + let n = bytes.len(); + let mut out = String::with_capacity(n); + let mut last = 0; + let mut i = 0; + while i < n { + if bytes[i] == b'<' + && i + 1 < n + && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_') + { + let name_start = i + 1; + let mut j = name_start; + while j < n + && (bytes[j].is_ascii_alphanumeric() || matches!(bytes[j], b'_' | b':' | b'-')) + { + j += 1; + } + if j + 1 < n && bytes[j] == b'/' && bytes[j + 1] == b'>' { + out.push_str(&s[last..i]); + let name = &s[name_start..j]; + out.push('<'); + out.push_str(name); + out.push_str(">'); + i = j + 2; + last = i; + continue; + } + i = j; // skip past the element name + continue; + } + i += 1; + } + out.push_str(&s[last..]); + out + } +} + +#[cfg(test)] +mod tests { + use super::ConfigEditor; + + #[test] + fn test_write_matches_loxone_formatting() { + // Loxone's on-disk conventions the emitter must reproduce for byte-clean round-trips: + // no space before '/>', attribute-less empties expanded, attributed empties + // self-closed, literal newlines in attribute values, trailing newline. + let xml = "\n\ +\n\ +\t\n\ +\t\t\n\ +\t\t\n\ +\t\n\ +\n"; + let editor = ConfigEditor::load(xml.as_bytes()).unwrap(); + let out = String::from_utf8(editor.to_bytes().unwrap()).unwrap(); + assert!(!out.contains(" />"), "no padded self-close"); + assert!(out.contains(""), "attr-less empty stays expanded"); + assert!(out.contains(r#""#), "attributed empty self-closes"); + assert!(out.contains("line1\nline2"), "literal newline in attr value"); + assert!(!out.contains(" "), "no escaped newline"); + assert!(out.ends_with('\n'), "trailing newline"); + } }