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