Skip to main content

mz_testdrive/action/
file.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::path::{self, PathBuf};
11use std::str::FromStr;
12
13use anyhow::bail;
14use async_compression::tokio::write::{BzEncoder, GzipEncoder, XzEncoder, ZstdEncoder};
15use tokio::fs::{self, OpenOptions};
16use tokio::io::{AsyncWrite, AsyncWriteExt, BufWriter};
17
18use crate::action::{ControlFlow, State};
19use crate::format::bytes;
20use crate::parser::BuiltinCommand;
21
22pub enum Compression {
23    Bzip2,
24    Gzip,
25    Xz,
26    Zstd,
27    None,
28}
29
30impl FromStr for Compression {
31    type Err = anyhow::Error;
32
33    fn from_str(s: &str) -> Result<Self, anyhow::Error> {
34        match s {
35            "bzip2" => Ok(Compression::Bzip2),
36            "gzip" => Ok(Compression::Gzip),
37            "xz" => Ok(Compression::Xz),
38            "zstd" => Ok(Compression::Zstd),
39            "none" => Ok(Compression::None),
40            f => bail!("unknown compression format: {}", f),
41        }
42    }
43}
44
45pub(crate) fn build_compression(cmd: &mut BuiltinCommand) -> Result<Compression, anyhow::Error> {
46    match cmd.args.opt_string("compression") {
47        Some(s) => s.parse(),
48        None => Ok(Compression::None),
49    }
50}
51
52/// The parsed contents of a file to be written: an optional header line
53/// followed by the input lines repeated `repeat` times. Every line is
54/// terminated by a newline, except that with `trailing_newline == false` the
55/// final newline is omitted.
56///
57/// Lines are streamed on write (see [`Contents::write_to`]) rather than
58/// materialized, so a large `repeat` generates a large file without holding
59/// the whole file in memory.
60pub(crate) struct Contents {
61    header: Option<String>,
62    /// Input lines with their escape sequences already resolved.
63    lines: Vec<Vec<u8>>,
64    repeat: usize,
65    trailing_newline: bool,
66}
67
68impl Contents {
69    pub(crate) fn parse(cmd: &mut BuiltinCommand) -> Result<Contents, anyhow::Error> {
70        let header = cmd.args.opt_string("header");
71        let trailing_newline = cmd.args.opt_bool("trailing-newline")?.unwrap_or(true);
72        let repeat: usize = cmd.args.opt_parse("repeat")?.unwrap_or(1);
73
74        let mut lines = vec![];
75        for line in &cmd.input {
76            lines.push(bytes::unescape(line.as_bytes())?);
77        }
78
79        Ok(Contents {
80            header,
81            lines,
82            repeat,
83            trailing_newline,
84        })
85    }
86
87    /// The output lines in order: the header, if any, then the input lines
88    /// repeated `repeat` times. Newlines are not included.
89    fn output_lines(&self) -> impl Iterator<Item = &[u8]> {
90        let header = self.header.as_deref().map(str::as_bytes).into_iter();
91        let body = (0..self.repeat).flat_map(move |_| self.lines.iter().map(Vec::as_slice));
92        header.chain(body)
93    }
94
95    /// Streams the contents to `writer`, terminating each line with a newline
96    /// and suppressing the final newline when `trailing_newline` is false.
97    pub(crate) async fn write_to<W>(&self, writer: &mut W) -> Result<(), anyhow::Error>
98    where
99        W: AsyncWrite + Unpin,
100    {
101        let mut wrote_line = false;
102        for line in self.output_lines() {
103            // Emit the newline that terminates the previous line only once we
104            // know another line follows, so the final newline can be dropped.
105            if wrote_line {
106                writer.write_all(b"\n").await?;
107            }
108            writer.write_all(line).await?;
109            wrote_line = true;
110        }
111        if wrote_line && self.trailing_newline {
112            writer.write_all(b"\n").await?;
113        }
114        Ok(())
115    }
116}
117
118fn build_path(state: &State, cmd: &mut BuiltinCommand) -> Result<PathBuf, anyhow::Error> {
119    let path = cmd.args.string("path")?;
120    let container = cmd.args.opt_string("container");
121
122    if path.contains(path::MAIN_SEPARATOR) {
123        // The goal isn't security, but preventing mistakes.
124        bail!("separators in paths are forbidden")
125    }
126
127    match container.as_deref() {
128        None => Ok(state.temp_path.join(path)),
129        Some("fivetran") => Ok(PathBuf::from(&state.fivetran_destination_files_path).join(path)),
130        Some(x) => bail!("Unrecognized container '{x}'"),
131    }
132}
133
134pub async fn run_append(
135    mut cmd: BuiltinCommand,
136    state: &State,
137) -> Result<ControlFlow, anyhow::Error> {
138    let path = build_path(state, &mut cmd)?;
139    let compression = build_compression(&mut cmd)?;
140    let contents = Contents::parse(&mut cmd)?;
141    cmd.args.done()?;
142
143    println!("Appending to file {}", path.display());
144    let file = OpenOptions::new()
145        .create(true)
146        .append(true)
147        .open(&path)
148        .await?;
149
150    let mut file: Box<dyn AsyncWrite + Unpin + Send> = match compression {
151        Compression::Gzip => Box::new(GzipEncoder::new(file)),
152        Compression::Bzip2 => Box::new(BzEncoder::new(file)),
153        Compression::Xz => Box::new(XzEncoder::new(file)),
154        Compression::Zstd => Box::new(ZstdEncoder::new(file)),
155        // The compression encoders buffer their writes, but a bare
156        // `tokio::fs::File` turns every per-line `write_all` into a separate
157        // blocking filesystem job. Buffer it so a large `repeat` issues writes
158        // in bounded chunks rather than two jobs per line.
159        Compression::None => Box::new(BufWriter::new(file)),
160    };
161
162    contents.write_to(&mut file).await?;
163    file.shutdown().await?;
164
165    Ok(ControlFlow::Continue)
166}
167
168pub async fn run_delete(
169    mut cmd: BuiltinCommand,
170    state: &State,
171) -> Result<ControlFlow, anyhow::Error> {
172    let path = build_path(state, &mut cmd)?;
173    cmd.args.done()?;
174    println!("Deleting file {}", path.display());
175    fs::remove_file(&path).await?;
176    Ok(ControlFlow::Continue)
177}