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