1use crate::error::{Result, TdbError};
34use std::io::{Read, Write};
35use std::time::Instant;
36
37const MIN_SAMPLE_SIZE: usize = 8;
39
40const MAX_TOTAL_SAMPLE_SIZE: usize = 1024 * 1024 * 1024;
42
43#[derive(Debug, Clone)]
49pub struct ZstdCodec {
50 pub compression_level: i32,
52 pub dict: Option<Vec<u8>>,
54}
55
56#[derive(Debug, Clone)]
58pub struct ZstdCodecStats {
59 pub original_size: usize,
61 pub compressed_size: usize,
63 pub compression_ratio: f64,
65 pub space_savings_pct: f64,
67 pub duration_us: u64,
69 pub dictionary_used: bool,
71}
72
73impl ZstdCodec {
74 pub fn new(level: i32) -> Self {
80 Self {
81 compression_level: level.clamp(1, 22),
82 dict: None,
83 }
84 }
85
86 pub fn with_dict(level: i32, dict: Vec<u8>) -> Self {
96 Self {
97 compression_level: level.clamp(1, 22),
98 dict: Some(dict),
99 }
100 }
101
102 pub fn compress(&self, data: &[u8]) -> Result<Vec<u8>> {
107 if data.is_empty() {
108 return Ok(Vec::new());
109 }
110
111 let compressed = match &self.dict {
112 None => {
113 let mut encoder =
115 oxiarc_zstd::ZstdStreamEncoder::new(Vec::new(), self.compression_level);
116 encoder
117 .write_all(data)
118 .map_err(|e| TdbError::Other(format!("zstd write failed: {e}")))?;
119 encoder
120 .finish()
121 .map_err(|e| TdbError::Other(format!("zstd finish failed: {e}")))?
122 }
123 Some(dict_bytes) => {
124 let mut enc = oxiarc_zstd::ZstdEncoder::new();
126 enc.set_level(self.compression_level);
127 enc.set_dictionary(dict_bytes);
128 enc.compress(data)
129 .map_err(|e| TdbError::Other(format!("zstd dict compress failed: {e}")))?
130 }
131 };
132
133 Ok(compressed)
134 }
135
136 pub fn decompress(&self, data: &[u8]) -> Result<Vec<u8>> {
142 if data.is_empty() {
143 return Ok(Vec::new());
144 }
145
146 let decompressed = match &self.dict {
147 None => {
148 let mut decoder = oxiarc_zstd::ZstdStreamDecoder::new(data);
149 let mut out = Vec::new();
150 decoder
151 .read_to_end(&mut out)
152 .map_err(|e| TdbError::Other(format!("zstd decompress failed: {e}")))?;
153 out
154 }
155 Some(dict_bytes) => {
156 oxiarc_zstd::decompress_with_dict(data, dict_bytes)
158 .map_err(|e| TdbError::Other(format!("zstd dict decompress failed: {e}")))?
159 }
160 };
161
162 Ok(decompressed)
163 }
164
165 pub fn compress_with_stats(&self, data: &[u8]) -> Result<(Vec<u8>, ZstdCodecStats)> {
167 let start = Instant::now();
168 let compressed = self.compress(data)?;
169 let duration_us = start.elapsed().as_micros() as u64;
170
171 let original_size = data.len();
172 let compressed_size = compressed.len();
173 let compression_ratio = if original_size == 0 {
174 1.0
175 } else {
176 compressed_size as f64 / original_size as f64
177 };
178 let space_savings_pct = (1.0 - compression_ratio) * 100.0;
179
180 let stats = ZstdCodecStats {
181 original_size,
182 compressed_size,
183 compression_ratio,
184 space_savings_pct,
185 duration_us,
186 dictionary_used: self.dict.is_some(),
187 };
188
189 Ok((compressed, stats))
190 }
191
192 pub fn train_dict(samples: &[Vec<u8>], dict_size: usize) -> Result<Vec<u8>> {
216 if samples.is_empty() {
217 return Err(TdbError::InvalidInput(
218 "Cannot train dictionary from empty sample set".to_string(),
219 ));
220 }
221
222 let mut total_size = 0usize;
224 let mut valid_samples = Vec::new();
225 for (idx, sample) in samples.iter().enumerate() {
226 if sample.len() < MIN_SAMPLE_SIZE {
227 log::debug!(
228 "Skipping sample {} (size {} < min {})",
229 idx,
230 sample.len(),
231 MIN_SAMPLE_SIZE
232 );
233 continue;
234 }
235 total_size = total_size
236 .checked_add(sample.len())
237 .ok_or_else(|| TdbError::Other("Sample total size overflow".to_string()))?;
238 if total_size > MAX_TOTAL_SAMPLE_SIZE {
239 return Err(TdbError::InvalidInput(format!(
240 "Total sample size {} exceeds limit of {} bytes",
241 total_size, MAX_TOTAL_SAMPLE_SIZE
242 )));
243 }
244 valid_samples.push(sample.as_slice());
245 }
246
247 if valid_samples.is_empty() {
248 return Err(TdbError::InvalidInput(format!(
249 "No valid samples found (all samples had fewer than {} bytes)",
250 MIN_SAMPLE_SIZE
251 )));
252 }
253
254 let dict_obj = oxiarc_zstd::train_dictionary(&valid_samples, dict_size)
256 .map_err(|e| TdbError::Other(format!("zstd dictionary training failed: {e}")))?;
257 let dict = dict_obj.data().to_vec();
258
259 log::debug!(
260 "Trained Zstd dictionary: {} samples, {} total bytes -> {} byte dictionary",
261 valid_samples.len(),
262 total_size,
263 dict.len()
264 );
265
266 Ok(dict)
267 }
268
269 pub fn has_dict(&self) -> bool {
271 self.dict.is_some()
272 }
273
274 pub fn dict_size(&self) -> usize {
276 self.dict.as_ref().map(|d| d.len()).unwrap_or(0)
277 }
278}
279
280impl Default for ZstdCodec {
281 fn default() -> Self {
282 Self::new(3)
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
293 fn test_zstd_codec_empty_data() {
294 let codec = ZstdCodec::new(3);
295 let compressed = codec.compress(&[]).expect("compress empty");
296 assert!(compressed.is_empty(), "compressed empty should be empty");
297 let decompressed = codec.decompress(&[]).expect("decompress empty");
298 assert!(
299 decompressed.is_empty(),
300 "decompressed empty should be empty"
301 );
302 }
303
304 #[test]
305 fn test_zstd_codec_roundtrip_basic() {
306 let codec = ZstdCodec::new(3);
307 let original = b"Hello, RDF World! This is a test of ZstdCodec.".repeat(20);
308 let compressed = codec.compress(&original).expect("compress");
309 let decompressed = codec.decompress(&compressed).expect("decompress");
310 assert_eq!(decompressed, original, "roundtrip must be identical");
311 }
312
313 #[test]
314 fn test_zstd_codec_compression_levels() {
315 let data = b"<http://example.org/subject> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Type> .\n".repeat(50);
316
317 for level in [1, 3, 9, 19] {
318 let codec = ZstdCodec::new(level);
319 let compressed = codec
320 .compress(&data)
321 .unwrap_or_else(|e| panic!("compress at level {level} failed: {e}"));
322 let decompressed = codec
323 .decompress(&compressed)
324 .unwrap_or_else(|e| panic!("decompress at level {level} failed: {e}"));
325 assert_eq!(
326 decompressed, data,
327 "roundtrip failed at compression level {level}"
328 );
329 assert!(
330 compressed.len() < data.len(),
331 "level {level} should compress repetitive RDF data"
332 );
333 }
334 }
335
336 #[test]
337 fn test_zstd_codec_level_clamping() {
338 let low = ZstdCodec::new(0);
339 let high = ZstdCodec::new(100);
340 assert_eq!(
341 low.compression_level, 1,
342 "level should be clamped to minimum 1"
343 );
344 assert_eq!(
345 high.compression_level, 22,
346 "level should be clamped to maximum 22"
347 );
348 }
349
350 #[test]
351 fn test_zstd_codec_large_data() {
352 let codec = ZstdCodec::new(3);
353 let rdf_triple = b"<http://data.example.org/resource/12345678> <http://schema.org/name> \"Example Resource\" .\n";
355 let mut data = Vec::with_capacity(1024 * 1024);
356 while data.len() < 1024 * 1024 {
357 data.extend_from_slice(rdf_triple);
358 }
359
360 let compressed = codec.compress(&data).expect("compress large data");
361 let decompressed = codec
362 .decompress(&compressed)
363 .expect("decompress large data");
364 assert_eq!(decompressed, data, "large data roundtrip must be identical");
365 assert!(
367 compressed.len() < data.len() / 10,
368 "repetitive RDF data should compress >10x, got ratio: {:.2}",
369 compressed.len() as f64 / data.len() as f64
370 );
371 }
372
373 #[test]
376 fn test_train_dict_basic() {
377 let rdf_triple = b"<http://example.org/subject> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://example.org/Class> .";
378 let samples: Vec<Vec<u8>> = (0..200)
379 .map(|i| {
380 format!(
381 "<http://example.org/resource/{}> <http://schema.org/name> \"Name {}\" .",
382 i, i
383 )
384 .into_bytes()
385 })
386 .collect();
387
388 let dict = ZstdCodec::train_dict(&samples, 4096).expect("dictionary training");
389 assert!(!dict.is_empty(), "trained dictionary must not be empty");
390 assert!(
391 dict.len() <= 4096 + 100,
392 "dictionary should not greatly exceed requested size"
393 );
394
395 let codec = ZstdCodec::with_dict(3, dict);
397 let test_data: Vec<u8> = rdf_triple.to_vec();
398 let compressed = codec.compress(&test_data).expect("compress with dict");
399 let decompressed = codec.decompress(&compressed).expect("decompress with dict");
400 assert_eq!(decompressed, test_data, "dict roundtrip must be identical");
401 }
402
403 #[test]
404 fn test_train_dict_empty_samples_error() {
405 let result = ZstdCodec::train_dict(&[], 1024);
406 assert!(result.is_err(), "empty samples should return error");
407 }
408
409 #[test]
410 fn test_train_dict_too_small_samples_error() {
411 let samples: Vec<Vec<u8>> = vec![b"abc".to_vec(), b"xy".to_vec()];
413 let result = ZstdCodec::train_dict(&samples, 1024);
414 assert!(result.is_err(), "all-too-small samples should return error");
415 }
416
417 #[test]
420 fn test_with_dict_roundtrip() {
421 let samples: Vec<Vec<u8>> = (0..300)
422 .map(|i| {
423 format!(
424 "<http://example.org/entity/{}> <http://purl.org/dc/terms/title> \"Title {}\" .",
425 i, i
426 )
427 .into_bytes()
428 })
429 .collect();
430
431 let dict = ZstdCodec::train_dict(&samples, 8192).expect("train dict");
432 let codec = ZstdCodec::with_dict(3, dict);
433
434 for i in 0..10 {
435 let triple = format!(
436 "<http://example.org/entity/{}> <http://purl.org/dc/terms/title> \"Title {}\" .",
437 i * 100,
438 i * 100
439 )
440 .into_bytes();
441 let compressed = codec.compress(&triple).expect("compress with dict");
442 let decompressed = codec.decompress(&compressed).expect("decompress with dict");
443 assert_eq!(
444 decompressed, triple,
445 "dict roundtrip must match at index {i}"
446 );
447 }
448 }
449
450 #[test]
451 fn test_dict_compression_better_than_no_dict() {
452 let samples: Vec<Vec<u8>> = (0..500)
454 .map(|i| {
455 format!(
456 "<http://dbpedia.org/resource/Resource_{}> <http://dbpedia.org/ontology/type> <http://dbpedia.org/ontology/Class> .",
457 i
458 )
459 .into_bytes()
460 })
461 .collect();
462
463 let dict = ZstdCodec::train_dict(&samples, 16384).expect("train dict");
464 let codec_dict = ZstdCodec::with_dict(3, dict);
465 let codec_plain = ZstdCodec::new(3);
466
467 let test_triple = b"<http://dbpedia.org/resource/Resource_999> <http://dbpedia.org/ontology/type> <http://dbpedia.org/ontology/Class> .".repeat(5);
468
469 let (compressed_dict, stats_dict) = codec_dict
470 .compress_with_stats(&test_triple)
471 .expect("compress with dict stats");
472 let (compressed_plain, stats_plain) = codec_plain
473 .compress_with_stats(&test_triple)
474 .expect("compress plain stats");
475
476 println!(
478 "Dict compression: {} -> {} ({:.2}x ratio)",
479 stats_dict.original_size,
480 stats_dict.compressed_size,
481 1.0 / stats_dict.compression_ratio
482 );
483 println!(
484 "Plain compression: {} -> {} ({:.2}x ratio)",
485 stats_plain.original_size,
486 stats_plain.compressed_size,
487 1.0 / stats_plain.compression_ratio
488 );
489
490 let dec_dict = codec_dict
492 .decompress(&compressed_dict)
493 .expect("decompress dict");
494 let dec_plain = codec_plain
495 .decompress(&compressed_plain)
496 .expect("decompress plain");
497 assert_eq!(dec_dict, test_triple);
498 assert_eq!(dec_plain, test_triple);
499
500 assert!(
501 stats_dict.dictionary_used,
502 "stats should reflect dictionary usage"
503 );
504 assert!(
505 !stats_plain.dictionary_used,
506 "stats should reflect no dictionary"
507 );
508 }
509
510 #[test]
513 fn test_compress_with_stats() {
514 let codec = ZstdCodec::new(3);
515 let data = b"RDF triple data for stats testing".repeat(100);
516 let (compressed, stats) = codec
517 .compress_with_stats(&data)
518 .expect("compress with stats");
519
520 assert_eq!(stats.original_size, data.len());
521 assert_eq!(stats.compressed_size, compressed.len());
522 assert!(stats.compression_ratio > 0.0);
523 assert!(stats.space_savings_pct >= 0.0 && stats.space_savings_pct <= 100.0);
524 assert!(stats.duration_us > 0 || stats.original_size < 1000);
525 assert!(!stats.dictionary_used);
526 }
527
528 #[test]
531 fn test_has_dict_no_dict() {
532 let codec = ZstdCodec::new(3);
533 assert!(!codec.has_dict());
534 assert_eq!(codec.dict_size(), 0);
535 }
536
537 #[test]
538 fn test_has_dict_with_dict() {
539 let samples: Vec<Vec<u8>> = (0..200)
540 .map(|i| format!("<http://example.org/{}> <p> <o> .", i).into_bytes())
541 .collect();
542 let dict = ZstdCodec::train_dict(&samples, 2048).expect("train dict");
543 let dict_len = dict.len();
544 let codec = ZstdCodec::with_dict(3, dict);
545 assert!(codec.has_dict());
546 assert_eq!(codec.dict_size(), dict_len);
547 }
548}