From d75a914ce997f9b2c8242a619789e8648e924be7 Mon Sep 17 00:00:00 2001 From: Tobias Schlottke Date: Tue, 25 Aug 2026 07:38:18 +0200 Subject: [PATCH] fix(config_edit): byte-clean writes matching Loxone's format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every write re-serialized the whole file, so a raw `diff` on a `.Loxone` reported ~11.5k changed lines for a single added block — real changes drowned in formatting noise, making review impossible. The churn was purely emitter formatting that differs from Loxone's own output. Match it so a round-trip touches only what actually changed: - `pad_self_closing(false)` — Loxone writes ``, xml-rs padded to `` (this alone caused ~99.5% of the diff). - Expand attribute-less empty tags: Loxone writes ``, never `` (attributed empties like `` stay self-closed). Verified: the config has 45 `` and zero attribute-less ``. - Un-escape ` ` → literal newline in attribute values (multi-line PicoC code, notification texts). Loxone keeps literal newlines and never emits ` `, so this only reverses xml-rs's own escaping. - Restore the trailing newline. Result: adding one block now produces a diff of exactly that block (+6/-0) instead of 11.5k lines. All 78 config_edit tests pass; adds a formatting round-trip test. Fixes #7 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01V1TT6BSmf3uXakmtfexfDt --- src/config_edit/write.rs | 95 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) 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"); + } }