Skip to main content

faf_rust_sdk/binary/
string_table.rs

1//! FAFb v2 String Table
2//!
3//! A dedicated string table section (classic ELF/IFF pattern).
4//! Section entries reference names by `u8` index into the table.
5//! Supports up to 256 unique names (matches `MAX_SECTIONS`).
6//!
7//! ## Wire format
8//!
9//! ```text
10//! [u16 count] [u16 len₀][utf8₀] [u16 len₁][utf8₁] ...
11//! ```
12//!
13//! Max 256 entries, max 255 bytes per name.
14
15use std::collections::HashMap;
16
17use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
18
19use super::error::{FafbError, FafbResult};
20
21/// Maximum number of entries in the string table
22pub const MAX_STRING_TABLE_ENTRIES: usize = 256;
23
24/// Maximum byte length of a single string table entry
25pub const MAX_STRING_LENGTH: usize = 255;
26
27/// A string table mapping u8 indices to UTF-8 names
28#[derive(Debug, Clone, Default)]
29pub struct StringTable {
30    /// Ordered list of names (index = position)
31    entries: Vec<String>,
32    /// Reverse lookup: name → index (for dedup)
33    index_map: HashMap<String, u8>,
34}
35
36impl StringTable {
37    /// Create an empty string table
38    pub fn new() -> Self {
39        Self {
40            entries: Vec::new(),
41            index_map: HashMap::new(),
42        }
43    }
44
45    /// Add a name to the table. Returns the index.
46    /// If the name already exists, returns the existing index (dedup).
47    pub fn add(&mut self, name: &str) -> FafbResult<u8> {
48        // Dedup: return existing index if name is already in the table
49        if let Some(&idx) = self.index_map.get(name) {
50            return Ok(idx);
51        }
52
53        // Validate
54        if self.entries.len() >= MAX_STRING_TABLE_ENTRIES {
55            return Err(FafbError::StringTableFull {
56                max: MAX_STRING_TABLE_ENTRIES,
57            });
58        }
59        if name.len() > MAX_STRING_LENGTH {
60            return Err(FafbError::StringTableEntryTooLong {
61                length: name.len(),
62                max: MAX_STRING_LENGTH,
63            });
64        }
65
66        let idx = self.entries.len() as u8;
67        self.entries.push(name.to_string());
68        self.index_map.insert(name.to_string(), idx);
69        Ok(idx)
70    }
71
72    /// Get a name by index
73    pub fn get(&self, index: u8) -> FafbResult<&str> {
74        self.entries.get(index as usize).map(|s| s.as_str()).ok_or(
75            FafbError::StringTableIndexOutOfBounds {
76                index,
77                count: self.entries.len() as u16,
78            },
79        )
80    }
81
82    /// Get the index for a name (if it exists)
83    pub fn index_of(&self, name: &str) -> Option<u8> {
84        self.index_map.get(name).copied()
85    }
86
87    /// Number of entries
88    pub fn len(&self) -> usize {
89        self.entries.len()
90    }
91
92    /// Check if empty
93    pub fn is_empty(&self) -> bool {
94        self.entries.is_empty()
95    }
96
97    /// Get all entries as a slice
98    pub fn entries(&self) -> &[String] {
99        &self.entries
100    }
101
102    /// Serialize to bytes
103    ///
104    /// Format: `[u16 count] [u16 len₀][utf8₀] [u16 len₁][utf8₁] ...`
105    pub fn to_bytes(&self) -> FafbResult<Vec<u8>> {
106        let mut buf = Vec::new();
107        buf.write_u16::<LittleEndian>(self.entries.len() as u16)?;
108        for entry in &self.entries {
109            buf.write_u16::<LittleEndian>(entry.len() as u16)?;
110            buf.extend_from_slice(entry.as_bytes());
111        }
112        Ok(buf)
113    }
114
115    /// Deserialize from bytes
116    pub fn from_bytes(data: &[u8]) -> FafbResult<Self> {
117        if data.len() < 2 {
118            return Err(FafbError::FileTooSmall {
119                expected: 2,
120                actual: data.len(),
121            });
122        }
123
124        let mut cursor = std::io::Cursor::new(data);
125        let count = cursor.read_u16::<LittleEndian>()? as usize;
126
127        if count > MAX_STRING_TABLE_ENTRIES {
128            return Err(FafbError::StringTableFull {
129                max: MAX_STRING_TABLE_ENTRIES,
130            });
131        }
132
133        let mut table = Self::new();
134        for _ in 0..count {
135            let len = cursor.read_u16::<LittleEndian>()? as usize;
136            if len > MAX_STRING_LENGTH {
137                return Err(FafbError::StringTableEntryTooLong {
138                    length: len,
139                    max: MAX_STRING_LENGTH,
140                });
141            }
142
143            let pos = cursor.position() as usize;
144            if pos + len > data.len() {
145                return Err(FafbError::FileTooSmall {
146                    expected: pos + len,
147                    actual: data.len(),
148                });
149            }
150
151            let name = std::str::from_utf8(&data[pos..pos + len])
152                .map_err(|e| FafbError::InvalidUtf8(e.to_string()))?;
153            cursor.set_position((pos + len) as u64);
154
155            // Use internal push to avoid re-validation
156            let idx = table.entries.len() as u8;
157            table.entries.push(name.to_string());
158            table.index_map.insert(name.to_string(), idx);
159        }
160
161        Ok(table)
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_add_and_get() {
171        let mut table = StringTable::new();
172        let idx = table.add("project").unwrap();
173        assert_eq!(idx, 0);
174        assert_eq!(table.get(0).unwrap(), "project");
175    }
176
177    #[test]
178    fn test_dedup() {
179        let mut table = StringTable::new();
180        let idx1 = table.add("project").unwrap();
181        let idx2 = table.add("project").unwrap();
182        assert_eq!(idx1, idx2);
183        assert_eq!(table.len(), 1);
184    }
185
186    #[test]
187    fn test_multiple_entries() {
188        let mut table = StringTable::new();
189        assert_eq!(table.add("project").unwrap(), 0);
190        assert_eq!(table.add("tech_stack").unwrap(), 1);
191        assert_eq!(table.add("commands").unwrap(), 2);
192        assert_eq!(table.len(), 3);
193    }
194
195    #[test]
196    fn test_index_of() {
197        let mut table = StringTable::new();
198        table.add("project").unwrap();
199        table.add("commands").unwrap();
200        assert_eq!(table.index_of("project"), Some(0));
201        assert_eq!(table.index_of("commands"), Some(1));
202        assert_eq!(table.index_of("missing"), None);
203    }
204
205    #[test]
206    fn test_roundtrip() {
207        let mut table = StringTable::new();
208        table.add("project").unwrap();
209        table.add("tech_stack").unwrap();
210        table.add("docs").unwrap();
211
212        let bytes = table.to_bytes().unwrap();
213        let recovered = StringTable::from_bytes(&bytes).unwrap();
214
215        assert_eq!(recovered.len(), 3);
216        assert_eq!(recovered.get(0).unwrap(), "project");
217        assert_eq!(recovered.get(1).unwrap(), "tech_stack");
218        assert_eq!(recovered.get(2).unwrap(), "docs");
219    }
220
221    #[test]
222    fn test_max_entries() {
223        let mut table = StringTable::new();
224        for i in 0..256 {
225            table.add(&format!("key_{}", i)).unwrap();
226        }
227        assert_eq!(table.len(), 256);
228
229        // 257th should fail
230        let result = table.add("overflow");
231        assert!(matches!(result, Err(FafbError::StringTableFull { .. })));
232    }
233
234    #[test]
235    fn test_max_name_length() {
236        let mut table = StringTable::new();
237        // 255 bytes is OK
238        let name_255 = "a".repeat(255);
239        assert!(table.add(&name_255).is_ok());
240
241        // 256 bytes should fail
242        let name_256 = "b".repeat(256);
243        let result = table.add(&name_256);
244        assert!(matches!(
245            result,
246            Err(FafbError::StringTableEntryTooLong { .. })
247        ));
248    }
249
250    #[test]
251    fn test_unicode_names() {
252        let mut table = StringTable::new();
253        table.add("日本語").unwrap();
254        table.add("émojis").unwrap();
255
256        let bytes = table.to_bytes().unwrap();
257        let recovered = StringTable::from_bytes(&bytes).unwrap();
258
259        assert_eq!(recovered.get(0).unwrap(), "日本語");
260        assert_eq!(recovered.get(1).unwrap(), "émojis");
261    }
262
263    #[test]
264    fn test_empty_table_roundtrip() {
265        let table = StringTable::new();
266        let bytes = table.to_bytes().unwrap();
267        let recovered = StringTable::from_bytes(&bytes).unwrap();
268        assert_eq!(recovered.len(), 0);
269        assert!(recovered.is_empty());
270    }
271
272    #[test]
273    fn test_index_out_of_bounds() {
274        let table = StringTable::new();
275        let result = table.get(0);
276        assert!(matches!(
277            result,
278            Err(FafbError::StringTableIndexOutOfBounds { .. })
279        ));
280    }
281
282    #[test]
283    fn test_truncated_data() {
284        let mut table = StringTable::new();
285        table.add("hello").unwrap();
286        let bytes = table.to_bytes().unwrap();
287
288        // Truncate the data mid-string
289        let truncated = &bytes[..5]; // count(2) + len(2) + 1 byte of "hello"
290        let result = StringTable::from_bytes(truncated);
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_empty_string_name() {
296        let mut table = StringTable::new();
297        let idx = table.add("").unwrap();
298        assert_eq!(idx, 0);
299        assert_eq!(table.get(0).unwrap(), "");
300    }
301
302    #[test]
303    fn test_dedup_preserves_first_index() {
304        let mut table = StringTable::new();
305        table.add("a").unwrap();
306        table.add("b").unwrap();
307        table.add("c").unwrap();
308        // Adding "a" again should return 0, not 3
309        assert_eq!(table.add("a").unwrap(), 0);
310        assert_eq!(table.len(), 3);
311    }
312
313    #[test]
314    fn test_serialized_size() {
315        let mut table = StringTable::new();
316        table.add("abc").unwrap(); // 3 bytes
317        table.add("de").unwrap(); // 2 bytes
318
319        let bytes = table.to_bytes().unwrap();
320        // 2 (count) + 2+3 (entry 0) + 2+2 (entry 1) = 11
321        assert_eq!(bytes.len(), 11);
322    }
323
324    #[test]
325    fn test_too_small_data() {
326        // Less than 2 bytes for count
327        let result = StringTable::from_bytes(&[0x01]);
328        assert!(matches!(result, Err(FafbError::FileTooSmall { .. })));
329    }
330}