foch 0.0.1

EU4 mod analysis and merge toolkit
Documentation
//! Paradox script text decoding helpers.
//!
//! Paradox engines historically write `.txt` (and `.csv` / `.yml`) files
//! in **windows-1252** for the bundled vanilla content, while modern mods
//! often save files as **UTF-8** (with or without BOM). Chinese
//! translation mods commonly emit **GBK** / **GB18030**; we delegate
//! charset detection to Mozilla's `chardetng` so the rendered output
//! matches the original characters in-game instead of producing mojibake.
//!
//! `decode_paradox_bytes` is the single funnel for all
//! Paradox-script-flavoured byte → string conversion for the crate.
//! Use it instead of ad-hoc `String::from_utf8_lossy` /
//! `WINDOWS_1252.decode` calls so the encoding rules stay uniform.

use std::borrow::Cow;

use chardetng::{EncodingDetector, Iso2022JpDetection, Utf8Detection};
use encoding_rs::{Encoding, WINDOWS_1252};

const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];
const UTF16_LE_BOM: [u8; 2] = [0xFF, 0xFE];
const UTF16_BE_BOM: [u8; 2] = [0xFE, 0xFF];

/// Decode `bytes` as a Paradox script-flavoured text blob.
///
/// Algorithm:
/// 1. Strip a leading UTF-8 BOM and decode as strict UTF-8.
/// 2. Strip a leading UTF-16 BOM and decode via `encoding_rs`.
/// 3. Try strict UTF-8 (the modern mod default) — borrowed if successful.
/// 4. Run `chardetng` over the byte stream. If the detector picks a
///    legacy CJK encoding (GB18030 / GBK / Big5) and that decoder
///    succeeds without errors, use the result. The detector itself can
///    return windows-1252 for Latin-1 content; we accept that too.
/// 5. Fall back to **windows-1252**.
pub fn decode_paradox_bytes(bytes: &[u8]) -> Cow<'_, str> {
	if let Some(rest) = bytes.strip_prefix(UTF8_BOM.as_slice()) {
		return Cow::Owned(String::from_utf8_lossy(rest).into_owned());
	}
	if bytes.starts_with(UTF16_LE_BOM.as_slice()) {
		let (decoded, _, _) = encoding_rs::UTF_16LE.decode(&bytes[2..]);
		return Cow::Owned(decoded.into_owned());
	}
	if bytes.starts_with(UTF16_BE_BOM.as_slice()) {
		let (decoded, _, _) = encoding_rs::UTF_16BE.decode(&bytes[2..]);
		return Cow::Owned(decoded.into_owned());
	}
	if let Ok(text) = std::str::from_utf8(bytes) {
		return Cow::Borrowed(text);
	}
	if let Some(decoded) = decode_via_chardet(bytes) {
		return Cow::Owned(decoded);
	}
	let (decoded, _, _) = WINDOWS_1252.decode(bytes);
	Cow::Owned(decoded.into_owned())
}

/// Run `chardetng` and accept the chosen encoding only when it's
/// a legacy CJK / Cyrillic / Greek codepage that wouldn't decode
/// losslessly through the windows-1252 fallback. windows-1252 itself,
/// macintosh, and any picky single-byte encoding fall through to the
/// caller's fallback path so we never lose round-trip stability.
fn decode_via_chardet(bytes: &[u8]) -> Option<String> {
	let mut detector = EncodingDetector::new(Iso2022JpDetection::Deny);
	detector.feed(bytes, true);
	let encoding = detector.guess(None, Utf8Detection::Deny);
	if !is_legacy_double_byte(encoding) {
		return None;
	}
	let (decoded, _, had_errors) = encoding.decode(bytes);
	if had_errors {
		return None;
	}
	Some(decoded.into_owned())
}

fn is_legacy_double_byte(encoding: &'static Encoding) -> bool {
	encoding == encoding_rs::GB18030
		|| encoding == encoding_rs::GBK
		|| encoding == encoding_rs::BIG5
		|| encoding == encoding_rs::EUC_JP
		|| encoding == encoding_rs::SHIFT_JIS
		|| encoding == encoding_rs::EUC_KR
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn pure_ascii_borrows() {
		let bytes = b"name = \"Catherine\"";
		let out = decode_paradox_bytes(bytes);
		assert!(matches!(out, Cow::Borrowed(_)));
		assert_eq!(out, "name = \"Catherine\"");
	}

	#[test]
	fn utf8_with_combining_chars_borrows() {
		// "de Bragança" in UTF-8: c3 a7 == ç
		let bytes = b"dynasty = \"de Bragan\xc3\xa7a\"";
		let out = decode_paradox_bytes(bytes);
		assert!(matches!(out, Cow::Borrowed(_)));
		assert!(out.contains("Bragança"));
	}

	#[test]
	fn windows_1252_high_byte_falls_back_losslessly() {
		// Same string, but in windows-1252: ç == single byte 0xe7.
		// Strict UTF-8 fails, fallback decodes to U+00E7.
		let bytes = b"dynasty = \"de Bragan\xe7a\"";
		let out = decode_paradox_bytes(bytes);
		assert_eq!(out, "dynasty = \"de Bragança\"");
	}

	#[test]
	fn utf8_and_windows_1252_converge_for_same_logical_text() {
		let utf8 = b"\xc3\xa7\xc3\xa9\xc3\xb1";
		let win1252 = b"\xe7\xe9\xf1";
		assert_eq!(decode_paradox_bytes(utf8), decode_paradox_bytes(win1252));
	}

	#[test]
	fn utf8_bom_is_stripped() {
		let mut bytes = vec![0xEF, 0xBB, 0xBF];
		bytes.extend_from_slice(b"name = yes");
		assert_eq!(decode_paradox_bytes(&bytes), "name = yes");
	}

	#[test]
	fn utf16_le_bom_decodes() {
		// "ab" in UTF-16LE with BOM
		let bytes = [0xFF, 0xFE, b'a', 0x00, b'b', 0x00];
		assert_eq!(decode_paradox_bytes(&bytes), "ab");
	}

	#[test]
	fn utf16_be_bom_decodes() {
		let bytes = [0xFE, 0xFF, 0x00, b'a', 0x00, b'b'];
		assert_eq!(decode_paradox_bytes(&bytes), "ab");
	}

	#[test]
	fn gbk_bytes_decode_to_chinese_characters() {
		// GBK encoding for "凯瑟琳". Strict UTF-8 fails; we now detect the
		// double-byte CJK shape and decode via GB18030 instead of letting
		// the windows-1252 fallback emit mojibake.
		let bytes = b"\xbf\xad\xc9\xaa\xc1\xd5";
		let decoded = decode_paradox_bytes(bytes);
		assert_eq!(decoded, "凯瑟琳");
		// Idempotence: re-decoding the same bytes always yields the same
		// string (downstream AST-equality checks rely on this).
		assert_eq!(decode_paradox_bytes(bytes), decoded);
	}

	#[test]
	fn windows_1252_only_path_still_falls_back() {
		// Bytes that are NOT plausible GBK leads (e.g. a single 0xE7 with
		// no trail byte fitting GBK) must still decode losslessly via
		// windows-1252.
		let bytes = b"\xe7";
		assert_eq!(decode_paradox_bytes(bytes), "ç");
	}
}