use std::{
env, fs,
io::{self, Read},
path::{Path, PathBuf},
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
};
use csv_nose::{SampleSize, Sniffer};
use log::{debug, info, warn};
use serde::de::{Deserialize, Deserializer, Error};
use crate::{
CliResult,
index::Indexed,
select::{SelectColumns, Selection},
util,
};
pub const DEFAULT_RDR_BUFFER_CAPACITY: usize = 128 * (1 << 10);
pub const DEFAULT_WTR_BUFFER_CAPACITY: usize = 512 * (1 << 10);
const DEFAULT_SNIFFER_SAMPLE: usize = 100;
const NO_INDEX_WARNING_FILESIZE: u64 = 100 * (1 << 20);
static AUTO_INDEXED: AtomicBool = AtomicBool::new(false);
pub static SPONSOR_MESSAGE: &str = r#"sponsored by datHere - Data Infrastructure Engineering (https://qsv.datHere.com)
Need a UI & more advanced data-wrangling? Upgrade to qsv pro (https://qsvpro.datHere.com)
"#;
pub static TEMP_FILE_DIR: OnceLock<PathBuf> = OnceLock::new();
#[cfg(feature = "polars")]
pub static POLARS_FLOAT_PRECISION: OnceLock<Option<usize>> = OnceLock::new();
#[allow(unused_variables)]
#[allow(dead_code)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SpecialFormat {
Avro,
Parquet,
Ipc,
Json, Jsonl, CompressedCsv,
CompressedTsv,
CompressedSsv,
Unknown,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Delimiter(pub u8);
impl Delimiter {
pub const fn as_byte(self) -> u8 {
self.0
}
pub fn decode_delimiter(s: &str) -> Result<Delimiter, String> {
if s == r"\t" {
return Ok(Delimiter(b'\t'));
}
if s.len() != 1 {
return fail_format!("Could not convert '{s}' to a single ASCII character.");
}
let c = s.chars().next().unwrap();
if c.is_ascii() {
Ok(Delimiter(c as u8))
} else {
fail_format!("Could not convert '{c}' to ASCII delimiter.")
}
}
}
impl<'de> Deserialize<'de> for Delimiter {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Delimiter, D::Error> {
let s = String::deserialize(d)?;
match Delimiter::decode_delimiter(&s) {
Ok(delim) => Ok(delim),
Err(msg) => Err(D::Error::custom(msg)),
}
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub path: Option<PathBuf>, idx_path: Option<PathBuf>,
select_columns: Option<SelectColumns>,
delimiter: u8,
pub no_headers: bool,
pub flexible: bool,
terminator: csv::Terminator,
pub quote: u8,
quote_style: csv::QuoteStyle,
double_quote: bool,
escape: Option<u8>,
quoting: bool,
pub preamble_rows: u64,
trim: csv::Trim,
pub autoindex_size: u64,
prefer_dmy: bool,
pub comment: Option<u8>,
snappy: bool, pub read_buffer: u32,
pub write_buffer: u32,
pub skip_format_check: bool,
pub format_error: Option<String>,
}
pub trait SeekRead: io::Seek + io::Read {}
impl<T: io::Seek + io::Read> SeekRead for T {}
impl Config {
pub fn new(path: Option<&String>) -> Config {
let default_delim = match env::var("QSV_DEFAULT_DELIMITER") {
Ok(delim) => Delimiter::decode_delimiter(&delim).unwrap().as_byte(),
_ => b',',
};
let mut sniff = util::get_envvar_flag("QSV_SNIFF_DELIMITER")
|| util::get_envvar_flag("QSV_SNIFF_PREAMBLE");
let mut skip_format_check = true;
let mut format_error = None;
let (path, mut delim, snappy) = match path {
None => (None, default_delim, false),
Some(s) if s == "-" => (None, default_delim, false),
Some(ref s) => {
let mut path = PathBuf::from(s);
let temp_dir = crate::config::TEMP_FILE_DIR
.get_or_init(|| tempfile::TempDir::new().unwrap().keep());
skip_format_check = sniff
|| util::get_envvar_flag("QSV_SKIP_FORMAT_CHECK")
|| path.starts_with(temp_dir);
#[cfg(feature = "polars")]
let special_format = {
let special_format = get_special_format(&path);
if special_format != SpecialFormat::Unknown {
skip_format_check = true;
}
special_format
};
#[cfg(not(feature = "polars"))]
let special_format = SpecialFormat::Unknown;
let (delim, snappy) = if special_format == SpecialFormat::Unknown {
let (file_extension, delim, snappy) =
get_delim_by_extension(&path, default_delim);
format_error = if skip_format_check {
None
} else {
match file_extension.as_str() {
"csv" | "tsv" | "tab" | "ssv" => None,
ext => Some(format!(
"{} is using an unsupported file format: {ext}. Set \
QSV_SKIP_FORMAT_CHECK to skip input format checking.",
path.display()
)),
}
};
(delim, snappy)
} else {
match util::convert_special_format(&path, special_format, default_delim) {
Ok(temp_path) => {
path.clone_from(&temp_path);
sniff = false;
let (_, delim, snappy) =
get_delim_by_extension(&temp_path, default_delim);
(delim, snappy)
},
Err(e) => {
format_error = Some(format!("Failed to convert special format: {e}"));
(default_delim, false)
},
}
};
(Some(path), delim, snappy)
},
};
let comment: Option<u8> = match env::var("QSV_COMMENT_CHAR") {
Ok(comment_char) => Some(comment_char.as_bytes().first().unwrap().to_owned()),
Err(_) => None,
};
let no_headers = util::get_envvar_flag("QSV_NO_HEADERS");
let mut preamble = 0_u64;
if sniff && path.is_some() {
#[allow(clippy::unnecessary_unwrap)]
let sniff_path = path.as_ref().unwrap().to_str().unwrap();
match Sniffer::new()
.sample_size(SampleSize::Records(DEFAULT_SNIFFER_SAMPLE))
.sniff_path(sniff_path)
{
Ok(metadata) => {
delim = metadata.dialect.delimiter;
preamble = metadata.dialect.header.num_preamble_rows as u64;
info!(
"sniffed delimiter {} and {preamble} preamble rows",
delim as char
);
},
Err(e) => {
warn!("sniff error: {e}");
},
}
}
Config {
path,
idx_path: None,
select_columns: None,
delimiter: delim,
no_headers,
flexible: false,
terminator: csv::Terminator::Any(b'\n'),
quote: b'"',
quote_style: csv::QuoteStyle::Necessary,
double_quote: true,
escape: None,
quoting: true,
preamble_rows: preamble,
trim: csv::Trim::None,
autoindex_size: std::env::var("QSV_AUTOINDEX_SIZE")
.unwrap_or_else(|_| "0".to_owned())
.parse()
.unwrap_or(0),
prefer_dmy: util::get_envvar_flag("QSV_PREFER_DMY"),
comment,
snappy,
read_buffer: std::env::var("QSV_RDR_BUFFER_CAPACITY")
.unwrap_or_else(|_| DEFAULT_RDR_BUFFER_CAPACITY.to_string())
.parse()
.unwrap_or(DEFAULT_RDR_BUFFER_CAPACITY as u32),
write_buffer: std::env::var("QSV_WTR_BUFFER_CAPACITY")
.unwrap_or_else(|_| DEFAULT_WTR_BUFFER_CAPACITY.to_string())
.parse()
.unwrap_or(DEFAULT_WTR_BUFFER_CAPACITY as u32),
format_error,
skip_format_check,
}
}
pub const fn delimiter(mut self, d: Option<Delimiter>) -> Config {
if let Some(d) = d {
self.delimiter = d.as_byte();
}
self
}
pub const fn get_delimiter(&self) -> u8 {
self.delimiter
}
pub const fn comment(mut self, c: Option<u8>) -> Config {
self.comment = c;
self
}
pub const fn get_dmy_preference(&self) -> bool {
self.prefer_dmy
}
pub const fn no_headers(mut self, yes: bool) -> Config {
self.no_headers = yes;
self
}
pub fn no_headers_flag(mut self, mut yes: bool) -> Config {
if env::var("QSV_TOGGLE_HEADERS").unwrap_or_else(|_| "0".to_owned()) == "1" {
yes = !yes;
}
self.no_headers = self.no_headers || yes;
self
}
pub const fn flexible(mut self, yes: bool) -> Config {
self.flexible = yes;
self
}
pub const fn skip_format_check(mut self, yes: bool) -> Config {
self.skip_format_check = yes;
self
}
#[cfg(any(feature = "feature_capable", feature = "lite"))]
pub const fn crlf(mut self, yes: bool) -> Config {
if yes {
self.terminator = csv::Terminator::CRLF;
} else {
self.terminator = csv::Terminator::Any(b'\n');
}
self
}
#[cfg(any(feature = "feature_capable", feature = "lite"))]
pub const fn terminator(mut self, term: csv::Terminator) -> Config {
self.terminator = term;
self
}
pub const fn quote(mut self, quote: u8) -> Config {
self.quote = quote;
self
}
pub const fn quote_style(mut self, style: csv::QuoteStyle) -> Config {
self.quote_style = style;
self
}
pub const fn double_quote(mut self, yes: bool) -> Config {
self.double_quote = yes;
self
}
pub const fn escape(mut self, escape: Option<u8>) -> Config {
self.escape = escape;
self
}
pub const fn quoting(mut self, yes: bool) -> Config {
self.quoting = yes;
self
}
pub const fn trim(mut self, trim_type: csv::Trim) -> Config {
self.trim = trim_type;
self
}
pub fn set_read_buffer(mut self, buffer: usize) -> Config {
self.read_buffer = buffer
.try_into()
.unwrap_or(DEFAULT_RDR_BUFFER_CAPACITY as u32);
self
}
pub fn set_write_buffer(mut self, buffer: usize) -> Config {
self.write_buffer = buffer
.try_into()
.unwrap_or(DEFAULT_WTR_BUFFER_CAPACITY as u32);
self
}
#[allow(clippy::missing_const_for_fn)]
pub fn select(mut self, sel_cols: SelectColumns) -> Config {
self.select_columns = Some(sel_cols);
self
}
pub const fn is_stdin(&self) -> bool {
self.path.is_none()
}
#[cfg(feature = "polars")]
pub const fn is_snappy(&self) -> bool {
self.snappy
}
#[inline]
pub fn selection(&self, first_record: &csv::ByteRecord) -> Result<Selection, String> {
match self.select_columns {
None => fail!("Config has no 'SelectColumns'. Did you call Config::select?"),
Some(ref sel) => sel.selection(first_record, !self.no_headers),
}
}
pub fn write_headers<R: io::Read, W: io::Write>(
&self,
r: &mut csv::Reader<R>,
w: &mut csv::Writer<W>,
) -> csv::Result<()> {
if !self.no_headers {
let r = r.byte_headers()?;
if !r.is_empty() {
w.write_record(r)?;
}
}
Ok(())
}
pub fn writer(&self) -> io::Result<csv::Writer<Box<dyn io::Write + 'static>>> {
Ok(self.from_writer(self.io_writer()?))
}
pub fn reader(&self) -> io::Result<csv::Reader<Box<dyn io::Read + Send + 'static>>> {
if !self.skip_format_check && self.format_error.is_some() {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
self.format_error.clone().unwrap(),
))
} else {
Ok(self.from_reader(self.io_reader()?))
}
}
pub fn reader_file(&self) -> io::Result<csv::Reader<fs::File>> {
match self.path {
None => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Cannot use <stdin> here",
)),
Some(ref p) => {
if !self.skip_format_check && self.format_error.is_some() {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
self.format_error.clone().unwrap(),
))
} else {
fs::File::open(p).map(|f| self.from_reader(f))
}
},
}
}
pub fn reader_file_stdin(&self) -> io::Result<csv::Reader<Box<dyn SeekRead + 'static>>> {
Ok(match self.path {
None => {
let mut buffer: Vec<u8> = Vec::new();
let stdin = io::stdin();
stdin.lock().read_to_end(&mut buffer)?;
self.from_reader(Box::new(io::Cursor::new(buffer)))
},
Some(ref p) => {
if !self.skip_format_check && self.format_error.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
self.format_error.clone().unwrap(),
));
}
self.from_reader(Box::new(fs::File::open(p)?))
},
})
}
fn autoindex_file(&self) {
if self.snappy {
return;
}
let Some(path_buf) = &self.path else { return };
let pidx = util::idx_path(Path::new(path_buf));
let Ok(idxfile) = fs::File::create(pidx) else {
return;
};
let Ok(mut rdr) = self.reader_file() else {
return;
};
let mut wtr = io::BufWriter::with_capacity(DEFAULT_WTR_BUFFER_CAPACITY, idxfile);
match csv_index::RandomAccessSimple::create(&mut rdr, &mut wtr) {
Ok(()) => {
let Ok(()) = io::Write::flush(&mut wtr) else {
return;
};
debug!("autoindex of {} successful.", path_buf.display());
AUTO_INDEXED.store(true, Ordering::Relaxed);
},
Err(e) => debug!("autoindex of {} failed: {e}", path_buf.display()),
}
}
pub fn index_files(&self) -> io::Result<Option<(csv::Reader<fs::File>, fs::File)>> {
let mut data_modified = 0_u64;
let data_fsize;
let mut idx_path_work = PathBuf::new();
let auto_indexed = AUTO_INDEXED.load(Ordering::Relaxed);
let (csv_file, mut idx_file) = if auto_indexed {
(
fs::File::open(self.path.clone().unwrap())?,
fs::File::open(util::idx_path(&self.path.clone().unwrap()))?,
)
} else {
match (&self.path, &self.idx_path) {
(&None, &None) => return Ok(None),
(&None, &Some(_)) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Cannot use <stdin> with indexes",
));
},
(Some(p), Some(ip)) => (fs::File::open(p)?, fs::File::open(ip)?),
(Some(p), &None) => {
(data_modified, data_fsize) = util::file_metadata(&p.metadata()?);
idx_path_work = util::idx_path(p);
let idx_file = match fs::File::open(&idx_path_work) {
Err(_) => {
if self.snappy {
return Ok(None);
} else if self.autoindex_size > 0 && data_fsize >= self.autoindex_size {
self.autoindex_file();
fs::File::open(&idx_path_work)?
} else if data_fsize >= NO_INDEX_WARNING_FILESIZE {
use indicatif::HumanCount;
warn!(
"The {} MB CSV file is larger than the {} MB \
NO_INDEX_WARNING_FILESIZE threshold. Consider creating an \
index file as it will make qsv commands much faster.",
HumanCount(data_fsize * 100),
HumanCount(NO_INDEX_WARNING_FILESIZE * 100)
);
return Ok(None);
} else {
return Ok(None);
}
},
Ok(f) => f,
};
(fs::File::open(p)?, idx_file)
},
}
};
let (idx_modified, _) = util::file_metadata(&idx_file.metadata()?);
if data_modified > idx_modified {
info!("index stale... autoindexing...");
self.autoindex_file();
idx_file = fs::File::open(&idx_path_work)?;
}
let csv_rdr = self.from_reader(csv_file);
Ok(Some((csv_rdr, idx_file)))
}
#[inline]
pub fn indexed(&self) -> CliResult<Option<Indexed<fs::File, fs::File>>> {
match self.index_files()? {
None => Ok(None),
Some((r, i)) => Ok(Some(Indexed::open(r, i)?)),
}
}
pub fn io_reader(&self) -> io::Result<Box<dyn io::Read + Send + 'static>> {
Ok(match self.path {
None => Box::new(io::stdin()),
Some(ref p) => match fs::File::open(p) {
Ok(x) => {
if self.snappy {
match util::is_valid_snappy_file(p) {
Ok(true) => {
info!("decoding snappy-compressed file: {}", p.display());
Box::new(snap::read::FrameDecoder::new(x))
},
Ok(false) => {
warn!(
"File {} has .sz extension but is not a valid Snappy file. \
Reading as plain file.",
p.display()
);
Box::new(x)
},
Err(e) => {
warn!(
"Failed to validate Snappy file {}: {}. Reading as plain file.",
p.display(),
e
);
Box::new(x)
},
}
} else {
Box::new(x)
}
},
Err(err) => {
let msg = format!("failed to open {}: {}", p.display(), err);
return Err(io::Error::new(io::ErrorKind::NotFound, msg));
},
},
})
}
#[allow(clippy::wrong_self_convention)]
pub fn from_reader<R: Read>(&self, rdr: R) -> csv::Reader<R> {
csv::ReaderBuilder::new()
.flexible(self.flexible)
.delimiter(self.delimiter)
.has_headers(!self.no_headers)
.quote(self.quote)
.quoting(self.quoting)
.escape(self.escape)
.buffer_capacity(self.read_buffer as usize)
.comment(self.comment)
.trim(self.trim)
.from_reader(rdr)
}
pub fn io_writer(&self) -> io::Result<Box<dyn io::Write + 'static>> {
Ok(match self.path {
None => Box::new(io::stdout()),
Some(ref p) => {
if p == "sink" {
Box::new(io::sink())
} else if self.snappy {
info!("writing snappy-compressed file: {}", p.display());
Box::new(snap::write::FrameEncoder::new(fs::File::create(p)?))
} else {
Box::new(fs::File::create(p)?)
}
},
})
}
#[allow(clippy::wrong_self_convention)]
pub fn from_writer<W: io::Write>(&self, mut wtr: W) -> csv::Writer<W> {
if util::get_envvar_flag("QSV_OUTPUT_BOM") {
wtr.write_all("\u{FEFF}".as_bytes()).unwrap();
}
csv::WriterBuilder::new()
.flexible(self.flexible)
.delimiter(self.delimiter)
.terminator(self.terminator)
.quote(self.quote)
.quote_style(self.quote_style)
.double_quote(self.double_quote)
.escape(self.escape.unwrap_or(b'\\'))
.buffer_capacity(self.write_buffer as usize)
.from_writer(wtr)
}
}
#[inline]
pub fn is_snappy_extension(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("sz"))
}
pub fn get_delim_by_extension(path: &Path, default_delim: u8) -> (String, u8, bool) {
let snappy = is_snappy_extension(path);
let file_extension = if snappy {
path.file_stem()
.and_then(|stem| Path::new(stem).extension())
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_ascii_lowercase()
} else {
path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_ascii_lowercase()
};
let delim = match file_extension.as_str() {
"tsv" | "tab" => b'\t',
"ssv" => b';',
"csv" => b',',
_ => default_delim,
};
(file_extension, delim, snappy)
}
pub fn get_special_format(path: &Path) -> SpecialFormat {
if !path.exists() {
return SpecialFormat::Unknown;
}
let extension = path.extension().unwrap_or_default();
match extension
.to_str()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"avro" => SpecialFormat::Avro,
"parquet" => SpecialFormat::Parquet,
"ipc" | "arrow" => SpecialFormat::Ipc,
"jsonl" | "ndjson" => SpecialFormat::Jsonl,
"json" => SpecialFormat::Json,
"gz" => {
let path_str = if let Some(s) = path.to_str() {
s.to_ascii_lowercase()
} else {
return SpecialFormat::Unknown;
};
if path_str.ends_with(".csv.gz") {
SpecialFormat::CompressedCsv
} else if path_str.ends_with(".tsv.gz") || path_str.ends_with(".tab.gz") {
SpecialFormat::CompressedTsv
} else if path_str.ends_with(".ssv.gz") {
SpecialFormat::CompressedSsv
} else {
SpecialFormat::Unknown
}
},
"zst" => {
let path_str = if let Some(s) = path.to_str() {
s.to_ascii_lowercase()
} else {
return SpecialFormat::Unknown;
};
if path_str.ends_with(".csv.zst") {
SpecialFormat::CompressedCsv
} else if path_str.ends_with(".tsv.zst") || path_str.ends_with(".tab.zst") {
SpecialFormat::CompressedTsv
} else if path_str.ends_with(".ssv.zst") {
SpecialFormat::CompressedSsv
} else {
SpecialFormat::Unknown
}
},
"zlib" => {
let path_str = if let Some(s) = path.to_str() {
s.to_ascii_lowercase()
} else {
return SpecialFormat::Unknown;
};
if path_str.ends_with(".csv.zlib") {
SpecialFormat::CompressedCsv
} else if path_str.ends_with(".tsv.zlib") || path_str.ends_with(".tab.zlib") {
SpecialFormat::CompressedTsv
} else if path_str.ends_with(".ssv.zlib") {
SpecialFormat::CompressedSsv
} else {
SpecialFormat::Unknown
}
},
_ => SpecialFormat::Unknown,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn test_csv_extension() {
let path = PathBuf::from("test.csv");
let (ext, delim, snappy) = get_delim_by_extension(&path, b',');
assert_eq!(ext, "csv");
assert_eq!(delim, b',');
assert!(!snappy);
}
#[test]
fn test_tsv_extension() {
let path = PathBuf::from("test.tsv");
let (ext, delim, snappy) = get_delim_by_extension(&path, b',');
assert_eq!(ext, "tsv");
assert_eq!(delim, b'\t');
assert!(!snappy);
}
#[test]
fn test_ssv_extension() {
let path = PathBuf::from("test.ssv");
let (ext, delim, snappy) = get_delim_by_extension(&path, b',');
assert_eq!(ext, "ssv");
assert_eq!(delim, b';');
assert!(!snappy);
}
#[test]
fn test_snappy_csv_extension() {
let path = PathBuf::from("test.csv.sz");
let (ext, delim, snappy) = get_delim_by_extension(&path, b',');
assert_eq!(ext, "csv");
assert_eq!(delim, b',');
assert!(snappy);
}
#[test]
fn test_snappy_tsv_extension() {
let path = PathBuf::from("test.tsv.sz");
let (ext, delim, snappy) = get_delim_by_extension(&path, b',');
assert_eq!(ext, "tsv");
assert_eq!(delim, b'\t');
assert!(snappy);
}
#[test]
fn test_unknown_extension() {
let path = PathBuf::from("test.unknown");
let default_delim = b'|';
let (ext, delim, snappy) = get_delim_by_extension(&path, default_delim);
assert_eq!(ext, "unknown");
assert_eq!(delim, default_delim);
assert!(!snappy);
}
#[test]
fn test_no_extension() {
let path = PathBuf::from("test");
let default_delim = b',';
let (ext, delim, snappy) = get_delim_by_extension(&path, default_delim);
assert_eq!(ext, "");
assert_eq!(delim, default_delim);
assert!(!snappy);
}
#[test]
fn test_is_snappy_extension_lowercase() {
assert!(is_snappy_extension(Path::new("file.csv.sz")));
assert!(is_snappy_extension(Path::new("file.sz")));
assert!(is_snappy_extension(Path::new("file.tsv.sz")));
}
#[test]
fn test_is_snappy_extension_uppercase() {
assert!(is_snappy_extension(Path::new("file.csv.SZ")));
assert!(is_snappy_extension(Path::new("file.SZ")));
}
#[test]
fn test_is_snappy_extension_mixed_case() {
assert!(is_snappy_extension(Path::new("file.csv.Sz")));
assert!(is_snappy_extension(Path::new("file.sZ")));
}
#[test]
fn test_is_snappy_extension_not_snappy() {
assert!(!is_snappy_extension(Path::new("file.csv")));
assert!(!is_snappy_extension(Path::new("file.gz")));
assert!(!is_snappy_extension(Path::new("file")));
assert!(!is_snappy_extension(Path::new("file.sz.backup")));
assert!(!is_snappy_extension(Path::new("file.esz")));
assert!(!is_snappy_extension(Path::new("file.KYpPcb8esz")));
assert!(!is_snappy_extension(Path::new("data.esz")));
}
#[test]
fn test_snappy_ssv_extension() {
let path = PathBuf::from("test.ssv.sz");
let (ext, delim, snappy) = get_delim_by_extension(&path, b',');
assert_eq!(ext, "ssv");
assert_eq!(delim, b';');
assert!(snappy);
}
#[test]
fn test_snappy_case_insensitive() {
let path = PathBuf::from("test.csv.SZ");
let (ext, delim, snappy) = get_delim_by_extension(&path, b',');
assert_eq!(ext, "csv");
assert_eq!(delim, b',');
assert!(snappy);
let path = PathBuf::from("test.TSV.sz");
let (ext, delim, snappy) = get_delim_by_extension(&path, b',');
assert_eq!(ext, "tsv");
assert_eq!(delim, b'\t');
assert!(snappy);
}
}