Skip to main content

grafeo_engine/
config.rs

1//! Database configuration.
2
3use std::fmt;
4use std::path::PathBuf;
5use std::time::Duration;
6
7/// The graph data model for a database.
8///
9/// Each database uses exactly one model, chosen at creation time and immutable
10/// after that. The engine initializes only the relevant store, saving memory.
11///
12/// Schema variants (OWL, RDFS, JSON Schema) are a server-level concern - from
13/// the engine's perspective those map to either `Lpg` or `Rdf`.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
15pub enum GraphModel {
16    /// Labeled Property Graph (default). Supports GQL, Cypher, Gremlin, GraphQL.
17    #[default]
18    Lpg,
19    /// RDF triple store. Supports SPARQL.
20    Rdf,
21}
22
23impl fmt::Display for GraphModel {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Lpg => write!(f, "LPG"),
27            Self::Rdf => write!(f, "RDF"),
28        }
29    }
30}
31
32/// Access mode for opening a database.
33///
34/// Controls whether the database is opened for full read-write access
35/// (the default) or read-only access. Read-only mode uses a shared file
36/// lock, allowing multiple processes to read the same `.grafeo` file
37/// concurrently.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
39pub enum AccessMode {
40    /// Full read-write access (default). Acquires an exclusive file lock.
41    #[default]
42    ReadWrite,
43    /// Read-only access. Acquires a shared file lock, allowing concurrent
44    /// readers. The database loads the last checkpoint snapshot but does not
45    /// replay the WAL or allow mutations.
46    ReadOnly,
47}
48
49impl fmt::Display for AccessMode {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::ReadWrite => write!(f, "read-write"),
53            Self::ReadOnly => write!(f, "read-only"),
54        }
55    }
56}
57
58/// Storage format for persistent databases.
59///
60/// Controls whether the database uses a single `.grafeo` file or a legacy
61/// WAL directory. The default (`Auto`) auto-detects based on the path:
62/// files ending in `.grafeo` use single-file format, directories use WAL.
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
64pub enum StorageFormat {
65    /// Auto-detect based on path: `.grafeo` extension = single file,
66    /// existing directory = WAL directory, new path without extension = WAL directory.
67    #[default]
68    Auto,
69    /// Legacy WAL directory format (directory with `wal/` subdirectory).
70    WalDirectory,
71    /// Single `.grafeo` file with a sidecar `.grafeo.wal/` directory during operation.
72    /// At rest (after checkpoint), only the `.grafeo` file exists.
73    SingleFile,
74}
75
76impl fmt::Display for StorageFormat {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::Auto => write!(f, "auto"),
80            Self::WalDirectory => write!(f, "wal-directory"),
81            Self::SingleFile => write!(f, "single-file"),
82        }
83    }
84}
85
86/// WAL durability mode controlling the trade-off between safety and speed.
87///
88/// This enum lives in config so that `Config` can always carry the desired
89/// durability regardless of whether the `wal` feature is compiled in. When
90/// WAL is enabled, the engine maps this to the adapter-level durability mode.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum DurabilityMode {
93    /// Fsync after every commit. Slowest but safest.
94    Sync,
95    /// Batch fsync periodically. Good balance of performance and durability.
96    Batch {
97        /// Maximum time between syncs in milliseconds.
98        max_delay_ms: u64,
99        /// Maximum records between syncs.
100        max_records: u64,
101    },
102    /// Adaptive sync via a background flusher thread.
103    Adaptive {
104        /// Target interval between flushes in milliseconds.
105        target_interval_ms: u64,
106    },
107    /// No sync - rely on OS buffer flushing. Fastest but may lose recent data.
108    NoSync,
109}
110
111impl Default for DurabilityMode {
112    fn default() -> Self {
113        Self::Batch {
114            max_delay_ms: 100,
115            max_records: 1000,
116        }
117    }
118}
119
120/// Errors from [`Config::validate()`].
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum ConfigError {
123    /// Memory limit must be greater than zero.
124    ZeroMemoryLimit,
125    /// Thread count must be greater than zero.
126    ZeroThreads,
127    /// WAL flush interval must be greater than zero.
128    ZeroWalFlushInterval,
129    /// RDF graph model requires the `rdf` feature flag.
130    RdfFeatureRequired,
131}
132
133impl fmt::Display for ConfigError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::ZeroMemoryLimit => write!(f, "memory_limit must be greater than zero"),
137            Self::ZeroThreads => write!(f, "threads must be greater than zero"),
138            Self::ZeroWalFlushInterval => {
139                write!(f, "wal_flush_interval_ms must be greater than zero")
140            }
141            Self::RdfFeatureRequired => {
142                write!(
143                    f,
144                    "RDF graph model requires the `rdf` feature flag to be enabled"
145                )
146            }
147        }
148    }
149}
150
151impl std::error::Error for ConfigError {}
152
153/// Database configuration.
154#[derive(Debug, Clone)]
155#[allow(clippy::struct_excessive_bools)] // Config structs naturally have many boolean flags
156pub struct Config {
157    /// Graph data model (LPG or RDF). Immutable after database creation.
158    pub graph_model: GraphModel,
159    /// Path to the database directory (None for in-memory only).
160    pub path: Option<PathBuf>,
161
162    /// Memory limit in bytes (None for unlimited).
163    pub memory_limit: Option<usize>,
164
165    /// Path for spilling data to disk under memory pressure.
166    pub spill_path: Option<PathBuf>,
167
168    /// Number of worker threads for query execution.
169    pub threads: usize,
170
171    /// Whether to enable WAL for durability.
172    pub wal_enabled: bool,
173
174    /// WAL flush interval in milliseconds.
175    pub wal_flush_interval_ms: u64,
176
177    /// Whether to maintain backward edges.
178    pub backward_edges: bool,
179
180    /// Whether to enable query logging.
181    pub query_logging: bool,
182
183    /// Adaptive execution configuration.
184    pub adaptive: AdaptiveConfig,
185
186    /// Whether to use factorized execution for multi-hop queries.
187    ///
188    /// When enabled, consecutive MATCH expansions are executed using factorized
189    /// representation which avoids Cartesian product materialization. This provides
190    /// 5-100x speedup for multi-hop queries with high fan-out.
191    ///
192    /// Enabled by default.
193    pub factorized_execution: bool,
194
195    /// WAL durability mode. Only used when `wal_enabled` is true.
196    pub wal_durability: DurabilityMode,
197
198    /// Storage format for persistent databases.
199    ///
200    /// `Auto` (default) detects the format from the path: `.grafeo` extension
201    /// uses single-file format, directories use the legacy WAL directory.
202    pub storage_format: StorageFormat,
203
204    /// Whether to enable catalog schema constraint enforcement.
205    ///
206    /// When true, the catalog enforces label, edge type, and property constraints
207    /// (e.g. required properties, uniqueness). The server sets this for JSON
208    /// Schema databases and populates constraints after creation.
209    pub schema_constraints: bool,
210
211    /// Maximum time a single query may run before being cancelled.
212    ///
213    /// When set, the executor checks the deadline between operator batches and
214    /// returns `QueryError::timeout()` if the wall-clock limit is exceeded.
215    /// `None` means no timeout (queries may run indefinitely).
216    pub query_timeout: Option<Duration>,
217
218    /// Run MVCC version garbage collection every N commits.
219    ///
220    /// Old versions that are no longer visible to any active transaction are
221    /// pruned to reclaim memory. Set to 0 to disable automatic GC.
222    pub gc_interval: usize,
223
224    /// Access mode: read-write (default) or read-only.
225    ///
226    /// Read-only mode uses a shared file lock, allowing multiple processes to
227    /// read the same database concurrently. Mutations are rejected at the
228    /// session level.
229    pub access_mode: AccessMode,
230}
231
232/// Configuration for adaptive query execution.
233///
234/// Adaptive execution monitors actual row counts during query processing and
235/// can trigger re-optimization when estimates are significantly wrong.
236#[derive(Debug, Clone)]
237pub struct AdaptiveConfig {
238    /// Whether adaptive execution is enabled.
239    pub enabled: bool,
240
241    /// Deviation threshold that triggers re-optimization.
242    ///
243    /// A value of 3.0 means re-optimization is triggered when actual cardinality
244    /// is more than 3x or less than 1/3x the estimated value.
245    pub threshold: f64,
246
247    /// Minimum number of rows before considering re-optimization.
248    ///
249    /// Helps avoid thrashing on small result sets.
250    pub min_rows: u64,
251
252    /// Maximum number of re-optimizations allowed per query.
253    pub max_reoptimizations: usize,
254}
255
256impl Default for AdaptiveConfig {
257    fn default() -> Self {
258        Self {
259            enabled: true,
260            threshold: 3.0,
261            min_rows: 1000,
262            max_reoptimizations: 3,
263        }
264    }
265}
266
267impl AdaptiveConfig {
268    /// Creates a disabled adaptive config.
269    #[must_use]
270    pub fn disabled() -> Self {
271        Self {
272            enabled: false,
273            ..Default::default()
274        }
275    }
276
277    /// Sets the deviation threshold.
278    #[must_use]
279    pub fn with_threshold(mut self, threshold: f64) -> Self {
280        self.threshold = threshold;
281        self
282    }
283
284    /// Sets the minimum rows before re-optimization.
285    #[must_use]
286    pub fn with_min_rows(mut self, min_rows: u64) -> Self {
287        self.min_rows = min_rows;
288        self
289    }
290
291    /// Sets the maximum number of re-optimizations.
292    #[must_use]
293    pub fn with_max_reoptimizations(mut self, max: usize) -> Self {
294        self.max_reoptimizations = max;
295        self
296    }
297}
298
299impl Default for Config {
300    fn default() -> Self {
301        Self {
302            graph_model: GraphModel::default(),
303            path: None,
304            memory_limit: None,
305            spill_path: None,
306            threads: num_cpus::get(),
307            wal_enabled: true,
308            wal_flush_interval_ms: 100,
309            backward_edges: true,
310            query_logging: false,
311            adaptive: AdaptiveConfig::default(),
312            factorized_execution: true,
313            wal_durability: DurabilityMode::default(),
314            storage_format: StorageFormat::default(),
315            schema_constraints: false,
316            query_timeout: None,
317            gc_interval: 100,
318            access_mode: AccessMode::default(),
319        }
320    }
321}
322
323impl Config {
324    /// Creates a new configuration for an in-memory database.
325    #[must_use]
326    pub fn in_memory() -> Self {
327        Self {
328            path: None,
329            wal_enabled: false,
330            ..Default::default()
331        }
332    }
333
334    /// Creates a new configuration for a persistent database.
335    #[must_use]
336    pub fn persistent(path: impl Into<PathBuf>) -> Self {
337        Self {
338            path: Some(path.into()),
339            wal_enabled: true,
340            ..Default::default()
341        }
342    }
343
344    /// Sets the memory limit.
345    #[must_use]
346    pub fn with_memory_limit(mut self, limit: usize) -> Self {
347        self.memory_limit = Some(limit);
348        self
349    }
350
351    /// Sets the number of worker threads.
352    #[must_use]
353    pub fn with_threads(mut self, threads: usize) -> Self {
354        self.threads = threads;
355        self
356    }
357
358    /// Disables backward edges.
359    #[must_use]
360    pub fn without_backward_edges(mut self) -> Self {
361        self.backward_edges = false;
362        self
363    }
364
365    /// Enables query logging.
366    #[must_use]
367    pub fn with_query_logging(mut self) -> Self {
368        self.query_logging = true;
369        self
370    }
371
372    /// Sets the memory budget as a fraction of system RAM.
373    #[must_use]
374    pub fn with_memory_fraction(mut self, fraction: f64) -> Self {
375        use grafeo_common::memory::buffer::BufferManagerConfig;
376        let system_memory = BufferManagerConfig::detect_system_memory();
377        self.memory_limit = Some((system_memory as f64 * fraction) as usize);
378        self
379    }
380
381    /// Sets the spill directory for out-of-core processing.
382    #[must_use]
383    pub fn with_spill_path(mut self, path: impl Into<PathBuf>) -> Self {
384        self.spill_path = Some(path.into());
385        self
386    }
387
388    /// Sets the adaptive execution configuration.
389    #[must_use]
390    pub fn with_adaptive(mut self, adaptive: AdaptiveConfig) -> Self {
391        self.adaptive = adaptive;
392        self
393    }
394
395    /// Disables adaptive execution.
396    #[must_use]
397    pub fn without_adaptive(mut self) -> Self {
398        self.adaptive.enabled = false;
399        self
400    }
401
402    /// Disables factorized execution for multi-hop queries.
403    ///
404    /// This reverts to the traditional flat execution model where each expansion
405    /// creates a full Cartesian product. Only use this if you encounter issues
406    /// with factorized execution.
407    #[must_use]
408    pub fn without_factorized_execution(mut self) -> Self {
409        self.factorized_execution = false;
410        self
411    }
412
413    /// Sets the graph data model.
414    #[must_use]
415    pub fn with_graph_model(mut self, model: GraphModel) -> Self {
416        self.graph_model = model;
417        self
418    }
419
420    /// Sets the WAL durability mode.
421    #[must_use]
422    pub fn with_wal_durability(mut self, mode: DurabilityMode) -> Self {
423        self.wal_durability = mode;
424        self
425    }
426
427    /// Sets the storage format for persistent databases.
428    #[must_use]
429    pub fn with_storage_format(mut self, format: StorageFormat) -> Self {
430        self.storage_format = format;
431        self
432    }
433
434    /// Enables catalog schema constraint enforcement.
435    #[must_use]
436    pub fn with_schema_constraints(mut self) -> Self {
437        self.schema_constraints = true;
438        self
439    }
440
441    /// Sets the maximum time a query may run before being cancelled.
442    #[must_use]
443    pub fn with_query_timeout(mut self, timeout: Duration) -> Self {
444        self.query_timeout = Some(timeout);
445        self
446    }
447
448    /// Sets the MVCC garbage collection interval (every N commits).
449    ///
450    /// Set to 0 to disable automatic GC.
451    #[must_use]
452    pub fn with_gc_interval(mut self, interval: usize) -> Self {
453        self.gc_interval = interval;
454        self
455    }
456
457    /// Sets the access mode (read-write or read-only).
458    #[must_use]
459    pub fn with_access_mode(mut self, mode: AccessMode) -> Self {
460        self.access_mode = mode;
461        self
462    }
463
464    /// Shorthand for opening a persistent database in read-only mode.
465    ///
466    /// Uses a shared file lock, allowing multiple processes to read the same
467    /// `.grafeo` file concurrently. Mutations are rejected at the session level.
468    #[must_use]
469    pub fn read_only(path: impl Into<PathBuf>) -> Self {
470        Self {
471            path: Some(path.into()),
472            wal_enabled: false,
473            access_mode: AccessMode::ReadOnly,
474            ..Default::default()
475        }
476    }
477
478    /// Validates the configuration, returning an error for invalid combinations.
479    ///
480    /// Called automatically by [`GrafeoDB::with_config()`](crate::GrafeoDB::with_config).
481    ///
482    /// # Errors
483    ///
484    /// Returns [`ConfigError`] if any setting is invalid.
485    pub fn validate(&self) -> std::result::Result<(), ConfigError> {
486        if let Some(limit) = self.memory_limit
487            && limit == 0
488        {
489            return Err(ConfigError::ZeroMemoryLimit);
490        }
491
492        if self.threads == 0 {
493            return Err(ConfigError::ZeroThreads);
494        }
495
496        if self.wal_flush_interval_ms == 0 {
497            return Err(ConfigError::ZeroWalFlushInterval);
498        }
499
500        #[cfg(not(feature = "rdf"))]
501        if self.graph_model == GraphModel::Rdf {
502            return Err(ConfigError::RdfFeatureRequired);
503        }
504
505        Ok(())
506    }
507}
508
509/// Helper function to get CPU count (fallback implementation).
510mod num_cpus {
511    #[cfg(not(target_arch = "wasm32"))]
512    pub fn get() -> usize {
513        std::thread::available_parallelism()
514            .map(|n| n.get())
515            .unwrap_or(4)
516    }
517
518    #[cfg(target_arch = "wasm32")]
519    pub fn get() -> usize {
520        1
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_config_default() {
530        let config = Config::default();
531        assert_eq!(config.graph_model, GraphModel::Lpg);
532        assert!(config.path.is_none());
533        assert!(config.memory_limit.is_none());
534        assert!(config.spill_path.is_none());
535        assert!(config.threads > 0);
536        assert!(config.wal_enabled);
537        assert_eq!(config.wal_flush_interval_ms, 100);
538        assert!(config.backward_edges);
539        assert!(!config.query_logging);
540        assert!(config.factorized_execution);
541        assert_eq!(config.wal_durability, DurabilityMode::default());
542        assert!(!config.schema_constraints);
543        assert!(config.query_timeout.is_none());
544        assert_eq!(config.gc_interval, 100);
545    }
546
547    #[test]
548    fn test_config_in_memory() {
549        let config = Config::in_memory();
550        assert!(config.path.is_none());
551        assert!(!config.wal_enabled);
552        assert!(config.backward_edges);
553    }
554
555    #[test]
556    fn test_config_persistent() {
557        let config = Config::persistent("/tmp/test_db");
558        assert_eq!(
559            config.path.as_deref(),
560            Some(std::path::Path::new("/tmp/test_db"))
561        );
562        assert!(config.wal_enabled);
563    }
564
565    #[test]
566    fn test_config_with_memory_limit() {
567        let config = Config::in_memory().with_memory_limit(1024 * 1024);
568        assert_eq!(config.memory_limit, Some(1024 * 1024));
569    }
570
571    #[test]
572    fn test_config_with_threads() {
573        let config = Config::in_memory().with_threads(8);
574        assert_eq!(config.threads, 8);
575    }
576
577    #[test]
578    fn test_config_without_backward_edges() {
579        let config = Config::in_memory().without_backward_edges();
580        assert!(!config.backward_edges);
581    }
582
583    #[test]
584    fn test_config_with_query_logging() {
585        let config = Config::in_memory().with_query_logging();
586        assert!(config.query_logging);
587    }
588
589    #[test]
590    fn test_config_with_spill_path() {
591        let config = Config::in_memory().with_spill_path("/tmp/spill");
592        assert_eq!(
593            config.spill_path.as_deref(),
594            Some(std::path::Path::new("/tmp/spill"))
595        );
596    }
597
598    #[test]
599    fn test_config_with_memory_fraction() {
600        let config = Config::in_memory().with_memory_fraction(0.5);
601        assert!(config.memory_limit.is_some());
602        assert!(config.memory_limit.unwrap() > 0);
603    }
604
605    #[test]
606    fn test_config_with_adaptive() {
607        let adaptive = AdaptiveConfig::default().with_threshold(5.0);
608        let config = Config::in_memory().with_adaptive(adaptive);
609        assert!((config.adaptive.threshold - 5.0).abs() < f64::EPSILON);
610    }
611
612    #[test]
613    fn test_config_without_adaptive() {
614        let config = Config::in_memory().without_adaptive();
615        assert!(!config.adaptive.enabled);
616    }
617
618    #[test]
619    fn test_config_without_factorized_execution() {
620        let config = Config::in_memory().without_factorized_execution();
621        assert!(!config.factorized_execution);
622    }
623
624    #[test]
625    fn test_config_builder_chaining() {
626        let config = Config::persistent("/tmp/db")
627            .with_memory_limit(512 * 1024 * 1024)
628            .with_threads(4)
629            .with_query_logging()
630            .without_backward_edges()
631            .with_spill_path("/tmp/spill");
632
633        assert!(config.path.is_some());
634        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
635        assert_eq!(config.threads, 4);
636        assert!(config.query_logging);
637        assert!(!config.backward_edges);
638        assert!(config.spill_path.is_some());
639    }
640
641    #[test]
642    fn test_adaptive_config_default() {
643        let config = AdaptiveConfig::default();
644        assert!(config.enabled);
645        assert!((config.threshold - 3.0).abs() < f64::EPSILON);
646        assert_eq!(config.min_rows, 1000);
647        assert_eq!(config.max_reoptimizations, 3);
648    }
649
650    #[test]
651    fn test_adaptive_config_disabled() {
652        let config = AdaptiveConfig::disabled();
653        assert!(!config.enabled);
654    }
655
656    #[test]
657    fn test_adaptive_config_with_threshold() {
658        let config = AdaptiveConfig::default().with_threshold(10.0);
659        assert!((config.threshold - 10.0).abs() < f64::EPSILON);
660    }
661
662    #[test]
663    fn test_adaptive_config_with_min_rows() {
664        let config = AdaptiveConfig::default().with_min_rows(500);
665        assert_eq!(config.min_rows, 500);
666    }
667
668    #[test]
669    fn test_adaptive_config_with_max_reoptimizations() {
670        let config = AdaptiveConfig::default().with_max_reoptimizations(5);
671        assert_eq!(config.max_reoptimizations, 5);
672    }
673
674    #[test]
675    fn test_adaptive_config_builder_chaining() {
676        let config = AdaptiveConfig::default()
677            .with_threshold(2.0)
678            .with_min_rows(100)
679            .with_max_reoptimizations(10);
680        assert!((config.threshold - 2.0).abs() < f64::EPSILON);
681        assert_eq!(config.min_rows, 100);
682        assert_eq!(config.max_reoptimizations, 10);
683    }
684
685    // --- GraphModel tests ---
686
687    #[test]
688    fn test_graph_model_default_is_lpg() {
689        assert_eq!(GraphModel::default(), GraphModel::Lpg);
690    }
691
692    #[test]
693    fn test_graph_model_display() {
694        assert_eq!(GraphModel::Lpg.to_string(), "LPG");
695        assert_eq!(GraphModel::Rdf.to_string(), "RDF");
696    }
697
698    #[test]
699    fn test_config_with_graph_model() {
700        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
701        assert_eq!(config.graph_model, GraphModel::Rdf);
702    }
703
704    // --- DurabilityMode tests ---
705
706    #[test]
707    fn test_durability_mode_default_is_batch() {
708        let mode = DurabilityMode::default();
709        assert_eq!(
710            mode,
711            DurabilityMode::Batch {
712                max_delay_ms: 100,
713                max_records: 1000
714            }
715        );
716    }
717
718    #[test]
719    fn test_config_with_wal_durability() {
720        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Sync);
721        assert_eq!(config.wal_durability, DurabilityMode::Sync);
722    }
723
724    #[test]
725    fn test_config_with_wal_durability_nosync() {
726        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::NoSync);
727        assert_eq!(config.wal_durability, DurabilityMode::NoSync);
728    }
729
730    #[test]
731    fn test_config_with_wal_durability_adaptive() {
732        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Adaptive {
733            target_interval_ms: 50,
734        });
735        assert_eq!(
736            config.wal_durability,
737            DurabilityMode::Adaptive {
738                target_interval_ms: 50
739            }
740        );
741    }
742
743    // --- schema_constraints tests ---
744
745    #[test]
746    fn test_config_with_schema_constraints() {
747        let config = Config::in_memory().with_schema_constraints();
748        assert!(config.schema_constraints);
749    }
750
751    // --- query_timeout tests ---
752
753    #[test]
754    fn test_config_with_query_timeout() {
755        let config = Config::in_memory().with_query_timeout(Duration::from_secs(30));
756        assert_eq!(config.query_timeout, Some(Duration::from_secs(30)));
757    }
758
759    // --- gc_interval tests ---
760
761    #[test]
762    fn test_config_with_gc_interval() {
763        let config = Config::in_memory().with_gc_interval(50);
764        assert_eq!(config.gc_interval, 50);
765    }
766
767    #[test]
768    fn test_config_gc_disabled() {
769        let config = Config::in_memory().with_gc_interval(0);
770        assert_eq!(config.gc_interval, 0);
771    }
772
773    // --- validate() tests ---
774
775    #[test]
776    fn test_validate_default_config() {
777        assert!(Config::default().validate().is_ok());
778    }
779
780    #[test]
781    fn test_validate_in_memory_config() {
782        assert!(Config::in_memory().validate().is_ok());
783    }
784
785    #[test]
786    fn test_validate_rejects_zero_memory_limit() {
787        let config = Config::in_memory().with_memory_limit(0);
788        assert_eq!(config.validate(), Err(ConfigError::ZeroMemoryLimit));
789    }
790
791    #[test]
792    fn test_validate_rejects_zero_threads() {
793        let config = Config::in_memory().with_threads(0);
794        assert_eq!(config.validate(), Err(ConfigError::ZeroThreads));
795    }
796
797    #[test]
798    fn test_validate_rejects_zero_wal_flush_interval() {
799        let mut config = Config::in_memory();
800        config.wal_flush_interval_ms = 0;
801        assert_eq!(config.validate(), Err(ConfigError::ZeroWalFlushInterval));
802    }
803
804    #[cfg(not(feature = "rdf"))]
805    #[test]
806    fn test_validate_rejects_rdf_without_feature() {
807        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
808        assert_eq!(config.validate(), Err(ConfigError::RdfFeatureRequired));
809    }
810
811    #[test]
812    fn test_config_error_display() {
813        assert_eq!(
814            ConfigError::ZeroMemoryLimit.to_string(),
815            "memory_limit must be greater than zero"
816        );
817        assert_eq!(
818            ConfigError::ZeroThreads.to_string(),
819            "threads must be greater than zero"
820        );
821        assert_eq!(
822            ConfigError::ZeroWalFlushInterval.to_string(),
823            "wal_flush_interval_ms must be greater than zero"
824        );
825        assert_eq!(
826            ConfigError::RdfFeatureRequired.to_string(),
827            "RDF graph model requires the `rdf` feature flag to be enabled"
828        );
829    }
830
831    // --- Builder chaining with new fields ---
832
833    #[test]
834    fn test_config_full_builder_chaining() {
835        let config = Config::persistent("/tmp/db")
836            .with_graph_model(GraphModel::Lpg)
837            .with_memory_limit(512 * 1024 * 1024)
838            .with_threads(4)
839            .with_query_logging()
840            .with_wal_durability(DurabilityMode::Sync)
841            .with_schema_constraints()
842            .without_backward_edges()
843            .with_spill_path("/tmp/spill")
844            .with_query_timeout(Duration::from_secs(60));
845
846        assert_eq!(config.graph_model, GraphModel::Lpg);
847        assert!(config.path.is_some());
848        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
849        assert_eq!(config.threads, 4);
850        assert!(config.query_logging);
851        assert_eq!(config.wal_durability, DurabilityMode::Sync);
852        assert!(config.schema_constraints);
853        assert!(!config.backward_edges);
854        assert!(config.spill_path.is_some());
855        assert_eq!(config.query_timeout, Some(Duration::from_secs(60)));
856        assert!(config.validate().is_ok());
857    }
858
859    // --- AccessMode tests ---
860
861    #[test]
862    fn test_access_mode_default_is_read_write() {
863        assert_eq!(AccessMode::default(), AccessMode::ReadWrite);
864    }
865
866    #[test]
867    fn test_access_mode_display() {
868        assert_eq!(AccessMode::ReadWrite.to_string(), "read-write");
869        assert_eq!(AccessMode::ReadOnly.to_string(), "read-only");
870    }
871
872    #[test]
873    fn test_config_with_access_mode() {
874        let config = Config::persistent("/tmp/db").with_access_mode(AccessMode::ReadOnly);
875        assert_eq!(config.access_mode, AccessMode::ReadOnly);
876    }
877
878    #[test]
879    fn test_config_read_only() {
880        let config = Config::read_only("/tmp/db.grafeo");
881        assert_eq!(config.access_mode, AccessMode::ReadOnly);
882        assert!(config.path.is_some());
883        assert!(!config.wal_enabled);
884    }
885
886    #[test]
887    fn test_config_default_is_read_write() {
888        let config = Config::default();
889        assert_eq!(config.access_mode, AccessMode::ReadWrite);
890    }
891}