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("").map(|end| i + end + 2) + } else if 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}>"); + } + + // 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]]>")); + } }