diff --git a/CHANGELOG.md b/CHANGELOG.md index 2234129..263a85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- Writing a `.Loxone` file no longer adds a carriage return to every line break inside attribute values. Loxone keeps bare LF in note text and CRLF in AutoPilot rule JSON; the CRLF post-process converted those too, and each save re-emitted the extra CR as ` `, so notes and AutoPilot rules grew one ` ` per line break per save. Saving an unedited real-world config is now byte-identical. + ## [0.14.2] — 2026-05-08 ### Fixed diff --git a/src/config_edit/write.rs b/src/config_edit/write.rs index 32f74b6..00a2a25 100644 --- a/src/config_edit/write.rs +++ b/src/config_edit/write.rs @@ -55,12 +55,58 @@ impl ConfigEditor { // Post-process: restore CRLF line endings if self.had_crlf { let s = String::from_utf8(buf).context("XML is not valid UTF-8")?; - buf = s.replace('\n', "\r\n").into_bytes(); + buf = Self::to_crlf_outside_attrs(&s).into_bytes(); } Ok(buf) } + /// Convert LF to CRLF everywhere except inside start tags. + /// + /// Loxone terminates lines with CRLF but writes newlines inside attribute values + /// (note text, AutoPilot JSON) as bare LF. Converting those too would plant a CR + /// in the value that xml-rs reads back literally and re-emits as ` `, adding + /// one more CR per newline on every save. + fn to_crlf_outside_attrs(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = String::with_capacity(bytes.len() + bytes.len() / 32); + let mut last = 0; + let mut i = 0; + + while i < bytes.len() { + if bytes[i] != b'<' { + i += 1; + continue; + } + + let tail = &s[i..]; + let opaque_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 { + None + }; + if let Some(end) = opaque_end { + i = end; + continue; + } + + let Some(end) = Self::xml_tag_end(bytes, i) else { + break; + }; + out.push_str(&s[last..i].replace('\n', "\r\n")); + out.push_str(&s[i..end]); + i = end; + last = i; + } + + out.push_str(&s[last..].replace('\n', "\r\n")); + out + } + /// Match Loxone's empty-element and attribute-newline formatting. /// /// Only start tags are rewritten so XML-like text in comments, CDATA, and processing @@ -139,16 +185,24 @@ impl ConfigEditor { return format!("<{name}>"); } - // xml-rs escapes newlines in attributes; Loxone writes them literally. + // xml-rs escapes CR and LF in attributes; Loxone writes them literally (bare LF + // in note text, CRLF in AutoPilot JSON), so restore whichever was read. 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" ") { + let literal = if bytes[i..].starts_with(b" ") { + Some('\n') + } else if bytes[i..].starts_with(b" ") { + Some('\r') + } else { + None + }; + if let Some(c) = literal { out.push_str(&tag[last..i]); - out.push('\n'); + out.push(c); i += 5; last = i; continue; @@ -201,6 +255,34 @@ mod tests { assert!(out.ends_with('\n'), "trailing newline"); } + #[test] + fn test_crlf_file_keeps_lf_in_attr_values_across_saves() { + // Loxone: CRLF line endings, bare LF in note text, CRLF in AutoPilot JSON. + let xml = "\u{feff}\r\n\ +\r\n\ +\t\r\n\ +\t\t\r\n\ +\t\r\n\ +\r\n"; + let first = ConfigEditor::load(xml.as_bytes()) + .unwrap() + .to_bytes() + .unwrap(); + let second = ConfigEditor::load(&first).unwrap().to_bytes().unwrap(); + let out = String::from_utf8(second.clone()).unwrap(); + + assert_eq!(String::from_utf8(first.clone()).unwrap(), xml, "byte-clean"); + assert_eq!(first, second, "save is idempotent"); + assert!(out.contains("},\r\n {"), "attr CRLF stays CRLF"); + assert!(!out.contains(" "), "no CR planted in attr values"); + assert!( + out.contains("Text=\"line1\nline2\""), + "attr newline stays LF" + ); + assert!(out.contains("\t\r\n"), "line endings stay CRLF"); + assert!(!out.contains("\r\r")); + } + #[test] fn test_write_preserves_comment_and_cdata_payloads() { let xml = "\n\