use std::ffi::CString;
use std::str::Utf8Error;
use quack_rs::error::ExtensionError;
use quack_rs::file_system::{FileFlag, FileOpenOptions};
use super::capture::{DuckFileSystem, file_system, with_file_system};
use crate::{DuckResult, duck_error};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum WriteMode {
#[default]
Replace,
FailIfExists,
Append,
}
const ZERO_FILE_SQL: &str = "COPY (SELECT 1 AS i WHERE false) TO ? (FORMAT csv, HEADER false)";
pub fn read(path: &str) -> DuckResult<Vec<u8>> {
let c_path = path_c_string("read", path)?;
with_file_system(|file_system| {
let handle = file_system
.open(&c_path, &FileOpenOptions::read_only())
.map_err(|error| file_error("read", path, error))?;
let mut buffer = Vec::new();
handle
.read_to_end(&mut buffer)
.map_err(|error| file_error("read", path, error))?;
Ok(buffer)
})
}
pub fn read_string(path: &str) -> DuckResult<String> {
let bytes = read(path)?;
String::from_utf8(bytes).map_err(|error| utf8_error(path, error.utf8_error()))
}
pub fn read_string_lossy(path: &str) -> DuckResult<String> {
Ok(String::from_utf8_lossy(&read(path)?).into_owned())
}
pub fn read_lines(path: &str) -> DuckResult<Vec<String>> {
Ok(split_lines(&read_string(path)?))
}
pub fn write(path: &str, bytes: &[u8]) -> DuckResult<()> {
write_with(path, bytes, WriteMode::Replace)
}
pub fn write_string(path: &str, text: &str) -> DuckResult<()> {
write(path, text.as_bytes())
}
pub fn write_with(path: &str, bytes: &[u8], mode: WriteMode) -> DuckResult<()> {
let c_path = path_c_string("write", path)?;
let file_system = file_system()?;
match mode {
WriteMode::FailIfExists => {
if file_system
.open(&c_path, &FileOpenOptions::read_only())
.is_ok()
{
return Err(duck_error(format!(
"duckfn::duck_vfs::write: '{path}' already exists and WriteMode::FailIfExists was \
requested; delete it or write to another path"
)));
}
let options = FileOpenOptions::new();
options.set_flag(FileFlag::Write, true);
options.set_flag(FileFlag::Create, true);
options.set_flag(FileFlag::CreateNew, true);
write_through(&file_system, &c_path, path, bytes, &options)
}
WriteMode::Append => {
let options = FileOpenOptions::new();
options.set_flag(FileFlag::Write, true);
options.set_flag(FileFlag::Create, true);
options.set_flag(FileFlag::Append, true);
write_through(&file_system, &c_path, path, bytes, &options)
}
WriteMode::Replace => {
if existing_len(&file_system, &c_path, path)? > bytes.len() as u64 {
zero_file(&file_system, path)?;
}
write_through(
&file_system,
&c_path,
path,
bytes,
&FileOpenOptions::write_create(),
)
}
}
}
pub fn write_string_with(path: &str, text: &str, mode: WriteMode) -> DuckResult<()> {
write_with(path, text.as_bytes(), mode)
}
pub fn append(path: &str, bytes: &[u8]) -> DuckResult<()> {
write_with(path, bytes, WriteMode::Append)
}
pub fn append_string(path: &str, text: &str) -> DuckResult<()> {
write_with(path, text.as_bytes(), WriteMode::Append)
}
pub fn size(path: &str) -> DuckResult<u64> {
let c_path = path_c_string("size", path)?;
with_file_system(|file_system| {
let handle = file_system
.open(&c_path, &FileOpenOptions::read_only())
.map_err(|error| file_error("size", path, error))?;
handle
.size()
.map_err(|error| file_error("size", path, error))
})
}
#[must_use]
pub fn exists(path: &str) -> bool {
let Ok(c_path) = path_c_string("exists", path) else {
return false;
};
matches!(
with_file_system(|file_system| Ok(file_system
.open(&c_path, &FileOpenOptions::read_only())
.is_ok())),
Ok(true)
)
}
fn write_through(
file_system: &DuckFileSystem,
c_path: &CString,
path: &str,
bytes: &[u8],
options: &FileOpenOptions,
) -> DuckResult<()> {
let handle = file_system
.open(c_path, options)
.map_err(|error| file_error("write", path, error))?;
handle
.write_all(bytes)
.map_err(|error| file_error("write", path, error))?;
Ok(())
}
fn existing_len(file_system: &DuckFileSystem, c_path: &CString, path: &str) -> DuckResult<u64> {
match file_system.open(c_path, &FileOpenOptions::read_only()) {
Ok(handle) => handle
.size()
.map_err(|error| file_error("write", path, error)),
Err(_) => Ok(0),
}
}
fn zero_file(file_system: &DuckFileSystem, path: &str) -> DuckResult<()> {
let explanation = || {
format!(
"duckfn::duck_vfs: cannot replace '{path}': the file already exists and is longer than the \
new contents, and zeroing it with COPY failed"
)
};
let connection = file_system.connection();
let statement = connection
.prepare(ZERO_FILE_SQL)
.map_err(|error| duck_error(format!("{}: {}", explanation(), error.as_str())))?;
statement
.bind_str(1, path)
.map_err(|error| duck_error(format!("{}: {}", explanation(), error.as_str())))?;
statement
.execute()
.map(|_| ())
.map_err(|error| duck_error(format!("{}: {}", explanation(), error.as_str())))
}
fn path_c_string(operation: &str, path: &str) -> DuckResult<CString> {
CString::new(path).map_err(|_| {
duck_error(format!(
"duckfn::duck_vfs::{operation}: the path contains a NUL byte: {path:?}"
))
})
}
fn file_error(operation: &str, path: &str, error: quack_rs::error_data::ErrorData) -> ExtensionError {
duck_error(format!(
"duckfn::duck_vfs::{operation}: '{path}': {}",
error
.message()
.unwrap_or_else(|| String::from("unknown file system error"))
))
}
fn utf8_error(path: &str, error: Utf8Error) -> ExtensionError {
duck_error(format!(
"duckfn::duck_vfs::read_string: '{path}' is not valid UTF-8 (invalid byte at offset {}); use \
read_string_lossy to replace it or read for raw bytes",
error.valid_up_to()
))
}
fn split_lines(text: &str) -> Vec<String> {
if text.is_empty() {
return Vec::new();
}
let mut lines: Vec<String> = text
.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line).to_owned())
.collect();
if lines.last().is_some_and(String::is_empty) {
lines.pop();
}
lines
}
#[cfg(test)]
mod tests {
use super::{WriteMode, path_c_string, split_lines, utf8_error};
#[test]
fn write_mode_defaults_to_replace() {
assert_eq!(WriteMode::default(), WriteMode::Replace);
assert_ne!(WriteMode::Append, WriteMode::FailIfExists);
}
#[test]
fn path_with_nul_byte_is_rejected() {
let error = path_c_string("read", "a\0b").expect_err("NUL must be rejected");
assert!(error.as_str().contains("duckfn::duck_vfs::read"), "{error}");
assert!(error.as_str().contains("NUL"), "{error}");
assert!(path_c_string("read", "plain/path.txt").is_ok());
}
#[test]
fn invalid_utf8_error_points_at_the_byte() {
let bytes: Vec<u8> = vec![b'a', 0xFF];
let error = std::str::from_utf8(&bytes).expect_err("must not be UTF-8");
let message = utf8_error("x.txt", error);
assert!(message.as_str().contains("'x.txt'"), "{message}");
assert!(message.as_str().contains("offset 1"), "{message}");
}
#[test]
fn lines_split_on_lf_and_tolerate_crlf() {
assert!(split_lines("").is_empty());
assert_eq!(split_lines("a"), ["a"]);
assert_eq!(split_lines("a\n"), ["a"]);
assert_eq!(split_lines("a\nb"), ["a", "b"]);
assert_eq!(split_lines("a\nb\n"), ["a", "b"]);
assert_eq!(split_lines("a\r\nb\r\n"), ["a", "b"]);
assert_eq!(split_lines("\n"), [""]);
assert_eq!(split_lines("a\n\nb"), ["a", "", "b"]);
}
}