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    /// Whether CDC (Change Data Capture) is enabled for new sessions by default.
232    ///
233    /// When `true`, sessions created via [`crate::GrafeoDB::session()`]
234    /// automatically track all mutations. Individual sessions can override
235    /// this via [`crate::GrafeoDB::session_with_cdc()`]. The `cdc` feature
236    /// flag must be compiled in for CDC to function; this field only controls
237    /// runtime activation.
238    ///
239    /// Default: `false` (CDC is opt-in to avoid overhead on the mutation
240    /// hot path).
241    pub cdc_enabled: bool,
242}
243
244/// Configuration for adaptive query execution.
245///
246/// Adaptive execution monitors actual row counts during query processing and
247/// can trigger re-optimization when estimates are significantly wrong.
248#[derive(Debug, Clone)]
249pub struct AdaptiveConfig {
250    /// Whether adaptive execution is enabled.
251    pub enabled: bool,
252
253    /// Deviation threshold that triggers re-optimization.
254    ///
255    /// A value of 3.0 means re-optimization is triggered when actual cardinality
256    /// is more than 3x or less than 1/3x the estimated value.
257    pub threshold: f64,
258
259    /// Minimum number of rows before considering re-optimization.
260    ///
261    /// Helps avoid thrashing on small result sets.
262    pub min_rows: u64,
263
264    /// Maximum number of re-optimizations allowed per query.
265    pub max_reoptimizations: usize,
266}
267
268impl Default for AdaptiveConfig {
269    fn default() -> Self {
270        Self {
271            enabled: true,
272            threshold: 3.0,
273            min_rows: 1000,
274            max_reoptimizations: 3,
275        }
276    }
277}
278
279impl AdaptiveConfig {
280    /// Creates a disabled adaptive config.
281    #[must_use]
282    pub fn disabled() -> Self {
283        Self {
284            enabled: false,
285            ..Default::default()
286        }
287    }
288
289    /// Sets the deviation threshold.
290    #[must_use]
291    pub fn with_threshold(mut self, threshold: f64) -> Self {
292        self.threshold = threshold;
293        self
294    }
295
296    /// Sets the minimum rows before re-optimization.
297    #[must_use]
298    pub fn with_min_rows(mut self, min_rows: u64) -> Self {
299        self.min_rows = min_rows;
300        self
301    }
302
303    /// Sets the maximum number of re-optimizations.
304    #[must_use]
305    pub fn with_max_reoptimizations(mut self, max: usize) -> Self {
306        self.max_reoptimizations = max;
307        self
308    }
309}
310
311impl Default for Config {
312    fn default() -> Self {
313        Self {
314            graph_model: GraphModel::default(),
315            path: None,
316            memory_limit: None,
317            spill_path: None,
318            threads: num_cpus::get(),
319            wal_enabled: true,
320            wal_flush_interval_ms: 100,
321            backward_edges: true,
322            query_logging: false,
323            adaptive: AdaptiveConfig::default(),
324            factorized_execution: true,
325            wal_durability: DurabilityMode::default(),
326            storage_format: StorageFormat::default(),
327            schema_constraints: false,
328            query_timeout: None,
329            gc_interval: 100,
330            access_mode: AccessMode::default(),
331            cdc_enabled: false,
332        }
333    }
334}
335
336impl Config {
337    /// Creates a new configuration for an in-memory database.
338    #[must_use]
339    pub fn in_memory() -> Self {
340        Self {
341            path: None,
342            wal_enabled: false,
343            ..Default::default()
344        }
345    }
346
347    /// Creates a new configuration for a persistent database.
348    #[must_use]
349    pub fn persistent(path: impl Into<PathBuf>) -> Self {
350        Self {
351            path: Some(path.into()),
352            wal_enabled: true,
353            ..Default::default()
354        }
355    }
356
357    /// Sets the memory limit.
358    #[must_use]
359    pub fn with_memory_limit(mut self, limit: usize) -> Self {
360        self.memory_limit = Some(limit);
361        self
362    }
363
364    /// Sets the number of worker threads.
365    #[must_use]
366    pub fn with_threads(mut self, threads: usize) -> Self {
367        self.threads = threads;
368        self
369    }
370
371    /// Disables backward edges.
372    #[must_use]
373    pub fn without_backward_edges(mut self) -> Self {
374        self.backward_edges = false;
375        self
376    }
377
378    /// Enables query logging.
379    #[must_use]
380    pub fn with_query_logging(mut self) -> Self {
381        self.query_logging = true;
382        self
383    }
384
385    /// Sets the memory budget as a fraction of system RAM.
386    #[must_use]
387    pub fn with_memory_fraction(mut self, fraction: f64) -> Self {
388        use grafeo_common::memory::buffer::BufferManagerConfig;
389        let system_memory = BufferManagerConfig::detect_system_memory();
390        self.memory_limit = Some((system_memory as f64 * fraction) as usize);
391        self
392    }
393
394    /// Sets the spill directory for out-of-core processing.
395    #[must_use]
396    pub fn with_spill_path(mut self, path: impl Into<PathBuf>) -> Self {
397        self.spill_path = Some(path.into());
398        self
399    }
400
401    /// Sets the adaptive execution configuration.
402    #[must_use]
403    pub fn with_adaptive(mut self, adaptive: AdaptiveConfig) -> Self {
404        self.adaptive = adaptive;
405        self
406    }
407
408    /// Disables adaptive execution.
409    #[must_use]
410    pub fn without_adaptive(mut self) -> Self {
411        self.adaptive.enabled = false;
412        self
413    }
414
415    /// Disables factorized execution for multi-hop queries.
416    ///
417    /// This reverts to the traditional flat execution model where each expansion
418    /// creates a full Cartesian product. Only use this if you encounter issues
419    /// with factorized execution.
420    #[must_use]
421    pub fn without_factorized_execution(mut self) -> Self {
422        self.factorized_execution = false;
423        self
424    }
425
426    /// Sets the graph data model.
427    #[must_use]
428    pub fn with_graph_model(mut self, model: GraphModel) -> Self {
429        self.graph_model = model;
430        self
431    }
432
433    /// Sets the WAL durability mode.
434    #[must_use]
435    pub fn with_wal_durability(mut self, mode: DurabilityMode) -> Self {
436        self.wal_durability = mode;
437        self
438    }
439
440    /// Sets the storage format for persistent databases.
441    #[must_use]
442    pub fn with_storage_format(mut self, format: StorageFormat) -> Self {
443        self.storage_format = format;
444        self
445    }
446
447    /// Enables catalog schema constraint enforcement.
448    #[must_use]
449    pub fn with_schema_constraints(mut self) -> Self {
450        self.schema_constraints = true;
451        self
452    }
453
454    /// Sets the maximum time a query may run before being cancelled.
455    #[must_use]
456    pub fn with_query_timeout(mut self, timeout: Duration) -> Self {
457        self.query_timeout = Some(timeout);
458        self
459    }
460
461    /// Sets the MVCC garbage collection interval (every N commits).
462    ///
463    /// Set to 0 to disable automatic GC.
464    #[must_use]
465    pub fn with_gc_interval(mut self, interval: usize) -> Self {
466        self.gc_interval = interval;
467        self
468    }
469
470    /// Sets the access mode (read-write or read-only).
471    #[must_use]
472    pub fn with_access_mode(mut self, mode: AccessMode) -> Self {
473        self.access_mode = mode;
474        self
475    }
476
477    /// Shorthand for opening a persistent database in read-only mode.
478    ///
479    /// Uses a shared file lock, allowing multiple processes to read the same
480    /// `.grafeo` file concurrently. Mutations are rejected at the session level.
481    #[must_use]
482    pub fn read_only(path: impl Into<PathBuf>) -> Self {
483        Self {
484            path: Some(path.into()),
485            wal_enabled: false,
486            access_mode: AccessMode::ReadOnly,
487            ..Default::default()
488        }
489    }
490
491    /// Enables CDC (Change Data Capture) for all new sessions by default.
492    ///
493    /// Sessions created via [`crate::GrafeoDB::session()`] will automatically
494    /// track mutations. Individual sessions can still opt out via
495    /// [`crate::GrafeoDB::session_with_cdc()`].
496    ///
497    /// Requires the `cdc` feature flag to be compiled in.
498    #[must_use]
499    pub fn with_cdc(mut self) -> Self {
500        self.cdc_enabled = true;
501        self
502    }
503
504    /// Validates the configuration, returning an error for invalid combinations.
505    ///
506    /// Called automatically by [`GrafeoDB::with_config()`](crate::GrafeoDB::with_config).
507    ///
508    /// # Errors
509    ///
510    /// Returns [`ConfigError`] if any setting is invalid.
511    pub fn validate(&self) -> std::result::Result<(), ConfigError> {
512        if let Some(limit) = self.memory_limit
513            && limit == 0
514        {
515            return Err(ConfigError::ZeroMemoryLimit);
516        }
517
518        if self.threads == 0 {
519            return Err(ConfigError::ZeroThreads);
520        }
521
522        if self.wal_flush_interval_ms == 0 {
523            return Err(ConfigError::ZeroWalFlushInterval);
524        }
525
526        #[cfg(not(feature = "rdf"))]
527        if self.graph_model == GraphModel::Rdf {
528            return Err(ConfigError::RdfFeatureRequired);
529        }
530
531        Ok(())
532    }
533}
534
535/// Helper function to get CPU count (fallback implementation).
536mod num_cpus {
537    #[cfg(not(target_arch = "wasm32"))]
538    pub fn get() -> usize {
539        std::thread::available_parallelism()
540            .map(|n| n.get())
541            .unwrap_or(4)
542    }
543
544    #[cfg(target_arch = "wasm32")]
545    pub fn get() -> usize {
546        1
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn test_config_default() {
556        let config = Config::default();
557        assert_eq!(config.graph_model, GraphModel::Lpg);
558        assert!(config.path.is_none());
559        assert!(config.memory_limit.is_none());
560        assert!(config.spill_path.is_none());
561        assert!(config.threads > 0);
562        assert!(config.wal_enabled);
563        assert_eq!(config.wal_flush_interval_ms, 100);
564        assert!(config.backward_edges);
565        assert!(!config.query_logging);
566        assert!(config.factorized_execution);
567        assert_eq!(config.wal_durability, DurabilityMode::default());
568        assert!(!config.schema_constraints);
569        assert!(config.query_timeout.is_none());
570        assert_eq!(config.gc_interval, 100);
571    }
572
573    #[test]
574    fn test_config_in_memory() {
575        let config = Config::in_memory();
576        assert!(config.path.is_none());
577        assert!(!config.wal_enabled);
578        assert!(config.backward_edges);
579    }
580
581    #[test]
582    fn test_config_persistent() {
583        let config = Config::persistent("/tmp/test_db");
584        assert_eq!(
585            config.path.as_deref(),
586            Some(std::path::Path::new("/tmp/test_db"))
587        );
588        assert!(config.wal_enabled);
589    }
590
591    #[test]
592    fn test_config_with_memory_limit() {
593        let config = Config::in_memory().with_memory_limit(1024 * 1024);
594        assert_eq!(config.memory_limit, Some(1024 * 1024));
595    }
596
597    #[test]
598    fn test_config_with_threads() {
599        let config = Config::in_memory().with_threads(8);
600        assert_eq!(config.threads, 8);
601    }
602
603    #[test]
604    fn test_config_without_backward_edges() {
605        let config = Config::in_memory().without_backward_edges();
606        assert!(!config.backward_edges);
607    }
608
609    #[test]
610    fn test_config_with_query_logging() {
611        let config = Config::in_memory().with_query_logging();
612        assert!(config.query_logging);
613    }
614
615    #[test]
616    fn test_config_with_spill_path() {
617        let config = Config::in_memory().with_spill_path("/tmp/spill");
618        assert_eq!(
619            config.spill_path.as_deref(),
620            Some(std::path::Path::new("/tmp/spill"))
621        );
622    }
623
624    #[test]
625    fn test_config_with_memory_fraction() {
626        let config = Config::in_memory().with_memory_fraction(0.5);
627        assert!(config.memory_limit.is_some());
628        assert!(config.memory_limit.unwrap() > 0);
629    }
630
631    #[test]
632    fn test_config_with_adaptive() {
633        let adaptive = AdaptiveConfig::default().with_threshold(5.0);
634        let config = Config::in_memory().with_adaptive(adaptive);
635        assert!((config.adaptive.threshold - 5.0).abs() < f64::EPSILON);
636    }
637
638    #[test]
639    fn test_config_without_adaptive() {
640        let config = Config::in_memory().without_adaptive();
641        assert!(!config.adaptive.enabled);
642    }
643
644    #[test]
645    fn test_config_without_factorized_execution() {
646        let config = Config::in_memory().without_factorized_execution();
647        assert!(!config.factorized_execution);
648    }
649
650    #[test]
651    fn test_config_builder_chaining() {
652        let config = Config::persistent("/tmp/db")
653            .with_memory_limit(512 * 1024 * 1024)
654            .with_threads(4)
655            .with_query_logging()
656            .without_backward_edges()
657            .with_spill_path("/tmp/spill");
658
659        assert!(config.path.is_some());
660        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
661        assert_eq!(config.threads, 4);
662        assert!(config.query_logging);
663        assert!(!config.backward_edges);
664        assert!(config.spill_path.is_some());
665    }
666
667    #[test]
668    fn test_adaptive_config_default() {
669        let config = AdaptiveConfig::default();
670        assert!(config.enabled);
671        assert!((config.threshold - 3.0).abs() < f64::EPSILON);
672        assert_eq!(config.min_rows, 1000);
673        assert_eq!(config.max_reoptimizations, 3);
674    }
675
676    #[test]
677    fn test_adaptive_config_disabled() {
678        let config = AdaptiveConfig::disabled();
679        assert!(!config.enabled);
680    }
681
682    #[test]
683    fn test_adaptive_config_with_threshold() {
684        let config = AdaptiveConfig::default().with_threshold(10.0);
685        assert!((config.threshold - 10.0).abs() < f64::EPSILON);
686    }
687
688    #[test]
689    fn test_adaptive_config_with_min_rows() {
690        let config = AdaptiveConfig::default().with_min_rows(500);
691        assert_eq!(config.min_rows, 500);
692    }
693
694    #[test]
695    fn test_adaptive_config_with_max_reoptimizations() {
696        let config = AdaptiveConfig::default().with_max_reoptimizations(5);
697        assert_eq!(config.max_reoptimizations, 5);
698    }
699
700    #[test]
701    fn test_adaptive_config_builder_chaining() {
702        let config = AdaptiveConfig::default()
703            .with_threshold(2.0)
704            .with_min_rows(100)
705            .with_max_reoptimizations(10);
706        assert!((config.threshold - 2.0).abs() < f64::EPSILON);
707        assert_eq!(config.min_rows, 100);
708        assert_eq!(config.max_reoptimizations, 10);
709    }
710
711    // --- GraphModel tests ---
712
713    #[test]
714    fn test_graph_model_default_is_lpg() {
715        assert_eq!(GraphModel::default(), GraphModel::Lpg);
716    }
717
718    #[test]
719    fn test_graph_model_display() {
720        assert_eq!(GraphModel::Lpg.to_string(), "LPG");
721        assert_eq!(GraphModel::Rdf.to_string(), "RDF");
722    }
723
724    #[test]
725    fn test_config_with_graph_model() {
726        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
727        assert_eq!(config.graph_model, GraphModel::Rdf);
728    }
729
730    // --- DurabilityMode tests ---
731
732    #[test]
733    fn test_durability_mode_default_is_batch() {
734        let mode = DurabilityMode::default();
735        assert_eq!(
736            mode,
737            DurabilityMode::Batch {
738                max_delay_ms: 100,
739                max_records: 1000
740            }
741        );
742    }
743
744    #[test]
745    fn test_config_with_wal_durability() {
746        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Sync);
747        assert_eq!(config.wal_durability, DurabilityMode::Sync);
748    }
749
750    #[test]
751    fn test_config_with_wal_durability_nosync() {
752        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::NoSync);
753        assert_eq!(config.wal_durability, DurabilityMode::NoSync);
754    }
755
756    #[test]
757    fn test_config_with_wal_durability_adaptive() {
758        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Adaptive {
759            target_interval_ms: 50,
760        });
761        assert_eq!(
762            config.wal_durability,
763            DurabilityMode::Adaptive {
764                target_interval_ms: 50
765            }
766        );
767    }
768
769    // --- schema_constraints tests ---
770
771    #[test]
772    fn test_config_with_schema_constraints() {
773        let config = Config::in_memory().with_schema_constraints();
774        assert!(config.schema_constraints);
775    }
776
777    // --- query_timeout tests ---
778
779    #[test]
780    fn test_config_with_query_timeout() {
781        let config = Config::in_memory().with_query_timeout(Duration::from_secs(30));
782        assert_eq!(config.query_timeout, Some(Duration::from_secs(30)));
783    }
784
785    // --- gc_interval tests ---
786
787    #[test]
788    fn test_config_with_gc_interval() {
789        let config = Config::in_memory().with_gc_interval(50);
790        assert_eq!(config.gc_interval, 50);
791    }
792
793    #[test]
794    fn test_config_gc_disabled() {
795        let config = Config::in_memory().with_gc_interval(0);
796        assert_eq!(config.gc_interval, 0);
797    }
798
799    // --- validate() tests ---
800
801    #[test]
802    fn test_validate_default_config() {
803        assert!(Config::default().validate().is_ok());
804    }
805
806    #[test]
807    fn test_validate_in_memory_config() {
808        assert!(Config::in_memory().validate().is_ok());
809    }
810
811    #[test]
812    fn test_validate_rejects_zero_memory_limit() {
813        let config = Config::in_memory().with_memory_limit(0);
814        assert_eq!(config.validate(), Err(ConfigError::ZeroMemoryLimit));
815    }
816
817    #[test]
818    fn test_validate_rejects_zero_threads() {
819        let config = Config::in_memory().with_threads(0);
820        assert_eq!(config.validate(), Err(ConfigError::ZeroThreads));
821    }
822
823    #[test]
824    fn test_validate_rejects_zero_wal_flush_interval() {
825        let mut config = Config::in_memory();
826        config.wal_flush_interval_ms = 0;
827        assert_eq!(config.validate(), Err(ConfigError::ZeroWalFlushInterval));
828    }
829
830    #[cfg(not(feature = "rdf"))]
831    #[test]
832    fn test_validate_rejects_rdf_without_feature() {
833        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
834        assert_eq!(config.validate(), Err(ConfigError::RdfFeatureRequired));
835    }
836
837    #[test]
838    fn test_config_error_display() {
839        assert_eq!(
840            ConfigError::ZeroMemoryLimit.to_string(),
841            "memory_limit must be greater than zero"
842        );
843        assert_eq!(
844            ConfigError::ZeroThreads.to_string(),
845            "threads must be greater than zero"
846        );
847        assert_eq!(
848            ConfigError::ZeroWalFlushInterval.to_string(),
849            "wal_flush_interval_ms must be greater than zero"
850        );
851        assert_eq!(
852            ConfigError::RdfFeatureRequired.to_string(),
853            "RDF graph model requires the `rdf` feature flag to be enabled"
854        );
855    }
856
857    // --- Builder chaining with new fields ---
858
859    #[test]
860    fn test_config_full_builder_chaining() {
861        let config = Config::persistent("/tmp/db")
862            .with_graph_model(GraphModel::Lpg)
863            .with_memory_limit(512 * 1024 * 1024)
864            .with_threads(4)
865            .with_query_logging()
866            .with_wal_durability(DurabilityMode::Sync)
867            .with_schema_constraints()
868            .without_backward_edges()
869            .with_spill_path("/tmp/spill")
870            .with_query_timeout(Duration::from_secs(60));
871
872        assert_eq!(config.graph_model, GraphModel::Lpg);
873        assert!(config.path.is_some());
874        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
875        assert_eq!(config.threads, 4);
876        assert!(config.query_logging);
877        assert_eq!(config.wal_durability, DurabilityMode::Sync);
878        assert!(config.schema_constraints);
879        assert!(!config.backward_edges);
880        assert!(config.spill_path.is_some());
881        assert_eq!(config.query_timeout, Some(Duration::from_secs(60)));
882        assert!(config.validate().is_ok());
883    }
884
885    // --- AccessMode tests ---
886
887    #[test]
888    fn test_access_mode_default_is_read_write() {
889        assert_eq!(AccessMode::default(), AccessMode::ReadWrite);
890    }
891
892    #[test]
893    fn test_access_mode_display() {
894        assert_eq!(AccessMode::ReadWrite.to_string(), "read-write");
895        assert_eq!(AccessMode::ReadOnly.to_string(), "read-only");
896    }
897
898    #[test]
899    fn test_config_with_access_mode() {
900        let config = Config::persistent("/tmp/db").with_access_mode(AccessMode::ReadOnly);
901        assert_eq!(config.access_mode, AccessMode::ReadOnly);
902    }
903
904    #[test]
905    fn test_config_read_only() {
906        let config = Config::read_only("/tmp/db.grafeo");
907        assert_eq!(config.access_mode, AccessMode::ReadOnly);
908        assert!(config.path.is_some());
909        assert!(!config.wal_enabled);
910    }
911
912    #[test]
913    fn test_config_default_is_read_write() {
914        let config = Config::default();
915        assert_eq!(config.access_mode, AccessMode::ReadWrite);
916    }
917}