1use crate::config::LinksConfig;
7use crate::core::LinkDefinition;
8use anyhow::{Result, anyhow};
9use std::collections::HashMap;
10use std::sync::Arc;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum LinkDirection {
15 Forward,
17 Reverse,
19}
20
21pub struct LinkRouteRegistry {
26 config: Arc<LinksConfig>,
27 routes: HashMap<(String, String), (LinkDefinition, LinkDirection)>,
29}
30
31impl LinkRouteRegistry {
32 pub fn new(config: Arc<LinksConfig>) -> Self {
34 let mut routes = HashMap::new();
35
36 for link_def in &config.links {
38 let forward_key = (
40 link_def.source_type.clone(),
41 link_def.forward_route_name.clone(),
42 );
43 routes.insert(forward_key, (link_def.clone(), LinkDirection::Forward));
44
45 let reverse_key = (
47 link_def.target_type.clone(),
48 link_def.reverse_route_name.clone(),
49 );
50 routes.insert(reverse_key, (link_def.clone(), LinkDirection::Reverse));
51 }
52
53 Self { config, routes }
54 }
55
56 pub fn resolve_route(
60 &self,
61 entity_type: &str,
62 route_name: &str,
63 ) -> Result<(LinkDefinition, LinkDirection)> {
64 let key = (entity_type.to_string(), route_name.to_string());
65
66 self.routes.get(&key).cloned().ok_or_else(|| {
67 anyhow!(
68 "No route '{}' found for entity type '{}'",
69 route_name,
70 entity_type
71 )
72 })
73 }
74
75 pub fn list_routes_for_entity(&self, entity_type: &str) -> Vec<RouteInfo> {
77 self.routes
78 .iter()
79 .filter(|((etype, _), _)| etype == entity_type)
80 .map(|((_, route_name), (link_def, direction))| {
81 let connected_to = match direction {
82 LinkDirection::Forward => &link_def.target_type,
83 LinkDirection::Reverse => &link_def.source_type,
84 };
85
86 RouteInfo {
87 route_name: route_name.clone(),
88 link_type: link_def.link_type.clone(),
89 direction: *direction,
90 connected_to: connected_to.clone(),
91 description: link_def.description.clone(),
92 }
93 })
94 .collect()
95 }
96
97 pub fn config(&self) -> &LinksConfig {
99 &self.config
100 }
101
102 pub fn detect_link_chains(&self, max_depth: usize) -> Vec<LinkChain> {
108 let mut chains = Vec::new();
109
110 for entity_config in &self.config.entities {
112 self.find_chains_from_entity(
113 &entity_config.singular,
114 &mut vec![LinkChainStep {
115 entity_type: entity_config.singular.clone(),
116 route_name: None,
117 direction: LinkDirection::Forward,
118 }],
119 &mut chains,
120 max_depth,
121 &mut std::collections::HashSet::new(),
122 );
123 }
124
125 for entity_config in &self.config.entities {
127 self.find_reverse_chains_from_entity(
128 &entity_config.singular,
129 &mut vec![LinkChainStep {
130 entity_type: entity_config.singular.clone(),
131 route_name: None,
132 direction: LinkDirection::Reverse,
133 }],
134 &mut chains,
135 max_depth,
136 &mut std::collections::HashSet::new(),
137 );
138 }
139
140 chains
141 }
142
143 fn find_chains_from_entity(
145 &self,
146 entity_type: &str,
147 current_chain: &mut Vec<LinkChainStep>,
148 chains: &mut Vec<LinkChain>,
149 remaining_depth: usize,
150 visited: &mut std::collections::HashSet<String>,
151 ) {
152 if remaining_depth == 0 {
153 return;
154 }
155
156 for link_def in &self.config.links {
158 if link_def.source_type == entity_type {
159 let edge = format!("{}->{}", link_def.source_type, link_def.target_type);
160
161 if visited.contains(&edge) {
163 continue;
164 }
165
166 visited.insert(edge.clone());
167
168 let route_name = Some(link_def.forward_route_name.clone());
170
171 current_chain.push(LinkChainStep {
172 entity_type: link_def.target_type.clone(),
173 route_name,
174 direction: LinkDirection::Forward,
175 });
176
177 if current_chain.len() >= 2 {
179 chains.push(LinkChain {
180 steps: current_chain.clone(),
181 config: self.config.clone(),
182 });
183 }
184
185 self.find_chains_from_entity(
187 &link_def.target_type,
188 current_chain,
189 chains,
190 remaining_depth - 1,
191 visited,
192 );
193
194 visited.remove(&edge);
196 current_chain.pop();
197 }
198 }
199 }
200
201 fn find_reverse_chains_from_entity(
203 &self,
204 entity_type: &str,
205 current_chain: &mut Vec<LinkChainStep>,
206 chains: &mut Vec<LinkChain>,
207 remaining_depth: usize,
208 visited: &mut std::collections::HashSet<String>,
209 ) {
210 if remaining_depth == 0 {
211 return;
212 }
213
214 for link_def in &self.config.links {
216 if link_def.target_type == entity_type {
217 let edge = format!("{}<-{}", link_def.source_type, link_def.target_type);
218
219 if visited.contains(&edge) {
221 continue;
222 }
223
224 visited.insert(edge.clone());
225
226 let route_name = Some(link_def.reverse_route_name.clone());
228
229 current_chain.push(LinkChainStep {
230 entity_type: link_def.source_type.clone(),
231 route_name,
232 direction: LinkDirection::Reverse,
233 });
234
235 if current_chain.len() >= 2 {
237 chains.push(LinkChain {
238 steps: current_chain.clone(),
239 config: self.config.clone(),
240 });
241 }
242
243 self.find_reverse_chains_from_entity(
245 &link_def.source_type,
246 current_chain,
247 chains,
248 remaining_depth - 1,
249 visited,
250 );
251
252 visited.remove(&edge);
254 current_chain.pop();
255 }
256 }
257 }
258}
259
260#[derive(Debug, Clone)]
262pub struct LinkChain {
263 pub steps: Vec<LinkChainStep>,
264 pub config: Arc<LinksConfig>,
265}
266
267#[derive(Debug, Clone)]
269pub struct LinkChainStep {
270 pub entity_type: String,
271 pub route_name: Option<String>,
272 pub direction: LinkDirection,
273}
274
275impl LinkChain {
276 pub fn to_route_pattern(&self) -> String {
284 let mut pattern = String::new();
285 let steps_count = self.steps.len();
286
287 for (idx, step) in self.steps.iter().enumerate() {
288 if step.route_name.is_none() {
289 let plural = self.get_plural(&step.entity_type);
291 let param_name = format!("{}_id", step.entity_type);
292 pattern.push_str(&format!("/{plural}/{{{}}}", param_name));
293 } else if let Some(route_name) = &step.route_name {
294 let segment = if idx == steps_count - 1 {
297 self.get_plural(&step.entity_type)
299 } else {
300 route_name.clone()
302 };
303 pattern.push_str(&format!("/{segment}"));
304
305 if idx < steps_count - 1 {
308 let param_name = format!("{}_id", step.entity_type);
309 pattern.push_str(&format!("/{{{}}}", param_name));
310 }
311 }
312 }
313
314 pattern
315 }
316
317 pub fn is_reverse(&self) -> bool {
319 self.steps
320 .first()
321 .map(|s| s.direction == LinkDirection::Reverse)
322 .unwrap_or(false)
323 }
324
325 fn get_plural(&self, singular: &str) -> String {
326 self.config
327 .entities
328 .iter()
329 .find(|e| e.singular == singular)
330 .map(|e| e.plural.clone())
331 .unwrap_or_else(|| format!("{}s", singular))
332 }
333}
334
335#[derive(Debug, Clone)]
337pub struct RouteInfo {
338 pub route_name: String,
340
341 pub link_type: String,
343
344 pub direction: LinkDirection,
346
347 pub connected_to: String,
349
350 pub description: Option<String>,
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::config::EntityConfig;
358
359 fn create_test_config() -> LinksConfig {
360 LinksConfig {
361 entities: vec![
362 EntityConfig {
363 singular: "user".to_string(),
364 plural: "users".to_string(),
365 auth: crate::config::EntityAuthConfig::default(),
366 },
367 EntityConfig {
368 singular: "car".to_string(),
369 plural: "cars".to_string(),
370 auth: crate::config::EntityAuthConfig::default(),
371 },
372 ],
373 links: vec![
374 LinkDefinition {
375 link_type: "owner".to_string(),
376 source_type: "user".to_string(),
377 target_type: "car".to_string(),
378 forward_route_name: "cars-owned".to_string(),
379 reverse_route_name: "users-owners".to_string(),
380 description: Some("User owns a car".to_string()),
381 required_fields: None,
382 auth: None,
383 },
384 LinkDefinition {
385 link_type: "driver".to_string(),
386 source_type: "user".to_string(),
387 target_type: "car".to_string(),
388 forward_route_name: "cars-driven".to_string(),
389 reverse_route_name: "users-drivers".to_string(),
390 description: Some("User drives a car".to_string()),
391 required_fields: None,
392 auth: None,
393 },
394 ],
395 validation_rules: None,
396 events: None,
397 sinks: None,
398 }
399 }
400
401 #[test]
402 fn test_resolve_forward_route() {
403 let config = Arc::new(create_test_config());
404 let registry = LinkRouteRegistry::new(config);
405
406 let (def, direction) = registry.resolve_route("user", "cars-owned").unwrap();
407
408 assert_eq!(def.link_type, "owner");
409 assert_eq!(def.source_type, "user");
410 assert_eq!(def.target_type, "car");
411 assert_eq!(direction, LinkDirection::Forward);
412 }
413
414 #[test]
415 fn test_resolve_reverse_route() {
416 let config = Arc::new(create_test_config());
417 let registry = LinkRouteRegistry::new(config);
418
419 let (def, direction) = registry.resolve_route("car", "users-owners").unwrap();
420
421 assert_eq!(def.link_type, "owner");
422 assert_eq!(def.source_type, "user");
423 assert_eq!(def.target_type, "car");
424 assert_eq!(direction, LinkDirection::Reverse);
425 }
426
427 #[test]
428 fn test_list_routes_for_entity() {
429 let config = Arc::new(create_test_config());
430 let registry = LinkRouteRegistry::new(config);
431
432 let routes = registry.list_routes_for_entity("user");
433
434 assert_eq!(routes.len(), 2);
435
436 let route_names: Vec<_> = routes.iter().map(|r| r.route_name.as_str()).collect();
437 assert!(route_names.contains(&"cars-owned"));
438 assert!(route_names.contains(&"cars-driven"));
439 }
440
441 #[test]
442 fn test_no_route_conflicts() {
443 let config = Arc::new(create_test_config());
444 let registry = LinkRouteRegistry::new(config);
445
446 let user_routes = registry.list_routes_for_entity("user");
447 let route_names: Vec<_> = user_routes.iter().map(|r| &r.route_name).collect();
448
449 let unique_names: std::collections::HashSet<_> = route_names.iter().collect();
450 assert_eq!(
451 route_names.len(),
452 unique_names.len(),
453 "Route names must be unique"
454 );
455 }
456
457 fn create_chain_config() -> LinksConfig {
460 LinksConfig {
461 entities: vec![
462 EntityConfig {
463 singular: "order".to_string(),
464 plural: "orders".to_string(),
465 auth: crate::config::EntityAuthConfig::default(),
466 },
467 EntityConfig {
468 singular: "invoice".to_string(),
469 plural: "invoices".to_string(),
470 auth: crate::config::EntityAuthConfig::default(),
471 },
472 EntityConfig {
473 singular: "payment".to_string(),
474 plural: "payments".to_string(),
475 auth: crate::config::EntityAuthConfig::default(),
476 },
477 ],
478 links: vec![
479 LinkDefinition {
480 link_type: "billing".to_string(),
481 source_type: "order".to_string(),
482 target_type: "invoice".to_string(),
483 forward_route_name: "invoices".to_string(),
484 reverse_route_name: "order".to_string(),
485 description: None,
486 required_fields: None,
487 auth: None,
488 },
489 LinkDefinition {
490 link_type: "payment".to_string(),
491 source_type: "invoice".to_string(),
492 target_type: "payment".to_string(),
493 forward_route_name: "payments".to_string(),
494 reverse_route_name: "invoice".to_string(),
495 description: None,
496 required_fields: None,
497 auth: None,
498 },
499 ],
500 validation_rules: None,
501 events: None,
502 sinks: None,
503 }
504 }
505
506 fn create_cycle_config() -> LinksConfig {
509 LinksConfig {
510 entities: vec![
511 EntityConfig {
512 singular: "a".to_string(),
513 plural: "as".to_string(),
514 auth: crate::config::EntityAuthConfig::default(),
515 },
516 EntityConfig {
517 singular: "b".to_string(),
518 plural: "bs".to_string(),
519 auth: crate::config::EntityAuthConfig::default(),
520 },
521 ],
522 links: vec![
523 LinkDefinition {
524 link_type: "ab".to_string(),
525 source_type: "a".to_string(),
526 target_type: "b".to_string(),
527 forward_route_name: "bs".to_string(),
528 reverse_route_name: "as-from-b".to_string(),
529 description: None,
530 required_fields: None,
531 auth: None,
532 },
533 LinkDefinition {
534 link_type: "ba".to_string(),
535 source_type: "b".to_string(),
536 target_type: "a".to_string(),
537 forward_route_name: "as".to_string(),
538 reverse_route_name: "bs-from-a".to_string(),
539 description: None,
540 required_fields: None,
541 auth: None,
542 },
543 ],
544 validation_rules: None,
545 events: None,
546 sinks: None,
547 }
548 }
549
550 fn create_empty_config() -> LinksConfig {
553 LinksConfig {
554 entities: vec![],
555 links: vec![],
556 validation_rules: None,
557 events: None,
558 sinks: None,
559 }
560 }
561
562 #[test]
567 fn test_detect_link_chains_simple_chain() {
568 let config = Arc::new(create_chain_config());
569 let registry = LinkRouteRegistry::new(config);
570
571 let chains = registry.detect_link_chains(5);
572
573 let forward_from_order: Vec<_> = chains
575 .iter()
576 .filter(|c| {
577 !c.is_reverse()
578 && c.steps
579 .first()
580 .map(|s| s.entity_type == "order")
581 .unwrap_or(false)
582 })
583 .collect();
584
585 assert!(
586 forward_from_order.len() >= 2,
587 "expected at least 2 forward chains from order (1-step and 2-step), got {}",
588 forward_from_order.len()
589 );
590
591 let three_step = forward_from_order
593 .iter()
594 .find(|c| c.steps.len() == 3)
595 .expect("expected a 3-step chain order→invoice→payment");
596
597 assert_eq!(three_step.steps[0].entity_type, "order");
598 assert_eq!(three_step.steps[1].entity_type, "invoice");
599 assert_eq!(three_step.steps[2].entity_type, "payment");
600 }
601
602 #[test]
603 fn test_detect_link_chains_cycle_detection() {
604 let config = Arc::new(create_cycle_config());
605 let registry = LinkRouteRegistry::new(config);
606
607 let chains = registry.detect_link_chains(10);
609
610 assert!(
614 !chains.is_empty(),
615 "should detect at least some chains even with cycles"
616 );
617
618 for chain in &chains {
620 let len = chain.steps.len();
621 assert!(
622 len <= 4,
623 "chain length {} is suspiciously long for a 2-node cycle graph",
624 len
625 );
626 }
627 }
628
629 #[test]
630 fn test_detect_link_chains_max_depth_limits_traversal() {
631 let config = Arc::new(create_chain_config());
632 let registry = LinkRouteRegistry::new(config);
633
634 let chains_depth1 = registry.detect_link_chains(1);
635 let chains_depth5 = registry.detect_link_chains(5);
636
637 for chain in &chains_depth1 {
639 assert!(
640 chain.steps.len() <= 2,
641 "max_depth=1 should limit chains to 2 steps, got {}",
642 chain.steps.len()
643 );
644 }
645
646 let has_three_step = chains_depth5.iter().any(|c| c.steps.len() == 3);
648 assert!(has_three_step, "max_depth=5 should allow 3-step chains");
649 }
650
651 #[test]
652 fn test_detect_link_chains_forward_chains_detected() {
653 let config = Arc::new(create_chain_config());
654 let registry = LinkRouteRegistry::new(config);
655
656 let chains = registry.detect_link_chains(5);
657 let forward_chains: Vec<_> = chains.iter().filter(|c| !c.is_reverse()).collect();
658
659 assert!(
660 !forward_chains.is_empty(),
661 "should detect at least one forward chain"
662 );
663
664 for chain in &forward_chains {
666 for step in &chain.steps {
667 assert_eq!(
668 step.direction,
669 LinkDirection::Forward,
670 "all steps in a forward chain should have Forward direction"
671 );
672 }
673 }
674 }
675
676 #[test]
677 fn test_detect_link_chains_reverse_chains_detected() {
678 let config = Arc::new(create_chain_config());
679 let registry = LinkRouteRegistry::new(config);
680
681 let chains = registry.detect_link_chains(5);
682 let reverse_chains: Vec<_> = chains.iter().filter(|c| c.is_reverse()).collect();
683
684 assert!(
685 !reverse_chains.is_empty(),
686 "should detect at least one reverse chain"
687 );
688
689 for chain in &reverse_chains {
691 for step in &chain.steps {
692 assert_eq!(
693 step.direction,
694 LinkDirection::Reverse,
695 "all steps in a reverse chain should have Reverse direction"
696 );
697 }
698 }
699 }
700
701 #[test]
702 fn test_detect_link_chains_empty_config() {
703 let config = Arc::new(create_empty_config());
704 let registry = LinkRouteRegistry::new(config);
705
706 let chains = registry.detect_link_chains(5);
707 assert!(
708 chains.is_empty(),
709 "empty config should produce no chains, got {}",
710 chains.len()
711 );
712 }
713
714 #[test]
719 fn test_to_route_pattern_single_step_chain() {
720 let config = Arc::new(create_chain_config());
721 let registry = LinkRouteRegistry::new(config);
722
723 let chains = registry.detect_link_chains(5);
724
725 let single_hop = chains
727 .iter()
728 .find(|c| {
729 c.steps.len() == 2
730 && !c.is_reverse()
731 && c.steps[0].entity_type == "order"
732 && c.steps[1].entity_type == "invoice"
733 })
734 .expect("expected a 2-step forward chain order→invoice");
735
736 let pattern = single_hop.to_route_pattern();
737
738 assert_eq!(
740 pattern, "/orders/{order_id}/invoices",
741 "single hop pattern mismatch"
742 );
743 }
744
745 #[test]
746 fn test_to_route_pattern_multi_step_chain() {
747 let config = Arc::new(create_chain_config());
748 let registry = LinkRouteRegistry::new(config);
749
750 let chains = registry.detect_link_chains(5);
751
752 let multi_hop = chains
754 .iter()
755 .find(|c| {
756 c.steps.len() == 3
757 && !c.is_reverse()
758 && c.steps[0].entity_type == "order"
759 && c.steps[2].entity_type == "payment"
760 })
761 .expect("expected a 3-step forward chain order→invoice→payment");
762
763 let pattern = multi_hop.to_route_pattern();
764
765 assert_eq!(
767 pattern, "/orders/{order_id}/invoices/{invoice_id}/payments",
768 "multi-step pattern mismatch"
769 );
770 }
771
772 #[test]
773 fn test_to_route_pattern_plural_fallback() {
774 let config = Arc::new(LinksConfig {
777 entities: vec![
778 EntityConfig {
779 singular: "widget".to_string(),
780 plural: "widgets".to_string(),
781 auth: crate::config::EntityAuthConfig::default(),
782 },
783 ],
785 links: vec![LinkDefinition {
786 link_type: "contains".to_string(),
787 source_type: "widget".to_string(),
788 target_type: "gadget".to_string(),
789 forward_route_name: "gadgets".to_string(),
790 reverse_route_name: "widget".to_string(),
791 description: None,
792 required_fields: None,
793 auth: None,
794 }],
795 validation_rules: None,
796 events: None,
797 sinks: None,
798 });
799
800 let chain = LinkChain {
802 steps: vec![
803 LinkChainStep {
804 entity_type: "unknown_thing".to_string(),
805 route_name: None,
806 direction: LinkDirection::Forward,
807 },
808 LinkChainStep {
809 entity_type: "gadget".to_string(),
810 route_name: Some("gadgets".to_string()),
811 direction: LinkDirection::Forward,
812 },
813 ],
814 config,
815 };
816
817 let pattern = chain.to_route_pattern();
818
819 assert_eq!(
822 pattern, "/unknown_things/{unknown_thing_id}/gadgets",
823 "fallback plural should append 's' for unknown entity types"
824 );
825 }
826
827 #[test]
832 fn test_is_reverse_forward_chain() {
833 let config = Arc::new(create_chain_config());
834
835 let chain = LinkChain {
836 steps: vec![
837 LinkChainStep {
838 entity_type: "order".to_string(),
839 route_name: None,
840 direction: LinkDirection::Forward,
841 },
842 LinkChainStep {
843 entity_type: "invoice".to_string(),
844 route_name: Some("invoices".to_string()),
845 direction: LinkDirection::Forward,
846 },
847 ],
848 config,
849 };
850
851 assert!(
852 !chain.is_reverse(),
853 "chain starting with Forward direction should not be reverse"
854 );
855 }
856
857 #[test]
858 fn test_is_reverse_reverse_chain() {
859 let config = Arc::new(create_chain_config());
860
861 let chain = LinkChain {
862 steps: vec![
863 LinkChainStep {
864 entity_type: "payment".to_string(),
865 route_name: None,
866 direction: LinkDirection::Reverse,
867 },
868 LinkChainStep {
869 entity_type: "invoice".to_string(),
870 route_name: Some("invoice".to_string()),
871 direction: LinkDirection::Reverse,
872 },
873 ],
874 config,
875 };
876
877 assert!(
878 chain.is_reverse(),
879 "chain starting with Reverse direction should be reverse"
880 );
881 }
882
883 #[test]
884 fn test_is_reverse_empty_chain() {
885 let config = Arc::new(create_chain_config());
886
887 let chain = LinkChain {
888 steps: vec![],
889 config,
890 };
891
892 assert!(
893 !chain.is_reverse(),
894 "empty chain should return false for is_reverse"
895 );
896 }
897
898 #[test]
903 fn test_resolve_route_nonexistent() {
904 let config = Arc::new(create_test_config());
905 let registry = LinkRouteRegistry::new(config);
906
907 let result = registry.resolve_route("user", "nonexistent-route");
908 assert!(
909 result.is_err(),
910 "resolving a nonexistent route should return an error"
911 );
912
913 let err_msg = result.unwrap_err().to_string();
914 assert!(
915 err_msg.contains("nonexistent-route"),
916 "error message should contain the route name, got: {}",
917 err_msg
918 );
919 assert!(
920 err_msg.contains("user"),
921 "error message should contain the entity type, got: {}",
922 err_msg
923 );
924 }
925
926 #[test]
927 fn test_list_routes_for_unknown_entity() {
928 let config = Arc::new(create_test_config());
929 let registry = LinkRouteRegistry::new(config);
930
931 let routes = registry.list_routes_for_entity("unknown_type");
932 assert!(
933 routes.is_empty(),
934 "listing routes for an unknown entity should return an empty vec"
935 );
936 }
937
938 #[test]
939 fn test_resolve_route_wrong_entity_type() {
940 let config = Arc::new(create_test_config());
942 let registry = LinkRouteRegistry::new(config);
943
944 let result = registry.resolve_route("car", "cars-owned");
945 assert!(
946 result.is_err(),
947 "resolving a route with wrong entity type should return an error"
948 );
949 }
950
951 #[test]
952 fn test_config_accessor() {
953 let config = Arc::new(create_test_config());
954 let registry = LinkRouteRegistry::new(config.clone());
955
956 let returned_config = registry.config();
957 assert_eq!(
958 returned_config.entities.len(),
959 config.entities.len(),
960 "config() should return the original configuration"
961 );
962 assert_eq!(
963 returned_config.links.len(),
964 config.links.len(),
965 "config() should return the original configuration"
966 );
967 }
968
969 #[test]
970 fn test_list_routes_for_entity_reverse_direction() {
971 let config = Arc::new(create_test_config());
972 let registry = LinkRouteRegistry::new(config);
973
974 let car_routes = registry.list_routes_for_entity("car");
976 assert_eq!(car_routes.len(), 2, "car should have 2 reverse routes");
977
978 for route in &car_routes {
979 assert_eq!(
980 route.direction,
981 LinkDirection::Reverse,
982 "car routes should all be Reverse direction"
983 );
984 assert_eq!(
985 route.connected_to, "user",
986 "car routes should connect to user"
987 );
988 }
989 }
990}