tact_parser/
config.rs

1//! Configuration file parser for TACT
2//!
3//! Parses build and CDN configuration files which use a simple key-value format.
4//! Some values contain hash-size pairs for referencing other TACT files.
5
6use std::collections::HashMap;
7use tracing::{debug, trace};
8
9use crate::Result;
10
11/// A hash-size pair found in config values
12#[derive(Debug, Clone, PartialEq)]
13pub struct HashPair {
14    /// The hash (usually MD5 hex string)
15    pub hash: String,
16    /// The size in bytes
17    pub size: u64,
18}
19
20/// Configuration file (build or CDN)
21#[derive(Debug, Clone)]
22pub struct ConfigFile {
23    /// All key-value pairs
24    pub values: HashMap<String, String>,
25    /// Hash-size pairs extracted from values
26    pub hashes: HashMap<String, HashPair>,
27}
28
29impl ConfigFile {
30    /// Parse a configuration file from text
31    pub fn parse(text: &str) -> Result<Self> {
32        let mut values = HashMap::new();
33        let mut hashes = HashMap::new();
34
35        for line in text.lines() {
36            let line = line.trim();
37
38            // Skip comments and empty lines
39            if line.is_empty() || line.starts_with('#') {
40                continue;
41            }
42
43            // Split on " = " (space equals space) or " =" (space equals, for empty values)
44            let (key, value) = if let Some(eq_pos) = line.find(" = ") {
45                let key = line[..eq_pos].trim();
46                let value = line[eq_pos + 3..].trim(); // Skip " = " and trim
47                (key, value)
48            } else if let Some(eq_pos) = line.find(" =") {
49                let key = line[..eq_pos].trim();
50                let value = line[eq_pos + 2..].trim(); // Skip " =" and trim
51                (key, value)
52            } else {
53                continue; // No valid key = value format found
54            };
55
56            trace!("Config entry: '{}' = '{}'", key, value);
57
58            // Always insert the value, even if empty
59            values.insert(key.to_string(), value.to_string());
60
61            // Check if value contains hash and optionally size
62            if !value.is_empty() {
63                let parts: Vec<&str> = value.split_whitespace().collect();
64                if !parts.is_empty() && is_hex_hash(parts[0]) {
65                    // Check if it's a hash-size pair
66                    if parts.len() >= 2 {
67                        if let Ok(size) = parts[1].parse::<u64>() {
68                            hashes.insert(
69                                key.to_string(),
70                                HashPair {
71                                    hash: parts[0].to_string(),
72                                    size,
73                                },
74                            );
75                        }
76                    }
77                    // Note: We don't store single hashes in the hashes map
78                    // They can be accessed via get_value() and extracted manually
79                }
80            }
81        }
82
83        debug!(
84            "Parsed config with {} entries, {} hash pairs",
85            values.len(),
86            hashes.len()
87        );
88
89        Ok(ConfigFile { values, hashes })
90    }
91
92    /// Get a value by key
93    pub fn get_value(&self, key: &str) -> Option<&str> {
94        self.values.get(key).map(|s| s.as_str())
95    }
96
97    /// Get a hash by key (extracts from hash-size pairs)
98    pub fn get_hash(&self, key: &str) -> Option<&str> {
99        self.hashes.get(key).map(|hp| hp.hash.as_str())
100    }
101
102    /// Get a size by key (extracts from hash-size pairs)
103    pub fn get_size(&self, key: &str) -> Option<u64> {
104        self.hashes.get(key).map(|hp| hp.size)
105    }
106
107    /// Get a hash pair by key
108    pub fn get_hash_pair(&self, key: &str) -> Option<&HashPair> {
109        self.hashes.get(key)
110    }
111
112    /// Check if a key exists
113    pub fn has_key(&self, key: &str) -> bool {
114        self.values.contains_key(key)
115    }
116
117    /// Get all keys
118    pub fn keys(&self) -> Vec<&str> {
119        self.values.keys().map(|s| s.as_str()).collect()
120    }
121}
122
123/// Common configuration keys for build configs
124pub mod build_keys {
125    /// Root file hash and size
126    pub const ROOT: &str = "root";
127    /// Install manifest hash and size
128    pub const INSTALL: &str = "install";
129    /// Download manifest hash and size
130    pub const DOWNLOAD: &str = "download";
131    /// Encoding file hash and size
132    pub const ENCODING: &str = "encoding";
133    /// Size file hash and size
134    pub const SIZE: &str = "size";
135    /// Patch file hash and size
136    pub const PATCH: &str = "patch";
137    /// Patch config hash and size
138    pub const PATCH_CONFIG: &str = "patch-config";
139    /// Build name
140    pub const BUILD_NAME: &str = "build-name";
141    /// Build UID
142    pub const BUILD_UID: &str = "build-uid";
143    /// Build product
144    pub const BUILD_PRODUCT: &str = "build-product";
145    /// Encoding sizes
146    pub const ENCODING_SIZE: &str = "encoding-size";
147    /// Install sizes
148    pub const INSTALL_SIZE: &str = "install-size";
149    /// Download sizes
150    pub const DOWNLOAD_SIZE: &str = "download-size";
151    /// Size sizes
152    pub const SIZE_SIZE: &str = "size-size";
153    /// VFS root
154    pub const VFS_ROOT: &str = "vfs-root";
155}
156
157/// Common configuration keys for CDN configs
158pub mod cdn_keys {
159    /// Archive group
160    pub const ARCHIVE_GROUP: &str = "archive-group";
161    /// Archives list
162    pub const ARCHIVES: &str = "archives";
163    /// Patch archives
164    pub const PATCH_ARCHIVES: &str = "patch-archives";
165    /// File index
166    pub const FILE_INDEX: &str = "file-index";
167    /// Patch file index
168    pub const PATCH_FILE_INDEX: &str = "patch-file-index";
169}
170
171/// Check if a string looks like a hex hash
172fn is_hex_hash(s: &str) -> bool {
173    s.len() >= 6 && s.chars().all(|c| c.is_ascii_hexdigit())
174}
175
176/// Build configuration
177#[derive(Debug, Clone)]
178pub struct BuildConfig {
179    /// Underlying config file
180    pub config: ConfigFile,
181}
182
183impl BuildConfig {
184    /// Parse a build configuration
185    pub fn parse(text: &str) -> Result<Self> {
186        let config = ConfigFile::parse(text)?;
187        Ok(BuildConfig { config })
188    }
189
190    /// Helper to extract hash from a key, handling both formats
191    fn extract_hash(&self, key: &str) -> Option<&str> {
192        // First try to get from hash-size pairs
193        if let Some(hash) = self.config.get_hash(key) {
194            return Some(hash);
195        }
196
197        // Fall back to single hash value or first hash in multi-hash value
198        if let Some(value) = self.config.get_value(key) {
199            let parts: Vec<&str> = value.split_whitespace().collect();
200            if !parts.is_empty() && is_hex_hash(parts[0]) {
201                return Some(parts[0]);
202            }
203        }
204
205        None
206    }
207
208    /// Get the root file hash (handles both single hash and hash-size formats)
209    pub fn root_hash(&self) -> Option<&str> {
210        self.extract_hash(build_keys::ROOT)
211    }
212
213    /// Get the encoding file hash (handles both single hash and hash-size formats)
214    pub fn encoding_hash(&self) -> Option<&str> {
215        self.extract_hash(build_keys::ENCODING)
216    }
217
218    /// Get the install manifest hash (handles both single hash and hash-size formats)
219    pub fn install_hash(&self) -> Option<&str> {
220        self.extract_hash(build_keys::INSTALL)
221    }
222
223    /// Get the download manifest hash (handles both single hash and hash-size formats)
224    pub fn download_hash(&self) -> Option<&str> {
225        self.extract_hash(build_keys::DOWNLOAD)
226    }
227
228    /// Get the size file hash (handles both single hash and hash-size formats)
229    pub fn size_hash(&self) -> Option<&str> {
230        self.extract_hash(build_keys::SIZE)
231    }
232
233    /// Get the build name
234    pub fn build_name(&self) -> Option<&str> {
235        self.config.get_value(build_keys::BUILD_NAME)
236    }
237}
238
239/// CDN configuration
240#[derive(Debug, Clone)]
241pub struct CdnConfig {
242    /// Underlying config file
243    pub config: ConfigFile,
244}
245
246impl CdnConfig {
247    /// Parse a CDN configuration
248    pub fn parse(text: &str) -> Result<Self> {
249        let config = ConfigFile::parse(text)?;
250        Ok(CdnConfig { config })
251    }
252
253    /// Get the archives list
254    pub fn archives(&self) -> Vec<&str> {
255        self.config
256            .get_value(cdn_keys::ARCHIVES)
257            .map(|v| v.split_whitespace().collect())
258            .unwrap_or_default()
259    }
260
261    /// Get the archive group
262    pub fn archive_group(&self) -> Option<&str> {
263        self.config.get_value(cdn_keys::ARCHIVE_GROUP)
264    }
265
266    /// Get the file index
267    pub fn file_index(&self) -> Option<&str> {
268        self.config.get_value(cdn_keys::FILE_INDEX)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_parse_simple_config() {
278        let config_text = r#"
279# This is a comment
280key1 = value1
281key2 = value2
282
283# Empty lines are ignored
284key3 = value with spaces
285        "#;
286
287        let config = ConfigFile::parse(config_text).unwrap();
288        assert_eq!(config.get_value("key1"), Some("value1"));
289        assert_eq!(config.get_value("key2"), Some("value2"));
290        assert_eq!(config.get_value("key3"), Some("value with spaces"));
291        assert_eq!(config.get_value("nonexistent"), None);
292    }
293
294    #[test]
295    fn test_parse_hash_pairs() {
296        let config_text = r#"
297encoding = abc123def456789 123456
298root = 0123456789abcdef 789
299install = fedcba9876543210 456789
300invalid = not_a_hash 123
301        "#;
302
303        let config = ConfigFile::parse(config_text).unwrap();
304
305        // Check hash extraction
306        assert_eq!(config.get_hash("encoding"), Some("abc123def456789"));
307        assert_eq!(config.get_size("encoding"), Some(123456));
308
309        assert_eq!(config.get_hash("root"), Some("0123456789abcdef"));
310        assert_eq!(config.get_size("root"), Some(789));
311
312        assert_eq!(config.get_hash("install"), Some("fedcba9876543210"));
313        assert_eq!(config.get_size("install"), Some(456789));
314
315        // Invalid hash should not be extracted
316        assert_eq!(config.get_hash("invalid"), None);
317
318        // But the raw value should still be there
319        assert_eq!(config.get_value("invalid"), Some("not_a_hash 123"));
320    }
321
322    #[test]
323    fn test_build_config() {
324        let config_text = r#"
325root = abc123 100
326encoding = def456 200
327install = 789abc 300
328download = cdef01 400
329size = 234567 500
330build-name = 10.0.0.12345
331build-uid = wow/game
332        "#;
333
334        let build = BuildConfig::parse(config_text).unwrap();
335
336        assert_eq!(build.root_hash(), Some("abc123"));
337        assert_eq!(build.encoding_hash(), Some("def456"));
338        assert_eq!(build.install_hash(), Some("789abc"));
339        assert_eq!(build.download_hash(), Some("cdef01"));
340        assert_eq!(build.size_hash(), Some("234567"));
341        assert_eq!(build.build_name(), Some("10.0.0.12345"));
342    }
343
344    #[test]
345    fn test_cdn_config() {
346        let config_text = r#"
347archives = archive1 archive2 archive3
348archive-group = abc123def456
349file-index = 789abcdef012
350patch-archives = patch1 patch2
351        "#;
352
353        let cdn = CdnConfig::parse(config_text).unwrap();
354
355        let archives = cdn.archives();
356        assert_eq!(archives.len(), 3);
357        assert_eq!(archives[0], "archive1");
358        assert_eq!(archives[1], "archive2");
359        assert_eq!(archives[2], "archive3");
360
361        assert_eq!(cdn.archive_group(), Some("abc123def456"));
362        assert_eq!(cdn.file_index(), Some("789abcdef012"));
363    }
364
365    #[test]
366    fn test_is_hex_hash() {
367        assert!(is_hex_hash("abc123def456"));
368        assert!(is_hex_hash("0123456789ABCDEF"));
369        assert!(!is_hex_hash("not_hex"));
370        assert!(!is_hex_hash("abc12g")); // 'g' is not hex
371        assert!(!is_hex_hash("abc")); // Too short
372    }
373}