Skip to main content

datui_lib/
tee.rs

1//! `--tee FILE`: standard input recorded to a file the user keeps, while it is viewed.
2//!
3//! The copy itself is a [`crate::follow::Spool`] writing to FILE rather than to a
4//! temporary file; this module creates FILE and, when the stream ends, fills in what
5//! a producer writing to a pipe could not: a WAV file's sizes. `--tee -` passes the
6//! stream on to standard output instead ([`pass_stdout_on`]).
7
8use std::fs::File;
9use std::io::{Read, Seek, SeekFrom, Write};
10use std::path::Path;
11
12/// Create `path` for the copy, refusing one that is there unless `force`. Never
13/// claimed for removal: the file is the user's, and stays whatever happens.
14pub(crate) fn create(path: &Path, force: bool) -> Result<File, String> {
15    let mut options = std::fs::OpenOptions::new();
16    options.read(true).write(true);
17    if force {
18        options.create(true).truncate(true);
19    } else {
20        options.create_new(true);
21    }
22    options.open(path).map_err(|e| match e.kind() {
23        std::io::ErrorKind::AlreadyExists => refusal(path),
24        _ => format!("Could not create {}: {e}", path.display()),
25    })
26}
27
28/// What is said of a FILE that is there already.
29pub fn refusal(path: &Path) -> String {
30    format!(
31        "{} is there already; --force overwrites it.",
32        path.display()
33    )
34}
35
36/// Why `--tee -` cannot pass the stream on when standard output is the terminal.
37const STDOUT_IS_THE_SCREEN: &str = "--tee - passes standard input on to standard output, \
38     which is the screen here: send it on to a pipe or a file, as in: \
39     some_logger | datui -f --tee - | gzip > run1.csv.gz";
40
41/// `--tee -`: keep standard output for the stream, and point the process's standard
42/// output at the terminal, where the screen is drawn. Everything that draws or writes
43/// to standard output, Ratatui and Crossterm among them, then reaches the terminal
44/// with no change of its own. Returns the stream's standard output.
45#[cfg(unix)]
46pub(crate) fn pass_stdout_on() -> Result<File, String> {
47    use std::io::IsTerminal;
48    use std::os::fd::{AsRawFd, FromRawFd};
49    if std::io::stdout().is_terminal() {
50        return Err(STDOUT_IS_THE_SCREEN.to_string());
51    }
52    let tty = std::fs::OpenOptions::new()
53        .read(true)
54        .write(true)
55        .open("/dev/tty")
56        .map_err(|e| format!("--tee - draws on the terminal, /dev/tty, which did not open: {e}"))?;
57    // Close-on-exec, so a program datui starts does not hold the pipe open after the
58    // stream ends.
59    // SAFETY: plain fd calls; their results are checked.
60    let kept = unsafe { libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD_CLOEXEC, 3) };
61    if kept < 0 {
62        return Err(format!(
63            "Could not keep standard output: {}",
64            std::io::Error::last_os_error()
65        ));
66    }
67    // SAFETY: `kept` was just opened and is owned by nothing else.
68    let kept = unsafe { File::from_raw_fd(kept) };
69    // SAFETY: both fds are open; dup2 replaces fd 1 atomically.
70    if unsafe { libc::dup2(tty.as_raw_fd(), libc::STDOUT_FILENO) } < 0 {
71        return Err(format!(
72            "Could not draw on the terminal: {}",
73            std::io::Error::last_os_error()
74        ));
75    }
76    Ok(kept)
77}
78
79/// As on Unix, with the console's screen buffer, `CONOUT$`, as standard output. Rust's
80/// standard output asks Windows for the handle on every write, and Crossterm sizes and
81/// sets up the console through `CONOUT$` and `CONIN$` itself.
82#[cfg(windows)]
83pub(crate) fn pass_stdout_on() -> Result<File, String> {
84    use std::io::IsTerminal;
85    use std::os::windows::io::{FromRawHandle, IntoRawHandle};
86    use windows_sys::Win32::System::Console::{GetStdHandle, STD_OUTPUT_HANDLE, SetStdHandle};
87    if std::io::stdout().is_terminal() {
88        return Err(STDOUT_IS_THE_SCREEN.to_string());
89    }
90    let console = std::fs::OpenOptions::new()
91        .read(true)
92        .write(true)
93        .open("CONOUT$")
94        .map_err(|e| format!("--tee - draws on the console, which did not open: {e}"))?;
95    // SAFETY: GetStdHandle takes no pointers.
96    let kept = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) };
97    if kept.is_null() || kept == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE {
98        return Err(
99            "--tee - passes the stream on to standard output, and there is none.".to_string(),
100        );
101    }
102    // The console handle stays open for the life of the process, as standard output.
103    let console = console.into_raw_handle();
104    // SAFETY: a live handle, owned from here by the process's standard output.
105    if unsafe { SetStdHandle(STD_OUTPUT_HANDLE, console) } == 0 {
106        return Err(format!(
107            "Could not draw on the console: {}",
108            std::io::Error::last_os_error()
109        ));
110    }
111    // SAFETY: the old standard output handle is no longer standard output; this is now
112    // its one owner.
113    Ok(unsafe { File::from_raw_handle(kept) })
114}
115
116#[cfg(not(any(unix, windows)))]
117pub(crate) fn pass_stdout_on() -> Result<File, String> {
118    Err("--tee - is not supported on this platform.".to_string())
119}
120
121/// What [`fix_wav_sizes`] did.
122#[derive(Debug, PartialEq, Eq)]
123pub(crate) enum Fixed {
124    /// Not a WAV file, or its sizes were right already.
125    Nothing,
126    /// The RIFF and `data` sizes now say how long the file is.
127    Riff,
128    /// Over 4 GB: the header is RF64's, its sizes in the `ds64` chunk that took the
129    /// place of the `JUNK` the producer reserved.
130    Rf64,
131    /// Over 4 GB with no room reserved for RF64's sizes: they are left as written.
132    TooLong,
133}
134
135/// Fill in the sizes of the WAV file `file` holds, which a producer writing to a pipe
136/// could not seek back to: the RIFF size and the `data` chunk's, 0 or 0xFFFFFFFF while
137/// it streamed. Over 4 GB the header becomes RF64's when the producer reserved room for
138/// it with a `JUNK` chunk first, as Broadcast WAV writers do.
139pub(crate) fn fix_wav_sizes(file: &mut File) -> std::io::Result<Fixed> {
140    let len = file.metadata()?.len();
141    let mut head = [0u8; 12];
142    file.seek(SeekFrom::Start(0))?;
143    if len < 12 || file.read_exact(&mut head).is_err() {
144        return Ok(Fixed::Nothing);
145    }
146    let rf64 = &head[0..4] == b"RF64";
147    if !(&head[0..4] == b"RIFF" || rf64) || &head[8..12] != b"WAVE" {
148        return Ok(Fixed::Nothing);
149    }
150    // Walk the chunks to `data`, the last a streaming producer writes.
151    let mut at = 12u64;
152    let mut junk = None;
153    let mut ds64 = None;
154    let data = loop {
155        if at + 8 > len {
156            return Ok(Fixed::Nothing);
157        }
158        let mut chunk = [0u8; 8];
159        file.seek(SeekFrom::Start(at))?;
160        file.read_exact(&mut chunk)?;
161        let size = u32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]) as u64;
162        match &chunk[0..4] {
163            b"data" => break at,
164            b"JUNK" if at == 12 => junk = Some(size),
165            b"ds64" => ds64 = Some(at),
166            _ => {}
167        }
168        // Chunks are padded to an even length.
169        at += 8 + size + (size & 1);
170    };
171    let data_size = len - (data + 8);
172    let riff_size = len - 8;
173    if let Some(ds64) = ds64.filter(|_| rf64) {
174        // RF64 already: its sizes live in `ds64`.
175        file.seek(SeekFrom::Start(ds64 + 8))?;
176        file.write_all(&riff_size.to_le_bytes())?;
177        file.write_all(&data_size.to_le_bytes())?;
178        return Ok(Fixed::Rf64);
179    }
180    if riff_size <= u32::MAX as u64 {
181        if read_u32(file, 4)? as u64 == riff_size && read_u32(file, data + 4)? as u64 == data_size {
182            return Ok(Fixed::Nothing);
183        }
184        write_u32(file, 4, riff_size as u32)?;
185        write_u32(file, data + 4, data_size as u32)?;
186        return Ok(Fixed::Riff);
187    }
188    // A ds64 chunk is 28 bytes: the RIFF size, the data size, the sample count and an
189    // empty table. It takes the reserved JUNK's place, size and all.
190    match junk {
191        Some(size) if size >= 28 => {
192            file.seek(SeekFrom::Start(0))?;
193            file.write_all(b"RF64")?;
194            file.write_all(&u32::MAX.to_le_bytes())?;
195            file.seek(SeekFrom::Start(12))?;
196            file.write_all(b"ds64")?;
197            file.write_all(&(size as u32).to_le_bytes())?;
198            file.write_all(&riff_size.to_le_bytes())?;
199            file.write_all(&data_size.to_le_bytes())?;
200            // The sample count is the fact chunk's business, which PCM has none of.
201            file.write_all(&0u64.to_le_bytes())?;
202            file.write_all(&0u32.to_le_bytes())?;
203            write_u32(file, data + 4, u32::MAX)?;
204            Ok(Fixed::Rf64)
205        }
206        _ => Ok(Fixed::TooLong),
207    }
208}
209
210fn read_u32(file: &mut File, at: u64) -> std::io::Result<u32> {
211    let mut b = [0u8; 4];
212    file.seek(SeekFrom::Start(at))?;
213    file.read_exact(&mut b)?;
214    Ok(u32::from_le_bytes(b))
215}
216
217fn write_u32(file: &mut File, at: u64, value: u32) -> std::io::Result<()> {
218    file.seek(SeekFrom::Start(at))?;
219    file.write_all(&value.to_le_bytes())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    /// A 16-bit stereo PCM WAV header as a producer writing to a pipe writes it: the
227    /// sizes 0xFFFFFFFF, and `junk` bytes reserved after `WAVE` when given.
228    pub(crate) fn streamed_wav(frames: u32, junk: Option<u32>) -> Vec<u8> {
229        let mut out = b"RIFF".to_vec();
230        out.extend(u32::MAX.to_le_bytes());
231        out.extend(b"WAVE");
232        if let Some(size) = junk {
233            out.extend(b"JUNK");
234            out.extend(size.to_le_bytes());
235            out.extend(vec![0u8; size as usize]);
236        }
237        out.extend(b"fmt ");
238        out.extend(16u32.to_le_bytes());
239        out.extend(1u16.to_le_bytes()); // PCM
240        out.extend(2u16.to_le_bytes()); // channels
241        out.extend(48_000u32.to_le_bytes());
242        out.extend((48_000u32 * 4).to_le_bytes());
243        out.extend(4u16.to_le_bytes());
244        out.extend(16u16.to_le_bytes());
245        out.extend(b"data");
246        out.extend(u32::MAX.to_le_bytes());
247        for i in 0..frames {
248            out.extend((i as i16).to_le_bytes());
249            out.extend((-(i as i16)).to_le_bytes());
250        }
251        out
252    }
253
254    fn file_of(bytes: &[u8]) -> (tempfile::TempDir, File) {
255        let dir = tempfile::tempdir().unwrap();
256        let path = dir.path().join("take.wav");
257        std::fs::write(&path, bytes).unwrap();
258        let file = std::fs::OpenOptions::new()
259            .read(true)
260            .write(true)
261            .open(&path)
262            .unwrap();
263        (dir, file)
264    }
265
266    #[test]
267    fn a_streamed_wav_gets_its_sizes() {
268        let bytes = streamed_wav(100, None);
269        let (_dir, mut file) = file_of(&bytes);
270        assert_eq!(fix_wav_sizes(&mut file).unwrap(), Fixed::Riff);
271        assert_eq!(read_u32(&mut file, 4).unwrap() as usize, bytes.len() - 8);
272        assert_eq!(
273            read_u32(&mut file, 40).unwrap(),
274            400,
275            "100 frames of 4 bytes"
276        );
277        let header =
278            crate::audio::read_header(&std::fs::read(_dir.path().join("take.wav")).unwrap());
279        assert!(header.is_ok(), "{header:?}");
280        assert_eq!(fix_wav_sizes(&mut file).unwrap(), Fixed::Nothing, "once");
281    }
282
283    #[test]
284    fn other_files_are_left_alone() {
285        let (_dir, mut file) = file_of(b"a,b\n1,2\n");
286        assert_eq!(fix_wav_sizes(&mut file).unwrap(), Fixed::Nothing);
287        assert_eq!(
288            std::fs::read(_dir.path().join("take.wav")).unwrap(),
289            b"a,b\n1,2\n"
290        );
291    }
292
293    /// Over 4 GB, the JUNK chunk a producer reserved becomes RF64's `ds64`. The file
294    /// is sparse: only its header is ever read.
295    #[test]
296    fn over_four_gigabytes_the_header_becomes_rf64() {
297        let bytes = streamed_wav(0, Some(28));
298        let (dir, mut file) = file_of(&bytes);
299        let len = 5u64 << 30;
300        file.set_len(len).unwrap();
301        assert_eq!(fix_wav_sizes(&mut file).unwrap(), Fixed::Rf64);
302        let head = {
303            let mut head = vec![0u8; 80];
304            file.seek(SeekFrom::Start(0)).unwrap();
305            file.read_exact(&mut head).unwrap();
306            head
307        };
308        assert_eq!(&head[0..4], b"RF64");
309        assert_eq!(&head[12..16], b"ds64");
310        let u64_at = |at: usize| u64::from_le_bytes(head[at..at + 8].try_into().unwrap());
311        assert_eq!(u64_at(20), len - 8, "the RIFF size");
312        let data_at = 12 + 8 + 28 + 8 + 16;
313        assert_eq!(&head[data_at..data_at + 4], b"data");
314        assert_eq!(u64_at(28), len - (data_at as u64 + 8), "the data size");
315        drop(dir);
316
317        // No room reserved: the sizes stay as they were written.
318        let (_dir, mut file) = file_of(&streamed_wav(0, None));
319        file.set_len(len).unwrap();
320        assert_eq!(fix_wav_sizes(&mut file).unwrap(), Fixed::TooLong);
321    }
322
323    /// FILE is never replaced without `--force`.
324    #[test]
325    fn an_existing_file_is_refused_without_force() {
326        let dir = tempfile::tempdir().unwrap();
327        let path = dir.path().join("run1.csv");
328        std::fs::write(&path, "keep me").unwrap();
329        let refused = create(&path, false).unwrap_err();
330        assert!(refused.contains("--force"), "{refused}");
331        assert_eq!(std::fs::read(&path).unwrap(), b"keep me");
332        create(&path, true).unwrap();
333        assert_eq!(
334            std::fs::read(&path).unwrap(),
335            b"",
336            "overwritten with --force"
337        );
338    }
339}