diff --git a/Cargo.lock b/Cargo.lock index 8e7802b..8ae30e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler" @@ -46,6 +46,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.81" @@ -209,6 +259,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.60", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "const_format" version = "0.2.32" @@ -252,9 +348,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.3.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if 1.0.0", ] @@ -305,7 +401,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 2.0.60", ] @@ -337,6 +433,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "filetime" version = "0.2.21" @@ -420,6 +525,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gta-ide-parser" version = "0.0.4" @@ -445,6 +556,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hmac" version = "0.12.1" @@ -551,6 +668,12 @@ dependencies = [ "libc", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "0.4.7" @@ -846,6 +969,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "password-hash" version = "0.4.2" @@ -890,6 +1019,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.36" @@ -962,9 +1101,14 @@ version = "0.4.2" dependencies = [ "anyhow", "base64", + "byteorder", "cached", + "clap", "const_format", + "crc32fast", "ctor", + "encoding_rs", + "glob", "gta-ide-parser", "hotwatch", "lazy_static", @@ -976,6 +1120,7 @@ dependencies = [ "nom", "nom_locate", "normpath", + "quick-xml", "serde", "serde_json", "simplelog", @@ -998,22 +1143,22 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.126" +version = "1.0.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.126" +version = "1.0.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +checksum = "389894603bd18c46fa56231694f8d827779c0951a667087194cf9de94ed24682" dependencies = [ "proc-macro2", "quote", - "syn 1.0.99", + "syn 2.0.60", ] [[package]] @@ -1088,6 +1233,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.5.0" @@ -1258,6 +1409,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version-compare" version = "0.2.0" @@ -1421,6 +1578,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-sys" version = "0.48.0" @@ -1439,6 +1602,15 @@ dependencies = [ "windows-targets 0.52.4", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + [[package]] name = "windows-targets" version = "0.48.0" @@ -1469,6 +1641,23 @@ dependencies = [ "windows_x86_64_msvc 0.52.4", ] +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.0" @@ -1481,6 +1670,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.48.0" @@ -1493,6 +1688,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.48.0" @@ -1505,6 +1706,18 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.48.0" @@ -1517,6 +1730,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.48.0" @@ -1529,6 +1748,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.0" @@ -1541,6 +1766,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.48.0" @@ -1553,6 +1784,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "ws2_32-sys" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7943f8e..5de7b78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,4 +34,10 @@ lines_lossy = "0.1.0" zip = { git = "https://github.com/x87/zip.git" } const_format = "0.2.32" cached = "0.50.0" -gta-ide-parser = "0.0.4" \ No newline at end of file +gta-ide-parser = "0.0.4" +byteorder = "1.5" +clap = { version = "4.5", features = ["derive"] } +crc32fast = "1.4" +encoding_rs = "0.8" +quick-xml = { version = "0.36", features = ["serialize"] } +glob = "0.3" diff --git a/src/gxt/ffi.rs b/src/gxt/ffi.rs new file mode 100644 index 0000000..ca60364 --- /dev/null +++ b/src/gxt/ffi.rs @@ -0,0 +1,31 @@ +use std::ffi::CString; +use crate::common_ffi::*; +use super::parser::GxtParser; + +#[no_mangle] +pub extern "C" fn gxt_new() -> *mut GxtParser { + ptr_new(GxtParser::new()) +} + +#[no_mangle] +pub unsafe extern "C" fn gxt_free(p: *mut GxtParser) { + ptr_free(p) +} + +#[no_mangle] +pub unsafe extern "C" fn gxt_load_file(p: *mut GxtParser, path: PChar) -> bool { + boolclosure!({ + let path = pchar_to_str(path)?; + (*p).load_file(path).ok() + }) +} + +#[no_mangle] +pub unsafe extern "C" fn gxt_get(p: *mut GxtParser, key: PChar, out: *mut PChar) -> bool { + boolclosure!({ + let key = pchar_to_str(key)?; + let value = (*p).get(key)?; + *out = CString::new(value).unwrap().into_raw(); + Some(()) + }) +} diff --git a/src/gxt/mod.rs b/src/gxt/mod.rs new file mode 100644 index 0000000..2a99a47 --- /dev/null +++ b/src/gxt/mod.rs @@ -0,0 +1,2 @@ +pub mod parser; +pub mod ffi; \ No newline at end of file diff --git a/src/gxt/parser.rs b/src/gxt/parser.rs new file mode 100644 index 0000000..dd46450 --- /dev/null +++ b/src/gxt/parser.rs @@ -0,0 +1,580 @@ +use anyhow::{bail, Context, Result}; +use byteorder::{LittleEndian, ReadBytesExt}; +use crc32fast::Hasher; +use std::collections::HashMap; +use std::fs::File; +use std::io::{Cursor, Read, Seek, SeekFrom}; +use std::path::Path; + +/// Read a null-terminated UTF-16 LE string from a reader +fn read_wide_string(reader: &mut R) -> Result { + let mut buffer = Vec::new(); + let mut char_buf = [0u8; 2]; + + // Read until we find a null terminator (0x0000) + loop { + reader.read_exact(&mut char_buf)?; + if char_buf[0] == 0 && char_buf[1] == 0 { + break; + } + buffer.push(char_buf[0]); + buffer.push(char_buf[1]); + } + + // Convert UTF-16 LE to String + let utf16_values: Vec = buffer + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + + String::from_utf16(&utf16_values).map_err(|e| anyhow::anyhow!("Invalid UTF-16: {}", e)) +} + +/// Represents different key types used in GXT formats +#[derive(Debug, Clone)] +enum GxtKey { + /// Text key (8 bytes, used in VC format) + Text([u8; 8]), + /// Hash key (32-bit JAMCRC32, used in SA format) + Hash(u32), +} + +impl GxtKey { + /// Get a string representation of the key + fn to_string(&self) -> String { + match self { + GxtKey::Text(bytes) => { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(8); + String::from_utf8_lossy(&bytes[..end]).to_string() + } + GxtKey::Hash(hash) => format!("{:08X}", hash), + } + } +} + +/// Represents a key entry in GXT formats +#[derive(Debug, Clone)] +struct GxtKeyEntry { + /// Offset to the text data + offset: u32, + /// The key (either text or hash) + key: GxtKey, +} + +/// Text encoding for GXT strings +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TextEncoding { + /// UTF-8 encoding + Utf8, + /// UTF-16 LE encoding + Utf16, +} + +// GXT format properties are now part of the parser and auto-detected during load + +/// Unified GXT parser that handles all format variations +/// +/// # Examples +/// +/// ```no_run +/// use gxt_parser::GxtParser; +/// +/// // Create a new parser (format and encoding will be auto-detected on load) +/// let mut parser = GxtParser::new(); +/// ``` +pub struct GxtParser { + /// Map of keys to their text values (for text-based keys) + entries: HashMap, + /// Map of hash values to text (for hash-based keys) + hash_entries: HashMap, + /// Whether keys are hashed (true) or text-based (false) + is_hashed: bool, + /// Text encoding for string data + encoding: TextEncoding, + /// Whether the format supports multiple tables (true) or single table (false) + is_multi_table: bool, +} + +impl GxtParser { + /// Create a new GXT parser (properties will be auto-detected on load) + pub fn new() -> Self { + Self { + entries: HashMap::new(), + hash_entries: HashMap::new(), + encoding: TextEncoding::Utf16, + is_hashed: false, + is_multi_table: false, + } + } + + /// Calculate JAMCRC32 hash for a key string (for SA format) + pub fn calculate_hash(key: &str) -> u32 { + let mut hasher = Hasher::new(); + hasher.update(key.to_uppercase().as_bytes()); + !hasher.finalize() // JAMCRC32 = ~CRC32 + } + + /// Load a GXT file from the given path + pub fn load_file>(&mut self, path: P) -> Result<()> { + let path = path.as_ref(); + let mut file = File::open(path) + .with_context(|| format!("Failed to open GXT file: {}", path.display()))?; + self.read(&mut file) + } + + fn read(&mut self, reader: &mut R) -> Result<()> { + let mut section_name = [0u8; 4]; + reader.read_exact(&mut section_name)?; + match §ion_name { + b"TKEY" => { + self.is_multi_table = false; + self.is_hashed = false; + self.encoding = TextEncoding::Utf16; + reader.rewind()?; + } + b"TABL" => { + self.is_multi_table = true; + self.is_hashed = false; + self.encoding = TextEncoding::Utf16; + reader.rewind()?; + } + _ => { + self.is_hashed = true; + self.is_multi_table = true; + reader.seek(SeekFrom::Start(2))?; + + // read bits per character + let encoding = reader.read_u16::()?; + match encoding { + 16 => self.encoding = TextEncoding::Utf16, + 8 => self.encoding = TextEncoding::Utf8, + _ => bail!("Invalid encoding: {}", encoding), + } + } + } + + fn expect_tag(reader: &mut R, tag: &[u8]) -> Result<()> { + let mut section_name = [0u8; 4]; + reader + .read_exact(&mut section_name) + .context("Failed to read section header")?; + if §ion_name != tag { + bail!( + "Expected {}, found: {:?}", + String::from_utf8_lossy(tag), + String::from_utf8_lossy(§ion_name) + ); + } + Ok(()) + } + + // handle multi-table formats (Vice City and San Andreas) + let table_entries = if self.is_multi_table { + // read TABL section + let tabl_size = { + expect_tag(reader, b"TABL")?; + + // read section size + let section_size = reader + .read_i32::() + .context("Failed to read TABL section size")?; + + if section_size < 0 { + bail!("Invalid TABL section size: {}", section_size); + } + section_size as u32 + }; + + if tabl_size % 12 != 0 { + bail!("Invalid TABL section size: {}", tabl_size); + } + + let num_tables = tabl_size / 12; + + // process each table entry immediately + let mut table_offsets: Vec = Vec::with_capacity(num_tables as usize); + for i in 0..num_tables { + // skip table name + reader.seek(SeekFrom::Current(8))?; + let offset = reader + .read_u32::() + .with_context(|| format!("Failed to read offset for table {}", i))?; + + table_offsets.push(offset); + } + table_offsets + } else { + // single table: offset 0 (no extra header) + vec![0u32] + }; + + // read each table + for (idx, off) in table_entries.iter().enumerate() { + if self.is_multi_table { + let seek_pos = if idx == 0 { + *off as u64 + } else { + (*off + 8) as u64 + }; + reader + .seek(SeekFrom::Start(seek_pos)) + .with_context(|| format!("Failed to seek to table at offset {}", off))?; + } + + // read TKEY section + expect_tag(reader, b"TKEY")?; + + // read TKEY section size + let tkey_size = reader + .read_u32::() + .context("Failed to read TKEY section size")?; + + // validate TKEY size + let key_record_size = if self.is_hashed { 8 } else { 12 }; + if tkey_size % key_record_size != 0 { + bail!("Invalid TKEY section size: {}", tkey_size); + } + + let num_keys = tkey_size / key_record_size; + + // read all key records + let mut keys = Vec::with_capacity(num_keys as usize); + for i in 0..num_keys { + let offset = reader + .read_u32::() + .with_context(|| format!("Failed to read offset for key {}", i))?; + let key_entry = if self.is_hashed { + let hash = reader + .read_u32::() + .with_context(|| format!("Failed to read hash for key {}", i))?; + GxtKeyEntry { + offset, + key: GxtKey::Hash(hash), + } + } else { + let mut name = [0u8; 8]; + reader + .read_exact(&mut name) + .with_context(|| format!("Failed to read name for key {}", i))?; + GxtKeyEntry { + offset, + key: GxtKey::Text(name), + } + }; + keys.push(key_entry); + } + + // read TDAT section + expect_tag(reader, b"TDAT")?; + + // read TDAT section size + let tdat_size = reader + .read_u32::() + .context("Failed to read TDAT section size")?; + + // read string data + let mut table_strings = vec![0u8; tdat_size as usize]; + reader + .read_exact(&mut table_strings) + .context("Failed to read string data")?; + + // process each key and extract its string + for key_entry in &keys { + let offset = key_entry.offset as usize; + if offset >= table_strings.len() { + continue; + } + + let text = match self.encoding { + TextEncoding::Utf16 => { + let mut cursor = Cursor::new(&table_strings[offset..]); + read_wide_string(&mut cursor).ok() + } + TextEncoding::Utf8 => { + let end = table_strings[offset..] + .iter() + .position(|&b| b == 0) + .map(|pos| offset + pos) + .unwrap_or(table_strings.len()); + + String::from_utf8(table_strings[offset..end].to_vec()).ok() + } + }; + + if let Some(text) = text { + if self.is_hashed { + if let GxtKey::Hash(hash) = key_entry.key { + self.hash_entries.insert(hash, text); + } + } else { + // store text keys in uppercase for case-insensitive lookup + self.entries + .insert(key_entry.key.to_string().to_uppercase(), text); + } + } + } + } + Ok(()) + } + + /// Get a text value by its key + pub fn get(&self, key: &str) -> Option { + if self.is_hashed { + // try to parse as a hex hash value (e.g., "15d4d373") + if let Ok(hash) = u32::from_str_radix(key, 16) { + if let Some(value) = self.hash_entries.get(&hash) { + return Some(value.clone()); + } + } + // try as a regular key name (will be hashed) + let hash = Self::calculate_hash(key); + if let Some(value) = self.hash_entries.get(&hash) { + return Some(value.clone()); + } + None + } else { + // text-based lookup (case-insensitive) + self.entries.get(&key.to_uppercase()).cloned() + } + } + + /// Get all keys + pub fn keys(&self) -> Vec { + if self.is_hashed { + self.hash_entries + .keys() + .map(|&hash| format!("{:08X}", hash)) + .collect() + } else { + self.entries.keys().cloned().collect() + } + } + + /// Get the number of entries + pub fn len(&self) -> usize { + if self.is_hashed { + self.hash_entries.len() + } else { + self.entries.len() + } + } + + /// Check if empty + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_gxt_key_to_string() { + let key = GxtKey::Text([b'T', b'E', b'S', b'T', 0, 0, 0, 0]); + assert_eq!(key.to_string(), "TEST"); + + let key_full = GxtKey::Text([b'F', b'U', b'L', b'L', b'N', b'A', b'M', b'E']); + assert_eq!(key_full.to_string(), "FULLNAME"); + + let hash_key = GxtKey::Hash(0x12345678); + assert_eq!(hash_key.to_string(), "12345678"); + } + + #[test] + fn test_parse_empty_gxt_iii() { + let mut data = Vec::new(); + // TKEY header + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&0i32.to_le_bytes()); // section size = 0 + // TDAT header + data.extend_from_slice(b"TDAT"); + data.extend_from_slice(&0i32.to_le_bytes()); // section size = 0 + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(); + + assert!(parser.read(&mut cursor).is_ok()); + assert_eq!(parser.len(), 0); + assert!(parser.is_empty()); + } + + #[test] + fn test_gxt_iii_with_data() { + let mut data = Vec::new(); + + // TKEY header + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&12i32.to_le_bytes()); // section size = 12 (1 key) + + // Key record + data.extend_from_slice(&0i32.to_le_bytes()); // offset = 0 + data.extend_from_slice(b"TEST\0\0\0\0"); // key name + + // TDAT header + data.extend_from_slice(b"TDAT"); + data.extend_from_slice(&12i32.to_le_bytes()); // section size (12 bytes for "Hello\0" in UTF-16) + + // Text data (UTF-16 LE "Hello") + data.extend_from_slice(&[b'H', 0, b'e', 0, b'l', 0, b'l', 0, b'o', 0, 0, 0]); + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(); + + assert!(parser.read(&mut cursor).is_ok()); + assert_eq!(parser.len(), 1); + + // Test case-insensitive lookup + assert_eq!(parser.get("TEST"), Some("Hello".to_string())); + assert_eq!(parser.get("test"), Some("Hello".to_string())); + assert_eq!(parser.get("Test"), Some("Hello".to_string())); + assert_eq!(parser.get("TeSt"), Some("Hello".to_string())); + } + + #[test] + fn test_invalid_header_iii() { + let data = b"INVALID_HEADER"; + let mut cursor = Cursor::new(data.to_vec()); + let mut parser = GxtParser::new(); + + assert!(parser.read(&mut cursor).is_err()); + } + + #[test] + fn test_parse_empty_gxt_vc() { + let mut data = Vec::new(); + // TABL header + data.extend_from_slice(b"TABL"); + data.extend_from_slice(&0i32.to_le_bytes()); // section size = 0 + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(); + + assert!(parser.read(&mut cursor).is_ok()); + assert_eq!(parser.len(), 0); + assert!(parser.is_empty()); + } + + #[test] + fn test_invalid_header_vc() { + let data = b"INVALID_HEADER"; + let mut cursor = Cursor::new(data.to_vec()); + let mut parser = GxtParser::new(); + + assert!(parser.read(&mut cursor).is_err()); + } + + #[test] + fn test_invalid_section_size() { + let mut data = Vec::new(); + // TKEY header with invalid size + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&13i32.to_le_bytes()); // Invalid: not divisible by 12 + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(); + + let result = parser.read(&mut cursor); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid TKEY section size")); + } + + #[test] + fn test_case_insensitive_vc() { + let mut data = Vec::new(); + + // TABL header + data.extend_from_slice(b"TABL"); + data.extend_from_slice(&12i32.to_le_bytes()); // section size = 12 (1 table) + + // Table entry + data.extend_from_slice(b"MAIN\0\0\0\0"); // table name + data.extend_from_slice(&20u32.to_le_bytes()); // offset to TKEY + + // TKEY header + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&12u32.to_le_bytes()); // section size = 12 (1 key) + + // Key record + data.extend_from_slice(&0u32.to_le_bytes()); // offset = 0 + data.extend_from_slice(b"MYKEY\0\0\0"); // key name + + // TDAT header + data.extend_from_slice(b"TDAT"); + data.extend_from_slice(&12u32.to_le_bytes()); // section size (12 bytes for "World\0" in UTF-16) + + // Text data (UTF-16 LE "World") + data.extend_from_slice(&[b'W', 0, b'o', 0, b'r', 0, b'l', 0, b'd', 0, 0, 0]); + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(); + + assert!(parser.read(&mut cursor).is_ok()); + assert_eq!(parser.len(), 1); + + // Test case-insensitive lookup for VC format + assert_eq!(parser.get("MYKEY"), Some("World".to_string())); + assert_eq!(parser.get("mykey"), Some("World".to_string())); + assert_eq!(parser.get("MyKey"), Some("World".to_string())); + assert_eq!(parser.get("mYkEy"), Some("World".to_string())); + } + + #[test] + fn test_case_insensitive_sa() { + // Test that SA format hashing is case-insensitive + let hash1 = GxtParser::calculate_hash("TEST"); + let hash2 = GxtParser::calculate_hash("test"); + let hash3 = GxtParser::calculate_hash("Test"); + let hash4 = GxtParser::calculate_hash("TeSt"); + + // All hashes should be the same due to uppercasing before hashing + assert_eq!(hash1, hash2); + assert_eq!(hash1, hash3); + assert_eq!(hash1, hash4); + } + + #[test] + fn test_new_parser_is_empty() { + // New parser should start empty before loading + let parser = GxtParser::new(); + assert!(parser.is_empty()); + let parser2 = GxtParser::new(); + assert!(parser2.is_empty()); + } + + #[test] + fn test_gxt_trait_case_insensitive() { + // Test that the GxtText trait methods are case-insensitive + let mut data = Vec::new(); + + // TKEY header + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&12i32.to_le_bytes()); + + // Key record + data.extend_from_slice(&0i32.to_le_bytes()); + data.extend_from_slice(b"HELLO\0\0\0"); + + // TDAT header + data.extend_from_slice(b"TDAT"); + data.extend_from_slice(&12i32.to_le_bytes()); // section size (12 bytes for "Greet\0" in UTF-16) + + // Text data (UTF-16 LE "Greet") + data.extend_from_slice(&[b'G', 0, b'r', 0, b'e', 0, b'e', 0, b't', 0, 0, 0]); + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(); + + assert!(parser.read(&mut cursor).is_ok()); + + // Test through the trait interface + let gxt = &parser; + assert_eq!(gxt.get("HELLO"), Some("Greet".to_string())); + assert_eq!(gxt.get("hello"), Some("Greet".to_string())); + assert_eq!(gxt.get("Hello"), Some("Greet".to_string())); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0a01f90..caaf887 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,8 @@ pub mod source_map; pub mod preprocessor; pub mod sanny_update; pub mod ide; +pub mod gxt; +pub mod modes; #[ctor] fn main() { diff --git a/src/modes/ffi.rs b/src/modes/ffi.rs new file mode 100644 index 0000000..572005e --- /dev/null +++ b/src/modes/ffi.rs @@ -0,0 +1,41 @@ +use std::path::Path; + +use libc::c_char; + +use super::modes::ModeManager; + +use crate::common_ffi::*; + +#[no_mangle] +pub extern "C" fn mode_manager_new() -> *mut ModeManager { + ptr_new(ModeManager::new()) +} + +#[no_mangle] +pub unsafe extern "C" fn mode_manager_free(manager: *mut ModeManager) { + ptr_free(manager) +} + +#[no_mangle] +pub unsafe extern "C" fn mode_manager_load_from_dir(manager: *mut ModeManager, dir: PChar) -> bool { + boolclosure!({ + let path = Path::new(pchar_to_str(dir)?); + if !path.is_dir() { + return None; + } + manager.as_mut()?.load_from_directory(path); + Some(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn mode_manager_set_current_mode_by_id( + manager: *mut ModeManager, + mode_id: PChar, +) -> bool { + boolclosure!({ + let id = pchar_to_str(mode_id)?; + manager.as_mut()?.set_current_mode_by_id(&id); + Some(()) + }) +} diff --git a/src/modes/mod.rs b/src/modes/mod.rs new file mode 100644 index 0000000..a080c27 --- /dev/null +++ b/src/modes/mod.rs @@ -0,0 +1,4 @@ +pub mod mode; +pub mod modes; +pub mod string_variables; +pub mod ffi; \ No newline at end of file diff --git a/src/modes/mode.rs b/src/modes/mode.rs new file mode 100644 index 0000000..7370e6e --- /dev/null +++ b/src/modes/mode.rs @@ -0,0 +1,515 @@ +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +/// Game enum representing supported game types +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "snake_case")] +pub enum Game { + #[serde(rename = "gta3")] + Gta3, + #[serde(rename = "vc")] + Vc, + #[serde(rename = "sa")] + Sa, + #[serde(rename = "lcs")] + Lcs, + #[serde(rename = "vcs")] + Vcs, + #[serde(rename = "sa_mobile")] + SaMobile, + #[serde(rename = "vc_mobile")] + VcMobile, +} + +impl std::fmt::Display for Game { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Game::Gta3 => write!(f, "gta3"), + Game::Vc => write!(f, "vc"), + Game::Sa => write!(f, "sa"), + Game::Lcs => write!(f, "lcs"), + Game::Vcs => write!(f, "vcs"), + Game::SaMobile => write!(f, "sa_mobile"), + Game::VcMobile => write!(f, "vc_mobile"), + } + } +} + +/// Text format enum representing supported text format types +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TextFormat { + #[serde(rename = "gta3")] + Gta3, + #[serde(rename = "vc")] + Vc, + #[serde(rename = "sa")] + Sa, + #[serde(rename = "sa_mobile")] + SAUnicode, +} + +impl std::fmt::Display for TextFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TextFormat::Gta3 => write!(f, "gta3"), + TextFormat::Vc => write!(f, "vc"), + TextFormat::Sa => write!(f, "sa"), + TextFormat::SAUnicode => write!(f, "sa_mobile"), + } + } +} + +/// Element with optional autoupdate attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct AutoUpdateElement { + #[serde(rename = "@autoupdate", skip_serializing_if = "Option::is_none")] + pub autoupdate: Option, + #[serde(rename = "$value")] + pub value: String, +} + +/// IDE element with optional base attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct IdeElement { + #[serde(rename = "@base", skip_serializing_if = "Option::is_none")] + pub base: Option, + #[serde(rename = "$value")] + pub value: String, +} + +/// Text element with optional format attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct TextElement { + #[serde(rename = "@format", skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(rename = "$value")] + pub value: String, +} + +/// Templates element with type attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct TemplateElement { + #[serde(rename = "@type")] + pub r#type: String, + #[serde(rename = "$value")] + pub value: String, +} + +/// Copy-directory element with type attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct CopyDirectoryElement { + #[serde(rename = "@type")] + pub r#type: String, + #[serde(rename = "$value")] + pub value: String, +} + +/// Main Mode structure representing the root XML element +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct Mode { + #[serde(rename = "@id")] + pub id: String, + #[serde(rename = "@title")] + pub title: String, + #[serde(rename = "@game")] + pub game: Game, + #[serde(rename = "@type", skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(rename = "@extends", skip_serializing_if = "Option::is_none")] + pub extends: Option, + + // Non-serialized fields for tracking + #[serde(skip)] + pub file_name: Option, + + // Simple text elements + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compiler: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub constants: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keywords: Option, + #[serde( + rename = "cleo-default-extensions", + skip_serializing_if = "Option::is_none" + )] + pub cleo_default_extensions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub arrays: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub missions: Option, + + // Elements with optional attributes + #[serde(skip_serializing_if = "Option::is_none")] + pub examples: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + + // Elements that can appear multiple times + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub opcodes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub library: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub classes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enums: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ide: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub templates: Vec, + #[serde( + rename = "copy-directory", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub copy_directory: Vec, +} + +impl Mode { + /// Validate that the Mode has valid attribute values + pub fn validate(&self) -> Result<()> { + // Validate type attribute - if present, must be "default" or "" + if let Some(type_value) = &self.r#type { + if type_value != "default" && type_value != "" { + bail!( + "Invalid type attribute '{}' for mode '{}'. Type attribute if present can only be 'default'", + type_value, self.id + ); + } + } + Ok(()) + } + + /// Merge fields from a parent mode, keeping this mode's non-None values + pub fn merge_from_parent(&mut self, parent: &Mode) { + // Only merge fields that are None in the child + // type is not inherited + + // Simple text elements + if self.data.is_none() { + self.data = parent.data.clone(); + } + if self.compiler.is_none() { + self.compiler = parent.compiler.clone(); + } + if self.constants.is_none() { + self.constants = parent.constants.clone(); + } + if self.keywords.is_none() { + self.keywords = parent.keywords.clone(); + } + if self.cleo_default_extensions.is_none() { + self.cleo_default_extensions = parent.cleo_default_extensions.clone(); + } + if self.variables.is_none() { + self.variables = parent.variables.clone(); + } + if self.labels.is_none() { + self.labels = parent.labels.clone(); + } + if self.arrays.is_none() { + self.arrays = parent.arrays.clone(); + } + if self.missions.is_none() { + self.missions = parent.missions.clone(); + } + + // Elements with optional attributes + if self.examples.is_none() { + self.examples = parent.examples.clone(); + } + if self.text.is_none() { + self.text = parent.text.clone(); + } + + // Vectors - only inherit if child has no entries + // If child defines any entries, it completely replaces parent's entries + if self.opcodes.is_empty() { + self.opcodes = parent.opcodes.clone(); + } + + if self.library.is_empty() { + self.library = parent.library.clone(); + } + + if self.classes.is_empty() { + self.classes = parent.classes.clone(); + } + + if self.enums.is_empty() { + self.enums = parent.enums.clone(); + } + + if self.ide.is_empty() { + self.ide = parent.ide.clone(); + } + + if self.templates.is_empty() { + self.templates = parent.templates.clone(); + } + + if self.copy_directory.is_empty() { + self.copy_directory = parent.copy_directory.clone(); + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use quick_xml::de::from_str; + + #[test] + fn test_mode_deserialization() { + // Test that Mode can be deserialized from XML + let xml = r#" + + @sb:\data\test\ + @sb:\compiler.exe + @sb:\constants.txt + @sb:\keywords.txt + @game:\variables.ini + @sb:\examples.txt + @game:\text.gxt + @sb:\opcodes1.ini + @sb:\opcodes2.ini + @sb:\library.json + @sb:\classes.db + @sb:\enums.txt + @game:\default.ide + @sb:\templates.txt + @game:\data\main +"#; + + let result = from_str::(xml); + assert!(result.is_ok(), "Failed to deserialize Mode from XML"); + + let mode = result.unwrap(); + assert_eq!(mode.id, "test_mode"); + assert_eq!(mode.title, "Test Mode"); + assert_eq!(mode.game, Game::Sa); + assert_eq!(mode.r#type, Some("default".to_string())); + assert!(mode.extends.is_none()); + + assert_eq!(mode.data, Some("@sb:\\data\\test\\".to_string())); + assert_eq!(mode.compiler, Some("@sb:\\compiler.exe".to_string())); + assert_eq!(mode.opcodes.len(), 2); + assert_eq!(mode.library.len(), 1); + assert_eq!(mode.classes.len(), 1); + assert!(mode.examples.is_some()); + assert!(mode.text.is_some()); + } + + #[test] + fn test_merge_from_parent() { + // Create parent mode + let parent_xml = r#" + + parent_data + parent_compiler + parent_keywords + parent_opcode1 + parent_opcode2 + parent_library +"#; + + let parent = from_str::(parent_xml).unwrap(); + + // Create child mode with some overrides + let child_xml = r#" + + child_compiler + child_constants + child_opcode +"#; + + let mut child = from_str::(child_xml).unwrap(); + + // Apply inheritance + child.merge_from_parent(&parent); + + // Test inherited fields + assert_eq!(child.data, Some("parent_data".to_string())); // Inherited + assert_eq!(child.keywords, Some("parent_keywords".to_string())); // Inherited + assert_eq!(child.r#type, None); // type is not inherited + + // Test overridden fields + assert_eq!(child.compiler, Some("child_compiler".to_string())); // Kept child's value + assert_eq!(child.constants, Some("child_constants".to_string())); // Child's new field + + // Test vector replacement (not merge) + assert_eq!(child.opcodes.len(), 1); // Child defined opcodes, so only has child's + assert_eq!(child.opcodes[0], "child_opcode"); + + // Test that empty vectors inherit from parent + assert_eq!(child.library.len(), 1); // Child didn't define library, so inherits parent's + assert_eq!(child.library[0].value, "parent_library"); + } + + + #[test] + fn test_mode_with_extends() { + let xml = r#" + + extended_data +"#; + + let mode = from_str::(xml).unwrap(); + assert_eq!(mode.extends, Some("base_mode".to_string())); + } + + #[test] + fn test_empty_vectors() { + let xml = r#" + + test_data +"#; + + let mode = from_str::(xml).unwrap(); + assert!(mode.opcodes.is_empty()); + assert!(mode.library.is_empty()); + assert!(mode.classes.is_empty()); + assert!(mode.enums.is_empty()); + assert!(mode.ide.is_empty()); + assert!(mode.templates.is_empty()); + assert!(mode.copy_directory.is_empty()); + } + + #[test] + fn test_element_attributes() { + let xml = r#" + + examples.txt + text.gxt + lib.json + classes.db + enums.txt + default.ide + templates.txt + main_dir +"#; + + let mode = from_str::(xml).unwrap(); + + assert_eq!(mode.examples.as_ref().unwrap().autoupdate, Some("no".to_string())); + assert_eq!(mode.examples.as_ref().unwrap().value, "examples.txt"); + + assert_eq!(mode.text.as_ref().unwrap().format, Some(TextFormat::Sa)); + assert_eq!(mode.text.as_ref().unwrap().value, "text.gxt"); + + assert_eq!(mode.library[0].autoupdate, Some("yes".to_string())); + assert_eq!(mode.classes[0].autoupdate, Some("no".to_string())); + assert_eq!(mode.enums[0].autoupdate, Some("yes".to_string())); + + assert_eq!(mode.ide[0].base, Some("/base/path/".to_string())); + assert_eq!(mode.templates[0].r#type, "custom"); + assert_eq!(mode.copy_directory[0].r#type, "main"); + } + + #[test] + fn test_invalid_game_value_causes_parse_error() { + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_err(), "Should fail to parse unknown game value"); + + if let Err(e) = result { + let error_msg = e.to_string(); + assert!( + error_msg.contains("unknown_game") || error_msg.contains("unknown variant"), + "Error should mention the invalid game value: {}", + error_msg + ); + } + } + + #[test] + fn test_invalid_text_format_causes_parse_error() { + let xml = r#" + + test.txt + + "#; + + let result: Result = from_str(xml); + assert!(result.is_err(), "Should fail to parse unknown text format value"); + + if let Err(e) = result { + let error_msg = e.to_string(); + assert!( + error_msg.contains("invalid_format") || error_msg.contains("unknown variant"), + "Error should mention the invalid format value: {}", + error_msg + ); + } + } + + #[test] + fn test_type_attribute_validation() { + // Test that type="default" is valid + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_ok(), "Should successfully parse mode with type='default'"); + let mode = result.unwrap(); + assert!(mode.validate().is_ok(), "Mode with type='default' should pass validation"); + + // Test that type="" is valid + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_ok(), "Should successfully parse mode with type=''"); + let mode = result.unwrap(); + assert!(mode.validate().is_ok(), "Mode with type='' should pass validation"); + + // Test that invalid type values fail validation + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_ok(), "Should parse XML successfully"); + let mode = result.unwrap(); + let validation_result = mode.validate(); + assert!(validation_result.is_err(), "Mode with type='custom' should fail validation"); + if let Err(e) = validation_result { + let error_msg = e.to_string(); + assert!(error_msg.contains("can only be 'default'"), "Error message should indicate that type can only be 'default'"); + } + + // Test that no type attribute is valid + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_ok(), "Should successfully parse mode without type attribute"); + let mode = result.unwrap(); + assert!(mode.validate().is_ok(), "Mode without type attribute should pass validation"); + } +} \ No newline at end of file diff --git a/src/modes/modes.rs b/src/modes/modes.rs new file mode 100644 index 0000000..282aa19 --- /dev/null +++ b/src/modes/modes.rs @@ -0,0 +1,1973 @@ +use super::mode::{ + AutoUpdateElement, CopyDirectoryElement, Game, IdeElement, Mode, TemplateElement, TextElement, +}; +use super::string_variables::StringVariables; +use anyhow::{Context, Result, anyhow, bail}; +use quick_xml::de::from_str; +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +/// ModeManager is responsible for loading and managing all modes from a directory +pub struct ModeManager { + /// All loaded modes stored in a vector + modes: Vec, + /// String variables for placeholder substitution + variables: StringVariables, + /// The currently selected mode index + current_mode_index: Option, +} + +impl ModeManager { + /// Create a new ModeManager instance + pub fn new() -> Self { + Self { + modes: Vec::new(), + variables: StringVariables::new(), + current_mode_index: None, + } + } + + /// Set the current mode by ID + pub fn set_current_mode_by_id(&mut self, mode_id: &str) -> bool { + if let Some(index) = self.modes.iter().position(|m| m.id == mode_id) { + self.current_mode_index = Some(index); + true + } else { + false + } + } + + /// Set the current mode by game (prioritizes modes with type="default") + pub fn set_current_mode_by_game(&mut self, game: Game) -> bool { + // First, try to find a mode with matching game and type="default" + if let Some(index) = self + .modes + .iter() + .position(|m| m.game == game && m.r#type.as_deref() == Some("default")) + { + self.current_mode_index = Some(index); + return true; + } + + // If no default mode found, fall back to any mode with matching game + if let Some(index) = self.modes.iter().position(|m| m.game == game) { + self.current_mode_index = Some(index); + true + } else { + false + } + } + + /// Set the current mode by index + pub fn set_current_mode_by_index(&mut self, index: usize) -> bool { + if index < self.modes.len() { + self.current_mode_index = Some(index); + true + } else { + false + } + } + + /// Register a variable for substitution + pub fn register_variable(&mut self, name: String, value: String) { + self.variables.register(name, value); + } + + /// Helper method to apply path substitutions + fn apply_variables(&self, s: &str) -> String { + self.variables.process(s) + } + + /// Helper method to find a mode by ID + fn find_mode_by_id(&self, mode_id: &str) -> Option<&Mode> { + self.modes.iter().find(|m| m.id == mode_id) + } + + /// Helper method to find a mutable mode by ID + fn find_mode_by_id_mut(&mut self, mode_id: &str) -> Option<&mut Mode> { + self.modes.iter_mut().find(|m| m.id == mode_id) + } + + /// Get the total number of modes + pub fn mode_count(&self) -> usize { + self.modes.len() + } + + /// Getters for mode properties with path substitution + + /// Get ID for mode at specific index + pub fn get_id_at(&self, index: usize) -> Option { + self.modes.get(index).map(|mode| mode.id.clone()) + } + + pub fn get_id(&self) -> Option { + self.current_mode_index.and_then(|idx| self.get_id_at(idx)) + } + + /// Get title for mode at specific index + pub fn get_title_at(&self, index: usize) -> Option { + self.modes.get(index).map(|mode| mode.title.clone()) + } + + pub fn get_title(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_title_at(idx)) + } + + /// Get game for mode at specific index + pub fn get_game_at(&self, index: usize) -> Option { + self.modes.get(index).map(|mode| mode.game.clone()) + } + + pub fn get_game(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_game_at(idx)) + } + + /// Get type for mode at specific index + pub fn get_type_at(&self, index: usize) -> Option { + self.modes.get(index).and_then(|mode| mode.r#type.clone()) + } + + pub fn get_type(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_type_at(idx)) + } + + /// Get extends for mode at specific index + pub fn get_extends_at(&self, index: usize) -> Option { + self.modes.get(index).and_then(|mode| mode.extends.clone()) + } + + pub fn get_extends(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_extends_at(idx)) + } + + /// Get data for mode at specific index + pub fn get_data_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.data.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_data(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_data_at(idx)) + } + + /// Get compiler for mode at specific index + pub fn get_compiler_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.compiler.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_compiler(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_compiler_at(idx)) + } + + /// Get constants for mode at specific index + pub fn get_constants_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.constants.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_constants(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_constants_at(idx)) + } + + /// Get keywords for mode at specific index + pub fn get_keywords_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.keywords.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_keywords(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_keywords_at(idx)) + } + + /// Get cleo_default_extensions for mode at specific index + pub fn get_cleo_default_extensions_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.cleo_default_extensions.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_cleo_default_extensions(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_cleo_default_extensions_at(idx)) + } + + /// Get mode_variables for mode at specific index + pub fn get_mode_variables_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.variables.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_mode_variables(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_mode_variables_at(idx)) + } + + /// Get labels for mode at specific index + pub fn get_labels_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.labels.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_labels(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_labels_at(idx)) + } + + /// Get arrays for mode at specific index + pub fn get_arrays_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.arrays.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_arrays(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_arrays_at(idx)) + } + + /// Get missions for mode at specific index + pub fn get_missions_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.missions.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_missions(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_missions_at(idx)) + } + + /// Get examples for mode at specific index + pub fn get_examples_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.examples.as_ref()) + .map(|elem| AutoUpdateElement { + autoupdate: elem.autoupdate.clone(), + value: self.apply_variables(&elem.value), + }) + } + + pub fn get_examples(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_examples_at(idx)) + } + + /// Get text for mode at specific index + pub fn get_text_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.text.as_ref()) + .map(|elem| TextElement { + format: elem.format.clone(), + value: self.apply_variables(&elem.value), + }) + } + + pub fn get_text(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_text_at(idx)) + } + + /// Get opcodes for mode at specific index + pub fn get_opcodes_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.opcodes + .iter() + .map(|s| self.apply_variables(s)) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_opcodes(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_opcodes_at(idx)) + .unwrap_or_default() + } + + /// Get library for mode at specific index + pub fn get_library_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.library + .iter() + .map(|elem| AutoUpdateElement { + autoupdate: elem.autoupdate.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_library(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_library_at(idx)) + .unwrap_or_default() + } + + /// Get classes for mode at specific index + pub fn get_classes_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.classes + .iter() + .map(|elem| AutoUpdateElement { + autoupdate: elem.autoupdate.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_classes(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_classes_at(idx)) + .unwrap_or_default() + } + + /// Get enums for mode at specific index + pub fn get_enums_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.enums + .iter() + .map(|elem| AutoUpdateElement { + autoupdate: elem.autoupdate.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_enums(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_enums_at(idx)) + .unwrap_or_default() + } + + /// Get ide for mode at specific index + pub fn get_ide_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.ide + .iter() + .map(|elem| IdeElement { + base: elem.base.as_ref().map(|s| self.apply_variables(s)), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_ide(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_ide_at(idx)) + .unwrap_or_default() + } + + /// Get templates for mode at specific index + pub fn get_templates_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.templates + .iter() + .map(|elem| TemplateElement { + r#type: elem.r#type.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_templates(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_templates_at(idx)) + .unwrap_or_default() + } + + /// Get copy_directory for mode at specific index + pub fn get_copy_directory_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.copy_directory + .iter() + .map(|elem| CopyDirectoryElement { + r#type: elem.r#type.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_copy_directory(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_copy_directory_at(idx)) + .unwrap_or_default() + } + + /// Get index of mode by game (prioritizes modes with type="default") + pub fn get_index_by_game(&self, game: Game) -> Option { + // First, try to find a mode with matching game and type="default" + if let Some(index) = self + .modes + .iter() + .position(|m| m.game == game && m.r#type.as_deref() == Some("default")) + { + return Some(index); + } + + // If no default mode found, fall back to any mode with matching game + self.modes.iter().position(|m| m.game == game) + } + + /// Get index of mode by ID + pub fn get_index_by_id(&self, mode_id: &str) -> Option { + self.modes.iter().position(|m| m.id == mode_id) + } + + /// Get parent mode index for mode at specific index + pub fn get_parent_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.extends.as_ref()) + .and_then(|parent_id| self.get_index_by_id(parent_id)) + } + + pub fn get_parent(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_parent_at(idx)) + } + + /// Check if mode at index is valid (no duplicate IDs) + pub fn is_valid_at(&self, index: usize) -> bool { + if let Some(mode) = self.modes.get(index) { + let count = self.modes.iter().filter(|m| m.id == mode.id).count(); + count == 1 + } else { + false + } + } + + pub fn is_valid(&self) -> bool { + self.current_mode_index + .map(|idx| self.is_valid_at(idx)) + .unwrap_or(false) + } + + /// Check if mode ID contains "SBL" + pub fn is_sbl_at(&self, index: usize) -> bool { + self.modes + .get(index) + .map(|mode| mode.id.to_uppercase().contains("SBL")) + .unwrap_or(false) + } + + pub fn is_sbl(&self) -> bool { + self.current_mode_index + .map(|idx| self.is_sbl_at(idx)) + .unwrap_or(false) + } + + /// Check if current mode has type="default" + pub fn is_default(&self) -> bool { + self.current_mode_index + .and_then(|idx| self.modes.get(idx)) + .and_then(|mode| mode.r#type.as_ref()) + .map(|t| t == "default") + .unwrap_or(false) + } + + /// Get raw game string for mode at specific index + pub fn get_game_raw_at(&self, index: usize) -> Option { + self.modes + .get(index) + .map(|mode| mode.game.to_string()) + } + + pub fn get_game_raw(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_game_raw_at(idx)) + } + + /// Get file name for mode at specific index + pub fn get_file_name_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.file_name.clone()) + } + + pub fn get_file_name(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_file_name_at(idx)) + } + + /// Check if autoupdate is allowed for a specific element type + pub fn is_autoupdate_allowed_for(&self, element: &str, index: usize) -> bool { + if let Some(mode) = self.modes.get(index) { + match element { + "opcodes" => true, // Opcodes don't have autoupdate attribute + "library" => { + // Check first library element's autoupdate attribute + mode.library + .first() + .and_then(|elem| elem.autoupdate.as_ref()) + .map(|v| v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true")) + .unwrap_or(true) // Default to true if not specified + } + "classes" => mode + .classes + .first() + .and_then(|elem| elem.autoupdate.as_ref()) + .map(|v| v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true")) + .unwrap_or(true), + "enums" => mode + .enums + .first() + .and_then(|elem| elem.autoupdate.as_ref()) + .map(|v| v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true")) + .unwrap_or(true), + _ => true, + } + } else { + true + } + } + + /// Convert string game name to Game enum + pub fn get_game_by_name(&self, name: &str) -> Option { + match name.to_lowercase().as_str() { + "gta3" => Some(Game::Gta3), + "vc" => Some(Game::Vc), + "sa" => Some(Game::Sa), + "lcs" => Some(Game::Lcs), + "vcs" => Some(Game::Vcs), + "sa_mobile" => Some(Game::SaMobile), + "vc_mobile" => Some(Game::VcMobile), + _ => None, + } + } + + /// Get first item from a list field (opcodes, library, classes, enums) + pub fn get_first_of(&self, field_name: &str, index: usize) -> Option { + if let Some(mode) = self.modes.get(index) { + match field_name { + "opcodes" => mode.opcodes.first().map(|s| self.apply_variables(s)), + "library" => mode + .library + .first() + .map(|elem| self.apply_variables(&elem.value)), + "classes" => mode + .classes + .first() + .map(|elem| self.apply_variables(&elem.value)), + "enums" => mode + .enums + .first() + .map(|elem| self.apply_variables(&elem.value)), + _ => None, + } + } else { + None + } + } + + /// Load files by wildcard pattern + pub fn load_by_mask(&self, mask: &str) -> Vec { + use glob::glob; + + let processed_mask = self.apply_variables(mask); + let mut files = Vec::new(); + + if let Ok(paths) = glob(&processed_mask) { + for path_result in paths { + if let Ok(path) = path_result { + if path.is_file() { + files.push(path.to_string_lossy().to_string()); + } + } + } + } + + files + } + + /// Get list of items with wildcard expansion support + pub fn get_list_of(&self, field_name: &str, index: usize) -> Vec { + if let Some(mode) = self.modes.get(index) { + let items = match field_name { + "opcodes" => mode.opcodes.clone(), + "library" => mode.library.iter().map(|e| e.value.clone()).collect(), + "classes" => mode.classes.iter().map(|e| e.value.clone()).collect(), + "enums" => mode.enums.iter().map(|e| e.value.clone()).collect(), + _ => Vec::new(), + }; + + let mut result = Vec::new(); + for item in items { + let processed = self.apply_variables(&item); + if processed.contains('*') { + // Expand wildcard + result.extend(self.load_by_mask(&processed)); + } else { + result.push(processed); + } + } + result + } else { + Vec::new() + } + } + + /// Convenience methods for specific list types + pub fn get_list_of_library(&self, index: usize) -> Vec { + self.get_list_of("library", index) + } + + pub fn get_list_of_opcodes(&self, index: usize) -> Vec { + self.get_list_of("opcodes", index) + } + + pub fn get_list_of_classes(&self, index: usize) -> Vec { + self.get_list_of("classes", index) + } + + pub fn get_list_of_enums(&self, index: usize) -> Vec { + self.get_list_of("enums", index) + } + + /// Load all modes from a directory (including 1 level of subdirectories) + /// + /// # Arguments + /// * `directory_path` - Path to the directory to scan + /// + /// # Returns + /// Result containing the number of successfully loaded modes, or an error + pub fn load_from_directory(&mut self, directory_path: &Path) -> Result { + let mut loaded_count = 0; + let mut xml_files = Vec::new(); + + // Scan the root directory + if let Ok(entries) = fs::read_dir(directory_path) { + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("xml") { + xml_files.push(path); + } else if path.is_dir() { + // Scan 1 level of subdirectories + if let Ok(sub_entries) = fs::read_dir(&path) { + for sub_entry in sub_entries.flatten() { + let sub_path = sub_entry.path(); + if sub_path.is_file() + && sub_path.extension().and_then(|s| s.to_str()) == Some("xml") + { + xml_files.push(sub_path); + } + } + } + } + } + } else { + bail!("Failed to read directory: {:?}", directory_path); + } + + // First pass: Load all modes without resolving inheritance + for xml_path in &xml_files { + if let Ok(mode) = self.parse_xml_file(xml_path) { + // Check if mode with this ID already exists + if !self.modes.iter().any(|m| m.id == mode.id) { + self.modes.push(mode); + loaded_count += 1; + } + } + } + + // Second pass: Resolve inheritance for all modes + let mode_ids: Vec = self.modes.iter().map(|m| m.id.clone()).collect(); + let mut processed = HashSet::new(); + for mode_id in mode_ids { + self.resolve_mode_inheritance(&mode_id, &mut processed) + .with_context(|| format!("Failed to resolve inheritance for mode '{}'", mode_id))?; + } + + Ok(loaded_count) + } + + /// Parse a single XML mode file without resolving extends + fn parse_xml_file(&self, xml_path: &Path) -> Result { + let xml_content = fs::read_to_string(xml_path) + .with_context(|| format!("Failed to read XML file: {:?}", xml_path))?; + + let mut mode = from_str::(&xml_content) + .with_context(|| format!("Failed to parse XML from file: {:?}", xml_path))?; + + // Set the file name + mode.file_name = Some(xml_path.to_string_lossy().to_string()); + + // Validate the mode + mode.validate() + .with_context(|| format!("Invalid mode in file: {:?}", xml_path))?; + + Ok(mode) + } + + /// Resolve inheritance for a specific mode + fn resolve_mode_inheritance( + &mut self, + mode_id: &str, + processed: &mut HashSet, + ) -> Result<()> { + // Skip if already processed + if processed.contains(mode_id) { + return Ok(()); + } + + // Check for circular dependencies + let mut inheritance_chain = HashSet::new(); + if let Err(e) = self.check_circular_dependency(mode_id, &mut inheritance_chain) { + // Log the circular dependency but don't fail - just skip inheritance for this mode + eprintln!("Warning: {}", e); + eprintln!("Skipping inheritance resolution for mode '{}'", mode_id); + + // Mark as processed so we don't try again + processed.insert(mode_id.to_string()); + + // Mode is loaded but inheritance is skipped due to circular dependency + + return Ok(()); + } + + // Resolve the inheritance + self.resolve_inheritance_recursive(mode_id, &mut HashSet::new(), processed) + .with_context(|| format!("Failed to resolve inheritance for mode '{}'", mode_id))?; + + Ok(()) + } + + /// Check for circular dependencies in the inheritance chain + fn check_circular_dependency( + &self, + mode_id: &str, + visited: &mut HashSet, + ) -> Result<()> { + if visited.contains(mode_id) { + let chain: Vec = visited.iter().cloned().collect(); + bail!( + "Circular inheritance detected: '{}' is already in the inheritance chain: {:?}", + mode_id, + chain + ); + } + + visited.insert(mode_id.to_string()); + + if let Some(mode) = self.find_mode_by_id(mode_id) { + if let Some(extends_id) = &mode.extends { + // Check if the parent exists + if !self.modes.iter().any(|m| &m.id == extends_id) { + // Parent doesn't exist in our loaded modes, this is okay + // The mode will just use its own values + return Ok(()); + } + + // Recursively check the parent + self.check_circular_dependency(extends_id, visited) + .with_context(|| { + format!( + "Failed checking circular dependency for parent '{}'", + extends_id + ) + })?; + } + } + + visited.remove(mode_id); + Ok(()) + } + + /// Recursively resolve inheritance for a mode + fn resolve_inheritance_recursive( + &mut self, + mode_id: &str, + processing: &mut HashSet, + processed: &mut HashSet, + ) -> Result<()> { + // Skip if already processed + if processed.contains(mode_id) { + return Ok(()); + } + + // Check for circular dependency during processing + if processing.contains(mode_id) { + bail!( + "Circular inheritance detected while processing '{}'", + mode_id + ); + } + + processing.insert(mode_id.to_string()); + + // Get the extends field if it exists + let extends_id = self + .find_mode_by_id(mode_id) + .and_then(|m| m.extends.clone()); + + if let Some(parent_id) = extends_id { + // Check if parent exists in our loaded modes + if self.modes.iter().any(|m| m.id == parent_id) { + // Resolve parent first + self.resolve_inheritance_recursive(&parent_id, processing, processed) + .with_context(|| { + format!( + "Failed to resolve parent '{}' for mode '{}'", + parent_id, mode_id + ) + })?; + + // Now merge parent into child + let parent = self.find_mode_by_id(&parent_id).unwrap().clone(); + if let Some(child) = self.find_mode_by_id_mut(mode_id) { + child.merge_from_parent(&parent); + } + } + // If parent doesn't exist, we just skip inheritance + } + + // Mode processing complete + + processing.remove(mode_id); + processed.insert(mode_id.to_string()); + + Ok(()) + } + + // Clear all loaded modes + pub fn clear(&mut self) { + self.modes.clear(); + self.current_mode_index = None; + } + + /// Load parent modes recursively from the same directory + fn load_parent_modes(&mut self, mode_id: &str, base_path: &Path) -> Result<()> { + let mode = self.find_mode_by_id(mode_id).cloned(); + + if let Some(mode) = mode { + if let Some(extends_id) = mode.extends { + // Skip if parent is already loaded + if self.modes.iter().any(|m| m.id == extends_id) { + return Ok(()); + } + + // Try to find parent in the same directory + let parent_dir = base_path + .parent() + .ok_or_else(|| anyhow!("Cannot get parent directory of {:?}", base_path))?; + + // Try common naming patterns + let possible_files = vec![ + parent_dir.join(format!("{}.xml", extends_id)), + parent_dir.join(format!("{}_mode.xml", extends_id)), + ]; + + for path in possible_files { + if path.exists() { + if let Ok(parent_mode) = self.parse_xml_file(&path) { + if parent_mode.id == extends_id { + let parent_id = parent_mode.id.clone(); + if !self.modes.iter().any(|m| m.id == parent_id) { + self.modes.push(parent_mode); + } + + // Recursively load parent's parents + self.load_parent_modes(&parent_id, &path).with_context(|| { + format!("Failed to load parent modes for '{}'", parent_id) + })?; + return Ok(()); + } + } + } + } + + // If not found by naming convention, scan all XML files in the directory + if let Ok(entries) = fs::read_dir(parent_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("xml") { + if let Ok(parent_mode) = self.parse_xml_file(&path) { + if parent_mode.id == extends_id { + let parent_id = parent_mode.id.clone(); + if !self.modes.iter().any(|m| m.id == parent_id) { + self.modes.push(parent_mode); + } + + // Recursively load parent's parents + self.load_parent_modes(&parent_id, &path)?; + return Ok(()); + } + } + } + } + } + + // Parent not found - this is okay, mode will just use its own values + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::modes::mode::TextFormat; + + use super::*; + use std::fs; + use std::path::Path; + + // #[test] + // fn test_load_shared_xml_directory() { + // let mut manager = ModeManager::new(); + // manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + // manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // let result = manager.load_from_directory(Path::new("shared_XML")); + // assert!( + // result.is_ok(), + // "Failed to load shared_XML directory: {:?}", + // result + // ); + + // let loaded_count = result.unwrap(); + // println!("Loaded {} modes from shared_XML", loaded_count); + // assert!(loaded_count > 0, "Should load at least some modes"); + + // // Check that sa_sbl mode was loaded + // assert!( + // manager.set_current_mode_by_id("sa_sbl"), + // "sa_sbl mode should be loaded" + // ); + // assert_eq!(manager.get_id(), Some("sa_sbl".to_string())); + // assert_eq!(manager.get_title(), Some("GTA SA (v1.0 - SBL)".to_string())); + // assert_eq!(manager.get_game(), Some(Game::Sa)); + + // // Set current mode and check placeholder replacement through getters + // manager.set_current_mode_by_id("sa_sbl"); + // let data = manager.get_data(); + // assert!( + // data.as_ref().unwrap().contains("C:\\SannyBuilder"), + // "Data should contain substituted path" + // ); + + // // Check that sa_sbl_sf inherits from sa_sbl + // // Check that sa_sbl_sf mode was loaded and extends sa_sbl + // assert!( + // manager.set_current_mode_by_id("sa_sbl_sf"), + // "sa_sbl_sf mode should be loaded" + // ); + // assert_eq!(manager.get_extends(), Some("sa_sbl".to_string())); + + // // Set current mode to child and check inheritance through getters + // manager.set_current_mode_by_id("sa_sbl_sf"); + // let child_data = manager.get_data(); + // let child_library = manager.get_library(); + + // manager.set_current_mode_by_id("sa_sbl"); + // let parent_data = manager.get_data(); + + // // Should have inherited fields from parent + // assert_eq!(child_data, parent_data); // Inherited from parent + + // // Should have its own library entries (not inherited since it defines its own) + // assert_eq!(child_library.len(), 2); + // assert!(child_library[0].value.contains("sa.json")); + // assert!(child_library[1].value.contains("sf.fix.json")); + // } + + #[test] + fn test_circular_dependency_detection() { + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\test".to_string()); + manager.register_variable("@game:".to_string(), "D:\\game".to_string()); + + // Load test cases with circular dependencies + let result = manager.load_from_directory(Path::new("test_cases")); + + // The load should succeed, but circular modes won't have inheritance resolved + assert!( + result.is_ok(), + "Should load files even with circular dependencies" + ); + + // Modes with circular dependencies should be loaded but won't have extends resolved + assert!( + manager.set_current_mode_by_id("test_circular_a"), + "Circular mode A should be loaded" + ); + } + + #[test] + fn test_subdirectory_scanning() { + // Create a temporary test structure + let test_dir = Path::new("test_subdir_scan"); + let sub_dir = test_dir.join("subdir"); + + fs::create_dir_all(&sub_dir).ok(); + + // Create test XML files + let root_xml = r#" + + @sb:\data\root\ +"#; + + let sub_xml = r#" + + @sb:\data\sub\ +"#; + + fs::write(test_dir.join("root.xml"), root_xml).unwrap(); + fs::write(sub_dir.join("sub.xml"), sub_xml).unwrap(); + + // Test loading + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\test".to_string()); + manager.register_variable("@game:".to_string(), "D:\\game".to_string()); + let result = manager.load_from_directory(test_dir); + assert!(result.is_ok()); + + let count = result.unwrap(); + assert_eq!(count, 2, "Should load both root and subdirectory XML files"); + + assert!(manager.set_current_mode_by_id("root_mode")); + assert!(manager.set_current_mode_by_id("sub_mode")); + + // Cleanup + fs::remove_dir_all(test_dir).ok(); + } + + #[test] + fn test_missing_parent_handling() { + // Create a mode that extends a non-existent parent + let orphan_xml = r#" + + @sb:\data\orphan\ +"#; + + let test_dir = Path::new("test_orphan"); + fs::create_dir_all(test_dir).ok(); + fs::write(test_dir.join("orphan.xml"), orphan_xml).unwrap(); + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\test".to_string()); + manager.register_variable("@game:".to_string(), "D:\\game".to_string()); + let result = manager.load_from_directory(test_dir); + + assert!(result.is_ok(), "Should handle missing parent gracefully"); + + assert!( + manager.set_current_mode_by_id("orphan_mode"), + "Orphan mode should be loaded" + ); + + manager.set_current_mode_by_id("orphan_mode"); + assert_eq!( + manager.get_data(), + Some("C:\\test\\data\\orphan\\".to_string()) + ); + + // Cleanup + fs::remove_dir_all(test_dir).ok(); + } + + // #[test] + // fn test_validate_all_shared_modes() { + // let mut manager = ModeManager::new(); + // manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + // manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // let result = manager.load_from_directory(Path::new("shared_XML")); + // assert!(result.is_ok(), "Failed to load shared_XML: {:?}", result); + + // let loaded_count = result.unwrap(); + // println!("\n=== ModeManager Validation ==="); + // println!("Successfully loaded {} modes", loaded_count); + + // // Collect all mode IDs first + // let mode_count = manager.mode_count(); + // let mut mode_ids = Vec::new(); + // for i in 0..mode_count { + // manager.set_current_mode_by_index(i); + // if let (Some(id), Some(title), Some(game)) = + // (manager.get_id(), manager.get_title(), manager.get_game()) + // { + // mode_ids.push((id, title, game)); + // } + // } + + // // Validate each mode + // for (id, title, game) in mode_ids { + // assert!(!id.is_empty(), "Mode ID should not be empty"); + // assert!( + // !title.is_empty(), + // "Mode title should not be empty for {}", + // id + // ); + // // Game is now an enum, so it always has a valid value + + // // Check that placeholders are replaced when accessed through getters + // manager.set_current_mode_by_id(&id); + // if let Some(data) = manager.get_data() { + // assert!( + // !data.contains("@sb:"), + // "Placeholder @sb: not replaced in mode {} data", + // id + // ); + // assert!( + // !data.contains("@game:"), + // "Placeholder @game: not replaced in mode {} data", + // id + // ); + // } + + // // Check other path fields + // if let Some(compiler) = manager.get_compiler() { + // assert!( + // !compiler.contains("@sb:"), + // "Placeholder @sb: not replaced in mode {} compiler", + // id + // ); + // assert!( + // !compiler.contains("@game:"), + // "Placeholder @game: not replaced in mode {} compiler", + // id + // ); + // } + + // println!("✓ {}: {} (game: {})", id, title, game); + // } + + // println!("\nAll {} modes validated successfully!", loaded_count); + // } + + #[test] + fn test_set_current_mode_by_game_prioritizes_default() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // Create modes with different type attributes + let mode1_xml = r#" + + @sb:\data\sa_regular\ + + "#; + + let mode2_xml = r#" + + @sb:\data\sa_default\ + + "#; + + let mode3_xml = r#" + + @sb:\data\sa_another\ + + "#; + + // Parse and add modes + let mode1: Mode = from_str(mode1_xml).unwrap(); + let mode2: Mode = from_str(mode2_xml).unwrap(); + let mode3: Mode = from_str(mode3_xml).unwrap(); + + // Add modes in order: regular, default, another + manager.modes.push(mode1); + manager.modes.push(mode2); + manager.modes.push(mode3); + + // Test that set_current_mode_by_game selects the mode with type="default" + assert!(manager.set_current_mode_by_game(Game::Sa)); + assert_eq!(manager.get_id(), Some("sa_default".to_string())); + + // Clear modes and test with no default type + manager.modes.clear(); + manager.current_mode_index = None; + + let mode4_xml = r#" + + @sb:\data\sa_first\ + + "#; + + let mode5_xml = r#" + + @sb:\data\sa_second\ + + "#; + + let mode4: Mode = from_str(mode4_xml).unwrap(); + let mode5: Mode = from_str(mode5_xml).unwrap(); + + manager.modes.push(mode4); + manager.modes.push(mode5); + + // Test that when no default exists, it selects the first matching mode + assert!(manager.set_current_mode_by_game(Game::Sa)); + assert_eq!(manager.get_id(), Some("sa_first".to_string())); + } + + #[test] + fn test_index_based_getters() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // Create test modes with different properties + let mode1_xml = r#" + + @sb:\data\test1\ + @sb:\compiler\test1.exe + @sb:\constants\test1.txt + @sb:\keywords\test1.txt + @sb:\opcodes\test1_1.txt + @sb:\opcodes\test1_2.txt + @sb:\library\test1.json + @sb:\classes\test1.json + @sb:\examples\test1\ + @sb:\text\test1.txt + + "#; + + let mode2_xml = r#" + + @sb:\data\test2\ + @sb:\compiler\test2.exe + @sb:\opcodes\test2_1.txt + @sb:\library\test2.json + + "#; + + let mode3_xml = r#" + + @sb:\data\test3\ + @sb:\missions\test3.txt + @sb:\labels\test3.txt + @sb:\arrays\test3.txt + + "#; + + // Parse and add modes + let mode1: Mode = from_str(mode1_xml).unwrap(); + let mode2: Mode = from_str(mode2_xml).unwrap(); + let mode3: Mode = from_str(mode3_xml).unwrap(); + + manager.modes.push(mode1); + manager.modes.push(mode2); + manager.modes.push(mode3); + + // Test simple field getters with index + assert_eq!(manager.get_id_at(0), Some("test_mode_1".to_string())); + assert_eq!(manager.get_id_at(1), Some("test_mode_2".to_string())); + assert_eq!(manager.get_id_at(2), Some("test_mode_3".to_string())); + assert_eq!(manager.get_id_at(3), None); // Out of bounds + + assert_eq!(manager.get_title_at(0), Some("Test Mode 1".to_string())); + assert_eq!(manager.get_title_at(1), Some("Test Mode 2".to_string())); + assert_eq!(manager.get_title_at(2), Some("Test Mode 3".to_string())); + + assert_eq!(manager.get_game_at(0), Some(Game::Sa)); + assert_eq!(manager.get_game_at(1), Some(Game::Vc)); + assert_eq!(manager.get_game_at(2), Some(Game::Gta3)); + + assert_eq!(manager.get_type_at(0), Some("default".to_string())); + assert_eq!(manager.get_type_at(1), None); + assert_eq!(manager.get_type_at(2), None); + + assert_eq!(manager.get_extends_at(0), None); + assert_eq!(manager.get_extends_at(1), Some("test_mode_1".to_string())); + assert_eq!(manager.get_extends_at(2), None); + + // Test path-substituted field getters with index + assert_eq!( + manager.get_data_at(0), + Some("C:\\SannyBuilder\\data\\test1\\".to_string()) + ); + assert_eq!( + manager.get_data_at(1), + Some("C:\\SannyBuilder\\data\\test2\\".to_string()) + ); + assert_eq!( + manager.get_data_at(2), + Some("C:\\SannyBuilder\\data\\test3\\".to_string()) + ); + + assert_eq!( + manager.get_compiler_at(0), + Some("C:\\SannyBuilder\\compiler\\test1.exe".to_string()) + ); + assert_eq!( + manager.get_compiler_at(1), + Some("C:\\SannyBuilder\\compiler\\test2.exe".to_string()) + ); + assert_eq!(manager.get_compiler_at(2), None); + + assert_eq!( + manager.get_constants_at(0), + Some("C:\\SannyBuilder\\constants\\test1.txt".to_string()) + ); + assert_eq!(manager.get_constants_at(1), None); + assert_eq!(manager.get_constants_at(2), None); + + assert_eq!( + manager.get_keywords_at(0), + Some("C:\\SannyBuilder\\keywords\\test1.txt".to_string()) + ); + assert_eq!(manager.get_keywords_at(1), None); + assert_eq!(manager.get_keywords_at(2), None); + + assert_eq!(manager.get_missions_at(0), None); + assert_eq!(manager.get_missions_at(1), None); + assert_eq!( + manager.get_missions_at(2), + Some("C:\\SannyBuilder\\missions\\test3.txt".to_string()) + ); + + assert_eq!(manager.get_labels_at(0), None); + assert_eq!(manager.get_labels_at(1), None); + assert_eq!( + manager.get_labels_at(2), + Some("C:\\SannyBuilder\\labels\\test3.txt".to_string()) + ); + + assert_eq!(manager.get_arrays_at(0), None); + assert_eq!(manager.get_arrays_at(1), None); + assert_eq!( + manager.get_arrays_at(2), + Some("C:\\SannyBuilder\\arrays\\test3.txt".to_string()) + ); + + // Test complex field getters with index + let examples_0 = manager.get_examples_at(0); + assert!(examples_0.is_some()); + let examples = examples_0.unwrap(); + assert_eq!(examples.autoupdate, Some("true".to_string())); + assert_eq!(examples.value, "C:\\SannyBuilder\\examples\\test1\\"); + + assert_eq!(manager.get_examples_at(1), None); + assert_eq!(manager.get_examples_at(2), None); + + let text_0 = manager.get_text_at(0); + assert!(text_0.is_some()); + let text = text_0.unwrap(); + // Note: format is parsed as TextFormat enum, not string + assert!(text.format.is_some()); + assert_eq!(text.value, "C:\\SannyBuilder\\text\\test1.txt"); + + assert_eq!(manager.get_text_at(1), None); + assert_eq!(manager.get_text_at(2), None); + + // Test vector field getters with index + let opcodes_0 = manager.get_opcodes_at(0); + assert_eq!(opcodes_0.len(), 2); + assert_eq!(opcodes_0[0], "C:\\SannyBuilder\\opcodes\\test1_1.txt"); + assert_eq!(opcodes_0[1], "C:\\SannyBuilder\\opcodes\\test1_2.txt"); + + let opcodes_1 = manager.get_opcodes_at(1); + assert_eq!(opcodes_1.len(), 1); + assert_eq!(opcodes_1[0], "C:\\SannyBuilder\\opcodes\\test2_1.txt"); + + let opcodes_2 = manager.get_opcodes_at(2); + assert_eq!(opcodes_2.len(), 0); + + let library_0 = manager.get_library_at(0); + assert_eq!(library_0.len(), 1); + assert_eq!(library_0[0].autoupdate, Some("true".to_string())); + assert_eq!(library_0[0].value, "C:\\SannyBuilder\\library\\test1.json"); + + let library_1 = manager.get_library_at(1); + assert_eq!(library_1.len(), 1); + assert_eq!(library_1[0].autoupdate, Some("false".to_string())); + assert_eq!(library_1[0].value, "C:\\SannyBuilder\\library\\test2.json"); + + let library_2 = manager.get_library_at(2); + assert_eq!(library_2.len(), 0); + + let classes_0 = manager.get_classes_at(0); + assert_eq!(classes_0.len(), 1); + assert_eq!(classes_0[0].autoupdate, Some("false".to_string())); + assert_eq!(classes_0[0].value, "C:\\SannyBuilder\\classes\\test1.json"); + + let classes_1 = manager.get_classes_at(1); + assert_eq!(classes_1.len(), 0); + + let classes_2 = manager.get_classes_at(2); + assert_eq!(classes_2.len(), 0); + + // Test that current mode methods still work and delegate correctly + manager.set_current_mode_by_index(1); + assert_eq!(manager.get_id(), Some("test_mode_2".to_string())); + assert_eq!(manager.get_title(), Some("Test Mode 2".to_string())); + assert_eq!(manager.get_game(), Some(Game::Vc)); + assert_eq!( + manager.get_data(), + Some("C:\\SannyBuilder\\data\\test2\\".to_string()) + ); + + let current_opcodes = manager.get_opcodes(); + assert_eq!(current_opcodes.len(), 1); + assert_eq!(current_opcodes[0], "C:\\SannyBuilder\\opcodes\\test2_1.txt"); + + // Verify delegation by comparing current mode methods with index-based methods + manager.set_current_mode_by_index(0); + assert_eq!(manager.get_id(), manager.get_id_at(0)); + assert_eq!(manager.get_title(), manager.get_title_at(0)); + assert_eq!(manager.get_game(), manager.get_game_at(0)); + assert_eq!(manager.get_data(), manager.get_data_at(0)); + assert_eq!(manager.get_compiler(), manager.get_compiler_at(0)); + assert_eq!(manager.get_opcodes(), manager.get_opcodes_at(0)); + assert_eq!(manager.get_library(), manager.get_library_at(0)); + } + + #[test] + fn test_index_based_getters_edge_cases() { + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\test".to_string()); + + // Test with empty manager + assert_eq!(manager.get_id_at(0), None); + assert_eq!(manager.get_title_at(0), None); + assert_eq!(manager.get_game_at(0), None); + assert_eq!(manager.get_data_at(0), None); + assert_eq!(manager.get_opcodes_at(0), Vec::::new()); + assert_eq!(manager.get_library_at(0), Vec::::new()); + + // Test with out of bounds index + use quick_xml::de::from_str; + let mode_xml = r#" + + @sb:\data\single\ + + "#; + let mode: Mode = from_str(mode_xml).unwrap(); + manager.modes.push(mode); + + // Valid index + assert_eq!(manager.get_id_at(0), Some("single_mode".to_string())); + + // Out of bounds indices + assert_eq!(manager.get_id_at(1), None); + assert_eq!(manager.get_id_at(100), None); + assert_eq!(manager.get_data_at(1), None); + assert_eq!(manager.get_opcodes_at(1), Vec::::new()); + + // Test that current mode methods return None when no current mode is set + assert_eq!(manager.get_id(), None); + assert_eq!(manager.get_title(), None); + assert_eq!(manager.get_data(), None); + assert_eq!(manager.get_opcodes(), Vec::::new()); + + // Set current mode and verify it works + manager.set_current_mode_by_index(0); + assert_eq!(manager.get_id(), Some("single_mode".to_string())); + assert_eq!( + manager.get_data(), + Some("C:\\test\\data\\single\\".to_string()) + ); + } + + #[test] + fn test_new_pascal_methods() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + + // Create test modes + let mode1_xml = r#" + + @sb:\data\sa\ + @sb:\library\sa.json + @sb:\classes\sa.json + @sb:\enums\sa.json + + "#; + + let mode2_xml = r#" + + @sb:\data\sa_child\ + + "#; + + let mode3_xml = r#" + + @sb:\data\vc\ + @sb:\opcodes\vc_1.txt + @sb:\opcodes\vc_2.txt + + "#; + + let mut mode1: Mode = from_str(mode1_xml).unwrap(); + let mut mode2: Mode = from_str(mode2_xml).unwrap(); + let mut mode3: Mode = from_str(mode3_xml).unwrap(); + + // Set file names + mode1.file_name = Some("/path/to/sa_sbl.xml".to_string()); + mode2.file_name = Some("/path/to/sa_sbl_child.xml".to_string()); + mode3.file_name = Some("/path/to/vc_normal.xml".to_string()); + + manager.modes.push(mode1); + manager.modes.push(mode2); + manager.modes.push(mode3); + + // Test get_index_by_game + assert_eq!(manager.get_index_by_game(Game::Sa), Some(0)); // Should find default + assert_eq!(manager.get_index_by_game(Game::Vc), Some(2)); + assert_eq!(manager.get_index_by_game(Game::Gta3), None); + + // Test get_index_by_id + assert_eq!(manager.get_index_by_id("sa_sbl"), Some(0)); + assert_eq!(manager.get_index_by_id("sa_sbl_child"), Some(1)); + assert_eq!(manager.get_index_by_id("nonexistent"), None); + + // Test get_parent_at + assert_eq!(manager.get_parent_at(0), None); // No parent + assert_eq!(manager.get_parent_at(1), Some(0)); // Parent is sa_sbl + assert_eq!(manager.get_parent_at(2), None); // No parent + + // Test is_valid_at (no duplicates) + assert!(manager.is_valid_at(0)); + assert!(manager.is_valid_at(1)); + assert!(manager.is_valid_at(2)); + + // Test is_sbl_at + assert!(manager.is_sbl_at(0)); // sa_sbl contains SBL + assert!(manager.is_sbl_at(1)); // sa_sbl_child contains SBL + assert!(!manager.is_sbl_at(2)); // vc_normal doesn't contain SBL + + // Test is_default with current mode + manager.set_current_mode_by_index(0); + assert!(manager.is_default()); // sa_sbl has type="default" + manager.set_current_mode_by_index(1); + assert!(!manager.is_default()); // sa_sbl_child doesn't have type="default" + + // Test get_game_raw_at + assert_eq!(manager.get_game_raw_at(0), Some("sa".to_string())); + assert_eq!(manager.get_game_raw_at(1), Some("sa".to_string())); + assert_eq!(manager.get_game_raw_at(2), Some("vc".to_string())); + + // Test get_file_name_at + assert_eq!( + manager.get_file_name_at(0), + Some("/path/to/sa_sbl.xml".to_string()) + ); + assert_eq!( + manager.get_file_name_at(1), + Some("/path/to/sa_sbl_child.xml".to_string()) + ); + assert_eq!( + manager.get_file_name_at(2), + Some("/path/to/vc_normal.xml".to_string()) + ); + + // Test is_autoupdate_allowed_for + assert!(manager.is_autoupdate_allowed_for("library", 0)); // autoupdate="yes" + assert!(!manager.is_autoupdate_allowed_for("classes", 0)); // autoupdate="no" + assert!(manager.is_autoupdate_allowed_for("enums", 0)); // autoupdate="true" + assert!(manager.is_autoupdate_allowed_for("opcodes", 0)); // Default true + + // Test get_game_by_name + assert_eq!(manager.get_game_by_name("sa"), Some(Game::Sa)); + assert_eq!(manager.get_game_by_name("VC"), Some(Game::Vc)); // Case insensitive + assert_eq!(manager.get_game_by_name("gta3"), Some(Game::Gta3)); + assert_eq!(manager.get_game_by_name("sa_mobile"), Some(Game::SaMobile)); + assert_eq!(manager.get_game_by_name("unknown"), None); + + // Test get_first_of + assert_eq!( + manager.get_first_of("library", 0), + Some("C:\\SannyBuilder\\library\\sa.json".to_string()) + ); + assert_eq!( + manager.get_first_of("opcodes", 2), + Some("C:\\SannyBuilder\\opcodes\\vc_1.txt".to_string()) + ); + assert_eq!(manager.get_first_of("opcodes", 0), None); // No opcodes in sa_sbl + + // Test get_list_of methods + let opcodes_list = manager.get_list_of_opcodes(2); + assert_eq!(opcodes_list.len(), 2); + assert_eq!(opcodes_list[0], "C:\\SannyBuilder\\opcodes\\vc_1.txt"); + assert_eq!(opcodes_list[1], "C:\\SannyBuilder\\opcodes\\vc_2.txt"); + + let library_list = manager.get_list_of_library(0); + assert_eq!(library_list.len(), 1); + assert_eq!(library_list[0], "C:\\SannyBuilder\\library\\sa.json"); + + // Test that current mode methods work with new functionality + manager.set_current_mode_by_index(0); + assert_eq!(manager.get_parent(), None); + assert!(manager.is_valid()); + assert!(manager.is_sbl()); + assert_eq!( + manager.get_file_name(), + Some("/path/to/sa_sbl.xml".to_string()) + ); + assert_eq!(manager.get_game_raw(), Some("sa".to_string())); + } + + #[test] + fn test_index_based_getters_comprehensive_fields() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + + // Create a comprehensive test mode with all possible fields + let comprehensive_xml = r#" + + @sb:\data\comprehensive\ + @sb:\compiler\comprehensive.exe + @sb:\constants\comprehensive.txt + @sb:\keywords\comprehensive.txt + @sb:\cleo\comprehensive.txt + @sb:\variables\comprehensive.txt + @sb:\labels\comprehensive.txt + @sb:\arrays\comprehensive.txt + @sb:\missions\comprehensive.txt + @sb:\opcodes\comprehensive_1.txt + @sb:\opcodes\comprehensive_2.txt + @sb:\library\comprehensive_1.json + @sb:\library\comprehensive_2.json + @sb:\classes\comprehensive_1.json + @sb:\classes\comprehensive_2.json + @sb:\enums\comprehensive_1.json + @sb:\enums\comprehensive_2.json + @sb:\ide\comprehensive_1.ide + @sb:\ide\comprehensive_2.ide + @sb:\templates\comprehensive_1.txt + @sb:\templates\comprehensive_2.txt + @sb:\copy\comprehensive_data\ + @sb:\copy\comprehensive_config\ + @sb:\examples\comprehensive\ + @sb:\text\comprehensive.txt + + "#; + + let mode: Mode = from_str(comprehensive_xml).unwrap(); + manager.modes.push(mode); + + // Test all field getters at index 0 + assert_eq!( + manager.get_cleo_default_extensions_at(0), + Some("C:\\SannyBuilder\\cleo\\comprehensive.txt".to_string()) + ); + assert_eq!( + manager.get_mode_variables_at(0), + Some("C:\\SannyBuilder\\variables\\comprehensive.txt".to_string()) + ); + + let enums = manager.get_enums_at(0); + assert_eq!(enums.len(), 2); + assert_eq!(enums[0].autoupdate, Some("true".to_string())); + assert_eq!( + enums[0].value, + "C:\\SannyBuilder\\enums\\comprehensive_1.json" + ); + assert_eq!(enums[1].autoupdate, Some("false".to_string())); + assert_eq!( + enums[1].value, + "C:\\SannyBuilder\\enums\\comprehensive_2.json" + ); + + let ide = manager.get_ide_at(0); + assert_eq!(ide.len(), 2); + assert_eq!( + ide[0].base, + Some("C:\\SannyBuilder\\ide\\base.ide".to_string()) + ); + assert_eq!(ide[0].value, "C:\\SannyBuilder\\ide\\comprehensive_1.ide"); + assert_eq!(ide[1].base, None); + assert_eq!(ide[1].value, "C:\\SannyBuilder\\ide\\comprehensive_2.ide"); + + let templates = manager.get_templates_at(0); + assert_eq!(templates.len(), 2); + assert_eq!(templates[0].r#type, "snippet".to_string()); + assert_eq!( + templates[0].value, + "C:\\SannyBuilder\\templates\\comprehensive_1.txt" + ); + assert_eq!(templates[1].r#type, "function".to_string()); + assert_eq!( + templates[1].value, + "C:\\SannyBuilder\\templates\\comprehensive_2.txt" + ); + + let copy_directory = manager.get_copy_directory_at(0); + assert_eq!(copy_directory.len(), 2); + assert_eq!(copy_directory[0].r#type, "data".to_string()); + assert_eq!( + copy_directory[0].value, + "C:\\SannyBuilder\\copy\\comprehensive_data\\" + ); + assert_eq!(copy_directory[1].r#type, "config".to_string()); + assert_eq!( + copy_directory[1].value, + "C:\\SannyBuilder\\copy\\comprehensive_config\\" + ); + + // Test that all current mode methods delegate correctly + manager.set_current_mode_by_index(0); + assert_eq!( + manager.get_cleo_default_extensions(), + manager.get_cleo_default_extensions_at(0) + ); + assert_eq!( + manager.get_mode_variables(), + manager.get_mode_variables_at(0) + ); + assert_eq!(manager.get_enums(), manager.get_enums_at(0)); + assert_eq!(manager.get_ide(), manager.get_ide_at(0)); + assert_eq!(manager.get_templates(), manager.get_templates_at(0)); + assert_eq!( + manager.get_copy_directory(), + manager.get_copy_directory_at(0) + ); + } + + #[test] + fn test_untested_getter_methods() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + + // Create a comprehensive test mode with all fields + let mode_xml = r#" + + @sb:\data\sa\ + @sb:\constants\sa.txt + cs + @sb:\variables\sa.txt + @sb:\labels\sa.txt + @sb:\arrays\sa.txt + @sb:\missions\sa.txt + @sb:\examples\sa\ + @sb:\text\sa.gxt + @sb:\opcodes\sa_1.txt + @sb:\opcodes\sa_2.txt + @sb:\classes\sa_1.json + @sb:\classes\sa_2.json + @sb:\enums\sa_1.json + @sb:\enums\sa_2.json + + "#; + + let mode: Mode = from_str(mode_xml).unwrap(); + manager.modes.push(mode); + + // Test constants getters + assert_eq!( + manager.get_constants_at(0), + Some("C:\\SannyBuilder\\constants\\sa.txt".to_string()) + ); + manager.set_current_mode_by_index(0); + assert_eq!( + manager.get_constants(), + Some("C:\\SannyBuilder\\constants\\sa.txt".to_string()) + ); + + // Test cleo_default_extensions getters + assert_eq!( + manager.get_cleo_default_extensions_at(0), + Some("cs".to_string()) + ); + assert_eq!( + manager.get_cleo_default_extensions(), + Some("cs".to_string()) + ); + + // Test variables getters + assert_eq!( + manager.get_mode_variables_at(0), + Some("C:\\SannyBuilder\\variables\\sa.txt".to_string()) + ); + assert_eq!( + manager.get_mode_variables(), + Some("C:\\SannyBuilder\\variables\\sa.txt".to_string()) + ); + + // Test labels getters + assert_eq!( + manager.get_labels_at(0), + Some("C:\\SannyBuilder\\labels\\sa.txt".to_string()) + ); + assert_eq!( + manager.get_labels(), + Some("C:\\SannyBuilder\\labels\\sa.txt".to_string()) + ); + + // Test arrays getters + assert_eq!( + manager.get_arrays_at(0), + Some("C:\\SannyBuilder\\arrays\\sa.txt".to_string()) + ); + assert_eq!( + manager.get_arrays(), + Some("C:\\SannyBuilder\\arrays\\sa.txt".to_string()) + ); + + // Test missions getters + assert_eq!( + manager.get_missions_at(0), + Some("C:\\SannyBuilder\\missions\\sa.txt".to_string()) + ); + assert_eq!( + manager.get_missions(), + Some("C:\\SannyBuilder\\missions\\sa.txt".to_string()) + ); + + // Test examples getters + let examples_at = manager.get_examples_at(0); + assert!(examples_at.is_some()); + let examples_at = examples_at.unwrap(); + assert_eq!(examples_at.value, "C:\\SannyBuilder\\examples\\sa\\"); + assert_eq!(examples_at.autoupdate, Some("yes".to_string())); + + let examples = manager.get_examples(); + assert!(examples.is_some()); + let examples = examples.unwrap(); + assert_eq!(examples.value, "C:\\SannyBuilder\\examples\\sa\\"); + assert_eq!(examples.autoupdate, Some("yes".to_string())); + + // Test text getters + let text_at = manager.get_text_at(0); + assert!(text_at.is_some()); + let text_at = text_at.unwrap(); + assert_eq!(text_at.value, "C:\\SannyBuilder\\text\\sa.gxt"); + assert_eq!(text_at.format, Some(TextFormat::Sa)); + + let text = manager.get_text(); + assert!(text.is_some()); + let text = text.unwrap(); + assert_eq!(text.value, "C:\\SannyBuilder\\text\\sa.gxt"); + assert_eq!(text.format, Some(TextFormat::Sa)); + + // Test opcodes getters + let opcodes_at = manager.get_opcodes_at(0); + assert_eq!(opcodes_at.len(), 2); + assert_eq!(opcodes_at[0], "C:\\SannyBuilder\\opcodes\\sa_1.txt"); + assert_eq!(opcodes_at[1], "C:\\SannyBuilder\\opcodes\\sa_2.txt"); + + let opcodes = manager.get_opcodes(); + assert_eq!(opcodes.len(), 2); + assert_eq!(opcodes[0], "C:\\SannyBuilder\\opcodes\\sa_1.txt"); + assert_eq!(opcodes[1], "C:\\SannyBuilder\\opcodes\\sa_2.txt"); + + // Test get_list_of method + let classes_list = manager.get_list_of("classes", 0); + assert_eq!(classes_list.len(), 2); + assert_eq!(classes_list[0], "C:\\SannyBuilder\\classes\\sa_1.json"); + assert_eq!(classes_list[1], "C:\\SannyBuilder\\classes\\sa_2.json"); + + // Test get_list_of_classes + let classes = manager.get_list_of_classes(0); + assert_eq!(classes.len(), 2); + assert_eq!(classes[0], "C:\\SannyBuilder\\classes\\sa_1.json"); + assert_eq!(classes[1], "C:\\SannyBuilder\\classes\\sa_2.json"); + + // Test get_list_of_enums + let enums = manager.get_list_of_enums(0); + assert_eq!(enums.len(), 2); + assert_eq!(enums[0], "C:\\SannyBuilder\\enums\\sa_1.json"); + assert_eq!(enums[1], "C:\\SannyBuilder\\enums\\sa_2.json"); + + // Test edge cases + assert_eq!(manager.get_constants_at(999), None); + assert_eq!(manager.get_list_of("nonexistent", 0).len(), 0); + } + + #[test] + fn test_load_by_mask() { + let manager = ModeManager::new(); + + // Test with non-existent pattern (should return empty list) + let result = manager.load_by_mask("nonexistent*.xml"); + assert_eq!(result.len(), 0); + + // Test with actual files (using the test cases directory) + let result = manager.load_by_mask("test_cases/test_*.xml"); + // The glob may or may not find files depending on working directory + // Just verify it returns a valid vector (no panic) + + // Test with absolute path that doesn't exist + let result = manager.load_by_mask("/nonexistent/path/*.xml"); + assert_eq!(result.len(), 0); + } + + #[test] + fn test_clear_method() { + let mut manager = ModeManager::new(); + manager.register_variable("@test:".to_string(), "value".to_string()); + + // Add a mode + let mode_xml = r#""#; + let mode: Mode = quick_xml::de::from_str(mode_xml).unwrap(); + manager.modes.push(mode); + manager.set_current_mode_by_index(0); + + assert_eq!(manager.modes.len(), 1); + assert!(manager.current_mode_index.is_some()); + assert_eq!(manager.variables.len(), 1); + + // Clear only clears modes and current_mode_index, not variables + manager.clear(); + + assert_eq!(manager.modes.len(), 0); + assert!(manager.current_mode_index.is_none()); + // Variables are NOT cleared by the clear() method + assert_eq!(manager.variables.len(), 1); + } +} diff --git a/src/modes/string_variables.rs b/src/modes/string_variables.rs new file mode 100644 index 0000000..9165b64 --- /dev/null +++ b/src/modes/string_variables.rs @@ -0,0 +1,166 @@ +use std::collections::HashMap; + +/// StringVariables manages placeholder substitution for string values +pub struct StringVariables { + /// Map of placeholder names to their replacement values + variables: HashMap, +} + +impl StringVariables { + /// Create a new StringVariables instance + pub fn new() -> Self { + Self { + variables: HashMap::new(), + } + } + + /// Register a new placeholder variable + /// + /// # Arguments + /// * `name` - The placeholder name (e.g., "@sb:") + /// * `value` - The replacement value (e.g., "C:\\SannyBuilder") + pub fn register(&mut self, name: String, value: String) { + self.variables.insert(name, value); + } + + /// Process a string, replacing all registered placeholders with their values + /// + /// # Arguments + /// * `input` - The string to process + /// + /// # Returns + /// A new string with all placeholders replaced + pub fn process(&self, input: &str) -> String { + let mut result = input.to_string(); + + // Apply all registered replacements + for (placeholder, replacement) in &self.variables { + result = result.replace(placeholder, replacement); + } + + result + } + + /// Get the value of a specific variable + pub fn get(&self, name: &str) -> Option<&String> { + self.variables.get(name) + } + + /// Clear all registered variables + pub fn clear(&mut self) { + self.variables.clear(); + } + + /// Get the number of registered variables + pub fn len(&self) -> usize { + self.variables.len() + } + + /// Check if there are no registered variables + pub fn is_empty(&self) -> bool { + self.variables.is_empty() + } + + /// Get all registered variable names + pub fn get_names(&self) -> Vec { + self.variables.keys().cloned().collect() + } +} + +impl Default for StringVariables { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_substitution() { + let mut vars = StringVariables::new(); + vars.register("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + vars.register("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + let input = "@sb:\\data\\@game:\\models.ide"; + let result = vars.process(input); + + assert_eq!(result, "C:\\SannyBuilder\\data\\D:\\Games\\GTA\\models.ide"); + } + + #[test] + fn test_multiple_same_placeholder() { + let mut vars = StringVariables::new(); + vars.register("@path:".to_string(), "/usr/local".to_string()); + + let input = "@path:/bin:@path:/lib:@path:/share"; + let result = vars.process(input); + + assert_eq!(result, "/usr/local/bin:/usr/local/lib:/usr/local/share"); + } + + #[test] + fn test_no_placeholders() { + let vars = StringVariables::new(); + + let input = "This string has no placeholders"; + let result = vars.process(input); + + assert_eq!(result, input); + } + + #[test] + fn test_unregistered_placeholder() { + let mut vars = StringVariables::new(); + vars.register("@known:".to_string(), "replaced".to_string()); + + let input = "@known: text @unknown: text"; + let result = vars.process(input); + + assert_eq!(result, "replaced text @unknown: text"); + } + + #[test] + fn test_custom_placeholders() { + let mut vars = StringVariables::new(); + vars.register("${user}".to_string(), "john".to_string()); + vars.register("${home}".to_string(), "/home/john".to_string()); + vars.register("{{env}}".to_string(), "production".to_string()); + + let input = "User: ${user}, Home: ${home}, Environment: {{env}}"; + let result = vars.process(input); + + assert_eq!(result, "User: john, Home: /home/john, Environment: production"); + } + + #[test] + fn test_clear_and_helpers() { + let mut vars = StringVariables::new(); + assert!(vars.is_empty()); + assert_eq!(vars.len(), 0); + + vars.register("@test:".to_string(), "value".to_string()); + assert!(!vars.is_empty()); + assert_eq!(vars.len(), 1); + assert_eq!(vars.get("@test:"), Some(&"value".to_string())); + + vars.clear(); + assert!(vars.is_empty()); + assert_eq!(vars.len(), 0); + assert_eq!(vars.get("@test:"), None); + } + + #[test] + fn test_get_names() { + let mut vars = StringVariables::new(); + vars.register("@a:".to_string(), "1".to_string()); + vars.register("@b:".to_string(), "2".to_string()); + vars.register("@c:".to_string(), "3".to_string()); + + let mut names = vars.get_names(); + names.sort(); // Sort for consistent comparison + + assert_eq!(names, vec!["@a:", "@b:", "@c:"]); + } +} \ No newline at end of file diff --git a/test_cases/test_base.xml b/test_cases/test_base.xml new file mode 100644 index 0000000..bafec72 --- /dev/null +++ b/test_cases/test_base.xml @@ -0,0 +1,14 @@ + + + @sb:\data\base\ + @sb:\data\base\library.json + @sb:\data\base\classes.db + @sb:\data\base\enums.txt + @sb:\data\base\constants.txt + @sb:\data\base\keywords.txt + @game:\variables.ini + @game:\text\base.txt + @game:\data\base.dat + @sb:\templates\base.txt + @game:\data\main + \ No newline at end of file diff --git a/test_cases/test_child.xml b/test_cases/test_child.xml new file mode 100644 index 0000000..c742aa5 --- /dev/null +++ b/test_cases/test_child.xml @@ -0,0 +1,12 @@ + + + + @sb:\compiler\child.exe + @sb:\data\child\constants.txt + + @sb:\data\child\extra_classes.db + + @game:\text\child.json + + @sb:\templates\child_custom.txt + \ No newline at end of file diff --git a/test_cases/test_circular_a.xml b/test_cases/test_circular_a.xml new file mode 100644 index 0000000..290f31c --- /dev/null +++ b/test_cases/test_circular_a.xml @@ -0,0 +1,4 @@ + + + @sb:\data\circular_a\ + \ No newline at end of file diff --git a/test_cases/test_circular_b.xml b/test_cases/test_circular_b.xml new file mode 100644 index 0000000..12dcc40 --- /dev/null +++ b/test_cases/test_circular_b.xml @@ -0,0 +1,4 @@ + + + @sb:\data\circular_b\constants.txt + \ No newline at end of file diff --git a/test_cases/test_circular_c.xml b/test_cases/test_circular_c.xml new file mode 100644 index 0000000..3a9e6e6 --- /dev/null +++ b/test_cases/test_circular_c.xml @@ -0,0 +1,4 @@ + + + @sb:\data\circular_c\ + \ No newline at end of file diff --git a/test_cases/test_circular_d.xml b/test_cases/test_circular_d.xml new file mode 100644 index 0000000..b8d5fd7 --- /dev/null +++ b/test_cases/test_circular_d.xml @@ -0,0 +1,4 @@ + + + @sb:\compiler\d.exe + \ No newline at end of file diff --git a/test_cases/test_circular_e.xml b/test_cases/test_circular_e.xml new file mode 100644 index 0000000..910cadf --- /dev/null +++ b/test_cases/test_circular_e.xml @@ -0,0 +1,4 @@ + + + @sb:\data\circular_e\keywords.txt + \ No newline at end of file diff --git a/test_cases/test_grandchild.xml b/test_cases/test_grandchild.xml new file mode 100644 index 0000000..d1d1bf5 --- /dev/null +++ b/test_cases/test_grandchild.xml @@ -0,0 +1,7 @@ + + + + @sb:\data\grandchild\missions.txt + + @sb:\data\grandchild\ + \ No newline at end of file