use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicU64;
use crate::cloud::download::{Opened, StreamError, TempDownload};
use crate::loading::unfinished::Writer;
use crate::{CompressionFormat, FileFormat, OpenOptions};
pub const PATH: &str = "-";
pub const NAME: &str = "stdin";
pub fn is_stdin(path: &Path) -> bool {
path.as_os_str() == PATH
}
pub fn named(path: &Path) -> PathBuf {
if is_stdin(path) {
PathBuf::from(NAME)
} else {
path.to_path_buf()
}
}
pub fn piped() -> bool {
use std::io::IsTerminal;
let stdin = std::io::stdin();
carries_data(stdin.is_terminal(), is_device(&stdin))
}
pub fn carries_data(terminal: bool, device: bool) -> bool {
!terminal && !device
}
#[cfg(unix)]
fn is_device(stdin: &std::io::Stdin) -> bool {
use std::os::fd::AsFd;
use std::os::unix::fs::FileTypeExt;
stdin
.as_fd()
.try_clone_to_owned()
.and_then(|fd| std::fs::File::from(fd).metadata())
.ok()
.is_none_or(|meta| meta.file_type().is_char_device())
}
#[cfg(windows)]
fn is_device(stdin: &std::io::Stdin) -> bool {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
let kind = unsafe { GetFileType(stdin.as_raw_handle()) };
!matches!(kind, FILE_TYPE_DISK | FILE_TYPE_PIPE)
}
#[cfg(not(any(unix, windows)))]
fn is_device(_stdin: &std::io::Stdin) -> bool {
false
}
pub fn as_file(path: PathBuf) -> PathBuf {
if is_stdin(&path) {
Path::new(".").join(path)
} else {
path
}
}
pub fn paths_or_stdin(paths: Vec<PathBuf>, piped: bool) -> Vec<PathBuf> {
if paths.is_empty() && piped {
vec![PathBuf::from(PATH)]
} else {
paths
}
}
pub fn refuse(paths: &[PathBuf], piped: bool) -> Option<&'static str> {
if !paths.iter().any(|path| is_stdin(path)) {
return None;
}
if paths.len() > 1 {
return Some("Standard input (-) is read on its own. Name it without other paths.");
}
(!piped)
.then_some("Nothing is piped to standard input. Pipe data in, as in: cat data.csv | datui")
}
pub fn sniff(head: &[u8]) -> (FileFormat, Option<CompressionFormat>) {
let (format, compression, _) = sniffed(head);
(format, compression)
}
pub(crate) fn sniff_for(
head: &[u8],
options: &OpenOptions,
) -> (FileFormat, Option<CompressionFormat>, bool) {
let (format, compression, guessed) = sniffed(head);
match guessed {
true => (
crate::formats::lines::as_asked(format, options),
compression,
true,
),
false => (format, compression, false),
}
}
fn sniffed(head: &[u8]) -> (FileFormat, Option<CompressionFormat>, bool) {
const COMPRESSED: [(&[u8], CompressionFormat); 4] = [
(b"\x1f\x8b", CompressionFormat::Gzip),
(b"\x28\xb5\x2f\xfd", CompressionFormat::Zstd),
(b"BZh", CompressionFormat::Bzip2),
(b"\xfd7zXZ\x00", CompressionFormat::Xz),
];
if let Some((_, compression)) = COMPRESSED.iter().find(|(magic, _)| head.starts_with(magic)) {
return (FileFormat::TEXT, Some(*compression), false);
}
if let Some(format) =
crate::formats::readers::sniff(head, None, crate::formats::readers::Asked::Pipe, |_| true)
{
return (format, None, false);
}
let format = crate::formats::lines::guess(head, head.len() < HEAD).unwrap_or(FileFormat::TEXT);
(format, None, true)
}
fn inside(file: &Path, compression: CompressionFormat) -> (FileFormat, bool) {
let Some(head) = crate::formats::head_of(file, Some(compression), HEAD as u64) else {
return (FileFormat::TEXT, false);
};
if let Some(format) = crate::formats::readers::sniff(
&head,
None,
crate::formats::readers::Asked::Pipe,
FileFormat::reads_into,
) {
return (format, false);
}
let format = crate::formats::lines::guess(&head, head.len() < HEAD)
.filter(|f| f.decompressed_once())
.unwrap_or(FileFormat::TEXT);
(format, true)
}
const HEAD: usize = crate::formats::readers::HEAD;
pub(crate) fn spool_dir(options: &OpenOptions) -> Option<PathBuf> {
if let Some(dir) = &options.temp_dir {
return Some(dir.clone());
}
let dir = crate::cache::CacheManager::new(crate::APP_NAME)
.ok()?
.cache_dir()
.join("spool");
std::fs::create_dir_all(&dir).ok()?;
forget_old_spools(&dir);
Some(dir)
}
const OLD_SPOOL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
fn forget_old_spools(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let old = entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|at| at.elapsed().ok())
.is_some_and(|age| age > OLD_SPOOL);
#[cfg(unix)]
let free = !crate::cloud::download::held_elsewhere(&entry.path());
#[cfg(not(unix))]
let free = true;
if old && free && entry.path().extension().is_some_and(|e| e == "tmp") {
let _ = std::fs::remove_file(entry.path());
}
}
}
pub(crate) fn spool<R: Read>(
open: impl FnOnce() -> Opened<R> + Send + 'static,
options: OpenOptions,
writer: &Writer,
read: &AtomicU64,
) -> Result<(TempDownload, OpenOptions), String> {
let file =
crate::cloud::download::spool_to_temp(spool_dir(&options).as_deref(), open, writer, read)
.map_err(|error| match error {
StreamError::Open(e) | StreamError::Read(e) => {
format!("Could not read standard input: {e}")
}
StreamError::Write(report) => {
crate::error_display::user_message_from_report(&report, None)
}
StreamError::Short { .. } | StreamError::Cut => {
"Reading standard input was stopped.".to_string()
}
})?;
let options = described(file.path(), options)?;
Ok((file, options))
}
pub(crate) fn described(file: &Path, options: OpenOptions) -> Result<OpenOptions, String> {
let mut head = Vec::with_capacity(HEAD);
std::fs::File::open(file)
.and_then(|f| f.take(HEAD as u64).read_to_end(&mut head))
.map_err(|e| format!("Could not read standard input back: {e}"))?;
if head.is_empty() {
return Err("Nothing came in on standard input.".to_string());
}
let (mut format, compression, mut guessed) = sniffed(&head);
if options.format.is_none()
&& let Some(compression) = options.compression.or(compression)
{
(format, guessed) = inside(file, compression);
}
if guessed {
format = crate::formats::lines::as_asked(format, &options);
}
Ok(match (options.format, options.compression) {
(Some(named), None) if named.separator().is_some() => OpenOptions {
compression,
..options
},
(Some(_), _) => options,
(None, Some(_)) => OpenOptions {
format: Some(format),
format_guessed: guessed,
..options
},
(None, None) => OpenOptions {
format: Some(format),
compression,
format_guessed: guessed,
..options
},
})
}
pub(crate) fn may_read_as_it_arrives(options: &OpenOptions) -> bool {
options.compression.is_none()
&& options
.format
.is_none_or(|format| format.follows() || format == FileFormat::Arrow)
}
#[cfg(test)]
mod tests;