Skip to main content

helios_persistence/composite/
config.rs

1//! Configuration types for composite storage.
2//!
3//! This module provides configuration types for setting up composite storage
4//! with multiple backends. The configuration defines:
5//!
6//! - Backend entries with their roles and capabilities
7//! - Routing rules for directing queries to appropriate backends
8//! - Synchronization settings between primary and secondary backends
9//! - Cost configuration for query optimization
10//!
11//! # Example
12//!
13//! ```ignore
14//! use helios_persistence::composite::{CompositeConfig, BackendRole};
15//!
16//! let config = CompositeConfigBuilder::new()
17//!     .with_backend("sqlite", BackendRole::Primary)
18//!     .with_backend("elasticsearch", BackendRole::Search)
19//!     .with_sync_mode(SyncMode::Asynchronous)
20//!     .build()?;
21//! ```
22
23use std::collections::HashMap;
24use std::time::Duration;
25
26use serde::{Deserialize, Serialize};
27
28use crate::core::{BackendCapability, BackendKind};
29
30use super::analyzer::QueryFeature;
31
32/// Role of a backend in the composite storage.
33///
34/// Each backend serves a specific role in the composite architecture:
35/// - **Primary**: The authoritative store for all FHIR resources (CRUD, versioning, history)
36/// - **Search**: Optimized for full-text and advanced search operations
37/// - **Graph**: Optimized for relationship traversal (chained searches, _has)
38/// - **Terminology**: Handles code system expansion (:above, :below, :in, :not-in)
39/// - **Archive**: Cold storage for historical data
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum BackendRole {
43    /// Primary storage - the single source of truth for all FHIR resources.
44    /// Handles all CRUD operations, versioning, and history.
45    Primary,
46
47    /// Search optimization backend (e.g., Elasticsearch).
48    /// Handles full-text search (_text, _content) and advanced text matching.
49    Search,
50
51    /// Graph query backend (e.g., Neo4j).
52    /// Handles chained parameters and reverse chaining (_has).
53    Graph,
54
55    /// Terminology service.
56    /// Handles code expansion for :above, :below, :in, :not-in modifiers.
57    Terminology,
58
59    /// Archive storage for cold data (e.g., S3).
60    /// Used for bulk export and historical data.
61    Archive,
62}
63
64impl BackendRole {
65    /// Returns true if this role can be used for primary resource storage.
66    pub fn is_primary(&self) -> bool {
67        matches!(self, BackendRole::Primary)
68    }
69
70    /// Returns true if this is a secondary/auxiliary role.
71    pub fn is_secondary(&self) -> bool {
72        !self.is_primary()
73    }
74
75    /// Returns the typical capabilities associated with this role.
76    pub fn typical_capabilities(&self) -> Vec<BackendCapability> {
77        match self {
78            BackendRole::Primary => vec![
79                BackendCapability::Crud,
80                BackendCapability::Versioning,
81                BackendCapability::InstanceHistory,
82                BackendCapability::TypeHistory,
83                BackendCapability::SystemHistory,
84                BackendCapability::BasicSearch,
85                BackendCapability::DateSearch,
86                BackendCapability::ReferenceSearch,
87                BackendCapability::Transactions,
88                BackendCapability::OptimisticLocking,
89                BackendCapability::Include,
90                BackendCapability::Revinclude,
91                BackendCapability::Sorting,
92                BackendCapability::OffsetPagination,
93                BackendCapability::CursorPagination,
94            ],
95            BackendRole::Search => vec![
96                BackendCapability::BasicSearch,
97                BackendCapability::FullTextSearch,
98                BackendCapability::Sorting,
99            ],
100            BackendRole::Graph => vec![
101                BackendCapability::ChainedSearch,
102                BackendCapability::ReverseChaining,
103                BackendCapability::Include,
104                BackendCapability::Revinclude,
105            ],
106            BackendRole::Terminology => vec![BackendCapability::TerminologySearch],
107            BackendRole::Archive => vec![
108                BackendCapability::Crud,
109                BackendCapability::Versioning,
110                BackendCapability::InstanceHistory,
111            ],
112        }
113    }
114}
115
116/// Configuration for a single backend in the composite storage.
117///
118/// Note: Serde serialization is not directly supported due to complex types.
119/// Use the builder pattern for configuration.
120#[derive(Debug, Clone)]
121pub struct BackendEntry {
122    /// Unique identifier for this backend.
123    pub id: String,
124
125    /// The role this backend plays in the composite.
126    pub role: BackendRole,
127
128    /// Backend kind (sqlite, postgres, elasticsearch, etc.).
129    pub kind: BackendKind,
130
131    /// Connection string or configuration for this backend.
132    pub connection: String,
133
134    /// Priority for routing (lower = preferred when multiple backends can handle a query).
135    pub priority: u8,
136
137    /// Whether this backend is enabled.
138    pub enabled: bool,
139
140    /// Explicit capabilities this backend provides.
141    /// If empty, derived from the backend kind and role.
142    pub capabilities: Vec<BackendCapability>,
143
144    /// Failover backend ID when this backend is unavailable.
145    pub failover_to: Option<String>,
146
147    /// Additional backend-specific configuration.
148    pub options: HashMap<String, serde_json::Value>,
149}
150
151fn default_priority() -> u8 {
152    100
153}
154
155impl BackendEntry {
156    /// Creates a new backend entry.
157    pub fn new(id: impl Into<String>, role: BackendRole, kind: BackendKind) -> Self {
158        Self {
159            id: id.into(),
160            role,
161            kind,
162            connection: String::new(),
163            priority: default_priority(),
164            enabled: true,
165            capabilities: Vec::new(),
166            failover_to: None,
167            options: HashMap::new(),
168        }
169    }
170
171    /// Sets the connection string.
172    pub fn with_connection(mut self, connection: impl Into<String>) -> Self {
173        self.connection = connection.into();
174        self
175    }
176
177    /// Sets the priority.
178    pub fn with_priority(mut self, priority: u8) -> Self {
179        self.priority = priority;
180        self
181    }
182
183    /// Sets the failover backend.
184    pub fn with_failover(mut self, failover_id: impl Into<String>) -> Self {
185        self.failover_to = Some(failover_id.into());
186        self
187    }
188
189    /// Adds explicit capabilities.
190    pub fn with_capabilities(mut self, capabilities: Vec<BackendCapability>) -> Self {
191        self.capabilities = capabilities;
192        self
193    }
194
195    /// Returns the effective capabilities (explicit or derived from role).
196    pub fn effective_capabilities(&self) -> Vec<BackendCapability> {
197        if self.capabilities.is_empty() {
198            self.role.typical_capabilities()
199        } else {
200            self.capabilities.clone()
201        }
202    }
203
204    /// Checks if this backend supports a capability.
205    pub fn supports(&self, capability: BackendCapability) -> bool {
206        self.effective_capabilities().contains(&capability)
207    }
208}
209
210/// A routing rule for directing specific queries to backends.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct RoutingRule {
213    /// Rule identifier.
214    pub id: String,
215
216    /// Feature(s) that trigger this rule.
217    pub triggers: Vec<QueryFeature>,
218
219    /// Target backend ID.
220    pub target_backend: String,
221
222    /// Priority (lower = higher priority).
223    #[serde(default = "default_priority")]
224    pub priority: u8,
225
226    /// Whether to fall back to primary if target is unavailable.
227    #[serde(default = "default_fallback")]
228    pub fallback_to_primary: bool,
229}
230
231fn default_fallback() -> bool {
232    true
233}
234
235impl RoutingRule {
236    /// Creates a new routing rule.
237    pub fn new(id: impl Into<String>, target_backend: impl Into<String>) -> Self {
238        Self {
239            id: id.into(),
240            triggers: Vec::new(),
241            target_backend: target_backend.into(),
242            priority: default_priority(),
243            fallback_to_primary: true,
244        }
245    }
246
247    /// Adds a trigger feature.
248    pub fn with_trigger(mut self, feature: QueryFeature) -> Self {
249        self.triggers.push(feature);
250        self
251    }
252
253    /// Adds multiple trigger features.
254    pub fn with_triggers(mut self, features: Vec<QueryFeature>) -> Self {
255        self.triggers.extend(features);
256        self
257    }
258
259    /// Sets the priority.
260    pub fn with_priority(mut self, priority: u8) -> Self {
261        self.priority = priority;
262        self
263    }
264
265    /// Sets whether to fall back to primary.
266    pub fn with_fallback(mut self, fallback: bool) -> Self {
267        self.fallback_to_primary = fallback;
268        self
269    }
270}
271
272/// Synchronization mode for secondary backends.
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
274#[serde(rename_all = "lowercase")]
275pub enum SyncMode {
276    /// Update secondaries in the same transaction/operation.
277    /// Higher latency but strong consistency.
278    Synchronous,
279
280    /// Update secondaries via event stream.
281    /// Lower latency but eventual consistency.
282    #[default]
283    Asynchronous,
284
285    /// Hybrid: sync critical data, async for the rest.
286    Hybrid {
287        /// Whether to sync search indexes synchronously.
288        sync_for_search: bool,
289    },
290}
291
292/// Retry configuration for sync operations.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct RetryConfig {
295    /// Maximum number of retry attempts.
296    #[serde(default = "default_max_retries")]
297    pub max_retries: u32,
298
299    /// Initial delay between retries.
300    #[serde(with = "humantime_serde", default = "default_initial_delay")]
301    pub initial_delay: Duration,
302
303    /// Maximum delay between retries.
304    #[serde(with = "humantime_serde", default = "default_max_delay")]
305    pub max_delay: Duration,
306
307    /// Backoff multiplier.
308    #[serde(default = "default_backoff_multiplier")]
309    pub backoff_multiplier: f64,
310}
311
312fn default_max_retries() -> u32 {
313    3
314}
315
316fn default_initial_delay() -> Duration {
317    Duration::from_millis(100)
318}
319
320fn default_max_delay() -> Duration {
321    Duration::from_secs(5)
322}
323
324fn default_backoff_multiplier() -> f64 {
325    2.0
326}
327
328impl Default for RetryConfig {
329    fn default() -> Self {
330        Self {
331            max_retries: default_max_retries(),
332            initial_delay: default_initial_delay(),
333            max_delay: default_max_delay(),
334            backoff_multiplier: default_backoff_multiplier(),
335        }
336    }
337}
338
339/// Synchronization configuration.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct SyncConfig {
342    /// Sync mode: synchronous, asynchronous, or hybrid.
343    #[serde(default)]
344    pub mode: SyncMode,
345
346    /// Maximum acceptable read lag in milliseconds.
347    /// Reads may wait up to this long for sync to complete.
348    #[serde(default = "default_max_read_lag")]
349    pub max_read_lag_ms: u64,
350
351    /// Batch size for async sync operations.
352    #[serde(default = "default_batch_size")]
353    pub batch_size: usize,
354
355    /// Retry configuration for failed sync operations.
356    #[serde(default)]
357    pub retry: RetryConfig,
358}
359
360fn default_max_read_lag() -> u64 {
361    500
362}
363
364fn default_batch_size() -> usize {
365    100
366}
367
368impl Default for SyncConfig {
369    fn default() -> Self {
370        Self {
371            mode: SyncMode::default(),
372            max_read_lag_ms: default_max_read_lag(),
373            batch_size: default_batch_size(),
374            retry: RetryConfig::default(),
375        }
376    }
377}
378
379/// Cost weights for query optimization.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct CostWeights {
382    /// Weight for latency in cost calculation.
383    #[serde(default = "default_latency_weight")]
384    pub latency: f64,
385
386    /// Weight for resource usage.
387    #[serde(default = "default_resource_weight")]
388    pub resource_usage: f64,
389
390    /// Weight for result quality (relevance scoring).
391    #[serde(default = "default_quality_weight")]
392    pub quality: f64,
393}
394
395fn default_latency_weight() -> f64 {
396    0.5
397}
398
399fn default_resource_weight() -> f64 {
400    0.3
401}
402
403fn default_quality_weight() -> f64 {
404    0.2
405}
406
407impl Default for CostWeights {
408    fn default() -> Self {
409        Self {
410            latency: default_latency_weight(),
411            resource_usage: default_resource_weight(),
412            quality: default_quality_weight(),
413        }
414    }
415}
416
417/// Cost configuration for query optimization.
418///
419/// Costs are derived from Criterion benchmarks and stored as defaults.
420/// These can be overridden based on deployment-specific performance characteristics.
421#[derive(Debug, Clone)]
422pub struct CostConfig {
423    /// Base costs per backend kind (in arbitrary units).
424    /// Derived from benchmark measurements.
425    pub base_costs: HashMap<BackendKind, f64>,
426
427    /// Cost multipliers per query feature.
428    pub feature_multipliers: HashMap<QueryFeature, f64>,
429
430    /// Weights for combining cost components.
431    pub weights: CostWeights,
432}
433
434impl Default for CostConfig {
435    fn default() -> Self {
436        let mut base_costs = HashMap::new();
437        // Default costs based on typical performance characteristics
438        // These should be updated from benchmark results
439        base_costs.insert(BackendKind::Sqlite, 1.0);
440        base_costs.insert(BackendKind::Postgres, 1.2);
441        base_costs.insert(BackendKind::Elasticsearch, 0.8);
442        base_costs.insert(BackendKind::Neo4j, 1.5);
443        base_costs.insert(BackendKind::S3, 2.0);
444
445        let mut feature_multipliers = HashMap::new();
446        // Default multipliers - higher means more expensive
447        feature_multipliers.insert(QueryFeature::BasicSearch, 1.0);
448        feature_multipliers.insert(QueryFeature::ChainedSearch, 3.0);
449        feature_multipliers.insert(QueryFeature::ReverseChaining, 3.5);
450        feature_multipliers.insert(QueryFeature::FullTextSearch, 1.5);
451        feature_multipliers.insert(QueryFeature::TerminologySearch, 2.0);
452
453        Self {
454            base_costs,
455            feature_multipliers,
456            weights: CostWeights::default(),
457        }
458    }
459}
460
461/// Health check configuration.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct HealthConfig {
464    /// Interval between health checks.
465    #[serde(with = "humantime_serde", default = "default_health_interval")]
466    pub check_interval: Duration,
467
468    /// Timeout for health check operations.
469    #[serde(with = "humantime_serde", default = "default_health_timeout")]
470    pub timeout: Duration,
471
472    /// Number of consecutive failures before marking unhealthy.
473    #[serde(default = "default_failure_threshold")]
474    pub failure_threshold: u32,
475
476    /// Number of consecutive successes before marking healthy.
477    #[serde(default = "default_success_threshold")]
478    pub success_threshold: u32,
479}
480
481fn default_health_interval() -> Duration {
482    Duration::from_secs(30)
483}
484
485fn default_health_timeout() -> Duration {
486    Duration::from_secs(5)
487}
488
489fn default_failure_threshold() -> u32 {
490    3
491}
492
493fn default_success_threshold() -> u32 {
494    2
495}
496
497impl Default for HealthConfig {
498    fn default() -> Self {
499        Self {
500            check_interval: default_health_interval(),
501            timeout: default_health_timeout(),
502            failure_threshold: default_failure_threshold(),
503            success_threshold: default_success_threshold(),
504        }
505    }
506}
507
508/// Complete composite storage configuration.
509#[derive(Debug, Clone)]
510pub struct CompositeConfig {
511    /// All configured backends.
512    pub backends: Vec<BackendEntry>,
513
514    /// Custom routing rules (override automatic detection).
515    pub routing_rules: Vec<RoutingRule>,
516
517    /// Synchronization settings.
518    pub sync_config: SyncConfig,
519
520    /// Cost model configuration.
521    pub cost_config: CostConfig,
522
523    /// Health check settings.
524    pub health_config: HealthConfig,
525}
526
527impl CompositeConfig {
528    /// Creates a new empty configuration.
529    pub fn new() -> Self {
530        Self {
531            backends: Vec::new(),
532            routing_rules: Vec::new(),
533            sync_config: SyncConfig::default(),
534            cost_config: CostConfig::default(),
535            health_config: HealthConfig::default(),
536        }
537    }
538
539    /// Creates a builder for constructing configuration.
540    pub fn builder() -> CompositeConfigBuilder {
541        CompositeConfigBuilder::new()
542    }
543
544    /// Returns the primary backend entry.
545    pub fn primary(&self) -> Option<&BackendEntry> {
546        self.backends
547            .iter()
548            .find(|b| b.role.is_primary() && b.enabled)
549    }
550
551    /// Returns the primary backend ID.
552    pub fn primary_id(&self) -> Option<&str> {
553        self.primary().map(|b| b.id.as_str())
554    }
555
556    /// Returns all secondary (non-primary) backends.
557    pub fn secondaries(&self) -> impl Iterator<Item = &BackendEntry> {
558        self.backends
559            .iter()
560            .filter(|b| b.role.is_secondary() && b.enabled)
561    }
562
563    /// Returns a backend by ID.
564    pub fn backend(&self, id: &str) -> Option<&BackendEntry> {
565        self.backends.iter().find(|b| b.id == id)
566    }
567
568    /// Returns backends with a specific role.
569    pub fn backends_with_role(&self, role: BackendRole) -> impl Iterator<Item = &BackendEntry> {
570        self.backends
571            .iter()
572            .filter(move |b| b.role == role && b.enabled)
573    }
574
575    /// Returns backends that support a specific capability.
576    pub fn backends_with_capability(
577        &self,
578        capability: BackendCapability,
579    ) -> impl Iterator<Item = &BackendEntry> {
580        self.backends
581            .iter()
582            .filter(move |b| b.enabled && b.supports(capability))
583    }
584
585    /// Validates the configuration and returns any errors.
586    pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
587        let mut warnings = Vec::new();
588
589        // Must have exactly one primary
590        let primaries: Vec<_> = self
591            .backends
592            .iter()
593            .filter(|b| b.role.is_primary() && b.enabled)
594            .collect();
595
596        if primaries.is_empty() {
597            return Err(ConfigError::NoPrimaryBackend);
598        }
599        if primaries.len() > 1 {
600            return Err(ConfigError::MultiplePrimaryBackends(
601                primaries.iter().map(|b| b.id.clone()).collect(),
602            ));
603        }
604
605        // Check for duplicate IDs
606        let mut seen_ids = std::collections::HashSet::new();
607        for backend in &self.backends {
608            if !seen_ids.insert(&backend.id) {
609                return Err(ConfigError::DuplicateBackendId(backend.id.clone()));
610            }
611        }
612
613        // Check failover references
614        for backend in &self.backends {
615            if let Some(ref failover_id) = backend.failover_to {
616                if self.backend(failover_id).is_none() {
617                    return Err(ConfigError::InvalidFailoverReference {
618                        backend_id: backend.id.clone(),
619                        failover_id: failover_id.clone(),
620                    });
621                }
622            }
623        }
624
625        // Check routing rule references
626        for rule in &self.routing_rules {
627            if self.backend(&rule.target_backend).is_none() {
628                return Err(ConfigError::InvalidRoutingTarget {
629                    rule_id: rule.id.clone(),
630                    target_id: rule.target_backend.clone(),
631                });
632            }
633        }
634
635        // Warnings for potential issues
636        if self.secondaries().count() == 0 {
637            warnings.push(ConfigWarning::NoSecondaryBackends);
638        }
639
640        // Check for redundant capabilities
641        let search_backends: Vec<_> = self
642            .backends_with_capability(BackendCapability::FullTextSearch)
643            .collect();
644        if search_backends.len() > 1 {
645            warnings.push(ConfigWarning::RedundantCapability {
646                capability: BackendCapability::FullTextSearch,
647                backends: search_backends.iter().map(|b| b.id.clone()).collect(),
648            });
649        }
650
651        Ok(warnings)
652    }
653}
654
655impl Default for CompositeConfig {
656    fn default() -> Self {
657        Self::new()
658    }
659}
660
661/// Builder for constructing [`CompositeConfig`].
662#[derive(Debug, Default)]
663pub struct CompositeConfigBuilder {
664    backends: Vec<BackendEntry>,
665    routing_rules: Vec<RoutingRule>,
666    sync_config: SyncConfig,
667    cost_config: CostConfig,
668    health_config: HealthConfig,
669}
670
671impl CompositeConfigBuilder {
672    /// Creates a new builder.
673    pub fn new() -> Self {
674        Self::default()
675    }
676
677    /// Adds a backend entry.
678    pub fn with_backend(mut self, backend: BackendEntry) -> Self {
679        self.backends.push(backend);
680        self
681    }
682
683    /// Adds a primary backend.
684    pub fn primary(mut self, id: impl Into<String>, kind: BackendKind) -> Self {
685        self.backends
686            .push(BackendEntry::new(id, BackendRole::Primary, kind));
687        self
688    }
689
690    /// Adds a search backend.
691    pub fn search_backend(mut self, id: impl Into<String>, kind: BackendKind) -> Self {
692        self.backends
693            .push(BackendEntry::new(id, BackendRole::Search, kind));
694        self
695    }
696
697    /// Adds a graph backend.
698    pub fn graph_backend(mut self, id: impl Into<String>, kind: BackendKind) -> Self {
699        self.backends
700            .push(BackendEntry::new(id, BackendRole::Graph, kind));
701        self
702    }
703
704    /// Adds a terminology backend.
705    pub fn terminology_backend(mut self, id: impl Into<String>, kind: BackendKind) -> Self {
706        self.backends
707            .push(BackendEntry::new(id, BackendRole::Terminology, kind));
708        self
709    }
710
711    /// Adds a routing rule.
712    pub fn with_routing_rule(mut self, rule: RoutingRule) -> Self {
713        self.routing_rules.push(rule);
714        self
715    }
716
717    /// Sets the sync mode.
718    pub fn sync_mode(mut self, mode: SyncMode) -> Self {
719        self.sync_config.mode = mode;
720        self
721    }
722
723    /// Sets the sync configuration.
724    pub fn with_sync_config(mut self, config: SyncConfig) -> Self {
725        self.sync_config = config;
726        self
727    }
728
729    /// Sets the cost configuration.
730    pub fn with_cost_config(mut self, config: CostConfig) -> Self {
731        self.cost_config = config;
732        self
733    }
734
735    /// Sets the health configuration.
736    pub fn with_health_config(mut self, config: HealthConfig) -> Self {
737        self.health_config = config;
738        self
739    }
740
741    /// Builds the configuration, validating it first.
742    pub fn build(self) -> Result<CompositeConfig, ConfigError> {
743        let config = CompositeConfig {
744            backends: self.backends,
745            routing_rules: self.routing_rules,
746            sync_config: self.sync_config,
747            cost_config: self.cost_config,
748            health_config: self.health_config,
749        };
750
751        // Validate and ignore warnings for build
752        let _ = config.validate()?;
753        Ok(config)
754    }
755
756    /// Builds the configuration and returns warnings.
757    pub fn build_with_warnings(self) -> Result<(CompositeConfig, Vec<ConfigWarning>), ConfigError> {
758        let config = CompositeConfig {
759            backends: self.backends,
760            routing_rules: self.routing_rules,
761            sync_config: self.sync_config,
762            cost_config: self.cost_config,
763            health_config: self.health_config,
764        };
765
766        let warnings = config.validate()?;
767        Ok((config, warnings))
768    }
769}
770
771/// Configuration errors.
772#[derive(Debug, Clone, thiserror::Error)]
773pub enum ConfigError {
774    /// No primary backend configured.
775    #[error("no primary backend configured - exactly one primary backend is required")]
776    NoPrimaryBackend,
777
778    /// Multiple primary backends configured.
779    #[error("multiple primary backends configured: {0:?} - only one primary is allowed")]
780    MultiplePrimaryBackends(Vec<String>),
781
782    /// Duplicate backend ID.
783    #[error("duplicate backend ID: {0}")]
784    DuplicateBackendId(String),
785
786    /// Invalid failover reference.
787    #[error("backend '{backend_id}' references non-existent failover backend '{failover_id}'")]
788    InvalidFailoverReference {
789        /// The backend with the invalid reference.
790        backend_id: String,
791        /// The non-existent failover ID.
792        failover_id: String,
793    },
794
795    /// Invalid routing rule target.
796    #[error("routing rule '{rule_id}' targets non-existent backend '{target_id}'")]
797    InvalidRoutingTarget {
798        /// The routing rule ID.
799        rule_id: String,
800        /// The non-existent target ID.
801        target_id: String,
802    },
803}
804
805/// Configuration warnings (non-fatal issues).
806#[derive(Debug, Clone)]
807pub enum ConfigWarning {
808    /// No secondary backends configured.
809    NoSecondaryBackends,
810
811    /// Redundant capability across multiple backends.
812    RedundantCapability {
813        /// The redundant capability.
814        capability: BackendCapability,
815        /// Backends providing this capability.
816        backends: Vec<String>,
817    },
818}
819
820impl std::fmt::Display for ConfigWarning {
821    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822        match self {
823            ConfigWarning::NoSecondaryBackends => {
824                write!(
825                    f,
826                    "no secondary backends configured - using primary for all operations"
827                )
828            }
829            ConfigWarning::RedundantCapability {
830                capability,
831                backends,
832            } => {
833                write!(
834                    f,
835                    "capability {:?} is provided by multiple backends: {:?}",
836                    capability, backends
837                )
838            }
839        }
840    }
841}
842
843/// Serde module for Duration with humantime format.
844mod humantime_serde {
845    use serde::{Deserialize, Deserializer, Serializer};
846    use std::time::Duration;
847
848    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
849    where
850        S: Serializer,
851    {
852        serializer.serialize_str(&humantime::format_duration(*duration).to_string())
853    }
854
855    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
856    where
857        D: Deserializer<'de>,
858    {
859        let s = String::deserialize(deserializer)?;
860        humantime::parse_duration(&s).map_err(serde::de::Error::custom)
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    #[test]
869    fn test_backend_role_capabilities() {
870        let primary_caps = BackendRole::Primary.typical_capabilities();
871        assert!(primary_caps.contains(&BackendCapability::Crud));
872        assert!(primary_caps.contains(&BackendCapability::Versioning));
873
874        let search_caps = BackendRole::Search.typical_capabilities();
875        assert!(search_caps.contains(&BackendCapability::FullTextSearch));
876
877        let graph_caps = BackendRole::Graph.typical_capabilities();
878        assert!(graph_caps.contains(&BackendCapability::ChainedSearch));
879    }
880
881    #[test]
882    fn test_config_builder_minimal() {
883        let config = CompositeConfigBuilder::new()
884            .primary("sqlite", BackendKind::Sqlite)
885            .build()
886            .unwrap();
887
888        assert_eq!(config.backends.len(), 1);
889        assert!(config.primary().is_some());
890        assert_eq!(config.primary_id(), Some("sqlite"));
891    }
892
893    #[test]
894    fn test_config_builder_with_secondaries() {
895        let config = CompositeConfigBuilder::new()
896            .primary("pg", BackendKind::Postgres)
897            .search_backend("es", BackendKind::Elasticsearch)
898            .graph_backend("neo4j", BackendKind::Neo4j)
899            .build()
900            .unwrap();
901
902        assert_eq!(config.backends.len(), 3);
903        assert_eq!(config.secondaries().count(), 2);
904    }
905
906    #[test]
907    fn test_config_validation_no_primary() {
908        let result = CompositeConfigBuilder::new()
909            .search_backend("es", BackendKind::Elasticsearch)
910            .build();
911
912        assert!(matches!(result, Err(ConfigError::NoPrimaryBackend)));
913    }
914
915    #[test]
916    fn test_config_validation_multiple_primaries() {
917        let result = CompositeConfigBuilder::new()
918            .primary("pg1", BackendKind::Postgres)
919            .primary("pg2", BackendKind::Postgres)
920            .build();
921
922        assert!(matches!(
923            result,
924            Err(ConfigError::MultiplePrimaryBackends(_))
925        ));
926    }
927
928    #[test]
929    fn test_backend_entry_effective_capabilities() {
930        let entry = BackendEntry::new("test", BackendRole::Search, BackendKind::Elasticsearch);
931        let caps = entry.effective_capabilities();
932        assert!(caps.contains(&BackendCapability::FullTextSearch));
933
934        // With explicit capabilities
935        let entry_explicit =
936            BackendEntry::new("test", BackendRole::Search, BackendKind::Elasticsearch)
937                .with_capabilities(vec![BackendCapability::BasicSearch]);
938        let caps_explicit = entry_explicit.effective_capabilities();
939        assert!(caps_explicit.contains(&BackendCapability::BasicSearch));
940        assert!(!caps_explicit.contains(&BackendCapability::FullTextSearch));
941    }
942}