Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 86 additions & 4 deletions src/config_edit/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!--") {
tail.find("-->").map(|end| i + end + 3)
} else if tail.starts_with("<![CDATA[") {
tail.find("]]>").map(|end| i + end + 3)
} else if tail.starts_with("<?") {
tail.find("?>").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
Expand Down Expand Up @@ -139,16 +185,24 @@ impl ConfigEditor {
return format!("<{name}></{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"&#xA;") {
let literal = if bytes[i..].starts_with(b"&#xA;") {
Some('\n')
} else if bytes[i..].starts_with(b"&#xD;") {
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;
Expand Down Expand Up @@ -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}<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n\
<ControlList Version=\"1\">\r\n\
\t<C Type=\"Text\" U=\"a\" Text=\"line1\nline2\" APC=\"{&quot;a&quot;:1},\r\n {&quot;b&quot;:2}\">\r\n\
\t\t<Co K=\"Q\" U=\"b\"/>\r\n\
\t</C>\r\n\
</ControlList>\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("&#xD;"), "no CR planted in attr values");
assert!(
out.contains("Text=\"line1\nline2\""),
"attr newline stays LF"
);
assert!(out.contains("\t</C>\r\n"), "line endings stay CRLF");
assert!(!out.contains("\r\r"));
}

#[test]
fn test_write_preserves_comment_and_cdata_payloads() {
let xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
Expand Down