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}