quack_rs/file_system.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. <https://github.com/tomtom215/>
3// My way of giving something small back to the open source community
4// and encouraging more Rust development!
5
6//! File system access (`DuckDB` 1.5.0+).
7//!
8//! This module exposes `DuckDB`'s virtual file system (VFS) to extensions, so a
9//! custom table function, replacement scan, or copy function can read and write
10//! files through the *same* abstraction `DuckDB` uses internally. That means
11//! transparently honouring `httpfs` (`s3://`, `http://`), in-memory files, and
12//! any other registered file system — rather than reaching for `std::fs` and
13//! only ever seeing local disk.
14//!
15//! # Obtaining a [`FileSystem`]
16//!
17//! Get one from a [`ClientContext`] (which you can obtain from most function
18//! callbacks):
19//!
20//! ```rust,no_run
21//! use quack_rs::client_context::ClientContext;
22//! use quack_rs::file_system::{FileOpenOptions, FileSystem};
23//!
24//! # fn demo(ctx: &ClientContext) -> Option<()> {
25//! let fs = FileSystem::from_client_context(ctx)?;
26//! let opts = FileOpenOptions::read_only();
27//! let handle = fs.open(c"data.csv", &opts).ok()?;
28//! let mut buf = vec![0u8; handle.size().max(0) as usize];
29//! let _n = handle.read(&mut buf).ok()?;
30//! # Some(())
31//! # }
32//! ```
33
34use std::ffi::CStr;
35use std::os::raw::c_void;
36
37use libduckdb_sys::{
38 duckdb_client_context_get_file_system, duckdb_create_file_open_options,
39 duckdb_destroy_file_handle, duckdb_destroy_file_open_options, duckdb_destroy_file_system,
40 duckdb_file_flag, duckdb_file_flag_DUCKDB_FILE_FLAG_APPEND,
41 duckdb_file_flag_DUCKDB_FILE_FLAG_CREATE, duckdb_file_flag_DUCKDB_FILE_FLAG_CREATE_NEW,
42 duckdb_file_flag_DUCKDB_FILE_FLAG_READ, duckdb_file_flag_DUCKDB_FILE_FLAG_WRITE,
43 duckdb_file_handle, duckdb_file_handle_close, duckdb_file_handle_error_data,
44 duckdb_file_handle_read, duckdb_file_handle_seek, duckdb_file_handle_size,
45 duckdb_file_handle_sync, duckdb_file_handle_tell, duckdb_file_handle_write,
46 duckdb_file_open_options, duckdb_file_open_options_set_flag, duckdb_file_system,
47 duckdb_file_system_error_data, duckdb_file_system_open, DuckDBSuccess,
48};
49
50use crate::client_context::ClientContext;
51use crate::error_data::ErrorData;
52
53/// A file-open mode flag, mirroring `duckdb_file_flag`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[non_exhaustive]
56pub enum FileFlag {
57 /// Open for reading.
58 Read,
59 /// Open for writing.
60 Write,
61 /// Create the file if it does not exist.
62 Create,
63 /// Create the file, failing if it already exists.
64 CreateNew,
65 /// Open in append mode.
66 Append,
67}
68
69impl FileFlag {
70 /// Converts to the `DuckDB` C API constant.
71 #[must_use]
72 const fn to_raw(self) -> duckdb_file_flag {
73 match self {
74 Self::Read => duckdb_file_flag_DUCKDB_FILE_FLAG_READ,
75 Self::Write => duckdb_file_flag_DUCKDB_FILE_FLAG_WRITE,
76 Self::Create => duckdb_file_flag_DUCKDB_FILE_FLAG_CREATE,
77 Self::CreateNew => duckdb_file_flag_DUCKDB_FILE_FLAG_CREATE_NEW,
78 Self::Append => duckdb_file_flag_DUCKDB_FILE_FLAG_APPEND,
79 }
80 }
81}
82
83/// RAII wrapper for `duckdb_file_open_options`.
84///
85/// Describes how a file should be opened. Automatically destroyed when dropped.
86pub struct FileOpenOptions {
87 options: duckdb_file_open_options,
88}
89
90impl FileOpenOptions {
91 /// Creates an empty set of file-open options.
92 #[must_use]
93 pub fn new() -> Self {
94 // SAFETY: duckdb_create_file_open_options allocates an owned handle.
95 let options = unsafe { duckdb_create_file_open_options() };
96 Self { options }
97 }
98
99 /// Creates options configured for read-only access.
100 #[must_use]
101 pub fn read_only() -> Self {
102 let opts = Self::new();
103 opts.set_flag(FileFlag::Read, true);
104 opts
105 }
106
107 /// Creates options configured for writing, creating the file if needed.
108 #[must_use]
109 pub fn write_create() -> Self {
110 let opts = Self::new();
111 opts.set_flag(FileFlag::Write, true);
112 opts.set_flag(FileFlag::Create, true);
113 opts
114 }
115
116 /// Sets a file-open flag, returning `true` on success.
117 pub fn set_flag(&self, flag: FileFlag, value: bool) -> bool {
118 if self.options.is_null() {
119 return false;
120 }
121 // SAFETY: self.options is a valid duckdb_file_open_options.
122 let state =
123 unsafe { duckdb_file_open_options_set_flag(self.options, flag.to_raw(), value) };
124 state == DuckDBSuccess
125 }
126
127 /// Returns the raw handle without consuming the options.
128 #[inline]
129 #[must_use]
130 pub const fn as_raw(&self) -> duckdb_file_open_options {
131 self.options
132 }
133}
134
135impl Default for FileOpenOptions {
136 fn default() -> Self {
137 Self::new()
138 }
139}
140
141impl Drop for FileOpenOptions {
142 fn drop(&mut self) {
143 if !self.options.is_null() {
144 // SAFETY: self.options is a valid handle that we own.
145 unsafe { duckdb_destroy_file_open_options(&raw mut self.options) };
146 }
147 }
148}
149
150/// RAII wrapper for a `duckdb_file_system`.
151///
152/// Automatically destroyed when dropped.
153pub struct FileSystem {
154 fs: duckdb_file_system,
155}
156
157impl FileSystem {
158 /// Obtains the file system associated with a [`ClientContext`].
159 ///
160 /// Returns `None` if `DuckDB` does not provide one.
161 #[must_use]
162 pub fn from_client_context(context: &ClientContext) -> Option<Self> {
163 // SAFETY: context.as_raw() is a valid duckdb_client_context.
164 let fs = unsafe { duckdb_client_context_get_file_system(context.as_raw()) };
165 if fs.is_null() {
166 None
167 } else {
168 Some(Self { fs })
169 }
170 }
171
172 /// Wraps a raw `duckdb_file_system` handle, taking ownership.
173 ///
174 /// # Safety
175 ///
176 /// `fs` must be a valid, non-null `duckdb_file_system` handle that the caller
177 /// no longer manages.
178 #[inline]
179 #[must_use]
180 pub const unsafe fn from_raw(fs: duckdb_file_system) -> Self {
181 Self { fs }
182 }
183
184 /// Returns the raw handle.
185 #[inline]
186 #[must_use]
187 pub const fn as_raw(&self) -> duckdb_file_system {
188 self.fs
189 }
190
191 /// Opens `path` with the given `options`.
192 ///
193 /// # Errors
194 ///
195 /// Returns the structured [`ErrorData`] if the file cannot be opened.
196 pub fn open(&self, path: &CStr, options: &FileOpenOptions) -> Result<FileHandle, ErrorData> {
197 let mut handle: duckdb_file_handle = std::ptr::null_mut();
198 // SAFETY: self.fs, path, and options.as_raw() are all valid; handle is a
199 // valid out-pointer.
200 let state = unsafe {
201 duckdb_file_system_open(self.fs, path.as_ptr(), options.as_raw(), &raw mut handle)
202 };
203 if state == DuckDBSuccess && !handle.is_null() {
204 // SAFETY: open succeeded, so handle is a valid owned file handle.
205 Ok(unsafe { FileHandle::from_raw(handle) })
206 } else {
207 Err(self.error_data())
208 }
209 }
210
211 /// Returns the structured error from the most recent failed operation.
212 #[must_use]
213 pub fn error_data(&self) -> ErrorData {
214 // SAFETY: self.fs is valid; the call returns an owned error data handle.
215 let raw = unsafe { duckdb_file_system_error_data(self.fs) };
216 // SAFETY: raw is an owned duckdb_error_data (possibly null).
217 unsafe { ErrorData::from_raw(raw) }
218 }
219}
220
221impl Drop for FileSystem {
222 fn drop(&mut self) {
223 if !self.fs.is_null() {
224 // SAFETY: self.fs is a valid handle that we own.
225 unsafe { duckdb_destroy_file_system(&raw mut self.fs) };
226 }
227 }
228}
229
230/// RAII wrapper for an open `duckdb_file_handle`.
231///
232/// Automatically closed and destroyed when dropped.
233pub struct FileHandle {
234 handle: duckdb_file_handle,
235}
236
237impl FileHandle {
238 /// Wraps a raw `duckdb_file_handle`, taking ownership.
239 ///
240 /// # Safety
241 ///
242 /// `handle` must be a valid, non-null `duckdb_file_handle` that the caller no
243 /// longer manages.
244 #[inline]
245 #[must_use]
246 pub const unsafe fn from_raw(handle: duckdb_file_handle) -> Self {
247 Self { handle }
248 }
249
250 /// Returns the raw handle.
251 #[inline]
252 #[must_use]
253 pub const fn as_raw(&self) -> duckdb_file_handle {
254 self.handle
255 }
256
257 /// Reads up to `buf.len()` bytes into `buf`, returning the number of bytes
258 /// read (0 at end of file).
259 ///
260 /// # Errors
261 ///
262 /// Returns the structured [`ErrorData`] on read failure.
263 pub fn read(&self, buf: &mut [u8]) -> Result<usize, ErrorData> {
264 let size = i64::try_from(buf.len()).unwrap_or(i64::MAX);
265 // SAFETY: self.handle is valid; buf is writable for `size` bytes.
266 let n = unsafe {
267 duckdb_file_handle_read(self.handle, buf.as_mut_ptr().cast::<c_void>(), size)
268 };
269 if n < 0 {
270 Err(self.error_data())
271 } else {
272 Ok(usize::try_from(n).unwrap_or(0))
273 }
274 }
275
276 /// Writes up to `buf.len()` bytes from `buf`, returning the number written.
277 ///
278 /// # Errors
279 ///
280 /// Returns the structured [`ErrorData`] on write failure.
281 pub fn write(&self, buf: &[u8]) -> Result<usize, ErrorData> {
282 let size = i64::try_from(buf.len()).unwrap_or(i64::MAX);
283 // SAFETY: self.handle is valid; buf is readable for `size` bytes.
284 let n =
285 unsafe { duckdb_file_handle_write(self.handle, buf.as_ptr().cast::<c_void>(), size) };
286 if n < 0 {
287 Err(self.error_data())
288 } else {
289 Ok(usize::try_from(n).unwrap_or(0))
290 }
291 }
292
293 /// Seeks to an absolute byte `position`.
294 ///
295 /// # Errors
296 ///
297 /// Returns the structured [`ErrorData`] if the seek fails.
298 pub fn seek(&self, position: u64) -> Result<(), ErrorData> {
299 let pos = i64::try_from(position).unwrap_or(i64::MAX);
300 // SAFETY: self.handle is valid.
301 let state = unsafe { duckdb_file_handle_seek(self.handle, pos) };
302 self.check(state)
303 }
304
305 /// Returns the current byte offset within the file.
306 #[must_use]
307 pub fn tell(&self) -> i64 {
308 // SAFETY: self.handle is valid.
309 unsafe { duckdb_file_handle_tell(self.handle) }
310 }
311
312 /// Returns the total size of the file in bytes.
313 #[must_use]
314 pub fn size(&self) -> i64 {
315 // SAFETY: self.handle is valid.
316 unsafe { duckdb_file_handle_size(self.handle) }
317 }
318
319 /// Flushes buffered writes to durable storage.
320 ///
321 /// # Errors
322 ///
323 /// Returns the structured [`ErrorData`] if the sync fails.
324 pub fn sync(&self) -> Result<(), ErrorData> {
325 // SAFETY: self.handle is valid.
326 let state = unsafe { duckdb_file_handle_sync(self.handle) };
327 self.check(state)
328 }
329
330 /// Closes the file. The handle is still destroyed on drop.
331 ///
332 /// # Errors
333 ///
334 /// Returns the structured [`ErrorData`] if the close fails.
335 pub fn close(&self) -> Result<(), ErrorData> {
336 // SAFETY: self.handle is valid.
337 let state = unsafe { duckdb_file_handle_close(self.handle) };
338 self.check(state)
339 }
340
341 /// Returns the structured error from the most recent failed operation.
342 #[must_use]
343 pub fn error_data(&self) -> ErrorData {
344 // SAFETY: self.handle is valid; the call returns an owned error data.
345 let raw = unsafe { duckdb_file_handle_error_data(self.handle) };
346 // SAFETY: raw is an owned duckdb_error_data (possibly null).
347 unsafe { ErrorData::from_raw(raw) }
348 }
349
350 /// Converts a `duckdb_state` into a `Result`, reading the handle's error
351 /// data on failure.
352 fn check(&self, state: libduckdb_sys::duckdb_state) -> Result<(), ErrorData> {
353 if state == DuckDBSuccess {
354 Ok(())
355 } else {
356 Err(self.error_data())
357 }
358 }
359}
360
361impl Drop for FileHandle {
362 fn drop(&mut self) {
363 if !self.handle.is_null() {
364 // SAFETY: self.handle is a valid handle that we own.
365 unsafe { duckdb_destroy_file_handle(&raw mut self.handle) };
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn file_flag_distinct_raw_values() {
376 let flags = [
377 FileFlag::Read,
378 FileFlag::Write,
379 FileFlag::Create,
380 FileFlag::CreateNew,
381 FileFlag::Append,
382 ];
383 for (i, a) in flags.iter().enumerate() {
384 for b in flags.iter().skip(i + 1) {
385 assert_ne!(a.to_raw(), b.to_raw(), "{a:?} and {b:?} share a raw value");
386 }
387 }
388 }
389}