From d75a914ce997f9b2c8242a619789e8648e924be7 Mon Sep 17 00:00:00 2001 From: Tobias Schlottke Date: Tue, 25 Aug 2026 07:38:18 +0200 Subject: [PATCH 1/3] 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 07bef245..10eec63b 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"); + } } From dfefff440f4360221167077b978dc29914513737 Mon Sep 17 00:00:00 2001 From: Markus Cozowicz Date: Tue, 8 Sep 2026 18:06:20 +0100 Subject: [PATCH 2/3] fix(config_edit): preserve XML node payloads Scope Loxone formatting rewrites to actual start tags and quoted attribute values so comments and CDATA remain unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/config_edit/write.rs | 148 ++++++++++++++++++++++++++++----------- 1 file changed, 109 insertions(+), 39 deletions(-) diff --git a/src/config_edit/write.rs b/src/config_edit/write.rs index 10eec63b..f96eb15d 100644 --- a/src/config_edit/write.rs +++ b/src/config_edit/write.rs @@ -33,16 +33,10 @@ 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. + // 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::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"); + 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'); @@ -67,48 +61,110 @@ impl ConfigEditor { Ok(buf) } - /// Expand attribute-less self-closing tags (`` → ``). + /// Match Loxone's empty-element and attribute-newline formatting. /// - /// 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 { + /// 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 n = bytes.len(); - let mut out = String::with_capacity(n); + let mut out = String::with_capacity(bytes.len()); 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; + + 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; } - 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; + } 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; } - i = j; // skip past the element name - continue; + if bytes[i] == delimiter { + quote = None; + } + } else if matches!(bytes[i], b'"' | b'\'') { + quote = Some(bytes[i]); } i += 1; } - out.push_str(&s[last..]); + out.push_str(&tag[last..]); out } } @@ -138,4 +194,18 @@ mod tests { 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]]>")); + } } From 374a5a661379a1bd9f457524f4eec9c3efc918d8 Mon Sep 17 00:00:00 2001 From: Markus Cozowicz Date: Tue, 8 Sep 2026 18:08:43 +0100 Subject: [PATCH 3/3] style(config_edit): format writer tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/config_edit/write.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/config_edit/write.rs b/src/config_edit/write.rs index f96eb15d..32f74b63 100644 --- a/src/config_edit/write.rs +++ b/src/config_edit/write.rs @@ -134,10 +134,7 @@ impl ConfigEditor { } // 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'>' - { + if name_end + 2 == bytes.len() && bytes[name_end] == b'/' && bytes[name_end + 1] == b'>' { let name = &tag[1..name_end]; return format!("<{name}>"); } @@ -188,9 +185,18 @@ mod tests { 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(""), + "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"); }