1use crate::FaucetError;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use std::pin::Pin;
16use tokio::io::{AsyncBufRead, AsyncWrite};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
20#[serde(rename_all = "lowercase")]
21pub enum CompressionConfig {
22 None,
23 Gzip,
24 Zstd,
25 #[default]
27 Auto,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Compression {
33 None,
34 Gzip,
35 Zstd,
36}
37
38impl CompressionConfig {
39 pub fn resolve(self, path: &str) -> Compression {
41 match self {
42 Self::None => Compression::None,
43 Self::Gzip => Compression::Gzip,
44 Self::Zstd => Compression::Zstd,
45 Self::Auto => detect_from_path(path),
46 }
47 }
48}
49
50pub fn detect_from_path(path: &str) -> Compression {
53 let lower = path.to_ascii_lowercase();
54 if lower.ends_with(".gz") {
55 Compression::Gzip
56 } else if lower.ends_with(".zst") {
57 Compression::Zstd
58 } else {
59 Compression::None
60 }
61}
62
63pub fn wrap_async_reader<'a, R>(
65 r: R,
66 c: Compression,
67) -> Pin<Box<dyn AsyncBufRead + Send + Unpin + 'a>>
68where
69 R: AsyncBufRead + Send + Unpin + 'a,
70{
71 match c {
72 Compression::None => Box::pin(r),
73 Compression::Gzip => {
74 let mut dec = async_compression::tokio::bufread::GzipDecoder::new(r);
75 dec.multiple_members(true);
76 Box::pin(tokio::io::BufReader::new(dec))
77 }
78 Compression::Zstd => {
86 let mut dec = async_compression::tokio::bufread::ZstdDecoder::new(r);
87 dec.multiple_members(true);
88 Box::pin(tokio::io::BufReader::new(dec))
89 }
90 }
91}
92
93pub fn wrap_async_writer<'a, W>(
97 w: W,
98 c: Compression,
99) -> Pin<Box<dyn AsyncWrite + Send + Unpin + 'a>>
100where
101 W: AsyncWrite + Send + Unpin + 'a,
102{
103 match c {
104 Compression::None => Box::pin(w),
105 Compression::Gzip => Box::pin(async_compression::tokio::write::GzipEncoder::new(w)),
106 Compression::Zstd => Box::pin(async_compression::tokio::write::ZstdEncoder::new(w)),
107 }
108}
109
110pub fn wrap_sync_reader<'a, R>(r: R, c: Compression) -> Box<dyn std::io::Read + Send + 'a>
112where
113 R: std::io::Read + Send + 'a,
114{
115 match c {
116 Compression::None => Box::new(r),
117 Compression::Gzip => Box::new(flate2::read::MultiGzDecoder::new(r)),
118 Compression::Zstd => Box::new(
119 zstd::stream::read::Decoder::new(r)
120 .expect("zstd decoder construction is infallible for any Read"),
121 ),
122 }
123}
124
125pub fn wrap_sync_writer<'a, W>(w: W, c: Compression) -> Box<dyn std::io::Write + Send + 'a>
140where
141 W: std::io::Write + Send + 'a,
142{
143 match c {
144 Compression::None => Box::new(w),
145 Compression::Gzip => Box::new(flate2::write::GzEncoder::new(
146 w,
147 flate2::Compression::default(),
148 )),
149 Compression::Zstd => Box::new(
150 zstd::stream::write::Encoder::new(w, 0)
151 .expect("zstd encoder construction is infallible")
152 .auto_finish(),
153 ),
154 }
155}
156
157pub enum SyncCompressWriter<W: std::io::Write> {
167 Plain(W),
168 Gzip(flate2::write::GzEncoder<W>),
169 Zstd(zstd::stream::write::Encoder<'static, W>),
170}
171
172impl<W: std::io::Write> SyncCompressWriter<W> {
173 pub fn finish(self) -> std::io::Result<W> {
176 match self {
177 SyncCompressWriter::Plain(w) => Ok(w),
178 SyncCompressWriter::Gzip(e) => e.finish(),
179 SyncCompressWriter::Zstd(e) => e.finish(),
180 }
181 }
182}
183
184impl<W: std::io::Write> std::io::Write for SyncCompressWriter<W> {
185 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
186 match self {
187 SyncCompressWriter::Plain(w) => w.write(buf),
188 SyncCompressWriter::Gzip(e) => e.write(buf),
189 SyncCompressWriter::Zstd(e) => e.write(buf),
190 }
191 }
192
193 fn flush(&mut self) -> std::io::Result<()> {
194 match self {
195 SyncCompressWriter::Plain(w) => w.flush(),
196 SyncCompressWriter::Gzip(e) => e.flush(),
197 SyncCompressWriter::Zstd(e) => e.flush(),
198 }
199 }
200}
201
202pub fn sync_compress_writer<W: std::io::Write>(w: W, c: Compression) -> SyncCompressWriter<W> {
206 match c {
207 Compression::None => SyncCompressWriter::Plain(w),
208 Compression::Gzip => SyncCompressWriter::Gzip(flate2::write::GzEncoder::new(
209 w,
210 flate2::Compression::default(),
211 )),
212 Compression::Zstd => SyncCompressWriter::Zstd(
213 zstd::stream::write::Encoder::new(w, 0)
214 .expect("zstd encoder construction is infallible"),
215 ),
216 }
217}
218
219pub fn compress_buf(data: &[u8], c: Compression) -> Result<Vec<u8>, FaucetError> {
222 use std::io::Write;
223 match c {
224 Compression::None => Ok(data.to_vec()),
225 Compression::Gzip => {
226 let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
227 enc.write_all(data)
228 .map_err(|e| FaucetError::Sink(format!("gzip compress failed: {e}")))?;
229 enc.finish()
230 .map_err(|e| FaucetError::Sink(format!("gzip finalise failed: {e}")))
231 }
232 Compression::Zstd => zstd::stream::encode_all(data, 0)
233 .map_err(|e| FaucetError::Sink(format!("zstd compress failed: {e}"))),
234 }
235}
236
237pub fn warn_mismatch(path: &str, declared: Compression) {
241 use std::collections::HashSet;
242 use std::sync::{Mutex, OnceLock};
243 const MAX_SEEN: usize = 4096;
248 static SEEN: OnceLock<Mutex<HashSet<(String, Compression)>>> = OnceLock::new();
249 let detected = detect_from_path(path);
250 if detected == declared {
251 return;
252 }
253 let key = (path.to_string(), declared);
254 let should_warn = {
255 let mut seen = SEEN
256 .get_or_init(|| Mutex::new(HashSet::new()))
257 .lock()
258 .expect("compression mismatch log mutex poisoned");
259 if seen.len() >= MAX_SEEN {
260 true
261 } else {
262 seen.insert(key)
263 }
264 };
265 if should_warn {
266 tracing::warn!(
267 path = %path,
268 declared = ?declared,
269 detected = ?detected,
270 "compression codec mismatch — explicit config wins, filename extension ignored",
271 );
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
279
280 #[test]
281 fn detect_extensions() {
282 assert_eq!(detect_from_path("foo.jsonl"), Compression::None);
283 assert_eq!(detect_from_path("foo.json.gz"), Compression::Gzip);
284 assert_eq!(detect_from_path("foo.csv.zst"), Compression::Zstd);
285 assert_eq!(detect_from_path("FOO.GZ"), Compression::Gzip);
286 assert_eq!(detect_from_path("a.gz.zst"), Compression::Zstd);
287 assert_eq!(detect_from_path(""), Compression::None);
288 }
289
290 #[test]
291 fn resolve_auto_uses_path() {
292 assert_eq!(CompressionConfig::Auto.resolve("foo.gz"), Compression::Gzip);
293 assert_eq!(
294 CompressionConfig::Auto.resolve("foo.zst"),
295 Compression::Zstd
296 );
297 assert_eq!(CompressionConfig::Auto.resolve("foo"), Compression::None);
298 }
299
300 #[test]
301 fn resolve_explicit_ignores_path() {
302 assert_eq!(
303 CompressionConfig::Gzip.resolve("foo.txt"),
304 Compression::Gzip
305 );
306 assert_eq!(CompressionConfig::None.resolve("foo.gz"), Compression::None);
307 }
308
309 #[test]
310 fn config_default_is_auto() {
311 assert_eq!(CompressionConfig::default(), CompressionConfig::Auto);
312 }
313
314 #[test]
315 fn config_serde_lowercase() {
316 for (variant, expected) in [
318 (CompressionConfig::None, "\"none\""),
319 (CompressionConfig::Gzip, "\"gzip\""),
320 (CompressionConfig::Zstd, "\"zstd\""),
321 (CompressionConfig::Auto, "\"auto\""),
322 ] {
323 let serialised = serde_json::to_string(&variant).unwrap();
324 assert_eq!(serialised, expected);
325 let deserialised: CompressionConfig = serde_json::from_str(expected).unwrap();
326 assert_eq!(deserialised, variant);
327 }
328 }
329
330 #[tokio::test]
331 async fn async_roundtrip_gzip() {
332 let original = b"hello, compressed world!\n".repeat(100);
333 let mut buf = Vec::new();
334 {
335 let mut w = wrap_async_writer(&mut buf, Compression::Gzip);
336 w.write_all(&original).await.unwrap();
337 w.shutdown().await.unwrap();
338 }
339 let mut decompressed = Vec::new();
340 let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Gzip);
341 r.read_to_end(&mut decompressed).await.unwrap();
342 assert_eq!(decompressed, original);
343 }
344
345 #[tokio::test]
346 async fn async_roundtrip_zstd() {
347 let original = b"zstd payload\n".repeat(50);
348 let mut buf = Vec::new();
349 {
350 let mut w = wrap_async_writer(&mut buf, Compression::Zstd);
351 w.write_all(&original).await.unwrap();
352 w.shutdown().await.unwrap();
353 }
354 let mut decompressed = Vec::new();
355 let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Zstd);
356 r.read_to_end(&mut decompressed).await.unwrap();
357 assert_eq!(decompressed, original);
358 }
359
360 #[tokio::test]
361 async fn async_multi_frame_zstd_reads_every_frame() {
362 let frame_a = b"first-frame-payload\n".repeat(10);
366 let frame_b = b"second-frame-payload\n".repeat(10);
367 let mut buf = Vec::new();
368 for frame in [&frame_a, &frame_b] {
369 let mut w = wrap_async_writer(&mut buf, Compression::Zstd);
370 w.write_all(frame).await.unwrap();
371 w.shutdown().await.unwrap();
372 }
373 let mut decompressed = Vec::new();
374 let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Zstd);
375 r.read_to_end(&mut decompressed).await.unwrap();
376 let mut expected = frame_a.clone();
377 expected.extend_from_slice(&frame_b);
378 assert_eq!(decompressed, expected, "both zstd frames must be read back");
379 }
380
381 #[test]
382 fn sync_multi_frame_zstd_reads_every_frame() {
383 use std::io::Read;
386 let frame_a = b"sync-frame-a\n".repeat(8);
387 let frame_b = b"sync-frame-b\n".repeat(8);
388 let mut buf = compress_buf(&frame_a, Compression::Zstd).unwrap();
389 buf.extend_from_slice(&compress_buf(&frame_b, Compression::Zstd).unwrap());
390 let mut decompressed = Vec::new();
391 wrap_sync_reader(&buf[..], Compression::Zstd)
392 .read_to_end(&mut decompressed)
393 .unwrap();
394 let mut expected = frame_a.clone();
395 expected.extend_from_slice(&frame_b);
396 assert_eq!(decompressed, expected);
397 }
398
399 #[tokio::test]
400 async fn async_none_passthrough() {
401 let original = b"plain text";
402 let mut buf = Vec::new();
403 {
404 let mut w = wrap_async_writer(&mut buf, Compression::None);
405 w.write_all(original).await.unwrap();
406 w.shutdown().await.unwrap();
407 }
408 assert_eq!(&buf[..], original);
409 }
410
411 #[test]
412 fn sync_roundtrip_gzip() {
413 use std::io::{Read, Write};
414 let original = b"sync gzip data".repeat(20);
415 let mut buf = Vec::new();
416 {
417 let mut w = wrap_sync_writer(&mut buf, Compression::Gzip);
418 w.write_all(&original).unwrap();
419 w.flush().unwrap();
424 }
425 let mut r = wrap_sync_reader(&buf[..], Compression::Gzip);
426 let mut decompressed = Vec::new();
427 r.read_to_end(&mut decompressed).unwrap();
428 assert_eq!(decompressed, original);
429 }
430
431 #[test]
432 fn sync_roundtrip_zstd() {
433 use std::io::{Read, Write};
434 let original = b"sync zstd data".repeat(20);
435 let mut buf = Vec::new();
436 {
437 let mut w = wrap_sync_writer(&mut buf, Compression::Zstd);
438 w.write_all(&original).unwrap();
439 w.flush().unwrap();
440 }
441 let mut r = wrap_sync_reader(&buf[..], Compression::Zstd);
442 let mut decompressed = Vec::new();
443 r.read_to_end(&mut decompressed).unwrap();
444 assert_eq!(decompressed, original);
445 }
446
447 #[test]
448 fn compress_buf_roundtrip_gzip() {
449 use std::io::Read;
450 let original = b"buffer compression".repeat(10);
451 let compressed = compress_buf(&original, Compression::Gzip).unwrap();
452 assert_ne!(compressed, original);
453 let mut r = wrap_sync_reader(&compressed[..], Compression::Gzip);
454 let mut decompressed = Vec::new();
455 r.read_to_end(&mut decompressed).unwrap();
456 assert_eq!(decompressed, original);
457 }
458
459 #[test]
460 fn compress_buf_roundtrip_zstd() {
461 use std::io::Read;
462 let original = b"buffer zstd".repeat(10);
463 let compressed = compress_buf(&original, Compression::Zstd).unwrap();
464 assert_ne!(compressed, original);
465 let mut r = wrap_sync_reader(&compressed[..], Compression::Zstd);
466 let mut decompressed = Vec::new();
467 r.read_to_end(&mut decompressed).unwrap();
468 assert_eq!(decompressed, original);
469 }
470
471 #[test]
472 fn compress_buf_none_is_clone() {
473 let original = b"unchanged";
474 let out = compress_buf(original, Compression::None).unwrap();
475 assert_eq!(out, original);
476 }
477
478 #[tokio::test]
479 async fn empty_compressed_stream_yields_zero_bytes() {
480 let mut buf = Vec::new();
482 {
483 let mut w = wrap_async_writer(&mut buf, Compression::Gzip);
484 w.shutdown().await.unwrap();
485 }
486 let mut decompressed = Vec::new();
488 let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Gzip);
489 r.read_to_end(&mut decompressed).await.unwrap();
490 assert!(decompressed.is_empty());
491 }
492
493 #[tokio::test]
494 async fn truncated_gzip_stream_errors() {
495 let original = b"this will be truncated mid-stream".repeat(50);
496 let mut buf = Vec::new();
497 {
498 let mut w = wrap_async_writer(&mut buf, Compression::Gzip);
499 w.write_all(&original).await.unwrap();
500 w.shutdown().await.unwrap();
501 }
502 buf.truncate(buf.len() / 2);
504 let mut decompressed = Vec::new();
505 let mut r = wrap_async_reader(BufReader::new(&buf[..]), Compression::Gzip);
506 let err = r.read_to_end(&mut decompressed).await.unwrap_err();
507 assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
508 }
509
510 #[test]
511 fn warn_mismatch_dedups_per_path_and_codec() {
512 let unique_path = format!("warn_mismatch_dedup_fixture_{}.txt", line!());
518 warn_mismatch(&unique_path, Compression::Gzip);
520 warn_mismatch(&unique_path, Compression::Gzip);
522 warn_mismatch(&unique_path, Compression::Zstd);
524 warn_mismatch("file.gz", Compression::Gzip);
526 }
531}