Skip to main content

sqlite_objs/
lib.rs

1//! # sqlite-objs - SQLite VFS backed by Azure Blob Storage
2//!
3//! This crate provides safe Rust bindings to sqlite-objs, a SQLite VFS (Virtual File System)
4//! that stores database files in Azure Blob Storage.
5//!
6//! ## Features
7//!
8//! - Store SQLite databases in Azure Blob Storage (page blobs for DB, block blobs for journal)
9//! - Blob lease-based locking for safe concurrent access
10//! - Full-blob caching for performance
11//! - SAS token and Shared Key authentication
12//! - URI-based per-database configuration
13//!
14//! ## Usage
15//!
16//! ### Basic Registration (Environment Variables)
17//!
18//! ```no_run
19//! use sqlite_objs::SqliteObjsVfs;
20//! use rusqlite::Connection;
21//!
22//! // Register VFS from environment variables
23//! SqliteObjsVfs::register(false)?;
24//!
25//! // Open a database using the sqlite-objs VFS
26//! let conn = Connection::open_with_flags_and_vfs(
27//!     "mydb.db",
28//!     rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE,
29//!     "sqlite-objs"
30//! )?;
31//! # Ok::<(), Box<dyn std::error::Error>>(())
32//! ```
33//!
34//! ### URI Mode (Per-Database Credentials)
35//!
36//! ```no_run
37//! use sqlite_objs::{SqliteObjsVfs, UriBuilder};
38//! use rusqlite::Connection;
39//!
40//! // Register VFS in URI mode (no global config)
41//! SqliteObjsVfs::register_uri(false)?;
42//!
43//! // Build URI with proper URL encoding
44//! let uri = UriBuilder::new("mydb.db", "myaccount", "databases")
45//!     .sas_token("sv=2024-08-04&ss=b&srt=sco&sp=rwdlacyx&se=2026-01-01T00:00:00Z&sig=abc123")
46//!     .cache_dir("/var/cache/myapp")
47//!     .cache_reuse(true)
48//!     .build();
49//!
50//! // Open database with Azure credentials in URI
51//! let conn = Connection::open_with_flags_and_vfs(
52//!     &uri,
53//!     rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE | rusqlite::OpenFlags::SQLITE_OPEN_URI,
54//!     "sqlite-objs"
55//! )?;
56//! # Ok::<(), Box<dyn std::error::Error>>(())
57//! ```
58//!
59//! ### Explicit Configuration
60//!
61//! ```no_run
62//! use sqlite_objs::{SqliteObjsVfs, SqliteObjsConfig};
63//! use rusqlite::Connection;
64//!
65//! let config = SqliteObjsConfig {
66//!     account: "myaccount".to_string(),
67//!     container: "databases".to_string(),
68//!     sas_token: Some("sv=2024-08-04&...".to_string()),
69//!     account_key: None,
70//!     endpoint: None,
71//! };
72//!
73//! SqliteObjsVfs::register_with_config(&config, false)?;
74//!
75//! let conn = Connection::open_with_flags_and_vfs(
76//!     "mydb.db",
77//!     rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE,
78//!     "sqlite-objs"
79//! )?;
80//! # Ok::<(), Box<dyn std::error::Error>>(())
81//! ```
82
83use std::ffi::CString;
84use std::fmt::Write;
85use std::ptr;
86use thiserror::Error;
87
88pub mod metrics;
89
90#[cfg(feature = "rusqlite")]
91pub mod pragmas;
92
93/// Error type for sqlite-objs operations.
94#[derive(Error, Debug)]
95pub enum SqliteObjsError {
96    /// SQLite returned an error code
97    #[error("SQLite error: {0}")]
98    Sqlite(i32),
99
100    /// Invalid configuration (e.g., null bytes in strings)
101    #[error("Invalid configuration: {0}")]
102    InvalidConfig(String),
103
104    /// VFS registration failed
105    #[error("VFS registration failed: {0}")]
106    RegistrationFailed(String),
107
108    /// Failed to parse VFS metrics output
109    #[error("Metrics parse error: {0}")]
110    MetricsParse(String),
111}
112
113/// Controls how the VFS prefetches blob data on open.
114///
115/// Passed to [`UriBuilder::prefetch`] and emitted as the `prefetch` URI
116/// parameter.
117///
118/// # Example
119///
120/// ```
121/// use sqlite_objs::{UriBuilder, PrefetchMode};
122///
123/// let uri = UriBuilder::new("mydb.db", "acct", "cont")
124///     .sas_token("tok")
125///     .prefetch(PrefetchMode::None)
126///     .build();
127/// assert!(uri.contains("prefetch=none"));
128/// ```
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
130pub enum PrefetchMode {
131    /// Download the entire blob into the local cache when the database is
132    /// opened. This is the default behaviour and gives the best read
133    /// performance for workloads that touch most pages.
134    #[default]
135    All,
136    /// Lazy mode — only fetch individual pages from Azure on demand.
137    /// Useful for large databases where you read a small subset of pages.
138    None,
139}
140
141impl PrefetchMode {
142    /// Returns the URI parameter value accepted by the C VFS.
143    fn as_uri_value(self) -> &'static str {
144        match self {
145            PrefetchMode::All => "all",
146            PrefetchMode::None => "none",
147        }
148    }
149}
150
151/// Result type for sqlite-objs operations.
152pub type Result<T> = std::result::Result<T, SqliteObjsError>;
153
154/// Configuration for the sqlite-objs VFS.
155///
156/// Maps to the C `sqlite_objs_config_t` struct. All fields are owned strings
157/// for safety and convenience.
158#[derive(Debug, Clone)]
159pub struct SqliteObjsConfig {
160    /// Azure Storage account name
161    pub account: String,
162    /// Blob container name
163    pub container: String,
164    /// SAS token (preferred)
165    pub sas_token: Option<String>,
166    /// Shared Key (fallback)
167    pub account_key: Option<String>,
168    /// Custom endpoint (e.g., for Azurite)
169    pub endpoint: Option<String>,
170}
171
172/// Handle to the sqlite-objs VFS.
173///
174/// The VFS is registered globally and persists for the lifetime of the process.
175/// This is a zero-sized type that provides static methods for VFS registration.
176pub struct SqliteObjsVfs;
177
178impl SqliteObjsVfs {
179    /// Register the sqlite-objs VFS using environment variables.
180    ///
181    /// Reads configuration from:
182    /// - `AZURE_STORAGE_ACCOUNT`
183    /// - `AZURE_STORAGE_CONTAINER`
184    /// - `AZURE_STORAGE_SAS` (checked first)
185    /// - `AZURE_STORAGE_KEY` (fallback)
186    ///
187    /// # Arguments
188    ///
189    /// * `make_default` - If true, sqlite-objs becomes the default VFS for all connections
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if registration fails (e.g., missing environment variables).
194    pub fn register(make_default: bool) -> Result<()> {
195        let rc = unsafe { sqlite_objs_sys::sqlite_objs_vfs_register(make_default as i32) };
196        if rc == sqlite_objs_sys::SQLITE_OK {
197            Ok(())
198        } else {
199            Err(SqliteObjsError::RegistrationFailed(format!(
200                "sqlite_objs_vfs_register returned {}",
201                rc
202            )))
203        }
204    }
205
206    /// Register the sqlite-objs VFS with explicit configuration.
207    ///
208    /// # Arguments
209    ///
210    /// * `config` - Azure Storage configuration
211    /// * `make_default` - If true, sqlite-objs becomes the default VFS for all connections
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if the configuration contains invalid data (null bytes)
216    /// or if registration fails.
217    pub fn register_with_config(config: &SqliteObjsConfig, make_default: bool) -> Result<()> {
218        // Convert Rust strings to C strings
219        let account = CString::new(config.account.as_str())
220            .map_err(|_| SqliteObjsError::InvalidConfig("account contains null byte".into()))?;
221        let container = CString::new(config.container.as_str())
222            .map_err(|_| SqliteObjsError::InvalidConfig("container contains null byte".into()))?;
223
224        let sas_token = config
225            .sas_token
226            .as_ref()
227            .map(|s| CString::new(s.as_str()))
228            .transpose()
229            .map_err(|_| SqliteObjsError::InvalidConfig("sas_token contains null byte".into()))?;
230
231        let account_key = config
232            .account_key
233            .as_ref()
234            .map(|s| CString::new(s.as_str()))
235            .transpose()
236            .map_err(|_| SqliteObjsError::InvalidConfig("account_key contains null byte".into()))?;
237
238        let endpoint = config
239            .endpoint
240            .as_ref()
241            .map(|s| CString::new(s.as_str()))
242            .transpose()
243            .map_err(|_| SqliteObjsError::InvalidConfig("endpoint contains null byte".into()))?;
244
245        let c_config = sqlite_objs_sys::sqlite_objs_config_t {
246            account: account.as_ptr(),
247            container: container.as_ptr(),
248            sas_token: sas_token
249                .as_ref()
250                .map(|s| s.as_ptr())
251                .unwrap_or(ptr::null()),
252            account_key: account_key
253                .as_ref()
254                .map(|s| s.as_ptr())
255                .unwrap_or(ptr::null()),
256            endpoint: endpoint.as_ref().map(|s| s.as_ptr()).unwrap_or(ptr::null()),
257            ops: ptr::null(),
258            ops_ctx: ptr::null_mut(),
259        };
260
261        let rc = unsafe {
262            sqlite_objs_sys::sqlite_objs_vfs_register_with_config(&c_config, make_default as i32)
263        };
264
265        if rc == sqlite_objs_sys::SQLITE_OK {
266            Ok(())
267        } else {
268            Err(SqliteObjsError::RegistrationFailed(format!(
269                "sqlite_objs_vfs_register_with_config returned {}",
270                rc
271            )))
272        }
273    }
274
275    /// Register the sqlite-objs VFS in URI mode.
276    ///
277    /// In this mode, Azure credentials must be provided via URI parameters for each database:
278    ///
279    /// ```text
280    /// file:mydb.db?azure_account=acct&azure_container=cont&azure_sas=token
281    /// ```
282    ///
283    /// Supported URI parameters:
284    /// - `azure_account` (required)
285    /// - `azure_container`
286    /// - `azure_sas`
287    /// - `azure_key`
288    /// - `azure_endpoint`
289    ///
290    /// # Arguments
291    ///
292    /// * `make_default` - If true, sqlite-objs becomes the default VFS for all connections
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if registration fails.
297    pub fn register_uri(make_default: bool) -> Result<()> {
298        let rc = unsafe { sqlite_objs_sys::sqlite_objs_vfs_register_uri(make_default as i32) };
299        if rc == sqlite_objs_sys::SQLITE_OK {
300            Ok(())
301        } else {
302            Err(SqliteObjsError::RegistrationFailed(format!(
303                "sqlite_objs_vfs_register_uri returned {}",
304                rc
305            )))
306        }
307    }
308}
309
310/// Builder for constructing sqlite-objs URIs with proper URL encoding.
311///
312/// SQLite URIs use query parameters to pass Azure credentials. SAS tokens contain
313/// special characters (`&`, `=`, `%`) that must be percent-encoded to avoid breaking
314/// the URI query string.
315///
316/// # Example
317///
318/// ```
319/// use sqlite_objs::UriBuilder;
320///
321/// let uri = UriBuilder::new("mydb.db", "myaccount", "databases")
322///     .sas_token("sv=2024-08-04&ss=b&srt=sco&sp=rwdlacyx&se=2026-01-01T00:00:00Z&sig=abc123")
323///     .build();
324///
325/// // URI is properly encoded:
326/// // file:mydb.db?azure_account=myaccount&azure_container=databases&azure_sas=sv%3D2024-08-04%26ss%3Db...
327/// ```
328///
329/// # Authentication
330///
331/// Use either `sas_token()` or `account_key()`, not both. If both are set, `sas_token`
332/// takes precedence.
333pub struct UriBuilder {
334    database: String,
335    account: String,
336    container: String,
337    sas_token: Option<String>,
338    account_key: Option<String>,
339    endpoint: Option<String>,
340    cache_dir: Option<String>,
341    cache_reuse: bool,
342    prefetch: Option<PrefetchMode>,
343}
344
345impl UriBuilder {
346    /// Create a new URI builder with required parameters.
347    ///
348    /// # Arguments
349    ///
350    /// * `database` - Database filename (e.g., "mydb.db")
351    /// * `account` - Azure Storage account name
352    /// * `container` - Blob container name
353    pub fn new(database: &str, account: &str, container: &str) -> Self {
354        Self {
355            database: database.to_string(),
356            account: account.to_string(),
357            container: container.to_string(),
358            sas_token: None,
359            account_key: None,
360            endpoint: None,
361            cache_dir: None,
362            cache_reuse: false,
363            prefetch: None,
364        }
365    }
366
367    /// Set the SAS token for authentication (preferred).
368    ///
369    /// The token will be URL-encoded automatically. Do not encode it yourself.
370    pub fn sas_token(mut self, token: &str) -> Self {
371        self.sas_token = Some(token.to_string());
372        self
373    }
374
375    /// Set the account key for Shared Key authentication (fallback).
376    ///
377    /// The key will be URL-encoded automatically.
378    pub fn account_key(mut self, key: &str) -> Self {
379        self.account_key = Some(key.to_string());
380        self
381    }
382
383    /// Set a custom endpoint (e.g., for Azurite: "http://127.0.0.1:10000").
384    pub fn endpoint(mut self, endpoint: &str) -> Self {
385        self.endpoint = Some(endpoint.to_string());
386        self
387    }
388
389    /// Set the local cache directory for downloaded database files.
390    ///
391    /// If not set, defaults to `/tmp`. The directory will be created if it doesn't exist.
392    pub fn cache_dir(mut self, dir: &str) -> Self {
393        self.cache_dir = Some(dir.to_string());
394        self
395    }
396
397    /// Enable persistent cache reuse across database connections.
398    ///
399    /// When enabled, the local cache file is kept after closing the database.
400    /// On reopen, the VFS checks the blob's ETag — if unchanged, the cached
401    /// file is reused instead of re-downloading (saving ~20s for large databases).
402    ///
403    /// Requires `cache_dir` to be set for predictable cache file locations.
404    /// Default: `false` (cache files are deleted on close).
405    pub fn cache_reuse(mut self, enabled: bool) -> Self {
406        self.cache_reuse = enabled;
407        self
408    }
409
410    /// Set the prefetch mode for blob data loading.
411    ///
412    /// - [`PrefetchMode::All`] (default) — download the entire blob into the
413    ///   local cache when the database is opened.
414    /// - [`PrefetchMode::None`] — lazy mode; pages are fetched from Azure only
415    ///   when SQLite reads them.
416    ///
417    /// Only emitted as a URI parameter when explicitly set to a non-default
418    /// value, keeping URIs short in the common case.
419    ///
420    /// # Example
421    ///
422    /// ```
423    /// use sqlite_objs::{UriBuilder, PrefetchMode};
424    ///
425    /// let uri = UriBuilder::new("big.db", "acct", "cont")
426    ///     .sas_token("tok")
427    ///     .prefetch(PrefetchMode::None)
428    ///     .build();
429    /// assert!(uri.contains("&prefetch=none"));
430    /// ```
431    pub fn prefetch(mut self, mode: PrefetchMode) -> Self {
432        self.prefetch = Some(mode);
433        self
434    }
435
436    /// Build the URI string with proper URL encoding.
437    ///
438    /// Returns a SQLite URI in the format:
439    /// `file:{database}?azure_account={account}&azure_container={container}&...`
440    pub fn build(self) -> String {
441        let mut uri = format!(
442            "file:{}?azure_account={}&azure_container={}",
443            self.database,
444            percent_encode(&self.account),
445            percent_encode(&self.container)
446        );
447
448        // Prefer SAS token over account key
449        if let Some(sas) = &self.sas_token {
450            uri.push_str("&azure_sas=");
451            uri.push_str(&percent_encode(sas));
452        } else if let Some(key) = &self.account_key {
453            uri.push_str("&azure_key=");
454            uri.push_str(&percent_encode(key));
455        }
456
457        if let Some(endpoint) = &self.endpoint {
458            uri.push_str("&azure_endpoint=");
459            uri.push_str(&percent_encode(endpoint));
460        }
461
462        if let Some(cache_dir) = &self.cache_dir {
463            uri.push_str("&cache_dir=");
464            uri.push_str(&percent_encode(cache_dir));
465        }
466
467        if self.cache_reuse {
468            uri.push_str("&cache_reuse=1");
469        }
470
471        if let Some(mode) = self.prefetch {
472            uri.push_str("&prefetch=");
473            uri.push_str(mode.as_uri_value());
474        }
475
476        uri
477    }
478}
479
480/// Percent-encode a string for use in URI query parameters.
481///
482/// Encodes characters that have special meaning in URIs:
483/// - Reserved: `&`, `=`, `%`, `#`, `?`, `+`, `/`, `:`, `@`
484/// - Space
485///
486/// This is a minimal implementation sufficient for SQLite URI parameters.
487/// Uses uppercase hex digits per RFC 3986.
488fn percent_encode(s: &str) -> String {
489    let mut result = String::with_capacity(s.len() * 2);
490
491    for byte in s.bytes() {
492        match byte {
493            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
494                // Unreserved characters (RFC 3986 section 2.3)
495                result.push(byte as char);
496            }
497            _ => {
498                // Encode everything else
499                write!(result, "%{:02X}", byte).unwrap();
500            }
501        }
502    }
503
504    result
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_register_uri() {
513        // URI mode should succeed without config
514        SqliteObjsVfs::register_uri(false).expect("URI registration should succeed");
515    }
516
517    #[test]
518    fn test_config_with_sas() {
519        let config = SqliteObjsConfig {
520            account: "testaccount".to_string(),
521            container: "testcontainer".to_string(),
522            sas_token: Some("sv=2024-08-04&sig=test".to_string()),
523            account_key: None,
524            endpoint: None,
525        };
526
527        // This will fail since we don't have real Azure creds,
528        // but it tests the FFI layer
529        let _ = SqliteObjsVfs::register_with_config(&config, false);
530    }
531
532    #[test]
533    fn test_invalid_config() {
534        let config = SqliteObjsConfig {
535            account: "test\0account".to_string(),
536            container: "container".to_string(),
537            sas_token: None,
538            account_key: None,
539            endpoint: None,
540        };
541
542        let result = SqliteObjsVfs::register_with_config(&config, false);
543        assert!(matches!(result, Err(SqliteObjsError::InvalidConfig(_))));
544    }
545
546    #[test]
547    fn test_uri_builder_basic() {
548        let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer").build();
549        assert_eq!(
550            uri,
551            "file:mydb.db?azure_account=myaccount&azure_container=mycontainer"
552        );
553    }
554
555    #[test]
556    fn test_uri_builder_with_sas() {
557        let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
558            .sas_token("sv=2024-08-04&ss=b&srt=sco&sp=rwdlacyx&se=2026-01-01T00:00:00Z&sig=abc123")
559            .build();
560
561        // Verify SAS token is encoded (&, =, :)
562        assert!(uri.contains("azure_sas=sv%3D2024-08-04%26ss%3Db%26srt%3Dsco%26sp%3Drwdlacyx%26se%3D2026-01-01T00%3A00%3A00Z%26sig%3Dabc123"));
563        assert!(uri.starts_with(
564            "file:mydb.db?azure_account=myaccount&azure_container=mycontainer&azure_sas="
565        ));
566    }
567
568    #[test]
569    fn test_uri_builder_with_account_key() {
570        let uri = UriBuilder::new("test.db", "account", "container")
571            .account_key("my/secret+key==")
572            .build();
573
574        // Verify account key is encoded (/, +, =)
575        assert!(uri.contains("azure_key=my%2Fsecret%2Bkey%3D%3D"));
576    }
577
578    #[test]
579    fn test_uri_builder_with_endpoint() {
580        let uri = UriBuilder::new("test.db", "devstoreaccount1", "testcontainer")
581            .endpoint("http://127.0.0.1:10000/devstoreaccount1")
582            .build();
583
584        // Verify endpoint is encoded (://)
585        assert!(uri.contains("azure_endpoint=http%3A%2F%2F127.0.0.1%3A10000%2Fdevstoreaccount1"));
586    }
587
588    #[test]
589    fn test_uri_builder_sas_precedence() {
590        let uri = UriBuilder::new("test.db", "account", "container")
591            .sas_token("sas_token_value")
592            .account_key("key_value")
593            .build();
594
595        // SAS token should be present, account key should not
596        assert!(uri.contains("azure_sas="));
597        assert!(!uri.contains("azure_key="));
598    }
599
600    #[test]
601    fn test_uri_builder_with_cache_dir() {
602        let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
603            .sas_token("token")
604            .cache_dir("/var/cache/myapp")
605            .build();
606
607        assert!(uri.contains("cache_dir=%2Fvar%2Fcache%2Fmyapp"));
608
609        // Verify it appears after the other parameters
610        let cache_pos = uri.find("cache_dir=").unwrap();
611        let sas_pos = uri.find("azure_sas=").unwrap();
612        assert!(cache_pos > sas_pos);
613    }
614
615    #[test]
616    fn test_uri_builder_cache_dir_without_auth() {
617        let uri = UriBuilder::new("test.db", "account", "container")
618            .cache_dir("/tmp/test")
619            .build();
620
621        assert_eq!(
622            uri,
623            "file:test.db?azure_account=account&azure_container=container&cache_dir=%2Ftmp%2Ftest"
624        );
625    }
626
627    #[test]
628    fn test_uri_builder_cache_reuse_enabled() {
629        let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
630            .sas_token("token")
631            .cache_reuse(true)
632            .build();
633
634        assert!(uri.contains("&cache_reuse=1"));
635    }
636
637    #[test]
638    fn test_uri_builder_cache_reuse_default_omitted() {
639        let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
640            .sas_token("token")
641            .build();
642
643        assert!(!uri.contains("cache_reuse"));
644    }
645
646    #[test]
647    fn test_uri_builder_cache_reuse_with_cache_dir() {
648        let uri = UriBuilder::new("mydb.db", "myaccount", "mycontainer")
649            .sas_token("token")
650            .cache_dir("/var/cache/myapp")
651            .cache_reuse(true)
652            .build();
653
654        assert!(uri.contains("cache_dir=%2Fvar%2Fcache%2Fmyapp"));
655        assert!(uri.contains("&cache_reuse=1"));
656
657        // cache_reuse should appear after cache_dir
658        let dir_pos = uri.find("cache_dir=").unwrap();
659        let reuse_pos = uri.find("cache_reuse=").unwrap();
660        assert!(reuse_pos > dir_pos);
661    }
662
663    #[test]
664    fn test_percent_encode_special_chars() {
665        // Test all special characters that need encoding
666        assert_eq!(percent_encode("hello&world"), "hello%26world");
667        assert_eq!(percent_encode("key=value"), "key%3Dvalue");
668        assert_eq!(percent_encode("100%"), "100%25");
669        assert_eq!(percent_encode("a#b"), "a%23b");
670        assert_eq!(percent_encode("a?b"), "a%3Fb");
671        assert_eq!(percent_encode("a+b"), "a%2Bb");
672        assert_eq!(percent_encode("a/b"), "a%2Fb");
673        assert_eq!(percent_encode("a:b"), "a%3Ab");
674        assert_eq!(percent_encode("a@b"), "a%40b");
675        assert_eq!(percent_encode("hello world"), "hello%20world");
676    }
677
678    #[test]
679    fn test_percent_encode_unreserved() {
680        // Unreserved characters should not be encoded
681        assert_eq!(percent_encode("azAZ09-_.~"), "azAZ09-_.~");
682    }
683
684    #[test]
685    fn test_percent_encode_empty() {
686        assert_eq!(percent_encode(""), "");
687    }
688
689    // -- PrefetchMode + UriBuilder::prefetch tests --
690
691    #[test]
692    fn test_prefetch_mode_default_is_all() {
693        assert_eq!(PrefetchMode::default(), PrefetchMode::All);
694    }
695
696    #[test]
697    fn test_uri_builder_prefetch_none() {
698        let uri = UriBuilder::new("big.db", "acct", "cont")
699            .prefetch(PrefetchMode::None)
700            .build();
701        assert!(uri.contains("&prefetch=none"));
702    }
703
704    #[test]
705    fn test_uri_builder_prefetch_all() {
706        let uri = UriBuilder::new("big.db", "acct", "cont")
707            .prefetch(PrefetchMode::All)
708            .build();
709        assert!(uri.contains("&prefetch=all"));
710    }
711
712    #[test]
713    fn test_uri_builder_prefetch_omitted_by_default() {
714        let uri = UriBuilder::new("test.db", "acct", "cont").build();
715        assert!(!uri.contains("prefetch"));
716    }
717
718    #[test]
719    fn test_uri_builder_prefetch_with_cache() {
720        let uri = UriBuilder::new("test.db", "acct", "cont")
721            .cache_dir("/cache")
722            .cache_reuse(true)
723            .prefetch(PrefetchMode::None)
724            .build();
725        assert!(uri.contains("cache_dir="));
726        assert!(uri.contains("&cache_reuse=1"));
727        assert!(uri.contains("&prefetch=none"));
728
729        // prefetch should appear after cache_reuse
730        let reuse_pos = uri.find("cache_reuse=").unwrap();
731        let prefetch_pos = uri.find("prefetch=").unwrap();
732        assert!(prefetch_pos > reuse_pos);
733    }
734}