Skip to main content

synx_core/
binary.rs

1//! SYNX Binary Format (.synxb) — compact binary serialization with string interning.
2//!
3//! Layout:
4//! ```text
5//! HEADER       5 bytes magic "SYNXB"
6//!              1 byte  version (currently 1)
7//!              1 byte  flags   (bit 0: active, bit 1: locked,
8//!                               bit 2: has_metadata, bit 3: resolved,
9//!                               bit 4: tool, bit 5: schema, bit 6: llm)
10//! STRING_TABLE varint count
11//!              for each: varint len + UTF-8 bytes
12//! VALUE_TREE   Root Value (recursive, strings as table indices)
13//! [METADATA]   if flag bit 2 set
14//! [INCLUDES]   if flag bit 2 set
15//! ```
16//!
17//! Type tags (1 byte):
18//!   0x00 Null
19//!   0x01 Bool(false)
20//!   0x02 Bool(true)
21//!   0x03 Int       + zigzag varint
22//!   0x04 Float     + 8 bytes LE
23//!   0x05 String    + varint string_table_index
24//!   0x06 Array     + varint count + values
25//!   0x07 Object    + varint count + (varint key_index + value) pairs
26//!   0x08 Secret    + varint string_table_index
27
28use crate::value::{
29    Constraints, IncludeDirective, Meta, MetaMap, Mode, ParseResult, Value,
30};
31use std::collections::HashMap;
32
33const MAGIC: &[u8; 5] = b"SYNXB";
34const FORMAT_VERSION: u8 = 1;
35
36const FLAG_ACTIVE: u8 = 0b0000_0001;
37const FLAG_LOCKED: u8 = 0b0000_0010;
38const FLAG_HAS_META: u8 = 0b0000_0100;
39const FLAG_RESOLVED: u8 = 0b0000_1000;
40const FLAG_TOOL: u8 = 0b0001_0000;
41const FLAG_SCHEMA: u8 = 0b0010_0000;
42const FLAG_LLM: u8 = 0b0100_0000;
43
44const TAG_NULL: u8 = 0x00;
45const TAG_FALSE: u8 = 0x01;
46const TAG_TRUE: u8 = 0x02;
47const TAG_INT: u8 = 0x03;
48const TAG_FLOAT: u8 = 0x04;
49const TAG_STRING: u8 = 0x05;
50const TAG_ARRAY: u8 = 0x06;
51const TAG_OBJECT: u8 = 0x07;
52const TAG_SECRET: u8 = 0x08;
53
54// ─── Varint Encoding (LEB128 unsigned) ───────────────────────────────────────
55
56fn encode_varint(out: &mut Vec<u8>, mut val: u64) {
57    loop {
58        let byte = (val & 0x7F) as u8;
59        val >>= 7;
60        if val == 0 {
61            out.push(byte);
62            return;
63        }
64        out.push(byte | 0x80);
65    }
66}
67
68fn decode_varint(data: &[u8], pos: &mut usize) -> Result<u64, String> {
69    let mut result: u64 = 0;
70    let mut shift = 0u32;
71    loop {
72        if *pos >= data.len() {
73            return Err("unexpected end of data in varint".into());
74        }
75        let byte = data[*pos];
76        *pos += 1;
77        result |= ((byte & 0x7F) as u64) << shift;
78        if byte & 0x80 == 0 {
79            return Ok(result);
80        }
81        shift += 7;
82        if shift >= 64 {
83            return Err("varint overflow".into());
84        }
85    }
86}
87
88// Zigzag encode i64 → u64
89fn zigzag_encode(n: i64) -> u64 {
90    ((n << 1) ^ (n >> 63)) as u64
91}
92
93// Zigzag decode u64 → i64
94fn zigzag_decode(n: u64) -> i64 {
95    ((n >> 1) as i64) ^ (-((n & 1) as i64))
96}
97
98// ─── String Table ────────────────────────────────────────────────────────────
99
100struct StringTable {
101    strings: Vec<String>,
102    index: HashMap<String, u32>,
103}
104
105impl StringTable {
106    fn new() -> Self {
107        Self { strings: Vec::new(), index: HashMap::new() }
108    }
109
110    fn intern(&mut self, s: &str) -> u32 {
111        if let Some(&idx) = self.index.get(s) {
112            return idx;
113        }
114        let idx = self.strings.len() as u32;
115        self.strings.push(s.to_string());
116        self.index.insert(s.to_string(), idx);
117        idx
118    }
119
120    fn collect_value(&mut self, val: &Value) {
121        match val {
122            Value::String(s) | Value::Secret(s) => { self.intern(s); }
123            Value::Array(arr) => {
124                for item in arr { self.collect_value(item); }
125            }
126            Value::Object(map) => {
127                for (key, val) in map {
128                    self.intern(key);
129                    self.collect_value(val);
130                }
131            }
132            _ => {}
133        }
134    }
135
136    fn collect_metadata(&mut self, metadata: &HashMap<String, MetaMap>) {
137        for (path, meta_map) in metadata {
138            self.intern(path);
139            for (key, meta) in meta_map {
140                self.intern(key);
141                for m in &meta.markers { self.intern(m); }
142                for a in &meta.args { self.intern(a); }
143                if let Some(ref th) = meta.type_hint { self.intern(th); }
144                if let Some(ref c) = meta.constraints {
145                    if let Some(ref tn) = c.type_name { self.intern(tn); }
146                    if let Some(ref pat) = c.pattern { self.intern(pat); }
147                    if let Some(ref ev) = c.enum_values {
148                        for v in ev { self.intern(v); }
149                    }
150                }
151            }
152        }
153    }
154
155    fn collect_includes(&mut self, includes: &[IncludeDirective]) {
156        for inc in includes {
157            self.intern(&inc.path);
158            self.intern(&inc.alias);
159        }
160    }
161
162    fn encode(&self, out: &mut Vec<u8>) {
163        encode_varint(out, self.strings.len() as u64);
164        for s in &self.strings {
165            encode_varint(out, s.len() as u64);
166            out.extend_from_slice(s.as_bytes());
167        }
168    }
169}
170
171struct StringTableReader {
172    strings: Vec<String>,
173}
174
175impl StringTableReader {
176    fn decode(data: &[u8], pos: &mut usize) -> Result<Self, String> {
177        let count = decode_varint(data, pos)? as usize;
178        let mut strings = Vec::with_capacity(count);
179        for _ in 0..count {
180            let len = decode_varint(data, pos)? as usize;
181            if *pos + len > data.len() {
182                return Err("unexpected end of data in string table".into());
183            }
184            let s = std::str::from_utf8(&data[*pos..*pos + len])
185                .map_err(|e| format!("invalid UTF-8 in string table: {}", e))?
186                .to_string();
187            *pos += len;
188            strings.push(s);
189        }
190        Ok(Self { strings })
191    }
192
193    fn get(&self, idx: u32) -> Result<&str, String> {
194        self.strings.get(idx as usize)
195            .map(|s| s.as_str())
196            .ok_or_else(|| format!("string index {} out of bounds (size {})", idx, self.strings.len()))
197    }
198}
199
200// ─── Value encoding (with string table) ─────────────────────────────────────
201
202fn encode_value(out: &mut Vec<u8>, val: &Value, st: &StringTable) {
203    match val {
204        Value::Null => out.push(TAG_NULL),
205        Value::Bool(false) => out.push(TAG_FALSE),
206        Value::Bool(true) => out.push(TAG_TRUE),
207        Value::Int(n) => {
208            out.push(TAG_INT);
209            encode_varint(out, zigzag_encode(*n));
210        }
211        Value::Float(f) => {
212            out.push(TAG_FLOAT);
213            out.extend_from_slice(&f.to_le_bytes());
214        }
215        Value::String(s) => {
216            out.push(TAG_STRING);
217            encode_varint(out, st.index[s] as u64);
218        }
219        Value::Array(arr) => {
220            out.push(TAG_ARRAY);
221            encode_varint(out, arr.len() as u64);
222            for item in arr {
223                encode_value(out, item, st);
224            }
225        }
226        Value::Object(map) => {
227            out.push(TAG_OBJECT);
228            let mut entries: Vec<(&str, &Value)> =
229                map.iter().map(|(k, v)| (k.as_str(), v)).collect();
230            entries.sort_unstable_by_key(|(k, _)| *k);
231            encode_varint(out, entries.len() as u64);
232            for (key, val) in entries {
233                encode_varint(out, st.index[key] as u64);
234                encode_value(out, val, st);
235            }
236        }
237        Value::Secret(s) => {
238            out.push(TAG_SECRET);
239            encode_varint(out, st.index[s] as u64);
240        }
241    }
242}
243
244fn decode_value(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<Value, String> {
245    if *pos >= data.len() {
246        return Err("unexpected end of data".into());
247    }
248    let tag = data[*pos];
249    *pos += 1;
250    match tag {
251        TAG_NULL => Ok(Value::Null),
252        TAG_FALSE => Ok(Value::Bool(false)),
253        TAG_TRUE => Ok(Value::Bool(true)),
254        TAG_INT => {
255            let raw = decode_varint(data, pos)?;
256            Ok(Value::Int(zigzag_decode(raw)))
257        }
258        TAG_FLOAT => {
259            if *pos + 8 > data.len() {
260                return Err("unexpected end of data in float".into());
261            }
262            let bytes: [u8; 8] = data[*pos..*pos + 8]
263                .try_into()
264                .map_err(|_| "float decode error")?;
265            *pos += 8;
266            Ok(Value::Float(f64::from_le_bytes(bytes)))
267        }
268        TAG_STRING => {
269            let idx = decode_varint(data, pos)? as u32;
270            Ok(Value::String(st.get(idx)?.to_string()))
271        }
272        TAG_ARRAY => {
273            let count = decode_varint(data, pos)? as usize;
274            let mut arr = Vec::with_capacity(count);
275            for _ in 0..count {
276                arr.push(decode_value(data, pos, st)?);
277            }
278            Ok(Value::Array(arr))
279        }
280        TAG_OBJECT => {
281            let count = decode_varint(data, pos)? as usize;
282            let mut map = HashMap::with_capacity(count);
283            for _ in 0..count {
284                let key_idx = decode_varint(data, pos)? as u32;
285                let key = st.get(key_idx)?.to_string();
286                let val = decode_value(data, pos, st)?;
287                map.insert(key, val);
288            }
289            Ok(Value::Object(map))
290        }
291        TAG_SECRET => {
292            let idx = decode_varint(data, pos)? as u32;
293            Ok(Value::Secret(st.get(idx)?.to_string()))
294        }
295        _ => Err(format!("unknown type tag: 0x{:02x}", tag)),
296    }
297}
298
299// ─── Metadata encoding ──────────────────────────────────────────────────────
300
301fn encode_constraints(out: &mut Vec<u8>, c: &Constraints, st: &StringTable) {
302    let mut bits: u8 = 0;
303    if c.min.is_some() { bits |= 0x01; }
304    if c.max.is_some() { bits |= 0x02; }
305    if c.type_name.is_some() { bits |= 0x04; }
306    if c.required { bits |= 0x08; }
307    if c.readonly { bits |= 0x10; }
308    if c.pattern.is_some() { bits |= 0x20; }
309    if c.enum_values.is_some() { bits |= 0x40; }
310    out.push(bits);
311
312    if let Some(min) = c.min { out.extend_from_slice(&min.to_le_bytes()); }
313    if let Some(max) = c.max { out.extend_from_slice(&max.to_le_bytes()); }
314    if let Some(ref tn) = c.type_name { encode_varint(out, st.index[tn] as u64); }
315    if let Some(ref pat) = c.pattern { encode_varint(out, st.index[pat] as u64); }
316    if let Some(ref ev) = c.enum_values {
317        encode_varint(out, ev.len() as u64);
318        for v in ev { encode_varint(out, st.index[v] as u64); }
319    }
320}
321
322fn decode_constraints(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<Constraints, String> {
323    if *pos >= data.len() {
324        return Err("unexpected end of data in constraints".into());
325    }
326    let bits = data[*pos];
327    *pos += 1;
328    let mut c = Constraints::default();
329
330    if bits & 0x01 != 0 {
331        if *pos + 8 > data.len() { return Err("truncated min".into()); }
332        let bytes: [u8; 8] = data[*pos..*pos + 8].try_into().map_err(|_| "min decode")?;
333        c.min = Some(f64::from_le_bytes(bytes));
334        *pos += 8;
335    }
336    if bits & 0x02 != 0 {
337        if *pos + 8 > data.len() { return Err("truncated max".into()); }
338        let bytes: [u8; 8] = data[*pos..*pos + 8].try_into().map_err(|_| "max decode")?;
339        c.max = Some(f64::from_le_bytes(bytes));
340        *pos += 8;
341    }
342    if bits & 0x04 != 0 {
343        let idx = decode_varint(data, pos)? as u32;
344        c.type_name = Some(st.get(idx)?.to_string());
345    }
346    if bits & 0x08 != 0 { c.required = true; }
347    if bits & 0x10 != 0 { c.readonly = true; }
348    if bits & 0x20 != 0 {
349        let idx = decode_varint(data, pos)? as u32;
350        c.pattern = Some(st.get(idx)?.to_string());
351    }
352    if bits & 0x40 != 0 {
353        let count = decode_varint(data, pos)? as usize;
354        let mut vals = Vec::with_capacity(count);
355        for _ in 0..count {
356            let idx = decode_varint(data, pos)? as u32;
357            vals.push(st.get(idx)?.to_string());
358        }
359        c.enum_values = Some(vals);
360    }
361    Ok(c)
362}
363
364fn encode_meta(out: &mut Vec<u8>, meta: &Meta, st: &StringTable) {
365    encode_varint(out, meta.markers.len() as u64);
366    for m in &meta.markers { encode_varint(out, st.index[m] as u64); }
367    encode_varint(out, meta.args.len() as u64);
368    for a in &meta.args { encode_varint(out, st.index[a] as u64); }
369    if let Some(ref th) = meta.type_hint {
370        out.push(1);
371        encode_varint(out, st.index[th] as u64);
372    } else {
373        out.push(0);
374    }
375    if let Some(ref c) = meta.constraints {
376        out.push(1);
377        encode_constraints(out, c, st);
378    } else {
379        out.push(0);
380    }
381}
382
383fn decode_meta(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<Meta, String> {
384    let marker_count = decode_varint(data, pos)? as usize;
385    let mut markers = Vec::with_capacity(marker_count);
386    for _ in 0..marker_count {
387        let idx = decode_varint(data, pos)? as u32;
388        markers.push(st.get(idx)?.to_string());
389    }
390
391    let arg_count = decode_varint(data, pos)? as usize;
392    let mut args = Vec::with_capacity(arg_count);
393    for _ in 0..arg_count {
394        let idx = decode_varint(data, pos)? as u32;
395        args.push(st.get(idx)?.to_string());
396    }
397
398    if *pos >= data.len() { return Err("unexpected end in meta".into()); }
399    let has_th = data[*pos];
400    *pos += 1;
401    let type_hint = if has_th != 0 {
402        let idx = decode_varint(data, pos)? as u32;
403        Some(st.get(idx)?.to_string())
404    } else { None };
405
406    if *pos >= data.len() { return Err("unexpected end in meta".into()); }
407    let has_c = data[*pos];
408    *pos += 1;
409    let constraints = if has_c != 0 { Some(decode_constraints(data, pos, st)?) } else { None };
410
411    Ok(Meta { markers, args, type_hint, constraints })
412}
413
414fn encode_metadata(out: &mut Vec<u8>, metadata: &HashMap<String, MetaMap>, st: &StringTable) {
415    let mut outer_keys: Vec<&str> = metadata.keys().map(|k| k.as_str()).collect();
416    outer_keys.sort_unstable();
417    encode_varint(out, outer_keys.len() as u64);
418    for key_path in outer_keys {
419        encode_varint(out, st.index[key_path] as u64);
420        let meta_map = &metadata[key_path];
421        let mut inner_keys: Vec<&str> = meta_map.keys().map(|k| k.as_str()).collect();
422        inner_keys.sort_unstable();
423        encode_varint(out, inner_keys.len() as u64);
424        for field_key in inner_keys {
425            encode_varint(out, st.index[field_key] as u64);
426            encode_meta(out, &meta_map[field_key], st);
427        }
428    }
429}
430
431fn decode_metadata(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<HashMap<String, MetaMap>, String> {
432    let outer_count = decode_varint(data, pos)? as usize;
433    let mut metadata = HashMap::with_capacity(outer_count);
434    for _ in 0..outer_count {
435        let path_idx = decode_varint(data, pos)? as u32;
436        let key_path = st.get(path_idx)?.to_string();
437        let inner_count = decode_varint(data, pos)? as usize;
438        let mut meta_map = HashMap::with_capacity(inner_count);
439        for _ in 0..inner_count {
440            let key_idx = decode_varint(data, pos)? as u32;
441            let field_key = st.get(key_idx)?.to_string();
442            let meta = decode_meta(data, pos, st)?;
443            meta_map.insert(field_key, meta);
444        }
445        metadata.insert(key_path, meta_map);
446    }
447    Ok(metadata)
448}
449
450fn encode_includes(out: &mut Vec<u8>, includes: &[IncludeDirective], st: &StringTable) {
451    encode_varint(out, includes.len() as u64);
452    for inc in includes {
453        encode_varint(out, st.index[&inc.path] as u64);
454        encode_varint(out, st.index[&inc.alias] as u64);
455    }
456}
457
458fn decode_includes(data: &[u8], pos: &mut usize, st: &StringTableReader) -> Result<Vec<IncludeDirective>, String> {
459    let count = decode_varint(data, pos)? as usize;
460    let mut includes = Vec::with_capacity(count);
461    for _ in 0..count {
462        let path_idx = decode_varint(data, pos)? as u32;
463        let alias_idx = decode_varint(data, pos)? as u32;
464        includes.push(IncludeDirective {
465            path: st.get(path_idx)?.to_string(),
466            alias: st.get(alias_idx)?.to_string(),
467        });
468    }
469    Ok(includes)
470}
471
472// ─── Public API ──────────────────────────────────────────────────────────────
473
474/// Compile a `ParseResult` into compact binary `.synxb` format.
475///
476/// Uses a string interning table so every unique string is stored once,
477/// then deflate-compresses the payload for maximum compactness.
478/// If `resolved` is true, metadata and includes are stripped.
479pub fn compile(result: &ParseResult, resolved: bool) -> Vec<u8> {
480    let mut st = StringTable::new();
481    st.collect_value(&result.root);
482    let has_meta = !resolved && !result.metadata.is_empty();
483    if has_meta {
484        st.collect_metadata(&result.metadata);
485        st.collect_includes(&result.includes);
486    }
487
488    // Build uncompressed payload
489    let mut payload = Vec::with_capacity(1024);
490    st.encode(&mut payload);
491    encode_value(&mut payload, &result.root, &st);
492    if has_meta {
493        encode_metadata(&mut payload, &result.metadata, &st);
494        encode_includes(&mut payload, &result.includes, &st);
495    }
496
497    // Compress payload.
498    //
499    // This is a storage format: favor size over speed. Using level 9 also makes
500    // size-reduction expectations stable across platforms/runner images.
501    let compressed = miniz_oxide::deflate::compress_to_vec(&payload, 9);
502
503    // Build final output: header + compressed data
504    let mut out = Vec::with_capacity(7 + 4 + compressed.len());
505
506    // Header (always uncompressed for magic detection)
507    out.extend_from_slice(MAGIC);
508    out.push(FORMAT_VERSION);
509
510    let mut flags: u8 = 0;
511    if result.mode == Mode::Active { flags |= FLAG_ACTIVE; }
512    if result.locked { flags |= FLAG_LOCKED; }
513    if has_meta { flags |= FLAG_HAS_META; }
514    if resolved { flags |= FLAG_RESOLVED; }
515    if result.tool { flags |= FLAG_TOOL; }
516    if result.schema { flags |= FLAG_SCHEMA; }
517    if result.llm { flags |= FLAG_LLM; }
518    out.push(flags);
519
520    // Uncompressed size (for pre-allocation on decode)
521    out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
522
523    // Compressed payload
524    out.extend_from_slice(&compressed);
525
526    out
527}
528
529/// Decompile a `.synxb` binary back into a `ParseResult`.
530pub fn decompile(data: &[u8]) -> Result<ParseResult, String> {
531    if data.len() < 11 {
532        return Err("file too small for .synxb header".into());
533    }
534    if &data[0..5] != MAGIC {
535        return Err("invalid .synxb magic (expected SYNXB)".into());
536    }
537    let version = data[5];
538    if version != FORMAT_VERSION {
539        return Err(format!("unsupported .synxb version: {} (expected {})", version, FORMAT_VERSION));
540    }
541    let flags = data[6];
542
543    // Read uncompressed size
544    let uncomp_size = u32::from_le_bytes(
545        data[7..11].try_into().map_err(|_| "failed to read size")?
546    ) as usize;
547
548    // Decompress payload
549    let payload = miniz_oxide::inflate::decompress_to_vec(&data[11..])
550        .map_err(|e| format!("decompression failed: {:?}", e))?;
551    if payload.len() != uncomp_size {
552        return Err(format!("size mismatch: expected {}, got {}", uncomp_size, payload.len()));
553    }
554
555    let mut pos = 0;
556
557    // String table
558    let st = StringTableReader::decode(&payload, &mut pos)?;
559
560    // Root value
561    let root = decode_value(&payload, &mut pos, &st)?;
562
563    let mode = if flags & FLAG_ACTIVE != 0 { Mode::Active } else { Mode::Static };
564    let locked = flags & FLAG_LOCKED != 0;
565    let tool = flags & FLAG_TOOL != 0;
566    let schema = flags & FLAG_SCHEMA != 0;
567    let llm = flags & FLAG_LLM != 0;
568
569    let (metadata, includes) = if flags & FLAG_HAS_META != 0 {
570        let meta = decode_metadata(&payload, &mut pos, &st)?;
571        let inc = decode_includes(&payload, &mut pos, &st)?;
572        (meta, inc)
573    } else {
574        (HashMap::new(), Vec::new())
575    };
576
577    Ok(ParseResult {
578        root,
579        mode,
580        locked,
581        tool,
582        schema,
583        llm,
584        metadata,
585        includes,
586        uses: Vec::new(),
587    })
588}
589
590/// Check if data starts with the `.synxb` magic bytes.
591pub fn is_synxb(data: &[u8]) -> bool {
592    data.len() >= 5 && &data[0..5] == MAGIC
593}
594
595// ─── Tests ───────────────────────────────────────────────────────────────────
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_varint_roundtrip() {
603        for &val in &[0u64, 1, 127, 128, 300, 16383, 16384, u64::MAX >> 1] {
604            let mut buf = Vec::new();
605            encode_varint(&mut buf, val);
606            let mut pos = 0;
607            let decoded = decode_varint(&buf, &mut pos).unwrap();
608            assert_eq!(val, decoded, "varint roundtrip failed for {}", val);
609        }
610    }
611
612    #[test]
613    fn test_zigzag_roundtrip() {
614        for &val in &[0i64, 1, -1, 42, -42, i64::MAX, i64::MIN] {
615            let encoded = zigzag_encode(val);
616            let decoded = zigzag_decode(encoded);
617            assert_eq!(val, decoded, "zigzag roundtrip failed for {}", val);
618        }
619    }
620
621    #[test]
622    fn test_compile_decompile_static() {
623        let mut root = HashMap::new();
624        root.insert("name".to_string(), Value::String("Test".into()));
625        root.insert("port".to_string(), Value::Int(8080));
626
627        let result = ParseResult {
628            root: Value::Object(root),
629            mode: Mode::Static,
630            locked: false,
631            tool: false,
632            schema: false,
633            llm: false,
634            metadata: HashMap::new(),
635            includes: Vec::new(),
636            uses: Vec::new(),
637        };
638
639        let binary = compile(&result, false);
640        assert!(is_synxb(&binary));
641
642        let restored = decompile(&binary).unwrap();
643        assert_eq!(restored.root, result.root);
644        assert_eq!(restored.mode, Mode::Static);
645        assert!(!restored.locked);
646    }
647
648    #[test]
649    fn test_compile_decompile_active_with_metadata() {
650        let mut root = HashMap::new();
651        root.insert("host".to_string(), Value::String("0.0.0.0".into()));
652        root.insert("port".to_string(), Value::Int(3000));
653
654        let mut meta_map = HashMap::new();
655        meta_map.insert("port".to_string(), Meta {
656            markers: vec!["env".to_string()],
657            args: vec!["default".to_string(), "3000".to_string()],
658            type_hint: Some("int".to_string()),
659            constraints: Some(Constraints {
660                min: Some(1.0),
661                max: Some(65535.0),
662                required: true,
663                ..Default::default()
664            }),
665        });
666
667        let mut metadata = HashMap::new();
668        metadata.insert(String::new(), meta_map);
669
670        let includes = vec![IncludeDirective {
671            path: "./base.synx".to_string(),
672            alias: "base".to_string(),
673        }];
674
675        let result = ParseResult {
676            root: Value::Object(root),
677            mode: Mode::Active,
678            locked: true,
679            tool: false,
680            schema: false,
681            llm: false,
682            metadata,
683        includes,
684        uses: Vec::new(),
685    };
686
687        let binary = compile(&result, false);
688        let restored = decompile(&binary).unwrap();
689
690        assert_eq!(restored.root, result.root);
691        assert_eq!(restored.mode, Mode::Active);
692        assert!(restored.locked);
693        assert_eq!(restored.metadata.len(), 1);
694        let rm = &restored.metadata[""];
695        assert_eq!(rm["port"].markers, vec!["env"]);
696        assert_eq!(rm["port"].args, vec!["default", "3000"]);
697        assert_eq!(rm["port"].type_hint, Some("int".to_string()));
698        let c = rm["port"].constraints.as_ref().unwrap();
699        assert_eq!(c.min, Some(1.0));
700        assert_eq!(c.max, Some(65535.0));
701        assert!(c.required);
702        assert_eq!(restored.includes.len(), 1);
703        assert_eq!(restored.includes[0].path, "./base.synx");
704    }
705
706    #[test]
707    fn test_compile_resolved_strips_metadata() {
708        let mut root = HashMap::new();
709        root.insert("val".to_string(), Value::Int(42));
710
711        let mut meta_map = HashMap::new();
712        meta_map.insert("val".to_string(), Meta {
713            markers: vec!["calc".to_string()],
714            args: Vec::new(),
715            type_hint: None,
716            constraints: None,
717        });
718        let mut metadata = HashMap::new();
719        metadata.insert(String::new(), meta_map);
720
721        let result = ParseResult {
722            root: Value::Object(root),
723            mode: Mode::Active,
724            locked: false,
725            tool: false,
726            schema: false,
727            llm: false,
728            metadata,
729            includes: Vec::new(),
730            uses: Vec::new(),
731        };
732
733        let binary = compile(&result, true);
734        let restored = decompile(&binary).unwrap();
735
736        assert_eq!(restored.root, result.root);
737        assert!(restored.metadata.is_empty());
738        assert!(restored.includes.is_empty());
739    }
740
741    #[test]
742    fn test_is_synxb() {
743        assert!(is_synxb(b"SYNXB\x01\x00"));
744        assert!(!is_synxb(b"JSON{"));
745        assert!(!is_synxb(b"SYN"));
746    }
747
748    #[test]
749    fn test_invalid_magic() {
750        let err = decompile(b"WRONG\x01\x00\x00\x00\x00\x00").unwrap_err();
751        assert!(err.contains("invalid .synxb magic"));
752    }
753
754    #[test]
755    fn test_invalid_version() {
756        let err = decompile(b"SYNXB\xFF\x00\x00\x00\x00\x00").unwrap_err();
757        assert!(err.contains("unsupported .synxb version"));
758    }
759
760    #[test]
761    fn test_nested_object_roundtrip() {
762        let mut inner = HashMap::new();
763        inner.insert("host".to_string(), Value::String("localhost".into()));
764        inner.insert("port".to_string(), Value::Int(5432));
765
766        let mut root = HashMap::new();
767        root.insert("name".to_string(), Value::String("app".into()));
768        root.insert("database".to_string(), Value::Object(inner));
769        root.insert("tags".to_string(), Value::Array(vec![
770            Value::String("prod".into()),
771            Value::String("v2".into()),
772        ]));
773
774        let result = ParseResult {
775            root: Value::Object(root),
776            mode: Mode::Static,
777            locked: false,
778            tool: false,
779            schema: false,
780            llm: false,
781            metadata: HashMap::new(),
782            includes: Vec::new(),
783            uses: Vec::new(),
784        };
785
786        let binary = compile(&result, false);
787        let restored = decompile(&binary).unwrap();
788        assert_eq!(restored.root, result.root);
789    }
790
791    #[test]
792    fn test_full_roundtrip_parse_compile_decompile() {
793        let synx_text = "name TotalWario\nversion 3.0.0\nport 8080\ndebug false\n";
794        let parsed = crate::parse(synx_text);
795        let binary = compile(&parsed, false);
796
797        let restored = decompile(&binary).unwrap();
798        assert_eq!(restored.root, parsed.root);
799        assert_eq!(restored.mode, parsed.mode);
800    }
801
802    #[test]
803    fn test_large_config_size_reduction() {
804        let synx_text = include_str!("../../../benchmarks/config.synx");
805        let parsed = crate::parse(synx_text);
806        let binary = compile(&parsed, false);
807        let ratio = binary.len() as f64 / synx_text.len() as f64;
808        // Compression ratios can vary slightly across platforms/toolchains.
809        // Keep this test as a regression guard (binary should be *meaningfully* smaller),
810        // without making CI brittle.
811        assert!(
812            ratio < 0.65,
813            "binary should be at least 35% smaller: {} bytes vs {} bytes (ratio {:.2})",
814            binary.len(), synx_text.len(), ratio
815        );
816    }
817
818    #[test]
819    fn test_large_config_full_roundtrip() {
820        let synx_text = include_str!("../../../benchmarks/config.synx");
821        let parsed = crate::parse(synx_text);
822        let binary = compile(&parsed, false);
823        let restored = decompile(&binary).unwrap();
824        assert_eq!(restored.root, parsed.root);
825        assert_eq!(restored.mode, parsed.mode);
826    }
827
828    #[test]
829    fn test_constraints_full_roundtrip() {
830        let c_orig = Constraints {
831            min: Some(0.0),
832            max: Some(100.0),
833            type_name: Some("int".to_string()),
834            required: true,
835            readonly: true,
836            pattern: Some(r"^\d+$".to_string()),
837            enum_values: Some(vec!["a".into(), "b".into(), "c".into()]),
838        };
839
840        let mut meta_map = HashMap::new();
841        meta_map.insert("field".to_string(), Meta {
842            markers: Vec::new(),
843            args: Vec::new(),
844            type_hint: None,
845            constraints: Some(c_orig.clone()),
846        });
847        let mut metadata = HashMap::new();
848        metadata.insert(String::new(), meta_map);
849
850        let mut root = HashMap::new();
851        root.insert("field".to_string(), Value::Int(42));
852
853        let result = ParseResult {
854            root: Value::Object(root),
855            mode: Mode::Active,
856            locked: false,
857            tool: false,
858            schema: false,
859            llm: false,
860            metadata,
861            includes: Vec::new(),
862            uses: Vec::new(),
863        };
864
865        let binary = compile(&result, false);
866        let restored = decompile(&binary).unwrap();
867        let rm = &restored.metadata[""];
868        let c = rm["field"].constraints.as_ref().unwrap();
869        assert_eq!(c.min, c_orig.min);
870        assert_eq!(c.max, c_orig.max);
871        assert_eq!(c.type_name, c_orig.type_name);
872        assert_eq!(c.required, c_orig.required);
873        assert_eq!(c.readonly, c_orig.readonly);
874        assert_eq!(c.pattern, c_orig.pattern);
875        assert_eq!(c.enum_values, c_orig.enum_values);
876    }
877
878    #[test]
879    fn test_all_value_types() {
880        let mut map = HashMap::new();
881        map.insert("null_val".to_string(), Value::Null);
882        map.insert("bool_t".to_string(), Value::Bool(true));
883        map.insert("bool_f".to_string(), Value::Bool(false));
884        map.insert("int_pos".to_string(), Value::Int(42));
885        map.insert("int_neg".to_string(), Value::Int(-100));
886        map.insert("int_zero".to_string(), Value::Int(0));
887        map.insert("float_val".to_string(), Value::Float(3.14));
888        map.insert("string_val".to_string(), Value::String("hello world".into()));
889        map.insert("secret_val".to_string(), Value::Secret("s3cr3t".into()));
890        map.insert("array_val".to_string(), Value::Array(vec![
891            Value::Int(1), Value::String("two".into()), Value::Null,
892        ]));
893
894        let result = ParseResult {
895            root: Value::Object(map),
896            mode: Mode::Static,
897            locked: false,
898            tool: false,
899            schema: false,
900            llm: false,
901            metadata: HashMap::new(),
902            includes: Vec::new(),
903            uses: Vec::new(),
904        };
905
906        let binary = compile(&result, false);
907        let restored = decompile(&binary).unwrap();
908        assert_eq!(restored.root, result.root);
909    }
910
911    #[test]
912    fn test_empty_object() {
913        let result = ParseResult {
914            root: Value::Object(HashMap::new()),
915            mode: Mode::Static,
916            locked: false,
917            tool: false,
918            schema: false,
919            llm: false,
920            metadata: HashMap::new(),
921            includes: Vec::new(),
922            uses: Vec::new(),
923        };
924
925        let binary = compile(&result, false);
926        let restored = decompile(&binary).unwrap();
927        assert_eq!(restored.root, result.root);
928    }
929
930    #[test]
931    fn test_llm_flag_roundtrip() {
932        let mut root = HashMap::new();
933        root.insert("task".to_string(), Value::String("ping".into()));
934        let result = ParseResult {
935            root: Value::Object(root),
936            mode: Mode::Static,
937            locked: false,
938            tool: false,
939            schema: false,
940            llm: true,
941            metadata: HashMap::new(),
942            includes: Vec::new(),
943            uses: Vec::new(),
944        };
945        let binary = compile(&result, false);
946        let restored = decompile(&binary).unwrap();
947        assert!(restored.llm);
948        assert_eq!(restored.root, result.root);
949    }
950
951    #[test]
952    fn test_synx_api_compile_decompile() {
953        use crate::Synx;
954
955        let text = "name Wario\nport 8080\ndebug false\n";
956        let binary = Synx::compile(text, false);
957        assert!(Synx::is_synxb(&binary));
958
959        let decompiled = Synx::decompile(&binary).unwrap();
960        let original = crate::parse(text);
961        let reparsed = crate::parse(&decompiled);
962        assert_eq!(original.root, reparsed.root);
963    }
964}