Skip to main content

serializer/llm/
convert.rs

1//! Format conversion functions
2//!
3//! Provides conversion between DX Serializer (LLM), Human, and Machine formats.
4//! All conversions go through the common DxDocument representation.
5
6use crate::llm::human_formatter::{HumanFormatConfig, HumanFormatter};
7use crate::llm::human_parser::{HumanParseError, HumanParser};
8use crate::llm::parser::{LlmParser, ParseError};
9use crate::llm::serializer::LlmSerializer;
10use crate::llm::types::DxDocument;
11use std::borrow::Cow;
12use thiserror::Error;
13
14/// Conversion errors
15#[derive(Debug, Error)]
16pub enum ConvertError {
17    /// DX LLM parser failed while reading LLM-format text.
18    #[error("DX Serializer parse error: {0}")]
19    LlmParse(#[from] ParseError),
20
21    /// Human-format parser failed while reading human-facing text.
22    #[error("Human parse error: {0}")]
23    HumanParse(#[from] HumanParseError),
24
25    /// Machine-format conversion failed.
26    #[error("Machine format error: {msg}")]
27    MachineFormat {
28        /// Human-readable machine-format failure message.
29        msg: String,
30    },
31}
32
33/// Convert DX Serializer format string to Human format string
34#[must_use = "conversion result should be used"]
35pub fn llm_to_human(llm_input: &str) -> Result<String, ConvertError> {
36    let doc = LlmParser::parse(llm_input)?;
37    let formatter = HumanFormatter::new();
38    Ok(formatter.format(&doc))
39}
40
41/// Convert DX Serializer format string to Human format string with custom config
42pub fn llm_to_human_with_config(
43    llm_input: &str,
44    config: HumanFormatConfig,
45) -> Result<String, ConvertError> {
46    let doc = LlmParser::parse(llm_input)?;
47    let formatter = HumanFormatter::with_config(config);
48    Ok(formatter.format(&doc))
49}
50
51/// Convert Human format string to DX Serializer format string
52#[must_use = "conversion result should be used"]
53pub fn human_to_llm(human_input: &str) -> Result<String, ConvertError> {
54    let trimmed = human_input.trim();
55
56    // Check if input is already DX Serializer format
57    if is_dsr_format(trimmed) {
58        return Ok(human_input.to_string());
59    }
60
61    // Parse as Human format and convert to DX Serializer
62    let parser = HumanParser::new();
63    let doc = parser.parse(human_input)?;
64    let serializer = LlmSerializer::new();
65    Ok(serializer.serialize(&doc))
66}
67
68/// Check if input is in DX Serializer format
69#[must_use]
70pub fn is_dsr_format(input: &str) -> bool {
71    let trimmed = input.trim();
72
73    // DX Serializer format indicators:
74    // - name[key=value,...] (objects) - NOT [name] which is TOML section
75    // - name:count(schema)[data] (tables)
76    // - name:count=items (arrays)
77    // - key=value (simple pairs, NO spaces around =)
78
79    // Human format indicators (should return false):
80    // - [section] (TOML section headers)
81    // - key = value (spaces around =)
82    // - key[count]: followed by - items (list format)
83
84    let mut has_dsr_indicators = false;
85    let mut has_human_indicators = false;
86
87    for line in trimmed.lines() {
88        let line = line.trim();
89        if line.is_empty() {
90            continue;
91        }
92
93        // TOML section headers start with [ - this is HUMAN format
94        if line.starts_with('[') {
95            has_human_indicators = true;
96            continue;
97        }
98
99        // List items starting with - are HUMAN format
100        if line.starts_with('-') {
101            has_human_indicators = true;
102            continue;
103        }
104
105        // Check for spaces around = (HUMAN format: "key = value")
106        if line.contains(" = ") {
107            has_human_indicators = true;
108            continue;
109        }
110
111        // Check for table syntax: name:count(schema)[
112        if line.contains(':') && line.contains('(') && line.contains('[') {
113            has_dsr_indicators = true;
114            continue;
115        }
116
117        // Check for array syntax: name:count=items (DSR format)
118        if line.contains(':') && line.contains('=') {
119            let colon_pos = line.find(':');
120            let eq_pos = line.find('=');
121            if let (Some(cp), Some(ep)) = (colon_pos, eq_pos) {
122                if cp < ep {
123                    has_dsr_indicators = true;
124                    continue;
125                }
126            }
127        }
128
129        // Check for compact key=value (NO spaces around =) - DSR format
130        if line.contains('=') && !line.contains(" = ") {
131            if let Some(eq_pos) = line.find('=') {
132                let before = &line[..eq_pos];
133                let after = &line[eq_pos + 1..];
134                // DSR has no trailing space before = and no leading space after =
135                if !before.ends_with(' ') && !after.starts_with(' ') {
136                    has_dsr_indicators = true;
137                    continue;
138                }
139            }
140        }
141    }
142
143    // If we found human format indicators, it's NOT DSR format
144    if has_human_indicators {
145        return false;
146    }
147
148    // Only return true if we found DSR indicators
149    has_dsr_indicators
150}
151
152/// Check if input is in LLM format (alias for is_dsr_format)
153#[must_use]
154pub fn is_llm_format(input: &str) -> bool {
155    is_dsr_format(input)
156}
157
158/// Convert DX Serializer format string to DxDocument
159#[must_use = "parsing result should be used"]
160pub fn llm_to_document(llm_input: &str) -> Result<DxDocument, ConvertError> {
161    Ok(LlmParser::parse(llm_input)?)
162}
163
164/// Convert Human format string to DxDocument
165#[must_use = "parsing result should be used"]
166pub fn human_to_document(human_input: &str) -> Result<DxDocument, ConvertError> {
167    let parser = HumanParser::new();
168    Ok(parser.parse(human_input)?)
169}
170
171/// Convert DxDocument to DX Serializer format string
172#[must_use]
173pub fn document_to_llm(doc: &DxDocument) -> String {
174    let serializer = LlmSerializer::new();
175    serializer.serialize(doc)
176}
177
178/// Convert DxDocument to Human format string
179#[must_use]
180pub fn document_to_human(doc: &DxDocument) -> String {
181    let formatter = HumanFormatter::new();
182    formatter.format(doc)
183}
184
185/// Convert DxDocument to Human format string with custom config
186pub fn document_to_human_with_config(doc: &DxDocument, config: HumanFormatConfig) -> String {
187    let formatter = HumanFormatter::with_config(config);
188    formatter.format(doc)
189}
190
191/// Compression algorithm for machine format
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub enum CompressionAlgorithm {
194    /// LZ4 compression (fastest, default)
195    #[default]
196    Lz4,
197    /// Zstd compression (better compression ratio)
198    Zstd,
199    /// No compression
200    None,
201}
202
203const MACHINE_ENVELOPE_MAGIC: &[u8; 4] = b"DXM1";
204const MACHINE_ENVELOPE_VERSION: u8 = 1;
205const MACHINE_ENVELOPE_HEADER_LEN: usize = 56;
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208enum MachineEnvelopeCodec {
209    None,
210    Lz4,
211    Zstd,
212}
213
214impl MachineEnvelopeCodec {
215    const fn as_u8(self) -> u8 {
216        match self {
217            Self::None => 0,
218            Self::Lz4 => 1,
219            Self::Zstd => 2,
220        }
221    }
222
223    fn from_u8(value: u8) -> Result<Self, ConvertError> {
224        match value {
225            0 => Ok(Self::None),
226            1 => Ok(Self::Lz4),
227            2 => Ok(Self::Zstd),
228            _ => Err(ConvertError::MachineFormat {
229                msg: format!("Unsupported machine envelope codec: {}", value),
230            }),
231        }
232    }
233}
234
235struct MachineEnvelope<'a> {
236    codec: MachineEnvelopeCodec,
237    payload: &'a [u8],
238    uncompressed_len: usize,
239}
240
241/// Machine format representation (binary)
242///
243/// Includes automatic decompression caching for optimal performance.
244#[derive(Debug, Clone)]
245pub struct MachineFormat {
246    /// Raw machine-format bytes.
247    pub data: Vec<u8>,
248    /// Cached decompressed data (lazy) - first access decompresses, subsequent accesses use cache
249    #[cfg(feature = "compression")]
250    cached: std::cell::RefCell<Option<Vec<u8>>>,
251}
252
253impl MachineFormat {
254    /// Create a new MachineFormat from raw data
255    pub fn new(data: Vec<u8>) -> Self {
256        Self {
257            data,
258            #[cfg(feature = "compression")]
259            cached: std::cell::RefCell::new(None),
260        }
261    }
262
263    /// Get the raw data
264    pub fn as_bytes(&self) -> &[u8] {
265        &self.data
266    }
267}
268
269/// Convert DX Serializer format to Machine format (RKYV + compression)
270pub fn llm_to_machine(llm_input: &str) -> Result<MachineFormat, ConvertError> {
271    let doc = LlmParser::parse(llm_input)?;
272    try_document_to_machine_with_compression(&doc, CompressionAlgorithm::default())
273}
274
275/// Convert DX Serializer format to Machine format with specific compression
276pub fn llm_to_machine_with_compression(
277    llm_input: &str,
278    compression: CompressionAlgorithm,
279) -> Result<MachineFormat, ConvertError> {
280    let doc = LlmParser::parse(llm_input)?;
281    try_document_to_machine_with_compression(&doc, compression)
282}
283
284/// Convert Human format to Machine format (RKYV + compression)
285pub fn human_to_machine(human_input: &str) -> Result<MachineFormat, ConvertError> {
286    let parser = HumanParser::new();
287    let doc = parser.parse(human_input)?;
288    try_document_to_machine_with_compression(&doc, CompressionAlgorithm::default())
289}
290
291/// Convert Human format to Machine format without compression (raw RKYV)
292pub fn human_to_machine_uncompressed(human_input: &str) -> Result<MachineFormat, ConvertError> {
293    let parser = HumanParser::new();
294    let doc = parser.parse(human_input)?;
295    try_document_to_machine_with_compression(&doc, CompressionAlgorithm::None)
296}
297
298/// Convert Human format to Machine format with specific compression
299pub fn human_to_machine_with_compression(
300    human_input: &str,
301    compression: CompressionAlgorithm,
302) -> Result<MachineFormat, ConvertError> {
303    let parser = HumanParser::new();
304    let doc = parser.parse(human_input)?;
305    try_document_to_machine_with_compression(&doc, compression)
306}
307
308/// Convert DxDocument to Machine format (RKYV + LZ4 by default)
309pub fn document_to_machine(doc: &DxDocument) -> MachineFormat {
310    document_to_machine_with_compression(doc, CompressionAlgorithm::default())
311}
312
313/// Convert DxDocument to Machine format with specific compression
314pub fn document_to_machine_with_compression(
315    doc: &DxDocument,
316    compression: CompressionAlgorithm,
317) -> MachineFormat {
318    try_document_to_machine_with_compression(doc, compression)
319        .unwrap_or_else(|error| panic!("Machine serialization failed: {}", error))
320}
321
322/// Try to convert DxDocument to Machine format with specific compression.
323pub fn try_document_to_machine_with_compression(
324    doc: &DxDocument,
325    compression: CompressionAlgorithm,
326) -> Result<MachineFormat, ConvertError> {
327    use crate::machine::machine_types::MachineDocument;
328    use crate::machine::serialize;
329
330    let machine_doc = MachineDocument::from(doc);
331    let rkyv_data = serialize(&machine_doc)
332        .map_err(|e| ConvertError::MachineFormat {
333            msg: format!("RKYV serialization failed: {}", e),
334        })?
335        .into_vec();
336
337    let (codec, payload): (MachineEnvelopeCodec, Cow<'_, [u8]>) = match compression {
338        CompressionAlgorithm::None => (MachineEnvelopeCodec::None, Cow::Borrowed(&rkyv_data)),
339
340        #[cfg(feature = "compression-lz4")]
341        CompressionAlgorithm::Lz4 => {
342            use crate::machine::compress::compress_lz4;
343            match compress_lz4(&rkyv_data) {
344                Ok(compressed) => {
345                    let savings_ratio =
346                        compression_savings_ratio(rkyv_data.len(), compressed.len());
347                    if savings_ratio > 0.10 {
348                        (MachineEnvelopeCodec::Lz4, Cow::Owned(compressed))
349                    } else {
350                        (MachineEnvelopeCodec::None, Cow::Borrowed(&rkyv_data))
351                    }
352                }
353                Err(_) => (MachineEnvelopeCodec::None, Cow::Borrowed(&rkyv_data)),
354            }
355        }
356
357        #[cfg(feature = "compression-zstd")]
358        CompressionAlgorithm::Zstd => {
359            use crate::machine::compress::{CompressionLevel, compress_zstd_level};
360            match compress_zstd_level(&rkyv_data, CompressionLevel::Fast) {
361                Ok(compressed) => {
362                    let savings_ratio =
363                        compression_savings_ratio(rkyv_data.len(), compressed.len());
364                    if savings_ratio > 0.10 {
365                        (MachineEnvelopeCodec::Zstd, Cow::Owned(compressed))
366                    } else {
367                        (MachineEnvelopeCodec::None, Cow::Borrowed(&rkyv_data))
368                    }
369                }
370                Err(_) => (MachineEnvelopeCodec::None, Cow::Borrowed(&rkyv_data)),
371            }
372        }
373
374        #[cfg(not(feature = "compression-lz4"))]
375        CompressionAlgorithm::Lz4 => (MachineEnvelopeCodec::None, Cow::Borrowed(&rkyv_data)),
376
377        #[cfg(not(feature = "compression-zstd"))]
378        CompressionAlgorithm::Zstd => (MachineEnvelopeCodec::None, Cow::Borrowed(&rkyv_data)),
379    };
380
381    Ok(MachineFormat::new(encode_machine_envelope(
382        codec,
383        payload.as_ref(),
384        rkyv_data.len(),
385    )))
386}
387
388/// Convert Machine format to DxDocument (auto-detects compression)
389pub fn machine_to_document(machine: &MachineFormat) -> Result<DxDocument, ConvertError> {
390    #[cfg(feature = "compression")]
391    {
392        // Check cache first
393        if let Some(cached) = machine.cached.borrow().as_ref() {
394            return rkyv_bytes_to_document(cached);
395        }
396
397        let decompressed = decode_machine_bytes(&machine.data)?;
398        *machine.cached.borrow_mut() = Some(decompressed.to_vec());
399        return rkyv_bytes_to_document(decompressed.as_ref());
400    }
401
402    #[cfg(not(feature = "compression"))]
403    machine_bytes_to_document(&machine.data)
404}
405
406/// Convert raw machine bytes to a DxDocument.
407pub fn machine_bytes_to_document(data: &[u8]) -> Result<DxDocument, ConvertError> {
408    let doc_data = decode_machine_bytes(data)?;
409    rkyv_bytes_to_document(doc_data.as_ref())
410}
411
412/// Convert a memory-mapped `.machine` file to a DxDocument.
413#[cfg(feature = "mmap")]
414#[allow(unsafe_code)]
415pub fn machine_file_to_document_mmap(
416    path: impl AsRef<std::path::Path>,
417) -> Result<DxDocument, ConvertError> {
418    let file = std::fs::File::open(path.as_ref()).map_err(|error| ConvertError::MachineFormat {
419        msg: format!("Machine file open failed: {}", error),
420    })?;
421    // SAFETY: The map is read-only, scoped to this function, and the file is not mutated here.
422    let mmap = unsafe { memmap2::MmapOptions::new().map(&file) }.map_err(|error| {
423        ConvertError::MachineFormat {
424            msg: format!("Machine file mmap failed: {}", error),
425        }
426    })?;
427
428    machine_bytes_to_document(&mmap)
429}
430
431fn rkyv_bytes_to_document(doc_data: &[u8]) -> Result<DxDocument, ConvertError> {
432    use crate::machine::machine_types::MachineDocument;
433
434    let machine_doc: MachineDocument =
435        rkyv::from_bytes(doc_data).map_err(|e: rkyv::rancor::Error| {
436            ConvertError::MachineFormat {
437                msg: format!("RKYV deserialize failed: {}", e),
438            }
439        })?;
440
441    Ok(DxDocument::from(&machine_doc))
442}
443
444#[cfg(feature = "compression")]
445fn compression_savings_ratio(uncompressed_len: usize, compressed_len: usize) -> f64 {
446    if uncompressed_len == 0 {
447        return 0.0;
448    }
449
450    1.0 - (compressed_len as f64 / uncompressed_len as f64)
451}
452
453fn encode_machine_envelope(
454    codec: MachineEnvelopeCodec,
455    payload: &[u8],
456    uncompressed_len: usize,
457) -> Vec<u8> {
458    let payload_len = payload.len() as u64;
459    let uncompressed_len = uncompressed_len as u64;
460    let payload_hash = blake3::hash(payload);
461    let mut output = Vec::with_capacity(MACHINE_ENVELOPE_HEADER_LEN + payload.len());
462
463    output.extend_from_slice(MACHINE_ENVELOPE_MAGIC);
464    output.push(MACHINE_ENVELOPE_VERSION);
465    output.push(codec.as_u8());
466    output.extend_from_slice(&[0, 0]);
467    output.extend_from_slice(&payload_len.to_le_bytes());
468    output.extend_from_slice(&uncompressed_len.to_le_bytes());
469    output.extend_from_slice(payload_hash.as_bytes());
470    output.extend_from_slice(payload);
471
472    output
473}
474
475fn decode_machine_envelope(data: &[u8]) -> Result<Option<MachineEnvelope<'_>>, ConvertError> {
476    if !data.starts_with(MACHINE_ENVELOPE_MAGIC) {
477        return Ok(None);
478    }
479
480    if data.len() < MACHINE_ENVELOPE_HEADER_LEN {
481        return Err(ConvertError::MachineFormat {
482            msg: "Machine envelope header is truncated".to_string(),
483        });
484    }
485
486    if data[4] != MACHINE_ENVELOPE_VERSION {
487        return Err(ConvertError::MachineFormat {
488            msg: format!("Unsupported machine envelope version: {}", data[4]),
489        });
490    }
491
492    if data[6] != 0 || data[7] != 0 {
493        return Err(ConvertError::MachineFormat {
494            msg: "Machine envelope reserved bytes must be zero".to_string(),
495        });
496    }
497
498    let codec = MachineEnvelopeCodec::from_u8(data[5])?;
499    let payload_len = read_u64_le(&data[8..16])?;
500    let uncompressed_len = read_u64_le(&data[16..24])?;
501    let expected_len = MACHINE_ENVELOPE_HEADER_LEN
502        .checked_add(payload_len)
503        .ok_or_else(|| ConvertError::MachineFormat {
504            msg: "Machine envelope payload length overflow".to_string(),
505        })?;
506
507    if data.len() != expected_len {
508        return Err(ConvertError::MachineFormat {
509            msg: format!(
510                "Machine envelope length mismatch: expected {}, found {}",
511                expected_len,
512                data.len()
513            ),
514        });
515    }
516
517    let payload = &data[MACHINE_ENVELOPE_HEADER_LEN..];
518    let actual_hash = blake3::hash(payload);
519    if actual_hash.as_bytes() != &data[24..56] {
520        return Err(ConvertError::MachineFormat {
521            msg: "Machine envelope payload checksum mismatch".to_string(),
522        });
523    }
524
525    Ok(Some(MachineEnvelope {
526        codec,
527        payload,
528        uncompressed_len,
529    }))
530}
531
532fn read_u64_le(bytes: &[u8]) -> Result<usize, ConvertError> {
533    let value = u64::from_le_bytes(bytes.try_into().map_err(|_| ConvertError::MachineFormat {
534        msg: "Machine envelope integer field has invalid length".to_string(),
535    })?);
536
537    usize::try_from(value).map_err(|_| ConvertError::MachineFormat {
538        msg: "Machine envelope integer is too large for this platform".to_string(),
539    })
540}
541
542fn validate_uncompressed_len(data: &[u8], expected_len: usize) -> Result<(), ConvertError> {
543    if data.len() != expected_len {
544        return Err(ConvertError::MachineFormat {
545            msg: format!(
546                "Machine envelope uncompressed length mismatch: expected {}, found {}",
547                expected_len,
548                data.len()
549            ),
550        });
551    }
552
553    Ok(())
554}
555
556#[cfg(feature = "compression")]
557fn decode_machine_bytes(data: &[u8]) -> Result<Cow<'_, [u8]>, ConvertError> {
558    if let Some(envelope) = decode_machine_envelope(data)? {
559        let decoded = decode_machine_envelope_payload(&envelope)?;
560        validate_uncompressed_len(decoded.as_ref(), envelope.uncompressed_len)?;
561        return Ok(decoded);
562    }
563
564    decompress_auto(data)
565}
566
567#[cfg(not(feature = "compression"))]
568fn decode_machine_bytes(data: &[u8]) -> Result<Cow<'_, [u8]>, ConvertError> {
569    if let Some(envelope) = decode_machine_envelope(data)? {
570        if envelope.codec != MachineEnvelopeCodec::None {
571            return Err(ConvertError::MachineFormat {
572                msg: "Compressed machine envelope requires the compression feature".to_string(),
573            });
574        }
575        validate_uncompressed_len(envelope.payload, envelope.uncompressed_len)?;
576        return Ok(Cow::Borrowed(envelope.payload));
577    }
578
579    Ok(Cow::Borrowed(data))
580}
581
582#[cfg(feature = "compression")]
583fn decode_machine_envelope_payload<'a>(
584    envelope: &MachineEnvelope<'a>,
585) -> Result<Cow<'a, [u8]>, ConvertError> {
586    match envelope.codec {
587        MachineEnvelopeCodec::None => Ok(Cow::Borrowed(envelope.payload)),
588        MachineEnvelopeCodec::Lz4 => {
589            #[cfg(feature = "compression-lz4")]
590            {
591                use crate::machine::compress::decompress_lz4;
592                decompress_lz4(envelope.payload)
593                    .map_err(|e| ConvertError::MachineFormat {
594                        msg: format!("LZ4 machine envelope decompression failed: {}", e),
595                    })
596                    .map(Cow::Owned)
597            }
598
599            #[cfg(not(feature = "compression-lz4"))]
600            {
601                Err(ConvertError::MachineFormat {
602                    msg: "LZ4 machine envelope requires the compression-lz4 feature".to_string(),
603                })
604            }
605        }
606        MachineEnvelopeCodec::Zstd => {
607            #[cfg(feature = "compression-zstd")]
608            {
609                use crate::machine::compress::decompress_zstd;
610                decompress_zstd(envelope.payload)
611                    .map_err(|e| ConvertError::MachineFormat {
612                        msg: format!("Zstd machine envelope decompression failed: {}", e),
613                    })
614                    .map(Cow::Owned)
615            }
616
617            #[cfg(not(feature = "compression-zstd"))]
618            {
619                Err(ConvertError::MachineFormat {
620                    msg: "Zstd machine envelope requires the compression-zstd feature".to_string(),
621                })
622            }
623        }
624    }
625}
626
627/// Auto-detect and decompress data (tries LZ4, then Zstd, then raw)
628#[cfg(feature = "compression")]
629fn decompress_auto(data: &[u8]) -> Result<Cow<'_, [u8]>, ConvertError> {
630    // Try LZ4 first (most common, fastest)
631    #[cfg(feature = "compression-lz4")]
632    {
633        use crate::machine::compress::decompress_lz4;
634        if let Ok(decompressed) = decompress_lz4(data) {
635            return Ok(Cow::Owned(decompressed));
636        }
637    }
638
639    // Try Zstd
640    #[cfg(feature = "compression-zstd")]
641    {
642        use crate::machine::compress::decompress_zstd;
643        if let Ok(decompressed) = decompress_zstd(data) {
644            return Ok(Cow::Owned(decompressed));
645        }
646    }
647
648    // Not compressed, return as-is
649    Ok(Cow::Borrowed(data))
650}
651
652/// Convert Machine format to DX Serializer format string
653pub fn machine_to_llm(machine: &MachineFormat) -> Result<String, ConvertError> {
654    let doc = machine_to_document(machine)?;
655    Ok(document_to_llm(&doc))
656}
657
658/// Convert Machine format to Human format string
659pub fn machine_to_human(machine: &MachineFormat) -> Result<String, ConvertError> {
660    let doc = machine_to_document(machine)?;
661    Ok(document_to_human(&doc))
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::llm::types::DxLlmValue;
668
669    #[test]
670    fn test_llm_to_human() {
671        let llm = "name=Test\ncount=42";
672        let human = llm_to_human(llm).unwrap();
673        assert!(human.contains("name") || human.contains("Test"));
674    }
675
676    #[test]
677    fn test_human_to_llm() {
678        let human = r#"
679[config]
680    name = "Test"
681    count = 42
682"#;
683        let llm = human_to_llm(human).unwrap();
684        // DX Serializer format uses : or :: for key-value pairs
685        assert!(llm.contains(":") || llm.contains("Test"));
686    }
687
688    #[test]
689    fn try_document_to_machine_reports_machine_format_errors() {
690        let doc = DxDocument::new();
691        let machine =
692            try_document_to_machine_with_compression(&doc, CompressionAlgorithm::None).unwrap();
693        let round_trip_doc = machine_to_document(&machine).unwrap();
694
695        assert_eq!(round_trip_doc.entry_order.len(), 0);
696    }
697
698    #[test]
699    fn test_machine_format_round_trip() {
700        let mut doc = DxDocument::new();
701        doc.context
702            .insert("name".to_string(), DxLlmValue::Str("Test".to_string()));
703        doc.context
704            .insert("count".to_string(), DxLlmValue::Num(42.0));
705        doc.context
706            .insert("active".to_string(), DxLlmValue::Bool(true));
707
708        let machine = document_to_machine(&doc);
709        assert!(machine.as_bytes().starts_with(MACHINE_ENVELOPE_MAGIC));
710        let round_trip_doc = machine_to_document(&machine).unwrap();
711
712        assert_eq!(doc.context.len(), round_trip_doc.context.len());
713        assert_eq!(
714            round_trip_doc.context.get("name").unwrap().as_str(),
715            Some("Test")
716        );
717        assert_eq!(
718            round_trip_doc.context.get("count").unwrap().as_num(),
719            Some(42.0)
720        );
721    }
722
723    #[test]
724    fn test_is_dsr_format() {
725        // DX Serializer format
726        assert!(is_dsr_format("name=Test"));
727        assert!(is_dsr_format("config[host=localhost,port=8080]"));
728        assert!(is_dsr_format("friends:3=ana,luis,sam"));
729        assert!(is_dsr_format("table:2(id,name)[1,John\n2,Jane]"));
730
731        // Not DX Serializer format (Human/TOML-like)
732        assert!(!is_dsr_format("[config]\nname = Test"));
733    }
734
735    #[test]
736    fn test_machine_format_rejects_corrupt_envelope() {
737        let mut doc = DxDocument::new();
738        doc.context
739            .insert("name".to_string(), DxLlmValue::Str("Test".to_string()));
740
741        let mut machine = document_to_machine_with_compression(&doc, CompressionAlgorithm::None);
742        let last = machine.data.len() - 1;
743        machine.data[last] ^= 0xFF;
744
745        let error = machine_to_document(&machine).unwrap_err();
746        assert!(error.to_string().contains("checksum mismatch"));
747    }
748
749    #[test]
750    fn test_machine_format_rejects_invalid_envelope_headers() {
751        let mut doc = DxDocument::new();
752        doc.context
753            .insert("name".to_string(), DxLlmValue::Str("Test".to_string()));
754        let machine = document_to_machine_with_compression(&doc, CompressionAlgorithm::None);
755
756        let truncated = MachineFormat::new(machine.data[..8].to_vec());
757        assert!(
758            machine_to_document(&truncated)
759                .unwrap_err()
760                .to_string()
761                .contains("header is truncated")
762        );
763
764        let mut bad_version = machine.clone();
765        bad_version.data[4] = MACHINE_ENVELOPE_VERSION + 1;
766        assert!(
767            machine_to_document(&bad_version)
768                .unwrap_err()
769                .to_string()
770                .contains("Unsupported machine envelope version")
771        );
772
773        let mut bad_reserved = machine.clone();
774        bad_reserved.data[6] = 1;
775        assert!(
776            machine_to_document(&bad_reserved)
777                .unwrap_err()
778                .to_string()
779                .contains("reserved bytes must be zero")
780        );
781
782        let mut bad_codec = machine.clone();
783        bad_codec.data[5] = 255;
784        assert!(
785            machine_to_document(&bad_codec)
786                .unwrap_err()
787                .to_string()
788                .contains("Unsupported machine envelope codec")
789        );
790    }
791
792    #[test]
793    fn test_machine_format_rejects_invalid_envelope_lengths() {
794        let mut doc = DxDocument::new();
795        doc.context
796            .insert("name".to_string(), DxLlmValue::Str("Test".to_string()));
797        let machine = document_to_machine_with_compression(&doc, CompressionAlgorithm::None);
798
799        let mut bad_payload_len = machine.clone();
800        bad_payload_len.data[8..16].copy_from_slice(&1u64.to_le_bytes());
801        assert!(
802            machine_to_document(&bad_payload_len)
803                .unwrap_err()
804                .to_string()
805                .contains("length mismatch")
806        );
807
808        let mut bad_uncompressed_len = machine.clone();
809        bad_uncompressed_len.data[16..24].copy_from_slice(&1u64.to_le_bytes());
810        assert!(
811            machine_to_document(&bad_uncompressed_len)
812                .unwrap_err()
813                .to_string()
814                .contains("uncompressed length mismatch")
815        );
816    }
817
818    #[test]
819    fn test_machine_format_reads_legacy_raw_rkyv() {
820        use crate::machine::machine_types::MachineDocument;
821        use crate::machine::serialize;
822
823        let mut doc = DxDocument::new();
824        doc.context.insert(
825            "legacy".to_string(),
826            DxLlmValue::Str("raw-rkyv".to_string()),
827        );
828        let machine_doc = MachineDocument::from(&doc);
829        let legacy_data = serialize(&machine_doc).unwrap().into_vec();
830        let machine = MachineFormat::new(legacy_data);
831        let round_trip_doc = machine_to_document(&machine).unwrap();
832
833        assert_eq!(
834            round_trip_doc.context.get("legacy").unwrap().as_str(),
835            Some("raw-rkyv")
836        );
837    }
838
839    #[cfg(feature = "mmap")]
840    #[test]
841    fn test_machine_file_to_document_mmap_reads_uncompressed_envelope() {
842        let temp = tempfile::tempdir().unwrap();
843        let machine_path = temp.path().join("config.machine");
844        let mut doc = DxDocument::new();
845        doc.context
846            .insert("name".to_string(), DxLlmValue::Str("mmap".to_string()));
847        let machine = document_to_machine_with_compression(&doc, CompressionAlgorithm::None);
848        std::fs::write(&machine_path, machine.as_bytes()).unwrap();
849
850        let round_trip_doc = machine_file_to_document_mmap(&machine_path).unwrap();
851
852        assert_eq!(
853            round_trip_doc.context.get("name").unwrap().as_str(),
854            Some("mmap")
855        );
856    }
857}