Skip to main content

serializer/
compress.rs

1/// DX Serializer: Reverse Formatter (Human → Machine)
2///
3/// Compresses human-readable DX format back to ultra-compact machine format.
4/// Enables bidirectional editing in editors.
5///
6/// # Architecture: Zero-Cache Design
7/// - Uses HashMap lookups (O(1)) - no additional cache needed
8/// - Mappings loaded once via OnceLock singleton
9/// - Every lookup is instant with automatic fallback for custom keys
10///
11/// # The Smart Logic
12/// ```text
13/// For every key encountered:
14///   IF key exists in mappings.dx:
15///       abbreviate it (popular)
16///   ELSE:
17///       keep it as-is (custom)
18/// ```
19use crate::mappings::Mappings;
20use std::io::Write;
21
22/// Compress human-readable DX to machine format
23pub fn format_machine(human_dx: &str) -> Result<Vec<u8>, String> {
24    let mappings = Mappings::get();
25    let mut output = Vec::new();
26
27    let lines: Vec<&str> = human_dx.lines().collect();
28    let mut i = 0;
29
30    while i < lines.len() {
31        let line = lines[i].trim();
32        i += 1;
33
34        // Skip empty lines and comments
35        if line.is_empty() || line.starts_with('#') {
36            continue;
37        }
38
39        // Handle table headers (skip for now, will be reconstructed)
40        if line.starts_with("# ") && line.contains("TABLE") {
41            // Skip table header and separator
42            while i < lines.len()
43                && (lines[i].trim().starts_with('#') || lines[i].trim().starts_with('-'))
44            {
45                i += 1;
46            }
47            continue;
48        }
49
50        // Parse property line: key : value
51        if let Some((key, value)) = line.split_once(':') {
52            let key = key.trim();
53            let value = value.trim();
54
55            // Handle prefix inheritance (^)
56            if let Some(rest) = key.strip_prefix('^') {
57                output.extend_from_slice(b"^");
58                let rest = rest.trim();
59                let compressed = compress_full_key(rest, mappings);
60                output.extend_from_slice(compressed.as_bytes());
61            } else {
62                let compressed = compress_full_key(key, mappings);
63                output.extend_from_slice(compressed.as_bytes());
64            }
65
66            output.extend_from_slice(b":");
67            output.extend_from_slice(value.as_bytes());
68
69            // Look ahead for inline properties (check next line for ^)
70            if i < lines.len() && lines[i].trim().starts_with('^') {
71                // Continue on same line with ^
72                continue;
73            }
74
75            output.extend_from_slice(b"\n");
76        }
77        // Handle array/stream line: key > item | item | item
78        else if line.contains('>') {
79            // SAFETY: We just checked that line contains '>', so split_once will succeed
80            let Some((key, values)) = line.split_once('>') else {
81                continue; // Skip malformed lines
82            };
83            let key = key.trim();
84            let values = values.trim();
85
86            let compressed = compress_full_key(key, mappings);
87            output.extend_from_slice(compressed.as_bytes());
88            output.extend_from_slice(b">");
89
90            // Compress array values (remove spaces around |)
91            let compressed_values = values
92                .split('|')
93                .map(|s| s.trim())
94                .collect::<Vec<_>>()
95                .join("|");
96            output.extend_from_slice(compressed_values.as_bytes());
97            output.extend_from_slice(b"\n");
98        }
99        // Handle table schema line: Key  OtherKey  ThirdKey
100        else if line.contains(char::is_whitespace) && !line.contains(':') {
101            // This is a table header or data row
102            let parts: Vec<&str> = line.split_whitespace().collect();
103
104            // Check if it's a schema (all caps) or data
105            if parts
106                .iter()
107                .all(|p| p.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
108            {
109                // Table schema - compress column names
110                let compressed_cols: Vec<String> = parts
111                    .iter()
112                    .map(|col| compress_table_column(col, mappings))
113                    .collect();
114
115                // Need to find the table key from context (previous line usually)
116                // For now, output compressed columns
117                output.extend_from_slice(compressed_cols.join(" ").as_bytes());
118                output.extend_from_slice(b"\n");
119            } else {
120                // Table data row - keep as-is
121                output.extend_from_slice(line.as_bytes());
122                output.extend_from_slice(b"\n");
123            }
124        }
125    }
126
127    Ok(output)
128}
129
130/// Compress full key with dots (e.g., "context.name" → "c.n")
131///
132/// # The Smart Logic
133/// - Splits by '.' only (underscore is NOT a separator to avoid ambiguity)
134/// - Compresses each part using HashMap lookup
135/// - IF popular → abbreviate, ELSE → keep as-is
136///
137/// # Examples
138/// - "context.name" → "c.n" (both popular)
139/// - "myModule.name" → "myModule.n" (mixed)
140/// - "myModule.myField" → "myModule.myField" (both custom)
141/// - "ui_a" → "ui_a" (underscore keys preserved as-is)
142#[inline]
143fn compress_full_key(full_key: &str, mappings: &Mappings) -> String {
144    if full_key.contains('.') {
145        // Handle nested keys: each part compressed independently
146        full_key
147            .split('.')
148            .map(|part| mappings.compress_key(part))
149            .collect::<Vec<_>>()
150            .join(".")
151    } else {
152        // Single key (including underscore keys): direct HashMap lookup (O(1))
153        // Note: We don't split by underscore because it's ambiguous and would
154        // cause round-trip failures (e.g., "ui_a" would become "u_a")
155        mappings.compress_key(full_key)
156    }
157}
158
159/// Compress table column name
160fn compress_table_column(col: &str, mappings: &Mappings) -> String {
161    mappings.compress_key(col)
162}
163
164/// Compress with writer output
165pub fn compress_to_writer<W: Write>(human_dx: &str, writer: &mut W) -> Result<(), String> {
166    let compressed = format_machine(human_dx)?;
167    writer
168        .write_all(&compressed)
169        .map_err(|e| format!("Failed to write: {}", e))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_simple_compression() {
178        let human = "context.name        : dx\n^version            : 0.0.1";
179        let machine = format_machine(human).unwrap();
180        let result = String::from_utf8(machine).unwrap();
181
182        // Should compress to: c.n:dx^v:0.0.1
183        assert!(result.contains("c.n:dx"));
184        assert!(result.contains("v:0.0.1"));
185    }
186
187    #[test]
188    fn test_array_compression() {
189        // Use a key that exists in default mappings
190        let human = "name           > frontend/www | frontend/mobile";
191        let machine = format_machine(human).unwrap();
192        let result = String::from_utf8(machine).unwrap();
193
194        // The compressor outputs the compressed key followed by array values
195        // "name" compresses to "n" in default mappings
196        assert!(result.contains("n>"));
197        assert!(result.contains("frontend/www"));
198        assert!(result.contains("frontend/mobile"));
199    }
200
201    #[test]
202    fn test_roundtrip() {
203        let _original = "c.n:dx^v:0.0.1\nws>frontend|backend";
204
205        // This would need format_human to test fully
206        // For now just test compression doesn't crash
207        let human = "context.name: dx\nworkspace > frontend | backend";
208        let compressed = format_machine(human).unwrap();
209
210        assert!(!compressed.is_empty());
211    }
212}