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