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()
687            .map(|n| n.get())
688            .unwrap_or(4)
689    }
690
691    #[cfg(target_arch = "wasm32")]
692    pub fn get() -> usize {
693        1
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn test_config_default() {
703        let config = Config::default();
704        assert_eq!(config.graph_model, GraphModel::Lpg);
705        assert!(config.path.is_none());
706        assert!(config.memory_limit.is_none());
707        assert!(config.spill_path.is_none());
708        assert!(config.threads > 0);
709        assert!(config.wal_enabled);
710        assert_eq!(config.wal_flush_interval_ms, 100);
711        assert!(config.backward_edges);
712        assert!(!config.query_logging);
713        assert!(config.factorized_execution);
714        assert_eq!(config.wal_durability, DurabilityMode::default());
715        assert!(!config.schema_constraints);
716        assert_eq!(config.query_timeout, Some(Duration::from_secs(30)));
717        assert_eq!(config.gc_interval, 100);
718    }
719
720    #[test]
721    fn test_config_in_memory() {
722        let config = Config::in_memory();
723        assert!(config.path.is_none());
724        assert!(!config.wal_enabled);
725        assert!(config.backward_edges);
726    }
727
728    #[test]
729    fn test_config_persistent() {
730        let config = Config::persistent("/tmp/test_db");
731        assert_eq!(
732            config.path.as_deref(),
733            Some(std::path::Path::new("/tmp/test_db"))
734        );
735        assert!(config.wal_enabled);
736    }
737
738    #[test]
739    fn test_config_with_memory_limit() {
740        let config = Config::in_memory().with_memory_limit(1024 * 1024);
741        assert_eq!(config.memory_limit, Some(1024 * 1024));
742    }
743
744    #[test]
745    fn test_config_with_threads() {
746        let config = Config::in_memory().with_threads(8);
747        assert_eq!(config.threads, 8);
748    }
749
750    #[test]
751    fn test_config_without_backward_edges() {
752        let config = Config::in_memory().without_backward_edges();
753        assert!(!config.backward_edges);
754    }
755
756    #[test]
757    fn test_config_with_query_logging() {
758        let config = Config::in_memory().with_query_logging();
759        assert!(config.query_logging);
760    }
761
762    #[test]
763    fn test_config_with_spill_path() {
764        let config = Config::in_memory().with_spill_path("/tmp/spill");
765        assert_eq!(
766            config.spill_path.as_deref(),
767            Some(std::path::Path::new("/tmp/spill"))
768        );
769    }
770
771    #[test]
772    fn test_config_with_memory_fraction() {
773        let config = Config::in_memory().with_memory_fraction(0.5);
774        assert!(config.memory_limit.is_some());
775        assert!(config.memory_limit.unwrap() > 0);
776    }
777
778    #[test]
779    fn test_config_with_adaptive() {
780        let adaptive = AdaptiveConfig::default().with_threshold(5.0);
781        let config = Config::in_memory().with_adaptive(adaptive);
782        assert!((config.adaptive.threshold - 5.0).abs() < f64::EPSILON);
783    }
784
785    #[test]
786    fn test_config_without_adaptive() {
787        let config = Config::in_memory().without_adaptive();
788        assert!(!config.adaptive.enabled);
789    }
790
791    #[test]
792    fn test_config_without_factorized_execution() {
793        let config = Config::in_memory().without_factorized_execution();
794        assert!(!config.factorized_execution);
795    }
796
797    #[test]
798    fn test_config_builder_chaining() {
799        let config = Config::persistent("/tmp/db")
800            .with_memory_limit(512 * 1024 * 1024)
801            .with_threads(4)
802            .with_query_logging()
803            .without_backward_edges()
804            .with_spill_path("/tmp/spill");
805
806        assert!(config.path.is_some());
807        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
808        assert_eq!(config.threads, 4);
809        assert!(config.query_logging);
810        assert!(!config.backward_edges);
811        assert!(config.spill_path.is_some());
812    }
813
814    #[test]
815    fn test_adaptive_config_default() {
816        let config = AdaptiveConfig::default();
817        assert!(config.enabled);
818        assert!((config.threshold - 3.0).abs() < f64::EPSILON);
819        assert_eq!(config.min_rows, 1000);
820        assert_eq!(config.max_reoptimizations, 3);
821    }
822
823    #[test]
824    fn test_adaptive_config_disabled() {
825        let config = AdaptiveConfig::disabled();
826        assert!(!config.enabled);
827    }
828
829    #[test]
830    fn test_adaptive_config_with_threshold() {
831        let config = AdaptiveConfig::default().with_threshold(10.0);
832        assert!((config.threshold - 10.0).abs() < f64::EPSILON);
833    }
834
835    #[test]
836    fn test_adaptive_config_with_min_rows() {
837        let config = AdaptiveConfig::default().with_min_rows(500);
838        assert_eq!(config.min_rows, 500);
839    }
840
841    #[test]
842    fn test_adaptive_config_with_max_reoptimizations() {
843        let config = AdaptiveConfig::default().with_max_reoptimizations(5);
844        assert_eq!(config.max_reoptimizations, 5);
845    }
846
847    #[test]
848    fn test_adaptive_config_builder_chaining() {
849        let config = AdaptiveConfig::default()
850            .with_threshold(2.0)
851            .with_min_rows(100)
852            .with_max_reoptimizations(10);
853        assert!((config.threshold - 2.0).abs() < f64::EPSILON);
854        assert_eq!(config.min_rows, 100);
855        assert_eq!(config.max_reoptimizations, 10);
856    }
857
858    // --- GraphModel tests ---
859
860    #[test]
861    fn test_graph_model_default_is_lpg() {
862        assert_eq!(GraphModel::default(), GraphModel::Lpg);
863    }
864
865    #[test]
866    fn test_graph_model_display() {
867        assert_eq!(GraphModel::Lpg.to_string(), "LPG");
868        assert_eq!(GraphModel::Rdf.to_string(), "RDF");
869    }
870
871    #[test]
872    fn test_config_with_graph_model() {
873        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
874        assert_eq!(config.graph_model, GraphModel::Rdf);
875    }
876
877    // --- DurabilityMode tests ---
878
879    #[test]
880    fn test_durability_mode_default_is_batch() {
881        let mode = DurabilityMode::default();
882        assert_eq!(
883            mode,
884            DurabilityMode::Batch {
885                max_delay_ms: 100,
886                max_records: 1000
887            }
888        );
889    }
890
891    #[test]
892    fn test_config_with_wal_durability() {
893        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Sync);
894        assert_eq!(config.wal_durability, DurabilityMode::Sync);
895    }
896
897    #[test]
898    fn test_config_with_wal_durability_nosync() {
899        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::NoSync);
900        assert_eq!(config.wal_durability, DurabilityMode::NoSync);
901    }
902
903    #[test]
904    fn test_config_with_wal_durability_adaptive() {
905        let config = Config::persistent("/tmp/db").with_wal_durability(DurabilityMode::Adaptive {
906            target_interval_ms: 50,
907        });
908        assert_eq!(
909            config.wal_durability,
910            DurabilityMode::Adaptive {
911                target_interval_ms: 50
912            }
913        );
914    }
915
916    // --- max_property_size tests ---
917
918    #[test]
919    fn test_config_default_max_property_size() {
920        let config = Config::in_memory();
921        assert_eq!(config.max_property_size, Some(16 * 1024 * 1024));
922    }
923
924    #[test]
925    fn test_config_with_max_property_size() {
926        let config = Config::in_memory().with_max_property_size(1024);
927        assert_eq!(config.max_property_size, Some(1024));
928    }
929
930    #[test]
931    fn test_config_without_max_property_size() {
932        let config = Config::in_memory().without_max_property_size();
933        assert!(config.max_property_size.is_none());
934    }
935
936    // --- schema_constraints tests ---
937
938    #[test]
939    fn test_config_with_schema_constraints() {
940        let config = Config::in_memory().with_schema_constraints();
941        assert!(config.schema_constraints);
942    }
943
944    // --- query_timeout tests ---
945
946    #[test]
947    fn test_config_with_query_timeout() {
948        let config = Config::in_memory().with_query_timeout(Duration::from_secs(60));
949        assert_eq!(config.query_timeout, Some(Duration::from_secs(60)));
950    }
951
952    #[test]
953    fn test_config_without_query_timeout() {
954        let config = Config::in_memory().without_query_timeout();
955        assert!(config.query_timeout.is_none());
956    }
957
958    #[test]
959    fn test_config_default_query_timeout() {
960        let config = Config::in_memory();
961        assert_eq!(config.query_timeout, Some(Duration::from_secs(30)));
962    }
963
964    // --- gc_interval tests ---
965
966    #[test]
967    fn test_config_with_gc_interval() {
968        let config = Config::in_memory().with_gc_interval(50);
969        assert_eq!(config.gc_interval, 50);
970    }
971
972    #[test]
973    fn test_config_gc_disabled() {
974        let config = Config::in_memory().with_gc_interval(0);
975        assert_eq!(config.gc_interval, 0);
976    }
977
978    // --- validate() tests ---
979
980    #[test]
981    fn test_validate_default_config() {
982        assert!(Config::default().validate().is_ok());
983    }
984
985    #[test]
986    fn test_validate_in_memory_config() {
987        assert!(Config::in_memory().validate().is_ok());
988    }
989
990    #[test]
991    fn test_validate_rejects_zero_memory_limit() {
992        let config = Config::in_memory().with_memory_limit(0);
993        assert_eq!(config.validate(), Err(ConfigError::ZeroMemoryLimit));
994    }
995
996    #[test]
997    fn test_validate_rejects_zero_threads() {
998        let config = Config::in_memory().with_threads(0);
999        assert_eq!(config.validate(), Err(ConfigError::ZeroThreads));
1000    }
1001
1002    #[test]
1003    fn test_validate_rejects_zero_wal_flush_interval() {
1004        let mut config = Config::in_memory();
1005        config.wal_flush_interval_ms = 0;
1006        assert_eq!(config.validate(), Err(ConfigError::ZeroWalFlushInterval));
1007    }
1008
1009    #[cfg(not(feature = "triple-store"))]
1010    #[test]
1011    fn test_validate_rejects_rdf_without_feature() {
1012        let config = Config::in_memory().with_graph_model(GraphModel::Rdf);
1013        assert_eq!(config.validate(), Err(ConfigError::RdfFeatureRequired));
1014    }
1015
1016    #[test]
1017    fn test_config_error_display() {
1018        assert_eq!(
1019            ConfigError::ZeroMemoryLimit.to_string(),
1020            "memory_limit must be greater than zero"
1021        );
1022        assert_eq!(
1023            ConfigError::ZeroThreads.to_string(),
1024            "threads must be greater than zero"
1025        );
1026        assert_eq!(
1027            ConfigError::ZeroWalFlushInterval.to_string(),
1028            "wal_flush_interval_ms must be greater than zero"
1029        );
1030        assert_eq!(
1031            ConfigError::RdfFeatureRequired.to_string(),
1032            "RDF graph model requires the `rdf` feature flag to be enabled"
1033        );
1034    }
1035
1036    // --- Builder chaining with new fields ---
1037
1038    #[test]
1039    fn test_config_full_builder_chaining() {
1040        let config = Config::persistent("/tmp/db")
1041            .with_graph_model(GraphModel::Lpg)
1042            .with_memory_limit(512 * 1024 * 1024)
1043            .with_threads(4)
1044            .with_query_logging()
1045            .with_wal_durability(DurabilityMode::Sync)
1046            .with_schema_constraints()
1047            .without_backward_edges()
1048            .with_spill_path("/tmp/spill")
1049            .with_query_timeout(Duration::from_secs(60));
1050
1051        assert_eq!(config.graph_model, GraphModel::Lpg);
1052        assert!(config.path.is_some());
1053        assert_eq!(config.memory_limit, Some(512 * 1024 * 1024));
1054        assert_eq!(config.threads, 4);
1055        assert!(config.query_logging);
1056        assert_eq!(config.wal_durability, DurabilityMode::Sync);
1057        assert!(config.schema_constraints);
1058        assert!(!config.backward_edges);
1059        assert!(config.spill_path.is_some());
1060        assert_eq!(config.query_timeout, Some(Duration::from_secs(60)));
1061        assert!(config.validate().is_ok());
1062    }
1063
1064    // --- AccessMode tests ---
1065
1066    #[test]
1067    fn test_access_mode_default_is_read_write() {
1068        assert_eq!(AccessMode::default(), AccessMode::ReadWrite);
1069    }
1070
1071    #[test]
1072    fn test_access_mode_display() {
1073        assert_eq!(AccessMode::ReadWrite.to_string(), "read-write");
1074        assert_eq!(AccessMode::ReadOnly.to_string(), "read-only");
1075    }
1076
1077    #[test]
1078    fn test_config_with_access_mode() {
1079        let config = Config::persistent("/tmp/db").with_access_mode(AccessMode::ReadOnly);
1080        assert_eq!(config.access_mode, AccessMode::ReadOnly);
1081    }
1082
1083    #[test]
1084    fn test_config_read_only() {
1085        let config = Config::read_only("/tmp/db.grafeo");
1086        assert_eq!(config.access_mode, AccessMode::ReadOnly);
1087        assert!(config.path.is_some());
1088        assert!(!config.wal_enabled);
1089    }
1090
1091    #[test]
1092    fn test_config_default_is_read_write() {
1093        let config = Config::default();
1094        assert_eq!(config.access_mode, AccessMode::ReadWrite);
1095    }
1096
1097    // --- StorageFormat tests ---
1098
1099    #[test]
1100    fn test_storage_format_default_is_auto() {
1101        assert_eq!(StorageFormat::default(), StorageFormat::Auto);
1102    }
1103
1104    #[test]
1105    fn test_storage_format_display() {
1106        assert_eq!(StorageFormat::Auto.to_string(), "auto");
1107        assert_eq!(StorageFormat::WalDirectory.to_string(), "wal-directory");
1108        assert_eq!(StorageFormat::SingleFile.to_string(), "single-file");
1109    }
1110
1111    #[test]
1112    fn test_config_with_storage_format() {
1113        let config = Config::in_memory().with_storage_format(StorageFormat::SingleFile);
1114        assert_eq!(config.storage_format, StorageFormat::SingleFile);
1115
1116        let config2 = Config::in_memory().with_storage_format(StorageFormat::WalDirectory);
1117        assert_eq!(config2.storage_format, StorageFormat::WalDirectory);
1118    }
1119
1120    // --- CDC config tests ---
1121
1122    #[test]
1123    fn test_config_with_cdc() {
1124        let config = Config::in_memory().with_cdc();
1125        assert!(config.cdc_enabled);
1126    }
1127
1128    #[test]
1129    fn test_config_cdc_default_false() {
1130        let config = Config::default();
1131        assert!(!config.cdc_enabled);
1132    }
1133
1134    // --- ConfigError as std::error::Error ---
1135
1136    #[test]
1137    fn test_config_error_is_std_error() {
1138        let err = ConfigError::ZeroMemoryLimit;
1139        // Ensure it implements std::error::Error (no source)
1140        let dyn_err: &dyn std::error::Error = &err;
1141        assert!(dyn_err.source().is_none());
1142        assert!(!dyn_err.to_string().is_empty());
1143    }
1144
1145    // --- Validate accepts non-zero memory limit ---
1146
1147    #[test]
1148    fn test_validate_accepts_nonzero_memory_limit() {
1149        let config = Config::in_memory().with_memory_limit(1);
1150        assert!(config.validate().is_ok());
1151    }
1152
1153    #[test]
1154    fn test_validate_accepts_none_memory_limit() {
1155        let config = Config::in_memory();
1156        assert!(config.memory_limit.is_none());
1157        assert!(config.validate().is_ok());
1158    }
1159
1160    // --- DurabilityMode variants ---
1161
1162    #[test]
1163    fn test_durability_mode_debug() {
1164        let sync = DurabilityMode::Sync;
1165        let debug = format!("{sync:?}");
1166        assert_eq!(debug, "Sync");
1167
1168        let no_sync = DurabilityMode::NoSync;
1169        let debug = format!("{no_sync:?}");
1170        assert_eq!(debug, "NoSync");
1171    }
1172
1173    // --- read_only config ---
1174
1175    #[test]
1176    fn test_read_only_config_full() {
1177        let config = Config::read_only("/tmp/data.grafeo");
1178        assert_eq!(config.access_mode, AccessMode::ReadOnly);
1179        assert!(!config.wal_enabled);
1180        assert!(config.path.is_some());
1181        // Other defaults should still apply
1182        assert!(config.backward_edges);
1183        assert_eq!(config.graph_model, GraphModel::Lpg);
1184    }
1185}