Skip to main content

datui_lib/loading/
stdin.rs

1//! Data piped in: `cmd | datui` and `datui -`.
2//!
3//! Standard input is read once, to a temporary file, as a phase of the open
4//! (`crate::loading`); the scan of that file then stays lazy, as for any file. The
5//! format is read off the first bytes, since a pipe has no extension to go by, unless
6//! `--format` or `--compression` says. Keys come from the terminal meanwhile: Crossterm
7//! reads `/dev/tty` on Unix when standard input is not one, and `CONIN$` on Windows.
8
9use 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
17/// The path that names standard input on the command line.
18pub const PATH: &str = "-";
19
20/// What data read from standard input is called on screen and in messages.
21pub const NAME: &str = "stdin";
22
23/// Whether `path` names standard input.
24pub fn is_stdin(path: &Path) -> bool {
25    path.as_os_str() == PATH
26}
27
28/// The name `path` goes by: `stdin` for standard input, itself otherwise.
29pub fn named(path: &Path) -> PathBuf {
30    if is_stdin(path) {
31        PathBuf::from(NAME)
32    } else {
33        path.to_path_buf()
34    }
35}
36
37/// Whether standard input carries data: a pipe or a file. A terminal there is the
38/// user, and a device such as `/dev/null` or Windows' `NUL`, where a launcher points
39/// it, holds nothing.
40pub 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
46/// What [`piped`] decides from what standard input is: neither a terminal nor a
47/// device.
48pub fn carries_data(terminal: bool, device: bool) -> bool {
49    !terminal && !device
50}
51
52/// Whether standard input is a character device. One that cannot be asked is taken
53/// for one, so nothing is read from it.
54#[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/// Whether standard input is anything but a file or a pipe: `NUL`, a console, which
67/// `is_terminal` has already answered for, or no handle at all, as a process started
68/// without one has; none of these is read.
69#[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    // SAFETY: the handle is standard input's, open for the life of the process; a null
74    // or invalid one makes `GetFileType` answer `FILE_TYPE_UNKNOWN`.
75    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
84/// `path` as a host other than the command line means it: `-` is a file of that name,
85/// since only the command line reads standard input.
86pub fn as_file(path: PathBuf) -> PathBuf {
87    if is_stdin(&path) {
88        Path::new(".").join(path)
89    } else {
90        path
91    }
92}
93
94/// The paths to open: those named, or standard input when none are and something is
95/// piped in.
96pub 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
104/// Why standard input cannot be read as asked, before anything is read: nothing is
105/// piped in, or it is named with other paths.
106pub 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
117/// The format and compression the first bytes of a file say it is: compression by its
118/// magic numbers, then whatever a format's signature says (`crate::formats::readers::sniff`),
119/// and text no format claims as JSON, CSV or TSV on evidence, lines otherwise
120/// ([`crate::formats::lines::guess`]). `head` is all there is when it is shorter than `HEAD`.
121pub fn sniff(head: &[u8]) -> (FileFormat, Option<CompressionFormat>) {
122    let (format, compression, _) = sniffed(head);
123    (format, compression)
124}
125
126/// [`sniff`] as `options` ask: a guess of lines is CSV with `--delimiter`. Also says
127/// whether the format was guessed rather than said by a signature.
128pub(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
143/// [`sniff`], and whether the format was guessed rather than said by a signature.
144fn 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        // What is inside is looked at once it is on disk ([`inside`]).
153        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
164/// What compressed data piped in holds, by its first bytes once decompressed: a
165/// format read through its compression by its signature, else delimited text on
166/// evidence, else lines.
167fn 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
185/// Bytes [`sniff`] looks at.
186const HEAD: usize = crate::formats::readers::HEAD;
187
188/// Where what comes in on standard input is copied: `--temp-dir`, else `spool` in the
189/// cache directory, on disk, rather than the system's temp directory, which is memory
190/// on many Linux machines. The system's when the cache directory cannot be made.
191pub(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
204/// How old a spool left behind is before it goes: one a session that crashed did not
205/// remove. Long enough that no session still reading its own is near it.
206const OLD_SPOOL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
207
208/// Remove the spools in `dir` older than [`OLD_SPOOL`]. Best effort: the cache is
209/// not the system temp directory, which a reboot empties.
210fn 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        // Not one a live datui still holds, however long it has been reading.
222        #[cfg(unix)]
223        let free = !crate::cloud::download::held_elsewhere(&entry.path());
224        // Elsewhere a file held open cannot be removed, and the removal fails.
225        #[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
233/// Read what `open` answers with into a temporary file in the spool directory
234/// ([`spool_dir`]), counting the bytes into `read`, and say what it holds: `options`
235/// with the format and compression the first bytes say, where the user did not.
236/// Stops, removing the file, once `writer`'s open is stopped.
237pub(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
260/// What all of standard input, copied to `file`, holds: `options` with the format and
261/// compression its first bytes say, where the user did not.
262pub(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        // A delimited format named and compression not: the bytes say whether it is
281        // compressed, as a file's extension would.
282        (Some(named), None) if named.separator().is_some() => OpenOptions {
283            compression,
284            ..options
285        },
286        // Named by the user: theirs, compression and all.
287        (Some(_), _) => options,
288        // Compression named, and what it holds read through it.
289        (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
303/// Whether standard input opened with `options` may be shown as it arrives: nothing
304/// named rules it out (a format read once it is finished, or compression).
305pub(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;