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/// Encryption-at-rest configuration.
8///
9/// Provides the key chain that derives per-component data encryption keys (DEKs)
10/// from a master encryption key (ME) via HKDF-SHA256. Each storage component
11/// (WAL, sections, vector pages) gets its own DEK.
12///
13/// Wrapped in `Arc` internally so `Config` can remain `Clone` without
14/// duplicating key material.
15#[cfg(feature = "encryption")]
16#[derive(Clone)]
17pub struct EncryptionConfig {
18    /// The key chain that derives per-component encryption keys.
19    /// Shared via Arc so Config can be cloned.
20    pub key_chain: std::sync::Arc<grafeo_common::encryption::KeyChain>,
21}
22
23#[cfg(feature = "encryption")]
24impl fmt::Debug for EncryptionConfig {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.debug_struct("EncryptionConfig")
27            .field("key_chain", &"[redacted]")
28            .finish()
29    }
30}
31
32/// The graph data model for a database.
33///
34/// Each database uses exactly one model, chosen at creation time and immutable
35/// after that. The engine initializes only the relevant store, saving memory.
36///
37/// Schema variants (OWL, RDFS, JSON Schema) are a server-level concern - from
38/// the engine's perspective those map to either `Lpg` or `Rdf`.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
40#[non_exhaustive]
41pub enum GraphModel {
42    /// Labeled Property Graph (default). Supports GQL, Cypher, Gremlin, GraphQL.
43    #[default]
44    Lpg,
45    /// RDF triple store. Supports SPARQL.
46    Rdf,
47}
48
49impl fmt::Display for GraphModel {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::Lpg => write!(f, "LPG"),
53            Self::Rdf => write!(f, "RDF"),
54        }
55    }
56}
57
58/// Access mode for opening a database.
59///
60/// Controls whether the database is opened for full read-write access
61/// (the default) or read-only access. Read-only mode uses a shared file
62/// lock, allowing multiple processes to read the same `.grafeo` file
63/// concurrently.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
65#[non_exhaustive]
66pub enum AccessMode {
67    /// Full read-write access (default). Acquires an exclusive file lock.
68    #[default]
69    ReadWrite,
70    /// Read-only access. Acquires a shared file lock, allowing concurrent
71    /// readers. The database loads the last checkpoint snapshot but does not
72    /// replay the WAL or allow mutations.
73    ReadOnly,
74}
75
76impl fmt::Display for AccessMode {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::ReadWrite => write!(f, "read-write"),
80            Self::ReadOnly => write!(f, "read-only"),
81        }
82    }
83}
84
85/// Storage format for persistent databases.
86///
87/// Controls whether the database uses a single `.grafeo` file or a legacy
88/// WAL directory. The default (`Auto`) auto-detects based on the path:
89/// files ending in `.grafeo` use single-file format, directories use WAL.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
91#[non_exhaustive]
92pub enum StorageFormat {
93    /// Auto-detect based on path: `.grafeo` extension = single file,
94    /// existing directory = WAL directory, new path without extension = WAL directory.
95    #[default]
96    Auto,
97    /// Legacy WAL directory format (directory with `wal/` subdirectory).
98    WalDirectory,
99    /// Single `.grafeo` file with a sidecar `.grafeo.wal/` directory during operation.
100    /// At rest (after checkpoint), only the `.grafeo` file exists.
101    SingleFile,
102}
103
104impl fmt::Display for StorageFormat {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            Self::Auto => write!(f, "auto"),
108            Self::WalDirectory => write!(f, "wal-directory"),
109            Self::SingleFile => write!(f, "single-file"),
110        }
111    }
112}
113
114/// WAL durability mode controlling the trade-off between safety and speed.
115///
116/// This enum lives in config so that `Config` can always carry the desired
117/// durability regardless of whether the `wal` feature is compiled in. When
118/// WAL is enabled, the engine maps this to the adapter-level durability mode.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120#[non_exhaustive]
121pub enum DurabilityMode {
122    /// Fsync after every commit. Slowest but safest.
123    Sync,
124    /// Batch fsync periodically. Good balance of performance and durability.
125    Batch {
126        /// Maximum time between syncs in milliseconds.
127        max_delay_ms: u64,
128        /// Maximum records between syncs.
129        max_records: u64,
130    },
131    /// Adaptive sync via a background flusher thread.
132    Adaptive {
133        /// Target interval between flushes in milliseconds.
134        target_interval_ms: u64,
135    },
136    /// No sync - rely on OS buffer flushing. Fastest but may lose recent data.
137    NoSync,
138}
139
140impl Default for DurabilityMode {
141    fn default() -> Self {
142        Self::Batch {
143            max_delay_ms: 100,
144            max_records: 1000,
145        }
146    }
147}
148
149/// Errors from [`Config::validate()`].
150#[derive(Debug, Clone, PartialEq, Eq)]
151#[non_exhaustive]
152pub enum ConfigError {
153    /// Memory limit must be greater than zero.
154    ZeroMemoryLimit,
155    /// Thread count must be greater than zero.
156    ZeroThreads,
157    /// WAL flush interval must be greater than zero.
158    ZeroWalFlushInterval,
159    /// RDF graph model requires the `rdf` feature flag.
160    RdfFeatureRequired,
161}
162
163impl fmt::Display for ConfigError {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        match self {
166            Self::ZeroMemoryLimit => write!(f, "memory_limit must be greater than zero"),
167            Self::ZeroThreads => write!(f, "threads must be greater than zero"),
168            Self::ZeroWalFlushInterval => {
169                write!(f, "wal_flush_interval_ms must be greater than zero")
170            }
171            Self::RdfFeatureRequired => {
172                write!(
173                    f,
174                    "RDF graph model requires the `rdf` feature flag to be enabled"
175                )
176            }
177        }
178    }
179}
180
181impl std::error::Error for ConfigError {}
182
183/// Database configuration.
184#[derive(Debug, Clone)]
185#[allow(clippy::struct_excessive_bools)] // Config structs naturally have many boolean flags
186pub struct Config {
187    /// Graph data model (LPG or RDF). Immutable after database creation.
188    pub graph_model: GraphModel,
189    /// Path to the database directory (None for in-memory only).
190    pub path: Option<PathBuf>,
191
192    /// Memory limit in bytes (None for unlimited).
193    pub memory_limit: Option<usize>,
194
195    /// Path for spilling data to disk under memory pressure.
196    pub spill_path: Option<PathBuf>,
197
198    /// Number of worker threads for query execution.
199    pub threads: usize,
200
201    /// Whether to enable WAL for durability.
202    pub wal_enabled: bool,
203
204    /// WAL flush interval in milliseconds.
205    pub wal_flush_interval_ms: u64,
206
207    /// Whether to maintain backward edges.
208    pub backward_edges: bool,
209
210    /// Whether to enable query logging.
211    pub query_logging: bool,
212
213    /// Adaptive execution configuration.
214    pub adaptive: AdaptiveConfig,
215
216    /// Whether to use factorized execution for multi-hop queries.
217    ///
218    /// When enabled, consecutive MATCH expansions are executed using factorized
219    /// representation which avoids Cartesian product materialization. This provides
220    /// 5-100x speedup for multi-hop queries with high fan-out.
221    ///
222    /// Enabled by default.
223    pub factorized_execution: bool,
224
225    /// WAL durability mode. Only used when `wal_enabled` is true.
226    pub wal_durability: DurabilityMode,
227
228    /// Storage format for persistent databases.
229    ///
230    /// `Auto` (default) detects the format from the path: `.grafeo` extension
231    /// uses single-file format, directories use the legacy WAL directory.
232    pub storage_format: StorageFormat,
233
234    /// Whether to enable catalog schema constraint enforcement.
235    ///
236    /// When true, the catalog enforces label, edge type, and property constraints
237    /// (e.g. required properties, uniqueness). The server sets this for JSON
238    /// Schema databases and populates constraints after creation.
239    pub schema_constraints: bool,
240
241    /// Maximum time a single query may run before being cancelled.
242    ///
243    /// When set, the executor checks the deadline between operator batches and
244    /// returns `QueryError::timeout()` if the wall-clock limit is exceeded.
245    /// `None` means no timeout (queries may run indefinitely).
246    ///
247    /// Default: 30 seconds. Use `with_query_timeout()` to change or
248    /// `without_query_timeout()` to disable.
249    pub query_timeout: Option<Duration>,
250
251    /// Maximum size in bytes for a single property value.
252    ///
253    /// When set, `set_node_property()` and `set_edge_property()` reject
254    /// values whose `estimated_size_bytes()` exceeds this limit.
255    /// `None` means no limit (any size is accepted).
256    ///
257    /// Default: 16 MiB. Use `with_max_property_size()` to change or
258    /// `without_max_property_size()` to disable.
259    pub max_property_size: Option<usize>,
260
261    /// Run MVCC version garbage collection every N commits.
262    ///
263    /// Old versions that are no longer visible to any active transaction are
264    /// pruned to reclaim memory. Set to 0 to disable automatic GC.
265    pub gc_interval: usize,
266
267    /// Access mode: read-write (default) or read-only.
268    ///
269    /// Read-only mode uses a shared file lock, allowing multiple processes to
270    /// read the same database concurrently. Mutations are rejected at the
271    /// session level.
272    pub access_mode: AccessMode,
273
274    /// Whether CDC (Change Data Capture) is enabled for new sessions by default.
275    ///
276    /// When `true`, sessions created via [`crate::GrafeoDB::session()`]
277    /// automatically track all mutations. Individual sessions can override
278    /// this via [`crate::GrafeoDB::session_with_cdc()`]. The `cdc` feature
279    /// flag must be compiled in for CDC to function; this field only controls
280    /// runtime activation.
281    ///
282    /// Default: `false` (CDC is opt-in to avoid overhead on the mutation
283    /// hot path).
284    pub cdc_enabled: bool,
285
286    /// CDC event retention policy.
287    ///
288    /// Controls how many events the CDC log retains in memory. By default,
289    /// retains up to 1,000 epochs and 100,000 events. Set to unlimited
290    /// (`max_epochs: None, max_events: None`) to disable pruning, but
291    /// beware of unbounded memory growth on long-running instances.
292    #[cfg(feature = "cdc")]
293    pub cdc_retention: crate::cdc::CdcRetentionConfig,
294
295    /// Per-section memory configuration.
296    ///
297    /// Maps `SectionType` to `SectionMemoryConfig` for sections that need
298    /// custom budgets or tier pinning. Sections not listed here use the
299    /// global `memory_limit` budget with automatic management.
300    pub section_configs: hashbrown::HashMap<
301        grafeo_common::storage::SectionType,
302        grafeo_common::storage::SectionMemoryConfig,
303    >,
304
305    /// Interval between automatic checkpoints.
306    ///
307    /// When set, the engine periodically flushes dirty sections to the
308    /// `.grafeo` container and truncates the WAL. `None` means checkpoints
309    /// only happen on explicit `wal_checkpoint()` or database close.
310    pub checkpoint_interval: Option<Duration>,
311
312    /// Encryption configuration.
313    ///
314    /// When set, all data written to disk (WAL records, sections, snapshots) is
315    /// encrypted with AES-256-GCM. The key chain derives per-component keys from
316    /// a master encryption key via HKDF-SHA256.
317    ///
318    /// Requires the `encryption` feature flag. Without it, this field is ignored.
319    #[cfg(feature = "encryption")]
320    pub encryption: Option<EncryptionConfig>,
321}
322
323/// Configuration for adaptive query execution.
324///
325/// Adaptive execution monitors actual row counts during query processing and
326/// can trigger re-optimization when estimates are significantly wrong.
327#[derive(Debug, Clone)]
328pub struct AdaptiveConfig {
329    /// Whether adaptive execution is enabled.
330    pub enabled: bool,
331
332    /// Deviation threshold that triggers re-optimization.
333    ///
334    /// A value of 3.0 means re-optimization is triggered when actual cardinality
335    /// is more than 3x or less than 1/3x the estimated value.
336    pub threshold: f64,
337
338    /// Minimum number of rows before considering re-optimization.
339    ///
340    /// Helps avoid thrashing on small result sets.
341    pub min_rows: u64,
342
343    /// Maximum number of re-optimizations allowed per query.
344    pub max_reoptimizations: usize,
345}
346
347impl Default for AdaptiveConfig {
348    fn default() -> Self {
349        Self {
350            enabled: true,
351            threshold: 3.0,
352            min_rows: 1000,
353            max_reoptimizations: 3,
354        }
355    }
356}
357
358impl AdaptiveConfig {
359    /// Creates a disabled adaptive config.
360    #[must_use]
361    pub fn disabled() -> Self {
362        Self {
363            enabled: false,
364            ..Default::default()
365        }
366    }
367
368    /// Sets the deviation threshold.
369    #[must_use]
370    pub fn with_threshold(mut self, threshold: f64) -> Self {
371        self.threshold = threshold;
372        self
373    }
374
375    /// Sets the minimum rows before re-optimization.
376    #[must_use]
377    pub fn with_min_rows(mut self, min_rows: u64) -> Self {
378        self.min_rows = min_rows;
379        self
380    }
381
382    /// Sets the maximum number of re-optimizations.
383    #[must_use]
384    pub fn with_max_reoptimizations(mut self, max: usize) -> Self {
385        self.max_reoptimizations = max;
386        self
387    }
388}
389
390impl Default for Config {
391    fn default() -> Self {
392        Self {
393            graph_model: GraphModel::default(),
394            path: None,
395            memory_limit: None,
396            spill_path: None,
397            threads: num_cpus::get(),
398            wal_enabled: true,
399            wal_flush_interval_ms: 100,
400            backward_edges: true,
401            query_logging: false,
402            adaptive: AdaptiveConfig::default(),
403            factorized_execution: true,
404            wal_durability: DurabilityMode::default(),
405            storage_format: StorageFormat::default(),
406            schema_constraints: false,
407            query_timeout: Some(Duration::from_secs(30)),
408            max_property_size: Some(16 * 1024 * 1024), // 16 MiB
409            gc_interval: 100,
410            access_mode: AccessMode::default(),
411            cdc_enabled: false,
412            #[cfg(feature = "cdc")]
413            cdc_retention: crate::cdc::CdcRetentionConfig::default(),
414            section_configs: hashbrown::HashMap::new(),
415            checkpoint_interval: None,
416            #[cfg(feature = "encryption")]
417            encryption: None,
418        }
419    }
420}
421
422impl Config {
423    /// Creates a new configuration for an in-memory database.
424    #[must_use]
425    pub fn in_memory() -> Self {
426        Self {
427            path: None,
428            wal_enabled: false,
429            ..Default::default()
430        }
431    }
432
433    /// Creates a new configuration for a persistent database.
434    #[must_use]
435    pub fn persistent(path: impl Into<PathBuf>) -> Self {
436        Self {
437            path: Some(path.into()),
438            wal_enabled: true,
439            ..Default::default()
440        }
441    }
442
443    /// Sets the memory limit.
444    #[must_use]
445    pub fn with_memory_limit(mut self, limit: usize) -> Self {
446        self.memory_limit = Some(limit);
447        self
448    }
449
450    /// Sets the number of worker threads.
451    #[must_use]
452    pub fn with_threads(mut self, threads: usize) -> Self {
453        self.threads = threads;
454        self
455    }
456
457    /// Disables backward edges.
458    #[must_use]
459    pub fn without_backward_edges(mut self) -> Self {
460        self.backward_edges = false;
461        self
462    }
463
464    /// Enables query logging.
465    #[must_use]
466    pub fn with_query_logging(mut self) -> Self {
467        self.query_logging = true;
468        self
469    }
470
471    /// Sets the memory budget as a fraction of system RAM.
472    #[must_use]
473    pub fn with_memory_fraction(mut self, fraction: f64) -> Self {
474        use grafeo_common::memory::buffer::BufferManagerConfig;
475        let system_memory = BufferManagerConfig::detect_system_memory();
476        // reason: product of system RAM and a 0..1 fraction is always a valid positive usize
477        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
478        let budget = (system_memory as f64 * fraction) as usize;
479        self.memory_limit = Some(budget);
480        self
481    }
482
483    /// Sets the spill directory for out-of-core processing.
484    #[must_use]
485    pub fn with_spill_path(mut self, path: impl Into<PathBuf>) -> Self {
486        self.spill_path = Some(path.into());
487        self
488    }
489
490    /// Sets the adaptive execution configuration.
491    #[must_use]
492    pub fn with_adaptive(mut self, adaptive: AdaptiveConfig) -> Self {
493        self.adaptive = adaptive;
494        self
495    }
496
497    /// Disables adaptive execution.
498    #[must_use]
499    pub fn without_adaptive(mut self) -> Self {
500        self.adaptive.enabled = false;
501        self
502    }
503
504    /// Disables factorized execution for multi-hop queries.
505    ///
506    /// This reverts to the traditional flat execution model where each expansion
507    /// creates a full Cartesian product. Only use this if you encounter issues
508    /// with factorized execution.
509    #[must_use]
510    pub fn without_factorized_execution(mut self) -> Self {
511        self.factorized_execution = false;
512        self
513    }
514
515    /// Sets the graph data model.
516    #[must_use]
517    pub fn with_graph_model(mut self, model: GraphModel) -> Self {
518        self.graph_model = model;
519        self
520    }
521
522    /// Sets the WAL durability mode.
523    #[must_use]
524    pub fn with_wal_durability(mut self, mode: DurabilityMode) -> Self {
525        self.wal_durability = mode;
526        self
527    }
528
529    /// Sets the storage format for persistent databases.
530    #[must_use]
531    pub fn with_storage_format(mut self, format: StorageFormat) -> Self {
532        self.storage_format = format;
533        self
534    }
535
536    /// Enables catalog schema constraint enforcement.
537    #[must_use]
538    pub fn with_schema_constraints(mut self) -> Self {
539        self.schema_constraints = true;
540        self
541    }
542
543    /// Sets the maximum time a query may run before being cancelled.
544    #[must_use]
545    pub fn with_query_timeout(mut self, timeout: Duration) -> Self {
546        self.query_timeout = Some(timeout);
547        self
548    }
549
550    /// Disables the query timeout, allowing queries to run indefinitely.
551    #[must_use]
552    pub fn without_query_timeout(mut self) -> Self {
553        self.query_timeout = None;
554        self
555    }
556
557    /// Sets the maximum size in bytes for a single property value.
558    #[must_use]
559    pub fn with_max_property_size(mut self, size: usize) -> Self {
560        self.max_property_size = Some(size);
561        self
562    }
563
564    /// Disables the property value size limit.
565    #[must_use]
566    pub fn without_max_property_size(mut self) -> Self {
567        self.max_property_size = None;
568        self
569    }
570
571    /// Sets the MVCC garbage collection interval (every N commits).
572    ///
573    /// Set to 0 to disable automatic GC.
574    #[must_use]
575    pub fn with_gc_interval(mut self, interval: usize) -> Self {
576        self.gc_interval = interval;
577        self
578    }
579
580    /// Sets the access mode (read-write or read-only).
581    #[must_use]
582    pub fn with_access_mode(mut self, mode: AccessMode) -> Self {
583        self.access_mode = mode;
584        self
585    }
586
587    /// Shorthand for opening a persistent database in read-only mode.
588    ///
589    /// Uses a shared file lock, allowing multiple processes to read the same
590    /// `.grafeo` file concurrently. Mutations are rejected at the session level.
591    #[must_use]
592    pub fn read_only(path: impl Into<PathBuf>) -> Self {
593        Self {
594            path: Some(path.into()),
595            wal_enabled: false,
596            access_mode: AccessMode::ReadOnly,
597            ..Default::default()
598        }
599    }
600
601    /// Enables CDC (Change Data Capture) for all new sessions by default.
602    ///
603    /// Sessions created via [`crate::GrafeoDB::session()`] will automatically
604    /// track mutations. Individual sessions can still opt out via
605    /// [`crate::GrafeoDB::session_with_cdc()`].
606    ///
607    /// Requires the `cdc` feature flag to be compiled in.
608    #[must_use]
609    pub fn with_cdc(mut self) -> Self {
610        self.cdc_enabled = true;
611        self
612    }
613
614    /// Sets memory configuration for a specific section type.
615    ///
616    /// Use this to cap a section's RAM usage or pin it to a storage tier.
617    /// Sections without explicit config use the global `memory_limit` budget.
618    ///
619    /// # Examples
620    ///
621    /// ```
622    /// # use grafeo_engine::Config;
623    /// use grafeo_common::storage::{SectionType, SectionMemoryConfig, TierOverride};
624    ///
625    /// let config = Config::in_memory()
626    ///     .with_section_config(SectionType::VectorStore, SectionMemoryConfig {
627    ///         max_ram: Some(500 * 1024 * 1024), // 500 MB cap
628    ///         tier: TierOverride::Auto,
629    ///     });
630    /// ```
631    #[must_use]
632    pub fn with_section_config(
633        mut self,
634        section_type: grafeo_common::storage::SectionType,
635        config: grafeo_common::storage::SectionMemoryConfig,
636    ) -> Self {
637        self.section_configs.insert(section_type, config);
638        self
639    }
640
641    /// Sets the automatic checkpoint interval.
642    ///
643    /// When set, the engine periodically flushes dirty sections to disk.
644    /// Typical values: 30-300 seconds.
645    #[must_use]
646    pub fn with_checkpoint_interval(mut self, interval: Duration) -> Self {
647        self.checkpoint_interval = Some(interval);
648        self
649    }
650
651    /// Validates the configuration, returning an error for invalid combinations.
652    ///
653    /// Called automatically by [`GrafeoDB::with_config()`](crate::GrafeoDB::with_config).
654    ///
655    /// # Errors
656    ///
657    /// Returns [`ConfigError`] if any setting is invalid.
658    pub fn validate(&self) -> std::result::Result<(), ConfigError> {
659        if let Some(limit) = self.memory_limit
660            && limit == 0
661        {
662            return Err(ConfigError::ZeroMemoryLimit);
663        }
664
665        if self.threads == 0 {
666            return Err(ConfigError::ZeroThreads);
667        }
668
669        if self.wal_flush_interval_ms == 0 {
670            return Err(ConfigError::ZeroWalFlushInterval);
671        }
672
673        #[cfg(not(feature = "triple-store"))]
674        if self.graph_model == GraphModel::Rdf {
675            return Err(ConfigError::RdfFeatureRequired);
676        }
677
678        Ok(())
679    }
680}
681
682/// Helper function to get CPU count (fallback implementation).
683mod num_cpus {
684    #[cfg(not(target_arch = "wasm32"))]
685    pub fn get() -> usize {
686        std::thread::available_parallelism().map_or(4, |n| n.get())
687    }
688
689    #[cfg(target_arch = "wasm32")]
690    pub fn get() -> usize {
691        1
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    #[test]
700    fn test_config_default() {
701        let config = Config::default();
702        assert_eq!(config.graph_model, GraphModel::Lpg);
703        assert!(config.path.is_none());
704        assert!(config.memory_limit.is_none());
705        assert!(config.spill_path.is_none());
706        assert!(config.threads > 0);
707        assert!(config.wal_enabled);
708        assert_eq!(config.wal_flush_interval_ms, 100);
709        assert!(config.backward_edges);
710        assert!(!config.query_logging);
711        assert!(config.factorized_execution);
712        assert_eq!(config.wal_durability, DurabilityMode::default());
713        assert!(!config.schema_constraints);
714        assert_eq!(config.query_timeout, Some(Duration::from_secs(30)));
715        assert_eq!(config.gc_interval, 100);
716    }
717
718    #[test]
719    fn test_config_in_memory() {
720        let config = Config::in_memory();
721        assert!(config.path.is_none());
722        assert!(!config.wal_enabled);
723        assert!(config.backward_edges);
724    }
725
726    #[test]
727    fn test_config_persistent() {
728        let config = Config::persistent("/tmp/test_db");
729        assert_eq!(
730            config.path.as_deref(),
731            Some(std::path::Path::new("/tmp/test_db"))
732        );
733        assert!(config.wal_enabled);
734    }
735
736    #[test]
737    fn test_config_with_memory_limit() {
738        let config = Config::in_memory().with_memory_limit(1024 * 1024);
739        assert_eq!(config.memory_limit, Some(1024 * 1024));
740    }
741
742    #[test]
743    fn test_config_with_threads() {
744        let config = Config::in_memory().with_threads(8);
745        assert_eq!(config.threads, 8);
746    }
747
748    #[test]
749    fn test_config_without_backward_edges() {
750        let config = Config::in_memory().without_backward_edges();
751        assert!(!config.backward_edges);
752    }
753
754    #[test]
755    fn test_config_with_query_logging() {
756        let config = Config::in_memory().with_query_logging();
757        assert!(config.query_logging);
758    }
759
760    #[test]
761    fn test_config_with_spill_path() {
762        let config = Config::in_memory().with_spill_path("/tmp/spill");
763        assert_eq!(
764            config.spill_path.as_deref(),
765            Some(std::path::Path::new("/tmp/spill"))
766        );
767    }
768
769    #[test]
770    fn test_config_with_memory_fraction() {
771        let config = Config::in_memory().with_memory_fraction(0.5);
772        assert!(config.memory_limit.is_some());
773        assert!(config.memory_limit.unwrap() > 0);
774    }
775
776    #[test]
777    fn test_config_with_adaptive() {
778        let adaptive = AdaptiveConfig::default().with_threshold(5.0);
779        let config = Config::in_memory().with_adaptive(adaptive);
780        assert!((config.adaptive.threshold - 5.0).abs() < f64::EPSILON);
781    }
782
783    #[test]
784    fn test_config_without_adaptive() {
785        let config = Config::in_memory().without_adaptive();
786        assert!(!config.adaptive.enabled);
787    }
788
789    #[test]
790    fn test_config_without_factorized_execution() {
791        let config = Config::in_memory().without_factorized_execution();
792        assert!(!config.factorized_execution);
793    }
794
795    #[test]
796    fn test_config_builder_chaining() {
797        let config = Config::persistent("/tmp/db")
798            .with_memory_limit(512 * 1024 * 1024)
799            .with_threads(4)
800            .with_query_logging()
801            .without_backward_edges()
802            .with_spill_path("/tmp/spill");
803
804        assert!(config.path.is_some());
805        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
806        assert_eq!(config.threads, 4);
807        assert!(config.query_logging);
808        assert!(!config.backward_edges);
809        assert!(config.spill_path.is_some());
810    }
811
812    #[test]
813    fn test_adaptive_config_default() {
814        let config = AdaptiveConfig::default();
815        assert!(config.enabled);
816        assert!((config.threshold - 3.0).abs() < f64::EPSILON);
817        assert_eq!(config.min_rows, 1000);
818        assert_eq!(config.max_reoptimizations, 3);
819    }
820
821    #[test]
822    fn test_adaptive_config_disabled() {
823        let config = AdaptiveConfig::disabled();
824        assert!(!config.enabled);
825    }
826
827    #[test]
828    fn test_adaptive_config_with_threshold() {
829        let config = AdaptiveConfig::default().with_threshold(10.0);
830        assert!((config.threshold - 10.0).abs() < f64::EPSILON);
831    }
832
833    #[test]
834    fn test_adaptive_config_with_min_rows() {
835        let config = AdaptiveConfig::default().with_min_rows(500);
836        assert_eq!(config.min_rows, 500);
837    }
838
839    #[test]
840    fn test_adaptive_config_with_max_reoptimizations() {
841        let config = AdaptiveConfig::default().with_max_reoptimizations(5);
842        assert_eq!(config.max_reoptimizations, 5);
843    }
844
845    #[test]
846    fn test_adaptive_config_builder_chaining() {
847        let config = AdaptiveConfig::default()
848            .with_threshold(2.0)
849            .with_min_rows(100)
850            .with_max_reoptimizations(10);
851        assert!((config.threshold - 2.0).abs() < f64::EPSILON);
852        assert_eq!(config.min_rows, 100);
853        assert_eq!(config.max_reoptimizations, 10);
854    }
855
856    // --- GraphModel tests ---
857
858    #[test]
859    fn test_graph_model_default_is_lpg() {
860        assert_eq!(GraphModel::default(), GraphModel::Lpg);
861    }
862
863    #[test]
864    fn test_graph_model_display() {
865        assert_eq!(GraphModel::Lpg.to_string(), "LPG");
866        assert_eq!(GraphModel::Rdf.to_string(), "RDF");
867    }
868
869    #[test]
870    fn test_config_with_graph_model() {
871        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
872        assert_eq!(config.graph_model, GraphModel::Rdf);
873    }
874
875    // --- DurabilityMode tests ---
876
877    #[test]
878    fn test_durability_mode_default_is_batch() {
879        let mode = DurabilityMode::default();
880        assert_eq!(
881            mode,
882            DurabilityMode::Batch {
883                max_delay_ms: 100,
884                max_records: 1000
885            }
886        );
887    }
888
889    #[test]
890    fn test_config_with_wal_durability() {
891        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Sync);
892        assert_eq!(config.wal_durability, DurabilityMode::Sync);
893    }
894
895    #[test]
896    fn test_config_with_wal_durability_nosync() {
897        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::NoSync);
898        assert_eq!(config.wal_durability, DurabilityMode::NoSync);
899    }
900
901    #[test]
902    fn test_config_with_wal_durability_adaptive() {
903        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Adaptive {
904            target_interval_ms: 50,
905        });
906        assert_eq!(
907            config.wal_durability,
908            DurabilityMode::Adaptive {
909                target_interval_ms: 50
910            }
911        );
912    }
913
914    // --- max_property_size tests ---
915
916    #[test]
917    fn test_config_default_max_property_size() {
918        let config = Config::in_memory();
919        assert_eq!(config.max_property_size, Some(16 * 1024 * 1024));
920    }
921
922    #[test]
923    fn test_config_with_max_property_size() {
924        let config = Config::in_memory().with_max_property_size(1024);
925        assert_eq!(config.max_property_size, Some(1024));
926    }
927
928    #[test]
929    fn test_config_without_max_property_size() {
930        let config = Config::in_memory().without_max_property_size();
931        assert!(config.max_property_size.is_none());
932    }
933
934    // --- schema_constraints tests ---
935
936    #[test]
937    fn test_config_with_schema_constraints() {
938        let config = Config::in_memory().with_schema_constraints();
939        assert!(config.schema_constraints);
940    }
941
942    // --- query_timeout tests ---
943
944    #[test]
945    fn test_config_with_query_timeout() {
946        let config = Config::in_memory().with_query_timeout(Duration::from_mins(1));
947        assert_eq!(config.query_timeout, Some(Duration::from_mins(1)));
948    }
949
950    #[test]
951    fn test_config_without_query_timeout() {
952        let config = Config::in_memory().without_query_timeout();
953        assert!(config.query_timeout.is_none());
954    }
955
956    #[test]
957    fn test_config_default_query_timeout() {
958        let config = Config::in_memory();
959        assert_eq!(config.query_timeout, Some(Duration::from_secs(30)));
960    }
961
962    // --- gc_interval tests ---
963
964    #[test]
965    fn test_config_with_gc_interval() {
966        let config = Config::in_memory().with_gc_interval(50);
967        assert_eq!(config.gc_interval, 50);
968    }
969
970    #[test]
971    fn test_config_gc_disabled() {
972        let config = Config::in_memory().with_gc_interval(0);
973        assert_eq!(config.gc_interval, 0);
974    }
975
976    // --- validate() tests ---
977
978    #[test]
979    fn test_validate_default_config() {
980        assert!(Config::default().validate().is_ok());
981    }
982
983    #[test]
984    fn test_validate_in_memory_config() {
985        assert!(Config::in_memory().validate().is_ok());
986    }
987
988    #[test]
989    fn test_validate_rejects_zero_memory_limit() {
990        let config = Config::in_memory().with_memory_limit(0);
991        assert_eq!(config.validate(), Err(ConfigError::ZeroMemoryLimit));
992    }
993
994    #[test]
995    fn test_validate_rejects_zero_threads() {
996        let config = Config::in_memory().with_threads(0);
997        assert_eq!(config.validate(), Err(ConfigError::ZeroThreads));
998    }
999
1000    #[test]
1001    fn test_validate_rejects_zero_wal_flush_interval() {
1002        let mut config = Config::in_memory();
1003        config.wal_flush_interval_ms = 0;
1004        assert_eq!(config.validate(), Err(ConfigError::ZeroWalFlushInterval));
1005    }
1006
1007    #[cfg(not(feature = "triple-store"))]
1008    #[test]
1009    fn test_validate_rejects_rdf_without_feature() {
1010        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
1011        assert_eq!(config.validate(), Err(ConfigError::RdfFeatureRequired));
1012    }
1013
1014    #[test]
1015    fn test_config_error_display() {
1016        assert_eq!(
1017            ConfigError::ZeroMemoryLimit.to_string(),
1018            "memory_limit must be greater than zero"
1019        );
1020        assert_eq!(
1021            ConfigError::ZeroThreads.to_string(),
1022            "threads must be greater than zero"
1023        );
1024        assert_eq!(
1025            ConfigError::ZeroWalFlushInterval.to_string(),
1026            "wal_flush_interval_ms must be greater than zero"
1027        );
1028        assert_eq!(
1029            ConfigError::RdfFeatureRequired.to_string(),
1030            "RDF graph model requires the `rdf` feature flag to be enabled"
1031        );
1032    }
1033
1034    // --- Builder chaining with new fields ---
1035
1036    #[test]
1037    fn test_config_full_builder_chaining() {
1038        let config = Config::persistent("/tmp/db")
1039            .with_graph_model(GraphModel::Lpg)
1040            .with_memory_limit(512 * 1024 * 1024)
1041            .with_threads(4)
1042            .with_query_logging()
1043            .with_wal_durability(DurabilityMode::Sync)
1044            .with_schema_constraints()
1045            .without_backward_edges()
1046            .with_spill_path("/tmp/spill")
1047            .with_query_timeout(Duration::from_mins(1));
1048
1049        assert_eq!(config.graph_model, GraphModel::Lpg);
1050        assert!(config.path.is_some());
1051        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
1052        assert_eq!(config.threads, 4);
1053        assert!(config.query_logging);
1054        assert_eq!(config.wal_durability, DurabilityMode::Sync);
1055        assert!(config.schema_constraints);
1056        assert!(!config.backward_edges);
1057        assert!(config.spill_path.is_some());
1058        assert_eq!(config.query_timeout, Some(Duration::from_mins(1)));
1059        assert!(config.validate().is_ok());
1060    }
1061
1062    // --- AccessMode tests ---
1063
1064    #[test]
1065    fn test_access_mode_default_is_read_write() {
1066        assert_eq!(AccessMode::default(), AccessMode::ReadWrite);
1067    }
1068
1069    #[test]
1070    fn test_access_mode_display() {
1071        assert_eq!(AccessMode::ReadWrite.to_string(), "read-write");
1072        assert_eq!(AccessMode::ReadOnly.to_string(), "read-only");
1073    }
1074
1075    #[test]
1076    fn test_config_with_access_mode() {
1077        let config = Config::persistent("/tmp/db").with_access_mode(AccessMode::ReadOnly);
1078        assert_eq!(config.access_mode, AccessMode::ReadOnly);
1079    }
1080
1081    #[test]
1082    fn test_config_read_only() {
1083        let config = Config::read_only("/tmp/db.grafeo");
1084        assert_eq!(config.access_mode, AccessMode::ReadOnly);
1085        assert!(config.path.is_some());
1086        assert!(!config.wal_enabled);
1087    }
1088
1089    #[test]
1090    fn test_config_default_is_read_write() {
1091        let config = Config::default();
1092        assert_eq!(config.access_mode, AccessMode::ReadWrite);
1093    }
1094
1095    // --- StorageFormat tests ---
1096
1097    #[test]
1098    fn test_storage_format_default_is_auto() {
1099        assert_eq!(StorageFormat::default(), StorageFormat::Auto);
1100    }
1101
1102    #[test]
1103    fn test_storage_format_display() {
1104        assert_eq!(StorageFormat::Auto.to_string(), "auto");
1105        assert_eq!(StorageFormat::WalDirectory.to_string(), "wal-directory");
1106        assert_eq!(StorageFormat::SingleFile.to_string(), "single-file");
1107    }
1108
1109    #[test]
1110    fn test_config_with_storage_format() {
1111        let config = Config::in_memory().with_storage_format(StorageFormat::SingleFile);
1112        assert_eq!(config.storage_format, StorageFormat::SingleFile);
1113
1114        let config2 = Config::in_memory().with_storage_format(StorageFormat::WalDirectory);
1115        assert_eq!(config2.storage_format, StorageFormat::WalDirectory);
1116    }
1117
1118    // --- CDC config tests ---
1119
1120    #[test]
1121    fn test_config_with_cdc() {
1122        let config = Config::in_memory().with_cdc();
1123        assert!(config.cdc_enabled);
1124    }
1125
1126    #[test]
1127    fn test_config_cdc_default_false() {
1128        let config = Config::default();
1129        assert!(!config.cdc_enabled);
1130    }
1131
1132    // --- ConfigError as std::error::Error ---
1133
1134    #[test]
1135    fn test_config_error_is_std_error() {
1136        let err = ConfigError::ZeroMemoryLimit;
1137        // Ensure it implements std::error::Error (no source)
1138        let dyn_err: &dyn std::error::Error = &err;
1139        assert!(dyn_err.source().is_none());
1140        assert!(!dyn_err.to_string().is_empty());
1141    }
1142
1143    // --- Validate accepts non-zero memory limit ---
1144
1145    #[test]
1146    fn test_validate_accepts_nonzero_memory_limit() {
1147        let config = Config::in_memory().with_memory_limit(1);
1148        assert!(config.validate().is_ok());
1149    }
1150
1151    #[test]
1152    fn test_validate_accepts_none_memory_limit() {
1153        let config = Config::in_memory();
1154        assert!(config.memory_limit.is_none());
1155        assert!(config.validate().is_ok());
1156    }
1157
1158    // --- DurabilityMode variants ---
1159
1160    #[test]
1161    fn test_durability_mode_debug() {
1162        let sync = DurabilityMode::Sync;
1163        let debug = format!("{sync:?}");
1164        assert_eq!(debug, "Sync");
1165
1166        let no_sync = DurabilityMode::NoSync;
1167        let debug = format!("{no_sync:?}");
1168        assert_eq!(debug, "NoSync");
1169    }
1170
1171    // --- read_only config ---
1172
1173    #[test]
1174    fn test_read_only_config_full() {
1175        let config = Config::read_only("/tmp/data.grafeo");
1176        assert_eq!(config.access_mode, AccessMode::ReadOnly);
1177        assert!(!config.wal_enabled);
1178        assert!(config.path.is_some());
1179        // Other defaults should still apply
1180        assert!(config.backward_edges);
1181        assert_eq!(config.graph_model, GraphModel::Lpg);
1182    }
1183}