1use std::collections::{BTreeMap, BTreeSet};
25
26use serde_json::{Map, Value};
27
28use crate::core::extractors::csv;
29use crate::core::json_crush::CrushResult;
30
31const MARKER: &str = "_lc_tbl";
35const ORDER_KEY: &str = "_order";
38const CONST_KEY: &str = "_const";
40const DROPPED_KEY: &str = "_dropped";
42const ROWS_KEY: &str = "_rows";
44
45const MIN_ROWS: usize = 3;
47
48pub fn crush_text_if_beneficial(text: &str, delimiter: char) -> Option<String> {
52 let res = crush(text, delimiter, 1.0)?;
53 (res.lossless && beneficial(&res.text, text)).then_some(res.text)
54}
55
56pub fn crush_text_lossy_if_beneficial(
64 text: &str,
65 delimiter: char,
66 drop_entropy: f64,
67) -> Option<CrushResult> {
68 let res = crush(text, delimiter, drop_entropy.clamp(0.0, 1.0))?;
69 (!res.lossless && beneficial(&res.text, text)).then_some(res)
70}
71
72const MIN_SAVE_RATIO_NUM: usize = 3;
78const MIN_SAVE_RATIO_DEN: usize = 4;
79
80fn beneficial(compact: &str, raw: &str) -> bool {
81 compact.len().saturating_mul(MIN_SAVE_RATIO_DEN) <= raw.len().saturating_mul(MIN_SAVE_RATIO_NUM)
82}
83
84fn crush(text: &str, delimiter: char, drop_entropy: f64) -> Option<CrushResult> {
87 let head = text.trim_start();
90 if head.starts_with('{') || head.starts_with('[') {
91 return None;
92 }
93
94 let rows = csv::parse(text, delimiter);
95 if rows.len() < MIN_ROWS + 1 {
96 return None; }
98 let header = &rows[0];
99 let ncols = header.len();
100 if ncols < 2 {
101 return None; }
103 let data = &rows[1..];
104
105 if data.iter().any(|r| r.len() != ncols) {
108 return None;
109 }
110 let mut header_seen = BTreeSet::new();
112 if !header.iter().all(|h| header_seen.insert(h.as_str())) {
113 return None;
114 }
115
116 let n = data.len();
117 let mut const_map = Map::new();
118 let mut dropped: Vec<String> = Vec::new();
119 let mut kept: Vec<usize> = Vec::new();
120
121 for (c, name) in header.iter().enumerate() {
122 let distinct = data
123 .iter()
124 .map(|row| row[c].as_str())
125 .collect::<BTreeSet<_>>()
126 .len();
127 if drop_entropy < 1.0 && (distinct as f64 / n as f64) >= drop_entropy {
128 dropped.push(name.clone());
129 continue;
130 }
131 if distinct == 1 {
132 const_map.insert(name.clone(), Value::String(data[0][c].clone()));
133 continue;
134 }
135 kept.push(c);
136 }
137
138 let had_drops = !dropped.is_empty();
139 if const_map.is_empty() && !had_drops {
140 return None; }
142
143 let rows_json: Vec<Value> = data
144 .iter()
145 .map(|row| {
146 Value::Array(
147 kept.iter()
148 .map(|&c| Value::String(row[c].clone()))
149 .collect(),
150 )
151 })
152 .collect();
153
154 let mut out = Map::new();
155 out.insert(MARKER.to_string(), Value::from(1u8));
156 out.insert(
157 ORDER_KEY.to_string(),
158 Value::Array(header.iter().cloned().map(Value::String).collect()),
159 );
160 if !const_map.is_empty() {
161 out.insert(CONST_KEY.to_string(), Value::Object(const_map));
162 }
163 if had_drops {
164 out.insert(
165 DROPPED_KEY.to_string(),
166 Value::Array(dropped.into_iter().map(Value::String).collect()),
167 );
168 }
169 out.insert(ROWS_KEY.to_string(), Value::Array(rows_json));
170
171 let text_out = serde_json::to_string(&Value::Object(out)).ok()?;
172 Some(CrushResult {
173 text: text_out,
174 lossless: !had_drops,
175 })
176}
177
178pub fn reconstruct(text: &str) -> Option<Vec<Vec<String>>> {
183 let v: Value = serde_json::from_str(text).ok()?;
184 let obj = v.as_object()?;
185 obj.get(MARKER)?;
186
187 let order: Vec<String> = obj
188 .get(ORDER_KEY)?
189 .as_array()?
190 .iter()
191 .map(|x| x.as_str().unwrap_or_default().to_string())
192 .collect();
193 let empty = Map::new();
194 let const_map = obj
195 .get(CONST_KEY)
196 .and_then(Value::as_object)
197 .unwrap_or(&empty);
198 let dropped: BTreeSet<&str> = obj
199 .get(DROPPED_KEY)
200 .and_then(Value::as_array)
201 .map(|a| a.iter().filter_map(Value::as_str).collect())
202 .unwrap_or_default();
203 let rows = obj.get(ROWS_KEY)?.as_array()?;
204
205 let out_cols: Vec<&str> = order
209 .iter()
210 .map(String::as_str)
211 .filter(|c| !dropped.contains(c))
212 .collect();
213 let varying: Vec<&str> = out_cols
214 .iter()
215 .copied()
216 .filter(|c| !const_map.contains_key(*c))
217 .collect();
218
219 let mut result: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
220 result.push(out_cols.iter().map(|c| (*c).to_string()).collect());
221
222 for row in rows {
223 let arr = row.as_array()?;
224 if arr.len() != varying.len() {
225 return None;
226 }
227 let vary_vals: BTreeMap<&str, &str> = varying
228 .iter()
229 .copied()
230 .zip(arr.iter().map(|x| x.as_str().unwrap_or_default()))
231 .collect();
232 let mut full = Vec::with_capacity(out_cols.len());
233 for col in &out_cols {
234 let cell = const_map
235 .get(*col)
236 .and_then(Value::as_str)
237 .or_else(|| vary_vals.get(col).copied())
238 .unwrap_or_default();
239 full.push(cell.to_string());
240 }
241 result.push(full);
242 }
243 Some(result)
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn roster_csv() -> String {
253 let mut s = String::from("id,name,status,region\n");
254 for i in 0..16 {
255 s.push_str(&format!("{i},user{i},active,eu-central-1\n"));
256 }
257 s
258 }
259
260 #[test]
261 fn lossless_factors_constant_columns() {
262 let csv = roster_csv();
263 let crushed = crush_text_if_beneficial(&csv, ',').expect("should crush");
264 assert_eq!(crushed.matches("eu-central-1").count(), 1);
266 assert_eq!(crushed.matches("active").count(), 1);
267 assert!(crushed.contains("_const"));
268 }
269
270 #[test]
271 fn lossless_roundtrips_exactly() {
272 let csv = roster_csv();
273 let crushed = crush_text_if_beneficial(&csv, ',').unwrap();
274 let restored = reconstruct(&crushed).unwrap();
275 let original = csv::parse(&csv, ',');
276 assert_eq!(restored, original);
277 }
278
279 #[test]
280 fn output_is_byte_stable_across_calls() {
281 let csv = roster_csv();
282 let run = || crush_text_if_beneficial(&csv, ',').unwrap();
283 assert_eq!(run(), run(), "crush output must be deterministic (#498)");
284 }
285
286 #[test]
287 fn never_inflates_and_clears_the_gate() {
288 let csv = roster_csv();
289 let crushed = crush_text_if_beneficial(&csv, ',').unwrap();
290 assert!(crushed.len() < csv.len(), "must never inflate");
291 assert!(beneficial(&crushed, &csv), "must clear the reduction gate");
292 }
293
294 #[test]
295 fn tsv_delimiter_is_honoured() {
296 let mut s = String::from("id\tname\tstatus\tregion\n");
297 for i in 0..16 {
298 s.push_str(&format!("{i}\tuser{i}\tactive\teu-central-1\n"));
299 }
300 let crushed = crush_text_if_beneficial(&s, '\t').expect("tsv crushes");
301 assert_eq!(reconstruct(&crushed).unwrap(), csv::parse(&s, '\t'));
302 }
303
304 #[test]
305 fn quoted_fields_with_embedded_delimiters_roundtrip() {
306 let mut s = String::from("id,note,status,region\n");
309 for i in 0..16 {
310 s.push_str(&format!("{i},\"a, {i} \"\"q\"\"\",active,eu-central-1\n"));
311 }
312 let crushed = crush_text_if_beneficial(&s, ',').unwrap();
313 let restored = reconstruct(&crushed).unwrap();
314 assert_eq!(restored, csv::parse(&s, ','));
315 assert_eq!(restored[1][1], "a, 0 \"q\"");
317 }
318
319 #[test]
320 fn skips_tables_with_nothing_to_factor() {
321 let mut s = String::from("a,b,c\n");
323 for i in 0..10 {
324 s.push_str(&format!("{i},{},{}\n", i + 100, i + 200));
325 }
326 assert!(crush_text_if_beneficial(&s, ',').is_none());
327 }
328
329 #[test]
330 fn skips_ragged_too_small_and_json_input() {
331 assert!(crush_text_if_beneficial("a,b\n1,ok\n2,ok", ',').is_none()); assert!(crush_text_if_beneficial("a,b\n1,ok\n2\n3,ok\n4,ok", ',').is_none()); assert!(crush_text_if_beneficial("col\n1\n2\n3\n4", ',').is_none()); assert!(crush_text_if_beneficial("[{\"a\":1}]", ',').is_none()); assert!(crush_text_if_beneficial("", ',').is_none());
336 }
337
338 #[test]
339 fn lossy_drops_high_entropy_columns_and_flags_lossy() {
340 let mut s = String::from("status,uuid\n");
342 for i in 0..40 {
343 s.push_str(&format!("ok,uuid-{i:08}\n"));
344 }
345 let res = crush_text_lossy_if_beneficial(&s, ',', 0.9).expect("lossy gate fires");
346 assert!(!res.lossless, "dropping a column must report lossy");
347 assert!(res.text.contains("_dropped"));
348 assert!(
349 !res.text.contains("uuid-00000000"),
350 "dropped values are gone"
351 );
352 assert!(beneficial(&res.text, &s));
353
354 let restored = reconstruct(&res.text).unwrap();
356 assert_eq!(restored[0], vec!["status"]);
357 assert_eq!(restored[1], vec!["ok"]);
358
359 assert!(crush_text_lossy_if_beneficial(&s, ',', 1.0).is_none());
361 }
362
363 #[test]
364 fn lossy_is_byte_stable() {
365 let mut s = String::from("status,uuid\n");
366 for i in 0..40 {
367 s.push_str(&format!("ok,uuid-{i:08}\n"));
368 }
369 let run = || crush_text_lossy_if_beneficial(&s, ',', 0.9).unwrap().text;
370 assert_eq!(run(), run());
371 }
372}