agentmux_srv\backend/
text_encoding.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Text encoding detection + transcoding for the editor.
5//!
6//! The editor used to read files with `std::fs::read_to_string`, which rejects
7//! anything that isn't valid UTF-8 — so a Windows-1252 `.ini`, a UTF-16 file,
8//! Shift_JIS, etc. simply failed to open. This module reads bytes, detects the
9//! encoding (BOM → valid-UTF-8 → `chardetng` heuristic), and decodes to a Rust
10//! `String` for the editor; and re-encodes on save so files round-trip in their
11//! original encoding + BOM + line endings.
12//!
13//! Spec: docs/specs/SPEC_EDITOR_FILE_ENCODINGS_2026_06_17.md
14
15use encoding_rs::{Encoding, UTF_16BE, UTF_16LE, UTF_8};
16
17/// Result of decoding a file's bytes for the editor.
18pub struct DecodedFile {
19    /// UTF-8 text for the editor/wire (BOM stripped).
20    pub content: String,
21    /// Encoding label (WHATWG / `encoding_rs` name, e.g. "windows-1252").
22    pub encoding: String,
23    /// Byte-order-mark that was present: "none" | "utf-8" | "utf-16le" | "utf-16be".
24    pub bom: &'static str,
25    /// Detected line ending: "crlf" if any CRLF present, else "lf".
26    pub line_ending: &'static str,
27    /// True when decoding inserted U+FFFD replacements (likely wrong encoding).
28    pub had_decode_errors: bool,
29}
30
31/// Detect the encoding of `bytes` and decode to UTF-8.
32///
33/// Order: BOM (authoritative) → valid UTF-8 (keeps the common case exact) →
34/// `chardetng` heuristic. encoding_rs always succeeds (replacement on malformed
35/// input), with `had_decode_errors` flagging low-confidence results.
36pub fn decode_file(bytes: &[u8]) -> DecodedFile {
37    // UTF-32 BOMs must be checked BEFORE UTF-16: a UTF-32LE BOM (FF FE 00 00)
38    // starts with the UTF-16LE BOM (FF FE) and would otherwise be misdecoded.
39    // encoding_rs has no UTF-32 codec, so we handle it by hand.
40    if bytes.starts_with(b"\xFF\xFE\x00\x00") {
41        return decode_utf32(&bytes[4..], false, "utf-32le", "UTF-32LE");
42    }
43    if bytes.starts_with(b"\x00\x00\xFE\xFF") {
44        return decode_utf32(&bytes[4..], true, "utf-32be", "UTF-32BE");
45    }
46
47    let (enc, bom, body): (&'static Encoding, &'static str, &[u8]) =
48        if bytes.starts_with(b"\xEF\xBB\xBF") {
49            (UTF_8, "utf-8", &bytes[3..])
50        } else if bytes.starts_with(b"\xFF\xFE") {
51            (UTF_16LE, "utf-16le", &bytes[2..])
52        } else if bytes.starts_with(b"\xFE\xFF") {
53            (UTF_16BE, "utf-16be", &bytes[2..])
54        } else if std::str::from_utf8(bytes).is_ok() {
55            (UTF_8, "none", bytes)
56        } else {
57            let mut det = chardetng::EncodingDetector::new();
58            det.feed(bytes, true);
59            // guess(tld, allow_eu): allow_eu=false — don't bias toward
60            // Central-European windows-1250 (no locale signal here; biasing
61            // would misread ambiguous Western text). tld=None: no domain hint.
62            (det.guess(None, false), "none", bytes)
63        };
64
65    let (cow, had_decode_errors) = enc.decode_without_bom_handling(body);
66    let content = cow.into_owned();
67    let line_ending = detect_line_ending(&content);
68
69    DecodedFile {
70        content,
71        encoding: enc.name().to_string(),
72        bom,
73        line_ending,
74        had_decode_errors,
75    }
76}
77
78/// The file's line ending, from the **first** line break (what VS Code does).
79/// Using "any CRLF present" would flag a mostly-LF file as CRLF and convert all
80/// its LF lines on save.
81fn detect_line_ending(s: &str) -> &'static str {
82    match s.find('\n') {
83        Some(i) if i > 0 && s.as_bytes()[i - 1] == b'\r' => "crlf",
84        _ => "lf",
85    }
86}
87
88/// Decode UTF-32 (LE/BE) bytes by hand — encoding_rs has no UTF-32 codec.
89fn decode_utf32(body: &[u8], big_endian: bool, bom: &'static str, label: &'static str) -> DecodedFile {
90    let mut content = String::new();
91    let mut had_decode_errors = false;
92    for chunk in body.chunks(4) {
93        if chunk.len() < 4 {
94            had_decode_errors = true; // trailing partial code unit
95            break;
96        }
97        let cp = if big_endian {
98            u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
99        } else {
100            u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
101        };
102        match char::from_u32(cp) {
103            Some(c) => content.push(c),
104            None => {
105                content.push('\u{FFFD}');
106                had_decode_errors = true;
107            }
108        }
109    }
110    let line_ending = detect_line_ending(&content);
111    DecodedFile {
112        content,
113        encoding: label.to_string(),
114        bom,
115        line_ending,
116        had_decode_errors,
117    }
118}
119
120fn utf16_bytes(s: &str, big_endian: bool) -> Vec<u8> {
121    let mut v = Vec::with_capacity(s.len() * 2);
122    for u in s.encode_utf16() {
123        v.extend_from_slice(&if big_endian { u.to_be_bytes() } else { u.to_le_bytes() });
124    }
125    v
126}
127
128fn utf32_bytes(s: &str, big_endian: bool) -> Vec<u8> {
129    let mut v = Vec::with_capacity(s.chars().count() * 4);
130    for c in s.chars() {
131        let u = c as u32;
132        v.extend_from_slice(&if big_endian { u.to_be_bytes() } else { u.to_le_bytes() });
133    }
134    v
135}
136
137/// Encode editor text (`\n`-delimited UTF-8) back to bytes in `encoding_label`,
138/// applying `bom` and `line_ending`. Returns the bytes to write plus whether any
139/// character was unmappable in the target encoding (the caller may warn).
140///
141/// `encoding_label` falls back to UTF-8 when unknown/empty, so existing callers
142/// that don't pass an encoding keep writing UTF-8.
143pub fn encode_file(
144    content: &str,
145    encoding_label: &str,
146    bom: &str,
147    line_ending: &str,
148) -> (Vec<u8>, bool) {
149    // Normalize to the file's line-ending convention. The editor buffer uses
150    // `\n`; collapse any stray CRLF first so we don't double-convert.
151    let normalized = if line_ending == "crlf" {
152        content.replace("\r\n", "\n").replace('\n', "\r\n")
153    } else {
154        content.to_string()
155    };
156
157    // UTF-16/UTF-32 are encoded by hand: encoding_rs has no UTF-16/32 *encoder*
158    // (per the Encoding Standard its `encode()` emits UTF-8), so a UTF-16/32
159    // file would otherwise save back as UTF-8. Match on the label first; the
160    // encoding_rs fallback handles legacy single-byte/CJK encodings, where
161    // `had_unmappable` reports characters outside the target charset.
162    let (mut out, had_unmappable): (Vec<u8>, bool) = match encoding_label.to_ascii_lowercase().as_str() {
163        "utf-16le" => (utf16_bytes(&normalized, false), false),
164        "utf-16be" => (utf16_bytes(&normalized, true), false),
165        "utf-32le" => (utf32_bytes(&normalized, false), false),
166        "utf-32be" => (utf32_bytes(&normalized, true), false),
167        other => {
168            let enc = if other.is_empty() {
169                UTF_8
170            } else {
171                Encoding::for_label(other.as_bytes()).unwrap_or(UTF_8)
172            };
173            let (bytes, _enc, had_unmappable) = enc.encode(&normalized);
174            (bytes.into_owned(), had_unmappable)
175        }
176    };
177
178    // Re-emit a BOM if the file had/uses one.
179    let bom_bytes: &[u8] = match bom {
180        "utf-8" => b"\xEF\xBB\xBF",
181        "utf-16le" => b"\xFF\xFE",
182        "utf-16be" => b"\xFE\xFF",
183        "utf-32le" => b"\xFF\xFE\x00\x00",
184        "utf-32be" => b"\x00\x00\xFE\xFF",
185        _ => b"",
186    };
187    if !bom_bytes.is_empty() {
188        let mut prefixed = Vec::with_capacity(bom_bytes.len() + out.len());
189        prefixed.extend_from_slice(bom_bytes);
190        prefixed.append(&mut out);
191        out = prefixed;
192    }
193
194    (out, had_unmappable)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn plain_ascii_is_utf8() {
203        let d = decode_file(b"hello = world\n");
204        assert_eq!(d.content, "hello = world\n");
205        assert_eq!(d.encoding, "UTF-8");
206        assert_eq!(d.bom, "none");
207        assert_eq!(d.line_ending, "lf");
208        assert!(!d.had_decode_errors);
209    }
210
211    #[test]
212    fn windows_1252_ini_decodes() {
213        // 0x92 = right single quote, 0xE9 = é in Windows-1252 — invalid UTF-8.
214        let bytes = b"name = O\x92Brien caf\xE9\r\n";
215        assert!(std::str::from_utf8(bytes).is_err(), "precondition: not UTF-8");
216        let d = decode_file(bytes);
217        assert!(d.content.contains("O\u{2019}Brien"), "got {:?}", d.content);
218        assert!(d.content.contains("caf\u{e9}"));
219        assert_eq!(d.line_ending, "crlf");
220        assert_ne!(d.encoding, "UTF-8"); // heuristic picked a legacy encoding
221    }
222
223    #[test]
224    fn utf8_bom_is_stripped_and_remembered() {
225        let d = decode_file(b"\xEF\xBB\xBFhi");
226        assert_eq!(d.content, "hi");
227        assert_eq!(d.bom, "utf-8");
228        assert_eq!(d.encoding, "UTF-8");
229    }
230
231    #[test]
232    fn utf16le_bom_decodes() {
233        // "Hi" in UTF-16LE with BOM.
234        let d = decode_file(b"\xFF\xFEH\x00i\x00");
235        assert_eq!(d.content, "Hi");
236        assert_eq!(d.bom, "utf-16le");
237    }
238
239    #[test]
240    fn windows_1252_round_trips() {
241        let original = "café — O\u{2019}Brien";
242        let (bytes, unmappable) = encode_file(original, "windows-1252", "none", "lf");
243        assert!(!unmappable);
244        assert!(std::str::from_utf8(&bytes).is_err(), "should be legacy bytes");
245        let back = decode_file(&bytes);
246        // chardetng may not name it "windows-1252" exactly, but the text must round-trip.
247        assert_eq!(back.content, original);
248    }
249
250    #[test]
251    fn utf16le_round_trips_with_bom() {
252        let (bytes, _) = encode_file("Hi", "utf-16le", "utf-16le", "lf");
253        assert_eq!(&bytes[..2], b"\xFF\xFE");
254        let back = decode_file(&bytes);
255        assert_eq!(back.content, "Hi");
256        assert_eq!(back.bom, "utf-16le");
257    }
258
259    #[test]
260    fn crlf_line_ending_applied_on_write() {
261        let (bytes, _) = encode_file("a\nb", "UTF-8", "none", "crlf");
262        assert_eq!(bytes, b"a\r\nb");
263    }
264
265    #[test]
266    fn unknown_label_falls_back_to_utf8() {
267        let (bytes, _) = encode_file("hi", "definitely-not-an-encoding", "none", "lf");
268        assert_eq!(bytes, b"hi");
269    }
270
271    #[test]
272    fn line_ending_uses_first_break_not_any_crlf() {
273        // Mostly-LF with a later CRLF → first break is LF → "lf"
274        // (so we don't normalize all the LF lines to CRLF on save).
275        assert_eq!(decode_file(b"a\nb\r\nc\n").line_ending, "lf");
276        assert_eq!(decode_file(b"a\r\nb\nc").line_ending, "crlf");
277        assert_eq!(decode_file(b"no breaks").line_ending, "lf");
278    }
279
280    #[test]
281    fn utf32le_bom_decodes_not_as_utf16() {
282        // "Hi" in UTF-32LE with BOM (FF FE 00 00) — must NOT be read as UTF-16LE.
283        let bytes = b"\xFF\xFE\x00\x00H\x00\x00\x00i\x00\x00\x00";
284        let d = decode_file(bytes);
285        assert_eq!(d.content, "Hi");
286        assert_eq!(d.bom, "utf-32le");
287        assert_eq!(d.encoding, "UTF-32LE");
288    }
289
290    #[test]
291    fn utf32_round_trips() {
292        let (bytes, _) = encode_file("Hi", "utf-32le", "utf-32le", "lf");
293        assert_eq!(&bytes[..4], b"\xFF\xFE\x00\x00");
294        let back = decode_file(&bytes);
295        assert_eq!(back.content, "Hi");
296        assert_eq!(back.bom, "utf-32le");
297    }
298
299    #[test]
300    fn unmappable_char_is_flagged_not_lossy() {
301        // An emoji can't be represented in windows-1252 → had_unmappable=true
302        // (the caller refuses the write instead of emitting NCR garbage).
303        let (_bytes, had_unmappable) = encode_file("hi 😀", "windows-1252", "none", "lf");
304        assert!(had_unmappable, "emoji in cp1252 must flag unmappable");
305        // UTF-8 represents everything → never unmappable.
306        let (_b, u8_unmappable) = encode_file("hi 😀", "UTF-8", "none", "lf");
307        assert!(!u8_unmappable);
308    }
309}