Skip to main content

this/config/
mod.rs

1//! Configuration loading and management
2
3pub mod events;
4pub mod sinks;
5
6use crate::core::LinkDefinition;
7use anyhow::Result;
8use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12pub use events::*;
13pub use sinks::*;
14
15/// Authorization configuration for an entity
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct EntityAuthConfig {
18    /// Policy for listing entities (GET /{entities})
19    #[serde(default = "default_auth_policy")]
20    pub list: String,
21
22    /// Policy for getting a single entity (GET /{entities}/{id})
23    #[serde(default = "default_auth_policy")]
24    pub get: String,
25
26    /// Policy for creating an entity (POST /{entities})
27    #[serde(default = "default_auth_policy")]
28    pub create: String,
29
30    /// Policy for updating an entity (PUT /{entities}/{id})
31    #[serde(default = "default_auth_policy")]
32    pub update: String,
33
34    /// Policy for deleting an entity (DELETE /{entities}/{id})
35    #[serde(default = "default_auth_policy")]
36    pub delete: String,
37
38    /// Policy for listing links (GET /{entities}/{id}/{link_route})
39    #[serde(default = "default_auth_policy")]
40    pub list_links: String,
41
42    /// Policy for creating links (POST /{entities}/{id}/{link_type}/{target_type}/{target_id})
43    #[serde(default = "default_auth_policy")]
44    pub create_link: String,
45
46    /// Policy for deleting links (DELETE /{entities}/{id}/{link_type}/{target_type}/{target_id})
47    #[serde(default = "default_auth_policy")]
48    pub delete_link: String,
49}
50
51fn default_auth_policy() -> String {
52    "authenticated".to_string()
53}
54
55impl Default for EntityAuthConfig {
56    fn default() -> Self {
57        Self {
58            list: default_auth_policy(),
59            get: default_auth_policy(),
60            create: default_auth_policy(),
61            update: default_auth_policy(),
62            delete: default_auth_policy(),
63            list_links: default_auth_policy(),
64            create_link: default_auth_policy(),
65            delete_link: default_auth_policy(),
66        }
67    }
68}
69
70/// Configuration for an entity type
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct EntityConfig {
73    /// Singular form (e.g., "user", "company")
74    pub singular: String,
75
76    /// Plural form (e.g., "users", "companies")
77    pub plural: String,
78
79    /// Authorization configuration
80    #[serde(default)]
81    pub auth: EntityAuthConfig,
82}
83
84/// Validation rule for a link type
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ValidationRule {
87    /// Source entity type
88    pub source: String,
89
90    /// Allowed target entity types
91    pub targets: Vec<String>,
92}
93
94/// Complete configuration for the links system
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct LinksConfig {
97    /// List of entity configurations
98    pub entities: Vec<EntityConfig>,
99
100    /// List of link definitions
101    pub links: Vec<LinkDefinition>,
102
103    /// Optional validation rules (link_type -> rules)
104    #[serde(default)]
105    pub validation_rules: Option<HashMap<String, Vec<ValidationRule>>>,
106
107    /// Optional event flow configuration (backend, flows, consumers)
108    #[serde(default)]
109    pub events: Option<EventsConfig>,
110
111    /// Optional sink configurations (notification destinations)
112    #[serde(default)]
113    pub sinks: Option<Vec<SinkConfig>>,
114}
115
116impl LinksConfig {
117    /// Load configuration from a YAML file
118    pub fn from_yaml_file(path: &str) -> Result<Self> {
119        let content = std::fs::read_to_string(path)?;
120        let config: Self = serde_yaml::from_str(&content)?;
121        Ok(config)
122    }
123
124    /// Load configuration from a YAML string
125    pub fn from_yaml_str(yaml: &str) -> Result<Self> {
126        let config: Self = serde_yaml::from_str(yaml)?;
127        Ok(config)
128    }
129
130    /// Merge multiple configurations into one
131    ///
132    /// Rules:
133    /// - Entities: Combined from all configs, duplicates (by singular name) use last definition
134    /// - Links: Combined from all configs, duplicates (by link_type+source+target) use last definition
135    /// - Validation rules: Merged by link_type, rules combined for each link type
136    pub fn merge(configs: Vec<LinksConfig>) -> Self {
137        if configs.is_empty() {
138            return Self {
139                entities: vec![],
140                links: vec![],
141                validation_rules: None,
142                events: None,
143                sinks: None,
144            };
145        }
146
147        if configs.len() == 1 {
148            return configs.into_iter().next().unwrap();
149        }
150
151        let mut entities_map: IndexMap<String, EntityConfig> = IndexMap::new();
152        let mut links_map: IndexMap<(String, String, String), LinkDefinition> = IndexMap::new();
153        let mut validation_rules_map: HashMap<String, Vec<ValidationRule>> = HashMap::new();
154
155        // Merge entities (last one wins for duplicates)
156        for config in &configs {
157            for entity in &config.entities {
158                entities_map.insert(entity.singular.clone(), entity.clone());
159            }
160        }
161
162        // Merge links (last one wins for duplicates)
163        for config in &configs {
164            for link in &config.links {
165                let key = (
166                    link.link_type.clone(),
167                    link.source_type.clone(),
168                    link.target_type.clone(),
169                );
170                links_map.insert(key, link.clone());
171            }
172        }
173
174        // Merge validation rules (combine rules for same link_type)
175        for config in &configs {
176            if let Some(rules) = &config.validation_rules {
177                for (link_type, link_rules) in rules {
178                    validation_rules_map
179                        .entry(link_type.clone())
180                        .or_default()
181                        .extend(link_rules.clone());
182                }
183            }
184        }
185
186        // Merge events: backend last-wins, flows are concatenated (with duplicate warning)
187        let mut merged_events: Option<EventsConfig> = None;
188        for config in &configs {
189            if let Some(events) = &config.events {
190                if let Some(ref mut existing) = merged_events {
191                    // Backend: last-wins (consistent with entities/links merge behavior)
192                    existing.backend = events.backend.clone();
193                    existing.flows.extend(events.flows.clone());
194                    existing.consumers.extend(events.consumers.clone());
195                } else {
196                    merged_events = Some(events.clone());
197                }
198            }
199        }
200
201        // Detect duplicate flow names and warn
202        if let Some(ref events) = merged_events {
203            let mut seen_names = std::collections::HashSet::new();
204            for flow in &events.flows {
205                if !seen_names.insert(&flow.name) {
206                    tracing::warn!(
207                        flow_name = %flow.name,
208                        "config merge: duplicate flow name detected — \
209                         later definition will shadow earlier one at runtime"
210                    );
211                }
212            }
213        }
214
215        // Merge sinks: deduplicate by name (last wins), preserving insertion order
216        let mut sinks_map: IndexMap<String, SinkConfig> = IndexMap::new();
217        for config in &configs {
218            if let Some(sinks) = &config.sinks {
219                for sink in sinks {
220                    sinks_map.insert(sink.name.clone(), sink.clone());
221                }
222            }
223        }
224        let merged_sinks = if sinks_map.is_empty() {
225            None
226        } else {
227            Some(sinks_map.into_values().collect())
228        };
229
230        // Convert back to vectors
231        let entities: Vec<EntityConfig> = entities_map.into_values().collect();
232        let links: Vec<LinkDefinition> = links_map.into_values().collect();
233        let validation_rules = if validation_rules_map.is_empty() {
234            None
235        } else {
236            Some(validation_rules_map)
237        };
238
239        Self {
240            entities,
241            links,
242            validation_rules,
243            events: merged_events,
244            sinks: merged_sinks,
245        }
246    }
247
248    /// Validate if a link combination is allowed
249    ///
250    /// If no validation rules are defined, all combinations are allowed (permissive mode)
251    pub fn is_valid_link(&self, link_type: &str, source_type: &str, target_type: &str) -> bool {
252        // If no validation rules, accept everything
253        let Some(rules) = &self.validation_rules else {
254            return true;
255        };
256
257        // Check if there are rules for this link type
258        let Some(link_rules) = rules.get(link_type) else {
259            return true; // No rules for this link type, accept
260        };
261
262        // Check if the combination is in the rules
263        link_rules.iter().any(|rule| {
264            rule.source == source_type && rule.targets.contains(&target_type.to_string())
265        })
266    }
267
268    /// Find a link definition
269    pub fn find_link_definition(
270        &self,
271        link_type: &str,
272        source_type: &str,
273        target_type: &str,
274    ) -> Option<&LinkDefinition> {
275        self.links.iter().find(|def| {
276            def.link_type == link_type
277                && def.source_type == source_type
278                && def.target_type == target_type
279        })
280    }
281
282    /// Create a default configuration for testing
283    pub fn default_config() -> Self {
284        Self {
285            entities: vec![
286                EntityConfig {
287                    singular: "user".to_string(),
288                    plural: "users".to_string(),
289                    auth: EntityAuthConfig::default(),
290                },
291                EntityConfig {
292                    singular: "company".to_string(),
293                    plural: "companies".to_string(),
294                    auth: EntityAuthConfig::default(),
295                },
296                EntityConfig {
297                    singular: "car".to_string(),
298                    plural: "cars".to_string(),
299                    auth: EntityAuthConfig::default(),
300                },
301            ],
302            links: vec![
303                LinkDefinition {
304                    link_type: "owner".to_string(),
305                    source_type: "user".to_string(),
306                    target_type: "car".to_string(),
307                    forward_route_name: "cars-owned".to_string(),
308                    reverse_route_name: "users-owners".to_string(),
309                    description: Some("User owns a car".to_string()),
310                    required_fields: None,
311                    auth: None,
312                },
313                LinkDefinition {
314                    link_type: "driver".to_string(),
315                    source_type: "user".to_string(),
316                    target_type: "car".to_string(),
317                    forward_route_name: "cars-driven".to_string(),
318                    reverse_route_name: "users-drivers".to_string(),
319                    description: Some("User drives a car".to_string()),
320                    required_fields: None,
321                    auth: None,
322                },
323                LinkDefinition {
324                    link_type: "worker".to_string(),
325                    source_type: "user".to_string(),
326                    target_type: "company".to_string(),
327                    forward_route_name: "companies-work".to_string(),
328                    reverse_route_name: "users-workers".to_string(),
329                    description: Some("User works at a company".to_string()),
330                    required_fields: Some(vec!["role".to_string()]),
331                    auth: None,
332                },
333            ],
334            validation_rules: None,
335            events: None,
336            sinks: None,
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_default_config() {
347        let config = LinksConfig::default_config();
348
349        assert_eq!(config.entities.len(), 3);
350        assert_eq!(config.links.len(), 3);
351    }
352
353    #[test]
354    fn test_yaml_serialization() {
355        let config = LinksConfig::default_config();
356        let yaml = serde_yaml::to_string(&config).unwrap();
357
358        // Should be able to parse it back
359        let parsed = LinksConfig::from_yaml_str(&yaml).unwrap();
360        assert_eq!(parsed.entities.len(), config.entities.len());
361        assert_eq!(parsed.links.len(), config.links.len());
362    }
363
364    #[test]
365    fn test_link_auth_config_parsing() {
366        let yaml = r#"
367entities:
368  - singular: order
369    plural: orders
370  - singular: invoice
371    plural: invoices
372
373links:
374  - link_type: has_invoice
375    source_type: order
376    target_type: invoice
377    forward_route_name: invoices
378    reverse_route_name: order
379    auth:
380      list: authenticated
381      create: service_only
382      delete: admin_only
383"#;
384
385        let config = LinksConfig::from_yaml_str(yaml).unwrap();
386        assert_eq!(config.links.len(), 1);
387
388        let link_def = &config.links[0];
389        assert!(link_def.auth.is_some());
390
391        let auth = link_def.auth.as_ref().unwrap();
392        assert_eq!(auth.list, "authenticated");
393        assert_eq!(auth.create, "service_only");
394        assert_eq!(auth.delete, "admin_only");
395    }
396
397    #[test]
398    fn test_link_without_auth_config() {
399        let yaml = r#"
400entities:
401  - singular: invoice
402    plural: invoices
403  - singular: payment
404    plural: payments
405
406links:
407  - link_type: payment
408    source_type: invoice
409    target_type: payment
410    forward_route_name: payments
411    reverse_route_name: invoice
412"#;
413
414        let config = LinksConfig::from_yaml_str(yaml).unwrap();
415        assert_eq!(config.links.len(), 1);
416
417        let link_def = &config.links[0];
418        assert!(link_def.auth.is_none());
419    }
420
421    #[test]
422    fn test_mixed_link_auth_configs() {
423        let yaml = r#"
424entities:
425  - singular: order
426    plural: orders
427  - singular: invoice
428    plural: invoices
429  - singular: payment
430    plural: payments
431
432links:
433  - link_type: has_invoice
434    source_type: order
435    target_type: invoice
436    forward_route_name: invoices
437    reverse_route_name: order
438    auth:
439      list: authenticated
440      create: service_only
441      delete: admin_only
442  
443  - link_type: payment
444    source_type: invoice
445    target_type: payment
446    forward_route_name: payments
447    reverse_route_name: invoice
448"#;
449
450        let config = LinksConfig::from_yaml_str(yaml).unwrap();
451        assert_eq!(config.links.len(), 2);
452
453        // First link has auth
454        assert!(config.links[0].auth.is_some());
455        let auth1 = config.links[0].auth.as_ref().unwrap();
456        assert_eq!(auth1.create, "service_only");
457
458        // Second link has no auth
459        assert!(config.links[1].auth.is_none());
460    }
461
462    #[test]
463    fn test_merge_empty() {
464        let merged = LinksConfig::merge(vec![]);
465        assert_eq!(merged.entities.len(), 0);
466        assert_eq!(merged.links.len(), 0);
467    }
468
469    #[test]
470    fn test_merge_single() {
471        let config = LinksConfig::default_config();
472        let merged = LinksConfig::merge(vec![config.clone()]);
473        assert_eq!(merged.entities.len(), config.entities.len());
474        assert_eq!(merged.links.len(), config.links.len());
475    }
476
477    #[test]
478    fn test_merge_disjoint_configs() {
479        let config1 = LinksConfig {
480            entities: vec![EntityConfig {
481                singular: "order".to_string(),
482                plural: "orders".to_string(),
483                auth: EntityAuthConfig::default(),
484            }],
485            links: vec![],
486            validation_rules: None,
487            events: None,
488            sinks: None,
489        };
490
491        let config2 = LinksConfig {
492            entities: vec![EntityConfig {
493                singular: "invoice".to_string(),
494                plural: "invoices".to_string(),
495                auth: EntityAuthConfig::default(),
496            }],
497            links: vec![],
498            validation_rules: None,
499            events: None,
500            sinks: None,
501        };
502
503        let merged = LinksConfig::merge(vec![config1, config2]);
504        assert_eq!(merged.entities.len(), 2);
505    }
506
507    #[test]
508    fn test_merge_overlapping_entities() {
509        let auth1 = EntityAuthConfig {
510            list: "public".to_string(),
511            ..Default::default()
512        };
513
514        let config1 = LinksConfig {
515            entities: vec![EntityConfig {
516                singular: "user".to_string(),
517                plural: "users".to_string(),
518                auth: auth1,
519            }],
520            links: vec![],
521            validation_rules: None,
522            events: None,
523            sinks: None,
524        };
525
526        let auth2 = EntityAuthConfig {
527            list: "authenticated".to_string(),
528            ..Default::default()
529        };
530
531        let config2 = LinksConfig {
532            entities: vec![EntityConfig {
533                singular: "user".to_string(),
534                plural: "users".to_string(),
535                auth: auth2,
536            }],
537            links: vec![],
538            validation_rules: None,
539            events: None,
540            sinks: None,
541        };
542
543        let merged = LinksConfig::merge(vec![config1, config2]);
544
545        // Should have only 1 entity (last wins)
546        assert_eq!(merged.entities.len(), 1);
547        assert_eq!(merged.entities[0].auth.list, "authenticated");
548    }
549
550    #[test]
551    fn test_merge_validation_rules() {
552        let mut rules1 = HashMap::new();
553        rules1.insert(
554            "works_at".to_string(),
555            vec![ValidationRule {
556                source: "user".to_string(),
557                targets: vec!["company".to_string()],
558            }],
559        );
560
561        let config1 = LinksConfig {
562            entities: vec![],
563            links: vec![],
564            validation_rules: Some(rules1),
565            events: None,
566            sinks: None,
567        };
568
569        let mut rules2 = HashMap::new();
570        rules2.insert(
571            "works_at".to_string(),
572            vec![ValidationRule {
573                source: "user".to_string(),
574                targets: vec!["project".to_string()],
575            }],
576        );
577
578        let config2 = LinksConfig {
579            entities: vec![],
580            links: vec![],
581            validation_rules: Some(rules2),
582            events: None,
583            sinks: None,
584        };
585
586        let merged = LinksConfig::merge(vec![config1, config2]);
587
588        // Validation rules should be combined
589        assert!(merged.validation_rules.is_some());
590        let rules = merged.validation_rules.unwrap();
591        assert_eq!(rules["works_at"].len(), 2);
592    }
593
594    #[test]
595    fn test_find_link_definition_found() {
596        let config = LinksConfig::default_config();
597
598        let def = config.find_link_definition("owner", "user", "car");
599        assert!(def.is_some(), "should find owner link from user to car");
600        let def = def.expect("link definition should exist");
601        assert_eq!(def.link_type, "owner");
602        assert_eq!(def.source_type, "user");
603        assert_eq!(def.target_type, "car");
604    }
605
606    #[test]
607    fn test_find_link_definition_not_found() {
608        let config = LinksConfig::default_config();
609
610        let def = config.find_link_definition("nonexistent", "user", "car");
611        assert!(def.is_none(), "should not find a nonexistent link type");
612
613        // Wrong source type
614        let def = config.find_link_definition("owner", "company", "car");
615        assert!(def.is_none(), "should not find link with wrong source type");
616    }
617
618    #[test]
619    fn test_is_valid_link_source_type_mismatch() {
620        let mut rules = HashMap::new();
621        rules.insert(
622            "owner".to_string(),
623            vec![ValidationRule {
624                source: "user".to_string(),
625                targets: vec!["car".to_string()],
626            }],
627        );
628
629        let config = LinksConfig {
630            entities: vec![],
631            links: vec![],
632            validation_rules: Some(rules),
633            events: None,
634            sinks: None,
635        };
636
637        // Correct combination
638        assert!(config.is_valid_link("owner", "user", "car"));
639
640        // Source type mismatch
641        assert!(
642            !config.is_valid_link("owner", "company", "car"),
643            "should reject mismatched source type"
644        );
645
646        // Target type mismatch
647        assert!(
648            !config.is_valid_link("owner", "user", "truck"),
649            "should reject mismatched target type"
650        );
651    }
652
653    #[test]
654    fn test_is_valid_link_empty_targets() {
655        let mut rules = HashMap::new();
656        rules.insert(
657            "membership".to_string(),
658            vec![ValidationRule {
659                source: "user".to_string(),
660                targets: vec![], // empty targets list
661            }],
662        );
663
664        let config = LinksConfig {
665            entities: vec![],
666            links: vec![],
667            validation_rules: Some(rules),
668            events: None,
669            sinks: None,
670        };
671
672        // With empty targets, no target type can match
673        assert!(
674            !config.is_valid_link("membership", "user", "group"),
675            "should reject when targets list is empty"
676        );
677    }
678
679    #[test]
680    fn test_yaml_backward_compatible_without_events() {
681        // Old-style YAML without events/sinks should still parse
682        let yaml = r#"
683entities:
684  - singular: user
685    plural: users
686links:
687  - link_type: follows
688    source_type: user
689    target_type: user
690    forward_route_name: following
691    reverse_route_name: followers
692"#;
693
694        let config = LinksConfig::from_yaml_str(yaml).unwrap();
695        assert_eq!(config.entities.len(), 1);
696        assert_eq!(config.links.len(), 1);
697        assert!(config.events.is_none());
698        assert!(config.sinks.is_none());
699    }
700
701    #[test]
702    fn test_yaml_with_events_and_sinks() {
703        let yaml = r#"
704entities:
705  - singular: user
706    plural: users
707  - singular: capture
708    plural: captures
709
710links:
711  - link_type: follows
712    source_type: user
713    target_type: user
714    forward_route_name: following
715    reverse_route_name: followers
716  - link_type: likes
717    source_type: user
718    target_type: capture
719    forward_route_name: liked-captures
720    reverse_route_name: likers
721  - link_type: owns
722    source_type: user
723    target_type: capture
724    forward_route_name: captures
725    reverse_route_name: owner
726
727events:
728  backend:
729    type: memory
730  flows:
731    - name: notify-new-follower
732      trigger:
733        kind: link.created
734        link_type: follows
735      pipeline:
736        - resolve:
737            from: source_id
738            as: follower
739        - map:
740            template:
741              type: follow
742              message: "{{ follower.name }} started following you"
743        - deliver:
744            sinks: [push-notification, in-app-notification]
745    - name: notify-like
746      trigger:
747        kind: link.created
748        link_type: likes
749      pipeline:
750        - resolve:
751            from: target_id
752            via: owns
753            direction: reverse
754            as: owner
755        - filter:
756            condition: "source_id != owner.id"
757        - batch:
758            key: target_id
759            window: 5m
760        - deliver:
761            sink: push-notification
762  consumers:
763    - name: mobile-feed
764      seek: last_acknowledged
765
766sinks:
767  - name: push-notification
768    type: push
769    config:
770      provider: expo
771  - name: in-app-notification
772    type: in_app
773    config:
774      ttl: 30d
775"#;
776
777        let config = LinksConfig::from_yaml_str(yaml).unwrap();
778
779        // Entities and links
780        assert_eq!(config.entities.len(), 2);
781        assert_eq!(config.links.len(), 3);
782
783        // Events
784        assert!(config.events.is_some());
785        let events = config.events.as_ref().unwrap();
786        assert_eq!(events.backend.backend_type, "memory");
787        assert_eq!(events.flows.len(), 2);
788        assert_eq!(events.flows[0].name, "notify-new-follower");
789        assert_eq!(events.flows[1].name, "notify-like");
790        assert_eq!(events.consumers.len(), 1);
791        assert_eq!(events.consumers[0].name, "mobile-feed");
792
793        // Sinks
794        assert!(config.sinks.is_some());
795        let sinks = config.sinks.as_ref().unwrap();
796        assert_eq!(sinks.len(), 2);
797        assert_eq!(sinks[0].name, "push-notification");
798        assert_eq!(sinks[0].sink_type, SinkType::Push);
799        assert_eq!(sinks[1].name, "in-app-notification");
800        assert_eq!(sinks[1].sink_type, SinkType::InApp);
801    }
802
803    #[test]
804    fn test_merge_configs_with_events() {
805        let config1 = LinksConfig {
806            entities: vec![EntityConfig {
807                singular: "user".to_string(),
808                plural: "users".to_string(),
809                auth: EntityAuthConfig::default(),
810            }],
811            links: vec![],
812            validation_rules: None,
813            events: Some(EventsConfig {
814                backend: BackendConfig::default(),
815                flows: vec![FlowConfig {
816                    name: "flow-a".to_string(),
817                    description: None,
818                    trigger: TriggerConfig {
819                        kind: "link.created".to_string(),
820                        link_type: Some("follows".to_string()),
821                        entity_type: None,
822                    },
823                    pipeline: vec![],
824                }],
825                consumers: vec![],
826            }),
827            sinks: Some(vec![SinkConfig {
828                name: "push".to_string(),
829                sink_type: SinkType::Push,
830                config: HashMap::new(),
831            }]),
832        };
833
834        let config2 = LinksConfig {
835            entities: vec![],
836            links: vec![],
837            validation_rules: None,
838            events: Some(EventsConfig {
839                backend: BackendConfig::default(),
840                flows: vec![FlowConfig {
841                    name: "flow-b".to_string(),
842                    description: None,
843                    trigger: TriggerConfig {
844                        kind: "entity.created".to_string(),
845                        link_type: None,
846                        entity_type: Some("user".to_string()),
847                    },
848                    pipeline: vec![],
849                }],
850                consumers: vec![ConsumerConfig {
851                    name: "mobile".to_string(),
852                    seek: SeekMode::LastAcknowledged,
853                }],
854            }),
855            sinks: Some(vec![SinkConfig {
856                name: "in-app".to_string(),
857                sink_type: SinkType::InApp,
858                config: HashMap::new(),
859            }]),
860        };
861
862        let merged = LinksConfig::merge(vec![config1, config2]);
863
864        // Events should be merged
865        let events = merged.events.unwrap();
866        assert_eq!(events.flows.len(), 2);
867        assert_eq!(events.flows[0].name, "flow-a");
868        assert_eq!(events.flows[1].name, "flow-b");
869        assert_eq!(events.consumers.len(), 1);
870
871        // Sinks should be merged (deduplicated by name)
872        let sinks = merged.sinks.unwrap();
873        assert_eq!(sinks.len(), 2);
874    }
875}