1use std::io::Read;
10use std::path::{Path, PathBuf};
11use std::sync::atomic::AtomicU64;
12
13use crate::cloud::download::{Opened, StreamError, TempDownload};
14use crate::loading::unfinished::Writer;
15use crate::{CompressionFormat, FileFormat, OpenOptions};
16
17pub const PATH: &str = "-";
19
20pub const NAME: &str = "stdin";
22
23pub fn is_stdin(path: &Path) -> bool {
25 path.as_os_str() == PATH
26}
27
28pub fn named(path: &Path) -> PathBuf {
30 if is_stdin(path) {
31 PathBuf::from(NAME)
32 } else {
33 path.to_path_buf()
34 }
35}
36
37pub fn piped() -> bool {
41 use std::io::IsTerminal;
42 let stdin = std::io::stdin();
43 carries_data(stdin.is_terminal(), is_device(&stdin))
44}
45
46pub fn carries_data(terminal: bool, device: bool) -> bool {
49 !terminal && !device
50}
51
52#[cfg(unix)]
55fn is_device(stdin: &std::io::Stdin) -> bool {
56 use std::os::fd::AsFd;
57 use std::os::unix::fs::FileTypeExt;
58 stdin
59 .as_fd()
60 .try_clone_to_owned()
61 .and_then(|fd| std::fs::File::from(fd).metadata())
62 .ok()
63 .is_none_or(|meta| meta.file_type().is_char_device())
64}
65
66#[cfg(windows)]
70fn is_device(stdin: &std::io::Stdin) -> bool {
71 use std::os::windows::io::AsRawHandle;
72 use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
73 let kind = unsafe { GetFileType(stdin.as_raw_handle()) };
76 !matches!(kind, FILE_TYPE_DISK | FILE_TYPE_PIPE)
77}
78
79#[cfg(not(any(unix, windows)))]
80fn is_device(_stdin: &std::io::Stdin) -> bool {
81 false
82}
83
84pub fn as_file(path: PathBuf) -> PathBuf {
87 if is_stdin(&path) {
88 Path::new(".").join(path)
89 } else {
90 path
91 }
92}
93
94pub fn paths_or_stdin(paths: Vec<PathBuf>, piped: bool) -> Vec<PathBuf> {
97 if paths.is_empty() && piped {
98 vec![PathBuf::from(PATH)]
99 } else {
100 paths
101 }
102}
103
104pub fn refuse(paths: &[PathBuf], piped: bool) -> Option<&'static str> {
107 if !paths.iter().any(|path| is_stdin(path)) {
108 return None;
109 }
110 if paths.len() > 1 {
111 return Some("Standard input (-) is read on its own. Name it without other paths.");
112 }
113 (!piped)
114 .then_some("Nothing is piped to standard input. Pipe data in, as in: cat data.csv | datui")
115}
116
117pub fn sniff(head: &[u8]) -> (FileFormat, Option<CompressionFormat>) {
122 let (format, compression, _) = sniffed(head);
123 (format, compression)
124}
125
126pub(crate) fn sniff_for(
129 head: &[u8],
130 options: &OpenOptions,
131) -> (FileFormat, Option<CompressionFormat>, bool) {
132 let (format, compression, guessed) = sniffed(head);
133 match guessed {
134 true => (
135 crate::formats::lines::as_asked(format, options),
136 compression,
137 true,
138 ),
139 false => (format, compression, false),
140 }
141}
142
143fn sniffed(head: &[u8]) -> (FileFormat, Option<CompressionFormat>, bool) {
145 const COMPRESSED: [(&[u8], CompressionFormat); 4] = [
146 (b"\x1f\x8b", CompressionFormat::Gzip),
147 (b"\x28\xb5\x2f\xfd", CompressionFormat::Zstd),
148 (b"BZh", CompressionFormat::Bzip2),
149 (b"\xfd7zXZ\x00", CompressionFormat::Xz),
150 ];
151 if let Some((_, compression)) = COMPRESSED.iter().find(|(magic, _)| head.starts_with(magic)) {
152 return (FileFormat::TEXT, Some(*compression), false);
154 }
155 if let Some(format) =
156 crate::formats::readers::sniff(head, None, crate::formats::readers::Asked::Pipe, |_| true)
157 {
158 return (format, None, false);
159 }
160 let format = crate::formats::lines::guess(head, head.len() < HEAD).unwrap_or(FileFormat::TEXT);
161 (format, None, true)
162}
163
164fn inside(file: &Path, compression: CompressionFormat) -> (FileFormat, bool) {
168 let Some(head) = crate::formats::head_of(file, Some(compression), HEAD as u64) else {
169 return (FileFormat::TEXT, false);
170 };
171 if let Some(format) = crate::formats::readers::sniff(
172 &head,
173 None,
174 crate::formats::readers::Asked::Pipe,
175 FileFormat::reads_into,
176 ) {
177 return (format, false);
178 }
179 let format = crate::formats::lines::guess(&head, head.len() < HEAD)
180 .filter(|f| f.decompressed_once())
181 .unwrap_or(FileFormat::TEXT);
182 (format, true)
183}
184
185const HEAD: usize = crate::formats::readers::HEAD;
187
188pub(crate) fn spool_dir(options: &OpenOptions) -> Option<PathBuf> {
192 if let Some(dir) = &options.temp_dir {
193 return Some(dir.clone());
194 }
195 let dir = crate::cache::CacheManager::new(crate::APP_NAME)
196 .ok()?
197 .cache_dir()
198 .join("spool");
199 std::fs::create_dir_all(&dir).ok()?;
200 forget_old_spools(&dir);
201 Some(dir)
202}
203
204const OLD_SPOOL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
207
208fn forget_old_spools(dir: &Path) {
211 let Ok(entries) = std::fs::read_dir(dir) else {
212 return;
213 };
214 for entry in entries.flatten() {
215 let old = entry
216 .metadata()
217 .and_then(|m| m.modified())
218 .ok()
219 .and_then(|at| at.elapsed().ok())
220 .is_some_and(|age| age > OLD_SPOOL);
221 #[cfg(unix)]
223 let free = !crate::cloud::download::held_elsewhere(&entry.path());
224 #[cfg(not(unix))]
226 let free = true;
227 if old && free && entry.path().extension().is_some_and(|e| e == "tmp") {
228 let _ = std::fs::remove_file(entry.path());
229 }
230 }
231}
232
233pub(crate) fn spool<R: Read>(
238 open: impl FnOnce() -> Opened<R> + Send + 'static,
239 options: OpenOptions,
240 writer: &Writer,
241 read: &AtomicU64,
242) -> Result<(TempDownload, OpenOptions), String> {
243 let file =
244 crate::cloud::download::spool_to_temp(spool_dir(&options).as_deref(), open, writer, read)
245 .map_err(|error| match error {
246 StreamError::Open(e) | StreamError::Read(e) => {
247 format!("Could not read standard input: {e}")
248 }
249 StreamError::Write(report) => {
250 crate::error_display::user_message_from_report(&report, None)
251 }
252 StreamError::Short { .. } | StreamError::Cut => {
253 "Reading standard input was stopped.".to_string()
254 }
255 })?;
256 let options = described(file.path(), options)?;
257 Ok((file, options))
258}
259
260pub(crate) fn described(file: &Path, options: OpenOptions) -> Result<OpenOptions, String> {
263 let mut head = Vec::with_capacity(HEAD);
264 std::fs::File::open(file)
265 .and_then(|f| f.take(HEAD as u64).read_to_end(&mut head))
266 .map_err(|e| format!("Could not read standard input back: {e}"))?;
267 if head.is_empty() {
268 return Err("Nothing came in on standard input.".to_string());
269 }
270 let (mut format, compression, mut guessed) = sniffed(&head);
271 if options.format.is_none()
272 && let Some(compression) = options.compression.or(compression)
273 {
274 (format, guessed) = inside(file, compression);
275 }
276 if guessed {
277 format = crate::formats::lines::as_asked(format, &options);
278 }
279 Ok(match (options.format, options.compression) {
280 (Some(named), None) if named.separator().is_some() => OpenOptions {
283 compression,
284 ..options
285 },
286 (Some(_), _) => options,
288 (None, Some(_)) => OpenOptions {
290 format: Some(format),
291 format_guessed: guessed,
292 ..options
293 },
294 (None, None) => OpenOptions {
295 format: Some(format),
296 compression,
297 format_guessed: guessed,
298 ..options
299 },
300 })
301}
302
303pub(crate) fn may_read_as_it_arrives(options: &OpenOptions) -> bool {
306 options.compression.is_none()
307 && options
308 .format
309 .is_none_or(|format| format.follows() || format == FileFormat::Arrow)
310}
311
312#[cfg(test)]
313mod tests;