diff --git a/src/config_edit/write.rs b/src/config_edit/write.rs
index 07bef24..32f74b6 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,17 @@ impl ConfigEditor {
buf = s.into_bytes();
}
+ // Post-process XML syntax emitted by xml-rs to match Loxone's formatting.
+ {
+ let s = String::from_utf8(buf).context("XML is not valid UTF-8")?;
+ let mut s = Self::normalize_loxone_xml(&s);
+ // 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 +60,158 @@ impl ConfigEditor {
Ok(buf)
}
+
+ /// Match Loxone's empty-element and attribute-newline formatting.
+ ///
+ /// Only start tags are rewritten so XML-like text in comments, CDATA, and processing
+ /// instructions remains unchanged.
+ fn normalize_loxone_xml(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out = String::with_capacity(bytes.len());
+ let mut last = 0;
+ let mut i = 0;
+
+ while i < bytes.len() {
+ if bytes[i] != b'<' {
+ i += 1;
+ continue;
+ }
+
+ let tail = &s[i..];
+ let skipped_end = if tail.starts_with("").map(|end| i + end + 3)
+ } else if tail.starts_with("").map(|end| i + end + 3)
+ } else if tail.starts_with("") {
+ tail.find("?>").map(|end| i + end + 2)
+ } else if tail.starts_with("") || tail.starts_with(" Option {
+ let mut quote = None;
+ for (i, &byte) in bytes.iter().enumerate().skip(start + 1) {
+ if let Some(delimiter) = quote {
+ if byte == delimiter {
+ quote = None;
+ }
+ } else if matches!(byte, b'"' | b'\'') {
+ quote = Some(byte);
+ } else if byte == b'>' {
+ return Some(i + 1);
+ }
+ }
+ None
+ }
+
+ fn normalize_start_tag(tag: &str) -> String {
+ let bytes = tag.as_bytes();
+ let mut name_end = 1;
+ while name_end < bytes.len()
+ && (bytes[name_end].is_ascii_alphanumeric()
+ || matches!(bytes[name_end], b'_' | b':' | b'-' | b'.'))
+ {
+ name_end += 1;
+ }
+
+ // Loxone expands attribute-less empty elements but keeps attributed empties closed.
+ if name_end + 2 == bytes.len() && bytes[name_end] == b'/' && bytes[name_end + 1] == b'>' {
+ let name = &tag[1..name_end];
+ return format!("<{name}>{name}>");
+ }
+
+ // xml-rs escapes newlines in attributes; Loxone writes them literally.
+ let mut out = String::with_capacity(tag.len());
+ let mut quote = None;
+ let mut last = 0;
+ let mut i = name_end;
+ while i < bytes.len() {
+ if let Some(delimiter) = quote {
+ if bytes[i..].starts_with(b"
") {
+ out.push_str(&tag[last..i]);
+ out.push('\n');
+ i += 5;
+ last = i;
+ continue;
+ }
+ if bytes[i] == delimiter {
+ quote = None;
+ }
+ } else if matches!(bytes[i], b'"' | b'\'') {
+ quote = Some(bytes[i]);
+ }
+ i += 1;
+ }
+ out.push_str(&tag[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");
+ }
+
+ #[test]
+ fn test_write_preserves_comment_and_cdata_payloads() {
+ let xml = "\n\
+\n\
+\t\n\
+\t and
literally]]>\n\
+\n";
+ let editor = ConfigEditor::load(xml.as_bytes()).unwrap();
+ let out = String::from_utf8(editor.to_bytes().unwrap()).unwrap();
+
+ assert!(out.contains(""));
+ assert!(out.contains(" and
literally]]>"));
+ }
}