1use std::path::{Path, PathBuf};
8
9#[cfg(feature = "parquet")]
10use parquet::basic::{BrotliLevel, Compression as ParquetCompressionCodec, GzipLevel, ZstdLevel};
11
12use crate::err::ReadStatError;
13
14#[non_exhaustive]
23#[derive(Debug, Clone, Copy)]
24pub enum OutFormat {
25 Csv,
27 Feather,
29 Ndjson,
31 Parquet,
33}
34
35impl std::fmt::Display for OutFormat {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::Csv => f.write_str("csv"),
39 Self::Feather => f.write_str("feather"),
40 Self::Ndjson => f.write_str("ndjson"),
41 Self::Parquet => f.write_str("parquet"),
42 }
43 }
44}
45
46impl std::str::FromStr for OutFormat {
47 type Err = ReadStatError;
48
49 fn from_str(s: &str) -> Result<Self, Self::Err> {
57 match s.to_lowercase().as_str() {
58 "csv" => Ok(Self::Csv),
59 "feather" => Ok(Self::Feather),
60 "ndjson" => Ok(Self::Ndjson),
61 "parquet" => Ok(Self::Parquet),
62 _ => Err(ReadStatError::UnknownFormat(s.to_string())),
63 }
64 }
65}
66
67#[non_exhaustive]
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum ParquetCompression {
74 Uncompressed,
76 Snappy,
78 Gzip,
80 Lz4Raw,
82 Brotli,
84 Zstd,
86}
87
88impl std::fmt::Display for ParquetCompression {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Uncompressed => f.write_str("uncompressed"),
92 Self::Snappy => f.write_str("snappy"),
93 Self::Gzip => f.write_str("gzip"),
94 Self::Lz4Raw => f.write_str("lz4-raw"),
95 Self::Brotli => f.write_str("brotli"),
96 Self::Zstd => f.write_str("zstd"),
97 }
98 }
99}
100
101impl std::str::FromStr for ParquetCompression {
102 type Err = ReadStatError;
103
104 fn from_str(s: &str) -> Result<Self, Self::Err> {
113 match s.to_lowercase().as_str() {
114 "uncompressed" => Ok(Self::Uncompressed),
115 "snappy" => Ok(Self::Snappy),
116 "gzip" => Ok(Self::Gzip),
117 "lz4-raw" | "lz4raw" => Ok(Self::Lz4Raw),
118 "brotli" => Ok(Self::Brotli),
119 "zstd" => Ok(Self::Zstd),
120 _ => Err(ReadStatError::UnknownFormat(s.to_string())),
121 }
122 }
123}
124
125#[derive(Debug, Clone)]
135pub struct WriteConfig {
136 pub(crate) out_path: Option<PathBuf>,
138 pub(crate) format: OutFormat,
140 pub(crate) overwrite: bool,
142 pub(crate) compression: Option<ParquetCompression>,
144 pub(crate) compression_level: Option<u32>,
146}
147
148impl WriteConfig {
149 pub fn from_output(path: impl Into<PathBuf>) -> Result<Self, ReadStatError> {
156 let path = path.into();
157 let format = match path
158 .extension()
159 .and_then(|extension| extension.to_str())
160 .map(str::to_ascii_lowercase)
161 .as_deref()
162 {
163 Some("csv") => OutFormat::Csv,
164 Some("feather") => OutFormat::Feather,
165 Some("ndjson") => OutFormat::Ndjson,
166 Some("parquet") => OutFormat::Parquet,
167 _ => {
168 return Err(ReadStatError::InvalidOutputConfig(format!(
169 "cannot infer output format from '{}'; supported extensions are .csv, .feather, .ndjson, and .parquet",
170 path.display()
171 )));
172 }
173 };
174 Self::new(format).output(path)
175 }
176
177 #[must_use]
180 pub const fn new(format: OutFormat) -> Self {
181 Self {
182 out_path: None,
183 format,
184 overwrite: false,
185 compression: None,
186 compression_level: None,
187 }
188 }
189
190 pub fn output(mut self, path: impl Into<PathBuf>) -> Result<Self, ReadStatError> {
192 let path = Self::validate_out_path(Some(path.into()))?.expect("path was supplied");
193 self.out_path = Self::validate_out_extension(&path, self.format)?;
194 Ok(self)
195 }
196
197 #[must_use]
201 pub const fn overwrite(mut self, overwrite: bool) -> Self {
202 self.overwrite = overwrite;
203 self
204 }
205
206 pub fn compression(
208 mut self,
209 codec: ParquetCompression,
210 level: Option<u32>,
211 ) -> Result<Self, ReadStatError> {
212 if !matches!(self.format, OutFormat::Parquet) {
213 return Err(ReadStatError::InvalidCompressionConfig(
214 "compression is only supported for Parquet".into(),
215 ));
216 }
217 self.compression_level = Self::validate_compression_level(codec, level)?;
218 self.compression = Some(codec);
219 Ok(self)
220 }
221
222 pub(crate) fn validate(&self) -> Result<(), ReadStatError> {
223 if self.out_path.is_none() && !matches!(self.format, OutFormat::Csv) {
224 return Err(ReadStatError::InvalidOutputConfig(
225 "only CSV may be written to stdout".into(),
226 ));
227 }
228 Ok(())
229 }
230
231 #[must_use]
233 pub fn out_path(&self) -> Option<&Path> {
234 self.out_path.as_deref()
235 }
236
237 #[must_use]
239 pub const fn format(&self) -> OutFormat {
240 self.format
241 }
242
243 #[must_use]
245 pub const fn is_overwrite(&self) -> bool {
246 self.overwrite
247 }
248
249 #[must_use]
251 pub const fn compression_codec(&self) -> Option<ParquetCompression> {
252 self.compression
253 }
254
255 #[must_use]
257 pub const fn compression_level(&self) -> Option<u32> {
258 self.compression_level
259 }
260
261 fn validate_out_extension(
263 path: &Path,
264 format: OutFormat,
265 ) -> Result<Option<PathBuf>, ReadStatError> {
266 match path.extension().and_then(|e| e.to_str()) {
267 Some(e) if e.eq_ignore_ascii_case(&format.to_string()) => Ok(Some(path.to_owned())),
268 _ => Err(ReadStatError::OutputExtensionMismatch {
269 path: path.to_owned(),
270 expected: format.to_string(),
271 }),
272 }
273 }
274
275 fn validate_out_path(path: Option<PathBuf>) -> Result<Option<PathBuf>, ReadStatError> {
277 match path {
278 None => Ok(None),
279 Some(p) => {
280 let abs_path = std::path::absolute(&p)
281 .map_err(|e| ReadStatError::Other(format!("Failed to resolve path: {e}")))?;
282
283 match abs_path.parent() {
284 None => Err(ReadStatError::OutputParentMissing(abs_path.clone())),
285 Some(parent) => {
286 if parent.exists() {
287 Ok(Some(abs_path))
288 } else {
289 Err(ReadStatError::OutputParentMissing(parent.to_path_buf()))
290 }
291 }
292 }
293 }
294 }
295 }
296
297 fn validate_compression_level(
299 compression: ParquetCompression,
300 compression_level: Option<u32>,
301 ) -> Result<Option<u32>, ReadStatError> {
302 let (name, max_level): (&str, Option<u32>) = match compression {
303 ParquetCompression::Uncompressed => ("uncompressed", None),
304 ParquetCompression::Snappy => ("snappy", None),
305 ParquetCompression::Lz4Raw => ("lz4-raw", None),
306 ParquetCompression::Gzip => ("gzip", Some(9)),
307 ParquetCompression::Brotli => ("brotli", Some(11)),
308 ParquetCompression::Zstd => ("zstd", Some(22)),
309 };
310
311 match (max_level, compression_level) {
312 (None | Some(_), None) => Ok(None),
313 (None, Some(_)) => Err(ReadStatError::Other(format!(
314 "compression codec {name} does not support a level"
315 ))),
316 (Some(max), Some(c)) => {
317 if c <= max {
318 Ok(Some(c))
319 } else {
320 Err(ReadStatError::Other(format!(
321 "The compression level of {c} is not a valid level for {name} compression. \
322 Instead, please use values between 0-{max}."
323 )))
324 }
325 }
326 }
327 }
328}
329
330#[cfg(any(
334 feature = "csv",
335 feature = "feather",
336 feature = "ndjson",
337 feature = "parquet"
338))]
339pub(crate) fn open_output(config: &WriteConfig) -> Result<(std::fs::File, PathBuf), ReadStatError> {
340 create_staging_file(
341 config
342 .out_path
343 .as_ref()
344 .ok_or_else(|| ReadStatError::Other("stdout has no output file".into()))?,
345 )
346}
347
348#[cfg(any(
349 feature = "csv",
350 feature = "feather",
351 feature = "ndjson",
352 feature = "parquet"
353))]
354pub(crate) fn create_staging_file(path: &Path) -> Result<(std::fs::File, PathBuf), ReadStatError> {
355 use std::sync::atomic::{AtomicU64, Ordering};
356
357 static NEXT_STAGING_ID: AtomicU64 = AtomicU64::new(0);
358 let parent = path.parent().expect("validated output has a parent");
359 let name = path
360 .file_name()
361 .and_then(|n| n.to_str())
362 .unwrap_or("output");
363 for _ in 0..100 {
364 let id = NEXT_STAGING_ID.fetch_add(1, Ordering::Relaxed);
365 let staging = parent.join(format!(".{name}.readstat-{}-{id}.tmp", std::process::id()));
366 match std::fs::OpenOptions::new()
367 .write(true)
368 .create_new(true)
369 .open(&staging)
370 {
371 Ok(file) => return Ok((file, staging)),
372 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
373 Err(error) => return Err(error.into()),
374 }
375 }
376 Err(ReadStatError::Other(format!(
377 "could not create a unique staging file for {}",
378 path.display()
379 )))
380}
381
382#[cfg(any(
383 feature = "csv",
384 feature = "feather",
385 feature = "ndjson",
386 feature = "parquet"
387))]
388pub(crate) fn publish_staging(
389 staging: &Path,
390 destination: &Path,
391 overwrite: bool,
392) -> Result<(), ReadStatError> {
393 if overwrite {
394 #[cfg(windows)]
395 {
396 use std::os::windows::ffi::OsStrExt;
397 use windows_sys::Win32::Storage::FileSystem::{
398 MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
399 };
400
401 let staging = staging
402 .as_os_str()
403 .encode_wide()
404 .chain(std::iter::once(0))
405 .collect::<Vec<_>>();
406 let destination = destination
407 .as_os_str()
408 .encode_wide()
409 .chain(std::iter::once(0))
410 .collect::<Vec<_>>();
411 let moved = unsafe {
414 MoveFileExW(
415 staging.as_ptr(),
416 destination.as_ptr(),
417 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
418 )
419 };
420 if moved == 0 {
421 Err(std::io::Error::last_os_error().into())
422 } else {
423 Ok(())
424 }
425 }
426 #[cfg(not(windows))]
427 {
428 std::fs::rename(staging, destination).map_err(Into::into)
429 }
430 } else {
431 std::fs::hard_link(staging, destination).map_err(|error| {
432 if error.kind() == std::io::ErrorKind::AlreadyExists {
433 ReadStatError::OutputFileExists(destination.to_owned())
434 } else {
435 error.into()
436 }
437 })?;
438 std::fs::remove_file(staging)?;
439 Ok(())
440 }
441}
442
443#[cfg(feature = "parquet")]
447#[allow(clippy::cast_possible_wrap)]
448pub fn resolve_parquet_compression(
449 compression: Option<ParquetCompression>,
450 compression_level: Option<u32>,
451) -> Result<ParquetCompressionCodec, ReadStatError> {
452 let codec = match compression {
453 Some(ParquetCompression::Uncompressed) => ParquetCompressionCodec::UNCOMPRESSED,
454 Some(ParquetCompression::Snappy) | None => ParquetCompressionCodec::SNAPPY,
455 Some(ParquetCompression::Gzip) => {
456 if let Some(level) = compression_level {
457 let gzip_level = GzipLevel::try_new(level).map_err(|e| {
458 ReadStatError::Other(format!("Invalid Gzip compression level: {e}"))
459 })?;
460 ParquetCompressionCodec::GZIP(gzip_level)
461 } else {
462 ParquetCompressionCodec::GZIP(GzipLevel::default())
463 }
464 }
465 Some(ParquetCompression::Lz4Raw) => ParquetCompressionCodec::LZ4_RAW,
466 Some(ParquetCompression::Brotli) => {
467 if let Some(level) = compression_level {
468 let brotli_level = BrotliLevel::try_new(level).map_err(|e| {
469 ReadStatError::Other(format!("Invalid Brotli compression level: {e}"))
470 })?;
471 ParquetCompressionCodec::BROTLI(brotli_level)
472 } else {
473 ParquetCompressionCodec::BROTLI(BrotliLevel::default())
474 }
475 }
476 Some(ParquetCompression::Zstd) => {
477 if let Some(level) = compression_level {
478 let zstd_level = ZstdLevel::try_new(level as i32).map_err(|e| {
479 ReadStatError::Other(format!("Invalid Zstd compression level: {e}"))
480 })?;
481 ParquetCompressionCodec::ZSTD(zstd_level)
482 } else {
483 ParquetCompressionCodec::ZSTD(ZstdLevel::default())
484 }
485 }
486 };
487 Ok(codec)
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn from_output_infers_format_case_insensitively() {
496 let config = WriteConfig::from_output("result.PARQUET").unwrap();
497 assert!(matches!(config.format(), OutFormat::Parquet));
498 }
499
500 #[test]
501 fn from_output_rejects_unknown_extension() {
502 assert!(WriteConfig::from_output("result.unknown").is_err());
503 }
504
505 #[test]
508 fn valid_csv_out_extension() {
509 let path = Path::new("/some/output.csv");
510 let result = WriteConfig::validate_out_extension(path, OutFormat::Csv).unwrap();
511 assert!(result.is_some());
512 }
513
514 #[test]
515 fn valid_parquet_out_extension() {
516 let path = Path::new("/some/output.parquet");
517 let result = WriteConfig::validate_out_extension(path, OutFormat::Parquet).unwrap();
518 assert!(result.is_some());
519 }
520
521 #[test]
522 fn valid_feather_out_extension() {
523 let path = Path::new("/some/output.feather");
524 let result = WriteConfig::validate_out_extension(path, OutFormat::Feather).unwrap();
525 assert!(result.is_some());
526 }
527
528 #[test]
529 fn valid_ndjson_out_extension() {
530 let path = Path::new("/some/output.ndjson");
531 let result = WriteConfig::validate_out_extension(path, OutFormat::Ndjson).unwrap();
532 assert!(result.is_some());
533 }
534
535 #[test]
536 fn mismatched_out_extension() {
537 let path = Path::new("/some/output.csv");
538 assert!(WriteConfig::validate_out_extension(path, OutFormat::Parquet).is_err());
539 }
540
541 #[test]
542 fn no_out_extension() {
543 let path = Path::new("/some/output");
544 assert!(WriteConfig::validate_out_extension(path, OutFormat::Csv).is_err());
545 }
546
547 #[test]
550 fn uncompressed_rejects_level() {
551 let result =
552 WriteConfig::validate_compression_level(ParquetCompression::Uncompressed, Some(5));
553 assert!(result.is_err());
554 }
555
556 #[test]
557 fn snappy_rejects_level() {
558 let result = WriteConfig::validate_compression_level(ParquetCompression::Snappy, Some(5));
559 assert!(result.is_err());
560 }
561
562 #[test]
563 fn lz4raw_rejects_level() {
564 let result = WriteConfig::validate_compression_level(ParquetCompression::Lz4Raw, Some(5));
565 assert!(result.is_err());
566 }
567
568 #[test]
569 fn gzip_valid_level() {
570 let result =
571 WriteConfig::validate_compression_level(ParquetCompression::Gzip, Some(5)).unwrap();
572 assert_eq!(result, Some(5));
573 }
574
575 #[test]
576 fn gzip_max_valid_level() {
577 let result =
578 WriteConfig::validate_compression_level(ParquetCompression::Gzip, Some(9)).unwrap();
579 assert_eq!(result, Some(9));
580 }
581
582 #[test]
583 fn gzip_invalid_level() {
584 assert!(
585 WriteConfig::validate_compression_level(ParquetCompression::Gzip, Some(10),).is_err()
586 );
587 }
588
589 #[test]
590 fn brotli_valid_level() {
591 let result =
592 WriteConfig::validate_compression_level(ParquetCompression::Brotli, Some(11)).unwrap();
593 assert_eq!(result, Some(11));
594 }
595
596 #[test]
597 fn brotli_invalid_level() {
598 assert!(
599 WriteConfig::validate_compression_level(ParquetCompression::Brotli, Some(12),).is_err()
600 );
601 }
602
603 #[test]
604 fn zstd_valid_level() {
605 let result =
606 WriteConfig::validate_compression_level(ParquetCompression::Zstd, Some(22)).unwrap();
607 assert_eq!(result, Some(22));
608 }
609
610 #[test]
611 fn zstd_invalid_level() {
612 assert!(
613 WriteConfig::validate_compression_level(ParquetCompression::Zstd, Some(23),).is_err()
614 );
615 }
616
617 #[test]
618 fn no_level_passes_through() {
619 let result =
620 WriteConfig::validate_compression_level(ParquetCompression::Gzip, None).unwrap();
621 assert_eq!(result, None);
622 }
623
624 #[test]
627 fn validate_out_path_none() {
628 assert!(WriteConfig::validate_out_path(None).unwrap().is_none());
629 }
630}