1use std::collections::HashSet;
79use std::fmt::Debug;
80use std::hash::Hash;
81
82use serde::Deserialize;
83use serde::Serialize;
84use thiserror::Error;
85
86const MAX_NAME_LENGTH: usize = 32;
88
89#[derive(Clone, Debug, Eq, Error, PartialEq)]
91pub enum NameError {
92 #[error("name cannot be empty")]
94 Empty,
95 #[error("name exceeds maximum length of {MAX_NAME_LENGTH} characters: {0} characters")]
97 TooLong(usize),
98 #[error("name contains non-ASCII characters")]
100 NonAscii,
101}
102
103fn validate_name(name: &str) -> Result<(), NameError> {
105 if name.is_empty() {
106 return Err(NameError::Empty);
107 }
108 if !name.is_ascii() {
109 return Err(NameError::NonAscii);
110 }
111 if name.len() > MAX_NAME_LENGTH {
112 return Err(NameError::TooLong(name.len()));
113 }
114 Ok(())
115}
116
117fn find_duplicates<'a, T, K, F>(items: &'a [T], key_fn: F) -> Vec<K>
121where
122 K: 'a + Clone + Eq + Hash + Ord,
123 F: Fn(&'a T) -> &'a K,
124{
125 let mut seen = HashSet::new();
126 let mut duplicates: Vec<_> = items
127 .iter()
128 .filter_map(|item| {
129 let key = key_fn(item);
130 if !seen.insert(key) {
131 Some(key.clone())
132 } else {
133 None
134 }
135 })
136 .collect();
137 duplicates.sort();
138 duplicates.dedup();
139 duplicates
140}
141
142#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)]
156#[serde(transparent)]
157pub struct RegionName(String);
158
159impl RegionName {
160 pub fn new(name: impl Into<String>) -> Result<Self, NameError> {
169 let name = name.into();
170 validate_name(&name)?;
171 Ok(Self(name))
172 }
173
174 pub fn as_str(&self) -> &str {
176 &self.0
177 }
178}
179
180impl<'de> Deserialize<'de> for RegionName {
181 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182 where
183 D: serde::Deserializer<'de>,
184 {
185 let s = String::deserialize(deserializer)?;
186 RegionName::new(s).map_err(serde::de::Error::custom)
187 }
188}
189
190impl std::fmt::Display for RegionName {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 write!(f, "{}", self.0)
193 }
194}
195
196#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)]
210#[serde(transparent)]
211pub struct TopologyName(String);
212
213impl TopologyName {
214 pub fn new(name: impl Into<String>) -> Result<Self, NameError> {
223 let name = name.into();
224 validate_name(&name)?;
225 Ok(Self(name))
226 }
227
228 pub fn as_str(&self) -> &str {
230 &self.0
231 }
232}
233
234impl<'de> Deserialize<'de> for TopologyName {
235 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
236 where
237 D: serde::Deserializer<'de>,
238 {
239 let s = String::deserialize(deserializer)?;
240 TopologyName::new(s).map_err(serde::de::Error::custom)
241 }
242}
243
244impl std::fmt::Display for TopologyName {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 write!(f, "{}", self.0)
247 }
248}
249
250#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
268#[serde(bound(
269 serialize = "T: Clone + Debug + Eq + PartialEq + Serialize",
270 deserialize = "T: Clone + Debug + Eq + PartialEq + serde::de::DeserializeOwned"
271))]
272pub struct ProviderRegion<T: Clone + Debug + Eq + PartialEq + Serialize + for<'a> Deserialize<'a>> {
273 name: RegionName,
275 provider: String,
277 region: String,
279 config: T,
281}
282
283impl<T: Clone + Debug + Eq + PartialEq + Serialize + for<'a> Deserialize<'a>> ProviderRegion<T> {
284 pub fn new(
299 name: RegionName,
300 provider: impl Into<String>,
301 region: impl Into<String>,
302 config: T,
303 ) -> Self {
304 Self {
305 name,
306 provider: provider.into(),
307 region: region.into(),
308 config,
309 }
310 }
311
312 pub fn name(&self) -> &RegionName {
314 &self.name
315 }
316
317 pub fn provider(&self) -> &str {
319 &self.provider
320 }
321
322 pub fn region(&self) -> &str {
324 &self.region
325 }
326
327 pub fn config(&self) -> &T {
329 &self.config
330 }
331}
332
333#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
351pub struct Topology {
352 name: TopologyName,
354 regions: Vec<RegionName>,
356}
357
358impl Topology {
359 pub fn new(name: TopologyName, regions: Vec<RegionName>) -> Self {
372 Self { name, regions }
373 }
374
375 pub fn name(&self) -> &TopologyName {
377 &self.name
378 }
379
380 pub fn regions(&self) -> &[RegionName] {
382 &self.regions
383 }
384}
385
386#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
413#[serde(into = "RawMultiCloudMultiRegionConfiguration<T>")]
414pub struct MultiCloudMultiRegionConfiguration<
415 T: Clone + Debug + Eq + PartialEq + Serialize + for<'a> Deserialize<'a>,
416> {
417 preferred: RegionName,
419 regions: Vec<ProviderRegion<T>>,
421 topologies: Vec<Topology>,
423}
424
425#[derive(Clone, Debug, Serialize, Deserialize)]
427#[serde(bound(
428 serialize = "T: Clone + Debug + Eq + PartialEq + Serialize",
429 deserialize = "T: Clone + Debug + Eq + PartialEq + serde::de::DeserializeOwned"
430))]
431struct RawMultiCloudMultiRegionConfiguration<
432 T: Clone + Debug + Eq + PartialEq + Serialize + for<'a> Deserialize<'a>,
433> {
434 preferred: RegionName,
435 regions: Vec<ProviderRegion<T>>,
436 topologies: Vec<Topology>,
437}
438
439impl<T: Clone + Debug + Eq + PartialEq + Serialize + for<'a> Deserialize<'a>>
440 From<MultiCloudMultiRegionConfiguration<T>> for RawMultiCloudMultiRegionConfiguration<T>
441{
442 fn from(config: MultiCloudMultiRegionConfiguration<T>) -> Self {
443 Self {
444 preferred: config.preferred,
445 regions: config.regions,
446 topologies: config.topologies,
447 }
448 }
449}
450
451impl<T: Clone + Debug + Eq + PartialEq + Serialize + for<'a> Deserialize<'a>>
452 TryFrom<RawMultiCloudMultiRegionConfiguration<T>> for MultiCloudMultiRegionConfiguration<T>
453{
454 type Error = ValidationError;
455
456 fn try_from(raw: RawMultiCloudMultiRegionConfiguration<T>) -> Result<Self, Self::Error> {
457 MultiCloudMultiRegionConfiguration::new(raw.preferred, raw.regions, raw.topologies)
458 }
459}
460
461impl<'de, T: Clone + Debug + Eq + PartialEq + Serialize + serde::de::DeserializeOwned>
462 Deserialize<'de> for MultiCloudMultiRegionConfiguration<T>
463{
464 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
465 where
466 D: serde::Deserializer<'de>,
467 {
468 let raw = RawMultiCloudMultiRegionConfiguration::<T>::deserialize(deserializer)?;
469 MultiCloudMultiRegionConfiguration::try_from(raw).map_err(serde::de::Error::custom)
470 }
471}
472
473#[derive(Clone, Debug, Default, Eq, Error, PartialEq)]
475#[error("{}", self.format_message())]
476pub struct ValidationError {
477 duplicate_region_names: Vec<RegionName>,
478 duplicate_topology_names: Vec<TopologyName>,
479 unknown_topology_regions: Vec<RegionName>,
480 unknown_preferred_region: Option<RegionName>,
481}
482
483impl ValidationError {
484 #[cfg(test)]
485 fn new(
486 duplicate_region_names: Vec<RegionName>,
487 duplicate_topology_names: Vec<TopologyName>,
488 unknown_topology_regions: Vec<RegionName>,
489 unknown_preferred_region: Option<RegionName>,
490 ) -> Self {
491 Self {
492 duplicate_region_names,
493 duplicate_topology_names,
494 unknown_topology_regions,
495 unknown_preferred_region,
496 }
497 }
498
499 pub fn has_errors(&self) -> bool {
501 !self.duplicate_region_names.is_empty()
502 || !self.duplicate_topology_names.is_empty()
503 || !self.unknown_topology_regions.is_empty()
504 || self.unknown_preferred_region.is_some()
505 }
506
507 pub fn duplicate_region_names(&self) -> &[RegionName] {
509 &self.duplicate_region_names
510 }
511
512 pub fn duplicate_topology_names(&self) -> &[TopologyName] {
514 &self.duplicate_topology_names
515 }
516
517 pub fn unknown_topology_regions(&self) -> &[RegionName] {
519 &self.unknown_topology_regions
520 }
521
522 pub fn unknown_preferred_region(&self) -> Option<&RegionName> {
524 self.unknown_preferred_region.as_ref()
525 }
526
527 fn format_message(&self) -> String {
528 if !self.has_errors() {
529 return "no validation errors".to_string();
530 }
531
532 let mut parts = Vec::new();
533
534 if !self.duplicate_region_names.is_empty() {
535 parts.push(format!(
536 "duplicate region names: {}",
537 format_name_list(&self.duplicate_region_names)
538 ));
539 }
540
541 if !self.duplicate_topology_names.is_empty() {
542 parts.push(format!(
543 "duplicate topology names: {}",
544 format_name_list(&self.duplicate_topology_names)
545 ));
546 }
547
548 if !self.unknown_topology_regions.is_empty() {
549 parts.push(format!(
550 "unknown topology regions: {}",
551 format_name_list(&self.unknown_topology_regions)
552 ));
553 }
554
555 if let Some(ref name) = self.unknown_preferred_region {
556 parts.push(format!("unknown preferred region: {}", name));
557 }
558
559 parts.join("; ")
560 }
561}
562
563fn format_name_list<T: std::fmt::Display>(names: &[T]) -> String {
565 names
566 .iter()
567 .map(|n| n.to_string())
568 .collect::<Vec<_>>()
569 .join(", ")
570}
571
572impl<T: Clone + Debug + Eq + PartialEq + Serialize + for<'a> Deserialize<'a>>
573 MultiCloudMultiRegionConfiguration<T>
574{
575 pub fn new(
617 preferred: RegionName,
618 regions: Vec<ProviderRegion<T>>,
619 topologies: Vec<Topology>,
620 ) -> Result<Self, ValidationError> {
621 let config = Self {
622 preferred,
623 regions,
624 topologies,
625 };
626 config.validate()?;
627 Ok(config)
628 }
629
630 pub fn preferred(&self) -> &RegionName {
632 &self.preferred
633 }
634
635 pub fn regions(&self) -> &[ProviderRegion<T>] {
637 &self.regions
638 }
639
640 pub fn topologies(&self) -> &[Topology] {
642 &self.topologies
643 }
644
645 fn validate(&self) -> Result<(), ValidationError> {
650 let mut error = ValidationError::default();
651 let all_region_names: HashSet<_> = self.regions.iter().map(|r| &r.name).collect();
652
653 error.duplicate_region_names = find_duplicates(&self.regions, |r| &r.name);
654 error.duplicate_topology_names = find_duplicates(&self.topologies, |t| &t.name);
655
656 let mut unknown_regions: Vec<_> = self
658 .topologies
659 .iter()
660 .flat_map(|t| &t.regions)
661 .filter(|r| !all_region_names.contains(r))
662 .cloned()
663 .collect();
664 unknown_regions.sort();
665 unknown_regions.dedup();
666 error.unknown_topology_regions = unknown_regions;
667
668 if !all_region_names.contains(&self.preferred) {
670 error.unknown_preferred_region = Some(self.preferred.clone());
671 }
672
673 if error.has_errors() {
674 Err(error)
675 } else {
676 Ok(())
677 }
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 fn region_name(s: impl Into<String>) -> RegionName {
686 RegionName::new(s).expect("test region name should be valid")
687 }
688
689 fn topology_name(s: impl Into<String>) -> TopologyName {
690 TopologyName::new(s).expect("test topology name should be valid")
691 }
692
693 fn provider_region(
694 name: impl Into<String>,
695 provider: impl Into<String>,
696 region: impl Into<String>,
697 ) -> ProviderRegion<()> {
698 ProviderRegion::new(
699 RegionName::new(name).expect("test region name should be valid"),
700 provider,
701 region,
702 (),
703 )
704 }
705
706 fn topology(name: impl Into<String>, regions: Vec<&str>) -> Topology {
707 Topology::new(
708 TopologyName::new(name).expect("test topology name should be valid"),
709 regions
710 .into_iter()
711 .map(|s| RegionName::new(s).expect("test region name should be valid"))
712 .collect(),
713 )
714 }
715
716 #[test]
717 fn region_name_as_str() {
718 let name = RegionName::new("aws-us-east-1").expect("valid name");
719 assert_eq!(name.as_str(), "aws-us-east-1");
720 }
721
722 #[test]
723 fn region_name_display() {
724 let name = RegionName::new("aws-us-east-1").expect("valid name");
725 assert_eq!(format!("{}", name), "aws-us-east-1");
726 }
727
728 #[test]
729 fn region_name_equality() {
730 let a = RegionName::new("aws-us-east-1");
731 let b = RegionName::new("aws-us-east-1");
732 let c = RegionName::new("gcp-europe-west1");
733 assert_eq!(a, b);
734 assert_ne!(a, c);
735 }
736
737 #[test]
738 fn region_name_clone() {
739 let a = RegionName::new("aws-us-east-1");
740 let b = a.clone();
741 assert_eq!(a, b);
742 }
743
744 #[test]
745 fn region_name_serde_roundtrip() {
746 let name = RegionName::new("aws-us-east-1").expect("valid name");
747 let json = serde_json::to_string(&name).unwrap();
748 assert_eq!(json, "\"aws-us-east-1\"");
749 let deserialized: RegionName = serde_json::from_str(&json).unwrap();
750 assert_eq!(name, deserialized);
751 }
752
753 #[test]
754 fn topology_name_as_str() {
755 let name = TopologyName::new("global").expect("valid name");
756 assert_eq!(name.as_str(), "global");
757 }
758
759 #[test]
760 fn topology_name_display() {
761 let name = TopologyName::new("global").expect("valid name");
762 assert_eq!(format!("{}", name), "global");
763 }
764
765 #[test]
766 fn topology_name_equality() {
767 let a = TopologyName::new("global");
768 let b = TopologyName::new("global");
769 let c = TopologyName::new("regional");
770 assert_eq!(a, b);
771 assert_ne!(a, c);
772 }
773
774 #[test]
775 fn topology_name_clone() {
776 let a = TopologyName::new("global");
777 let b = a.clone();
778 assert_eq!(a, b);
779 }
780
781 #[test]
782 fn topology_name_serde_roundtrip() {
783 let name = TopologyName::new("global").expect("valid name");
784 let json = serde_json::to_string(&name).unwrap();
785 assert_eq!(json, "\"global\"");
786 let deserialized: TopologyName = serde_json::from_str(&json).unwrap();
787 assert_eq!(name, deserialized);
788 }
789
790 #[test]
791 fn provider_region_accessors() {
792 let region = ProviderRegion::new(region_name("aws-us-east-1"), "aws", "us-east-1", ());
793 assert_eq!(region.name(), ®ion_name("aws-us-east-1"));
794 assert_eq!(region.provider(), "aws");
795 assert_eq!(region.region(), "us-east-1");
796 }
797
798 #[test]
799 fn provider_region_equality() {
800 let a = provider_region("aws-us-east-1", "aws", "us-east-1");
801 let b = provider_region("aws-us-east-1", "aws", "us-east-1");
802 let c = provider_region("gcp-europe-west1", "gcp", "europe-west1");
803 assert_eq!(a, b);
804 assert_ne!(a, c);
805 }
806
807 #[test]
808 fn provider_region_clone() {
809 let a = provider_region("aws-us-east-1", "aws", "us-east-1");
810 let b = a.clone();
811 assert_eq!(a, b);
812 }
813
814 #[test]
815 fn provider_region_serde_roundtrip() {
816 let region = provider_region("aws-us-east-1", "aws", "us-east-1");
817 let json = serde_json::to_string(®ion).unwrap();
818 let deserialized: ProviderRegion<()> = serde_json::from_str(&json).unwrap();
819 assert_eq!(region, deserialized);
820 }
821
822 #[test]
823 fn topology_accessors() {
824 let t = topology("global", vec!["aws-us-east-1", "gcp-europe-west1"]);
825 assert_eq!(t.name(), &topology_name("global"));
826 assert_eq!(
827 t.regions(),
828 &[
829 region_name("aws-us-east-1"),
830 region_name("gcp-europe-west1")
831 ]
832 );
833 }
834
835 #[test]
836 fn topology_equality() {
837 let a = topology("global", vec!["aws-us-east-1"]);
838 let b = topology("global", vec!["aws-us-east-1"]);
839 let c = topology("regional", vec!["aws-us-east-1"]);
840 assert_eq!(a, b);
841 assert_ne!(a, c);
842 }
843
844 #[test]
845 fn topology_clone() {
846 let a = topology("global", vec!["aws-us-east-1"]);
847 let b = a.clone();
848 assert_eq!(a, b);
849 }
850
851 #[test]
852 fn topology_serde_roundtrip() {
853 let t = topology("global", vec!["aws-us-east-1", "gcp-europe-west1"]);
854 let json = serde_json::to_string(&t).unwrap();
855 let deserialized: Topology = serde_json::from_str(&json).unwrap();
856 assert_eq!(t, deserialized);
857 }
858
859 #[test]
860 fn valid_configuration() {
861 let config = MultiCloudMultiRegionConfiguration::new(
862 region_name("aws-us-east-1"),
863 vec![
864 provider_region("aws-us-east-1", "aws", "us-east-1"),
865 provider_region("gcp-europe-west1", "gcp", "europe-west1"),
866 ],
867 vec![topology(
868 "global",
869 vec!["aws-us-east-1", "gcp-europe-west1"],
870 )],
871 );
872
873 assert!(config.is_ok(), "Expected valid configuration: {:?}", config);
874 }
875
876 #[test]
877 fn valid_configuration_accessors() {
878 let config = MultiCloudMultiRegionConfiguration::new(
879 region_name("aws-us-east-1"),
880 vec![provider_region("aws-us-east-1", "aws", "us-east-1")],
881 vec![topology("global", vec!["aws-us-east-1"])],
882 )
883 .expect("valid configuration");
884
885 assert_eq!(config.preferred(), ®ion_name("aws-us-east-1"));
886 assert_eq!(config.regions().len(), 1);
887 assert_eq!(config.topologies().len(), 1);
888 }
889
890 #[test]
891 fn configuration_serde_roundtrip() {
892 let config = MultiCloudMultiRegionConfiguration::new(
893 region_name("aws-us-east-1"),
894 vec![
895 provider_region("aws-us-east-1", "aws", "us-east-1"),
896 provider_region("gcp-europe-west1", "gcp", "europe-west1"),
897 ],
898 vec![topology(
899 "global",
900 vec!["aws-us-east-1", "gcp-europe-west1"],
901 )],
902 )
903 .expect("valid configuration");
904
905 let json = serde_json::to_string(&config).unwrap();
906 let deserialized: MultiCloudMultiRegionConfiguration<()> =
907 serde_json::from_str(&json).unwrap();
908 assert_eq!(config, deserialized);
909 }
910
911 #[test]
912 fn configuration_deserialize_valid() {
913 let json = r#"{
914 "preferred": "aws-us-east-1",
915 "regions": [
916 {"name": "aws-us-east-1", "provider": "aws", "region": "us-east-1", "config": null}
917 ],
918 "topologies": []
919 }"#;
920
921 let config: MultiCloudMultiRegionConfiguration<()> = serde_json::from_str(json).unwrap();
922 assert_eq!(config.preferred().as_str(), "aws-us-east-1");
923 }
924
925 #[test]
926 fn configuration_deserialize_invalid_preferred() {
927 let json = r#"{
928 "preferred": "nonexistent",
929 "regions": [
930 {"name": "aws-us-east-1", "provider": "aws", "region": "us-east-1", "config": null}
931 ],
932 "topologies": []
933 }"#;
934
935 let result: Result<MultiCloudMultiRegionConfiguration<()>, _> = serde_json::from_str(json);
936 assert!(result.is_err());
937 let err_msg = result.unwrap_err().to_string();
938 assert!(
939 err_msg.contains("unknown preferred region"),
940 "Expected error message to contain 'unknown preferred region', got: {}",
941 err_msg
942 );
943 }
944
945 #[test]
946 fn configuration_deserialize_duplicate_regions() {
947 let json = r#"{
948 "preferred": "aws-us-east-1",
949 "regions": [
950 {"name": "aws-us-east-1", "provider": "aws", "region": "us-east-1", "config": null},
951 {"name": "aws-us-east-1", "provider": "aws", "region": "us-east-1", "config": null}
952 ],
953 "topologies": []
954 }"#;
955
956 let result: Result<MultiCloudMultiRegionConfiguration<()>, _> = serde_json::from_str(json);
957 assert!(result.is_err());
958 let err_msg = result.unwrap_err().to_string();
959 assert!(
960 err_msg.contains("duplicate region names"),
961 "Expected error message to contain 'duplicate region names', got: {}",
962 err_msg
963 );
964 }
965
966 #[test]
967 fn configuration_deserialize_unknown_topology_region() {
968 let json = r#"{
969 "preferred": "aws-us-east-1",
970 "regions": [
971 {"name": "aws-us-east-1", "provider": "aws", "region": "us-east-1", "config": null}
972 ],
973 "topologies": [
974 {"name": "global", "regions": ["aws-us-east-1", "nonexistent"]}
975 ]
976 }"#;
977
978 let result: Result<MultiCloudMultiRegionConfiguration<()>, _> = serde_json::from_str(json);
979 assert!(result.is_err());
980 let err_msg = result.unwrap_err().to_string();
981 assert!(
982 err_msg.contains("unknown topology regions"),
983 "Expected error message to contain 'unknown topology regions', got: {}",
984 err_msg
985 );
986 }
987
988 #[test]
989 fn empty_configuration() {
990 let config = MultiCloudMultiRegionConfiguration::<()>::new(
991 region_name("nonexistent"),
992 vec![],
993 vec![],
994 );
995
996 let err = config.unwrap_err();
997 assert!(err.duplicate_region_names().is_empty());
998 assert!(err.duplicate_topology_names().is_empty());
999 assert!(err.unknown_topology_regions().is_empty());
1000 assert_eq!(
1001 err.unknown_preferred_region(),
1002 Some(®ion_name("nonexistent"))
1003 );
1004 }
1005
1006 #[test]
1007 fn empty_topology_regions() {
1008 let config = MultiCloudMultiRegionConfiguration::new(
1009 region_name("aws-us-east-1"),
1010 vec![provider_region("aws-us-east-1", "aws", "us-east-1")],
1011 vec![topology("empty", vec![])],
1012 );
1013
1014 assert!(
1015 config.is_ok(),
1016 "Topology with no regions should be valid: {:?}",
1017 config
1018 );
1019 }
1020
1021 #[test]
1022 fn duplicate_region_names() {
1023 let config = MultiCloudMultiRegionConfiguration::new(
1024 region_name("aws-us-east-1"),
1025 vec![
1026 provider_region("aws-us-east-1", "aws", "us-east-1"),
1027 provider_region("aws-us-east-1", "aws", "us-east-1"),
1028 ],
1029 vec![],
1030 );
1031
1032 let err = config.unwrap_err();
1033 assert_eq!(
1034 err.duplicate_region_names(),
1035 &[region_name("aws-us-east-1")]
1036 );
1037 assert!(err.duplicate_topology_names().is_empty());
1038 assert!(err.unknown_topology_regions().is_empty());
1039 assert_eq!(err.unknown_preferred_region(), None);
1040 }
1041
1042 #[test]
1043 fn duplicate_topology_names() {
1044 let config = MultiCloudMultiRegionConfiguration::new(
1045 region_name("aws-us-east-1"),
1046 vec![provider_region("aws-us-east-1", "aws", "us-east-1")],
1047 vec![
1048 topology("global", vec!["aws-us-east-1"]),
1049 topology("global", vec!["aws-us-east-1"]),
1050 ],
1051 );
1052
1053 let err = config.unwrap_err();
1054 assert!(err.duplicate_region_names().is_empty());
1055 assert_eq!(err.duplicate_topology_names(), &[topology_name("global")]);
1056 assert!(err.unknown_topology_regions().is_empty());
1057 assert_eq!(err.unknown_preferred_region(), None);
1058 }
1059
1060 #[test]
1061 fn unknown_topology_region() {
1062 let config = MultiCloudMultiRegionConfiguration::new(
1063 region_name("aws-us-east-1"),
1064 vec![provider_region("aws-us-east-1", "aws", "us-east-1")],
1065 vec![topology(
1066 "global",
1067 vec!["aws-us-east-1", "nonexistent-region"],
1068 )],
1069 );
1070
1071 let err = config.unwrap_err();
1072 assert!(err.duplicate_region_names().is_empty());
1073 assert!(err.duplicate_topology_names().is_empty());
1074 assert_eq!(
1075 err.unknown_topology_regions(),
1076 &[region_name("nonexistent-region")]
1077 );
1078 assert_eq!(err.unknown_preferred_region(), None);
1079 }
1080
1081 #[test]
1082 fn unknown_topology_region_duplicated_in_multiple_topologies() {
1083 let config = MultiCloudMultiRegionConfiguration::new(
1086 region_name("aws-us-east-1"),
1087 vec![provider_region("aws-us-east-1", "aws", "us-east-1")],
1088 vec![
1089 topology("topo1", vec!["aws-us-east-1", "nonexistent"]),
1090 topology("topo2", vec!["nonexistent"]),
1091 ],
1092 );
1093
1094 let err = config.unwrap_err();
1095 assert!(err.duplicate_region_names().is_empty());
1096 assert!(err.duplicate_topology_names().is_empty());
1097 assert_eq!(
1098 err.unknown_topology_regions(),
1099 &[region_name("nonexistent")]
1100 );
1101 assert_eq!(err.unknown_preferred_region(), None);
1102 }
1103
1104 #[test]
1105 fn unknown_preferred_region() {
1106 let config = MultiCloudMultiRegionConfiguration::new(
1107 region_name("nonexistent-region"),
1108 vec![provider_region("aws-us-east-1", "aws", "us-east-1")],
1109 vec![],
1110 );
1111
1112 let err = config.unwrap_err();
1113 assert!(err.duplicate_region_names().is_empty());
1114 assert!(err.duplicate_topology_names().is_empty());
1115 assert!(err.unknown_topology_regions().is_empty());
1116 assert_eq!(
1117 err.unknown_preferred_region(),
1118 Some(®ion_name("nonexistent-region"))
1119 );
1120 }
1121
1122 #[test]
1123 fn multiple_validation_errors() {
1124 let config = MultiCloudMultiRegionConfiguration::new(
1125 region_name("nonexistent-preferred"),
1126 vec![
1127 provider_region("aws-us-east-1", "aws", "us-east-1"),
1128 provider_region("aws-us-east-1", "aws", "us-east-1"),
1129 ],
1130 vec![
1131 topology("topo1", vec!["unknown-region"]),
1132 topology("topo1", vec!["aws-us-east-1"]),
1133 ],
1134 );
1135
1136 let err = config.unwrap_err();
1137 assert_eq!(
1138 err.duplicate_region_names(),
1139 &[region_name("aws-us-east-1")]
1140 );
1141 assert_eq!(err.duplicate_topology_names(), &[topology_name("topo1")]);
1142 assert_eq!(
1143 err.unknown_topology_regions(),
1144 &[region_name("unknown-region")]
1145 );
1146 assert_eq!(
1147 err.unknown_preferred_region(),
1148 Some(®ion_name("nonexistent-preferred"))
1149 );
1150 }
1151
1152 #[test]
1153 fn display_no_errors() {
1154 let error = ValidationError::default();
1155 assert_eq!(error.to_string(), "no validation errors");
1156 }
1157
1158 #[test]
1159 fn display_duplicate_region_names_only() {
1160 let error = ValidationError::new(
1161 vec![region_name("region-a"), region_name("region-b")],
1162 vec![],
1163 vec![],
1164 None,
1165 );
1166 assert_eq!(
1167 error.to_string(),
1168 "duplicate region names: region-a, region-b"
1169 );
1170 }
1171
1172 #[test]
1173 fn display_duplicate_topology_names_only() {
1174 let error = ValidationError::new(vec![], vec![topology_name("topo-x")], vec![], None);
1175 assert_eq!(error.to_string(), "duplicate topology names: topo-x");
1176 }
1177
1178 #[test]
1179 fn display_unknown_topology_regions_only() {
1180 let error = ValidationError::new(
1181 vec![],
1182 vec![],
1183 vec![region_name("missing-1"), region_name("missing-2")],
1184 None,
1185 );
1186 assert_eq!(
1187 error.to_string(),
1188 "unknown topology regions: missing-1, missing-2"
1189 );
1190 }
1191
1192 #[test]
1193 fn display_unknown_preferred_region_only() {
1194 let error =
1195 ValidationError::new(vec![], vec![], vec![], Some(region_name("missing-region")));
1196 assert_eq!(
1197 error.to_string(),
1198 "unknown preferred region: missing-region"
1199 );
1200 }
1201
1202 #[test]
1203 fn display_all_errors() {
1204 let error = ValidationError::new(
1205 vec![region_name("dup-region")],
1206 vec![topology_name("dup-topo")],
1207 vec![region_name("unknown-reg")],
1208 Some(region_name("bad-preferred")),
1209 );
1210 assert_eq!(
1211 error.to_string(),
1212 "duplicate region names: dup-region; duplicate topology names: dup-topo; unknown topology regions: unknown-reg; unknown preferred region: bad-preferred"
1213 );
1214 }
1215
1216 #[test]
1217 fn display_special_characters() {
1218 let error = ValidationError::new(
1219 vec![
1220 region_name("region-with-dash_and_underscore"),
1221 region_name("region with spaces"),
1222 ],
1223 vec![topology_name("topo.dot")],
1224 vec![region_name("region\nwith\nnewlines")],
1225 None,
1226 );
1227 assert_eq!(
1228 error.to_string(),
1229 "duplicate region names: region-with-dash_and_underscore, region with spaces; duplicate topology names: topo.dot; unknown topology regions: region\nwith\nnewlines"
1230 );
1231 }
1232
1233 #[test]
1234 fn validation_error_has_errors_default() {
1235 let error = ValidationError::default();
1236 assert!(!error.has_errors());
1237 }
1238
1239 #[test]
1240 fn validation_error_has_errors_with_duplicate_regions() {
1241 let error = ValidationError::new(vec![region_name("dup")], vec![], vec![], None);
1242 assert!(error.has_errors());
1243 }
1244
1245 #[test]
1246 fn validation_error_has_errors_with_duplicate_topologies() {
1247 let error = ValidationError::new(vec![], vec![topology_name("dup")], vec![], None);
1248 assert!(error.has_errors());
1249 }
1250
1251 #[test]
1252 fn validation_error_has_errors_with_unknown_topology_regions() {
1253 let error = ValidationError::new(vec![], vec![], vec![region_name("unknown")], None);
1254 assert!(error.has_errors());
1255 }
1256
1257 #[test]
1258 fn validation_error_has_errors_with_unknown_preferred() {
1259 let error = ValidationError::new(vec![], vec![], vec![], Some(region_name("unknown")));
1260 assert!(error.has_errors());
1261 }
1262
1263 #[test]
1264 fn configuration_clone() {
1265 let config = MultiCloudMultiRegionConfiguration::new(
1266 region_name("aws-us-east-1"),
1267 vec![provider_region("aws-us-east-1", "aws", "us-east-1")],
1268 vec![],
1269 )
1270 .expect("valid configuration");
1271
1272 let cloned = config.clone();
1273 assert_eq!(config, cloned);
1274 }
1275
1276 #[test]
1277 fn configuration_debug() {
1278 let config = MultiCloudMultiRegionConfiguration::new(
1279 region_name("aws-us-east-1"),
1280 vec![provider_region("aws-us-east-1", "aws", "us-east-1")],
1281 vec![],
1282 )
1283 .expect("valid configuration");
1284
1285 let debug_str = format!("{:?}", config);
1286 assert!(debug_str.contains("MultiCloudMultiRegionConfiguration"));
1287 assert!(debug_str.contains("aws-us-east-1"));
1288 }
1289
1290 #[test]
1291 fn region_name_valid() {
1292 assert!(RegionName::new("aws-us-east-1").is_ok());
1293 assert!(RegionName::new("a").is_ok());
1294 assert!(RegionName::new("12345678901234567890123456789012").is_ok());
1296 }
1297
1298 #[test]
1299 fn region_name_empty() {
1300 let result = RegionName::new("");
1301 assert!(result.is_err());
1302 println!(
1303 "region_name_empty error: {:?}",
1304 result.as_ref().unwrap_err()
1305 );
1306 assert!(matches!(result, Err(NameError::Empty)));
1307 }
1308
1309 #[test]
1310 fn region_name_too_long() {
1311 let result = RegionName::new("123456789012345678901234567890123");
1313 assert!(result.is_err());
1314 println!(
1315 "region_name_too_long error: {:?}",
1316 result.as_ref().unwrap_err()
1317 );
1318 assert!(matches!(result, Err(NameError::TooLong(33))));
1319 }
1320
1321 #[test]
1322 fn region_name_non_ascii() {
1323 let result = RegionName::new("region-🌍");
1324 assert!(result.is_err());
1325 println!(
1326 "region_name_non_ascii error: {:?}",
1327 result.as_ref().unwrap_err()
1328 );
1329 assert!(matches!(result, Err(NameError::NonAscii)));
1330 }
1331
1332 #[test]
1333 fn topology_name_valid() {
1334 assert!(TopologyName::new("global").is_ok());
1335 assert!(TopologyName::new("a").is_ok());
1336 assert!(TopologyName::new("12345678901234567890123456789012").is_ok());
1338 }
1339
1340 #[test]
1341 fn topology_name_empty() {
1342 let result = TopologyName::new("");
1343 assert!(result.is_err());
1344 println!(
1345 "topology_name_empty error: {:?}",
1346 result.as_ref().unwrap_err()
1347 );
1348 assert!(matches!(result, Err(NameError::Empty)));
1349 }
1350
1351 #[test]
1352 fn topology_name_too_long() {
1353 let result = TopologyName::new("123456789012345678901234567890123");
1355 assert!(result.is_err());
1356 println!(
1357 "topology_name_too_long error: {:?}",
1358 result.as_ref().unwrap_err()
1359 );
1360 assert!(matches!(result, Err(NameError::TooLong(33))));
1361 }
1362
1363 #[test]
1364 fn topology_name_non_ascii() {
1365 let result = TopologyName::new("拓扑名");
1366 assert!(result.is_err());
1367 println!(
1368 "topology_name_non_ascii error: {:?}",
1369 result.as_ref().unwrap_err()
1370 );
1371 assert!(matches!(result, Err(NameError::NonAscii)));
1372 }
1373
1374 #[test]
1375 fn name_error_display_empty() {
1376 let err = NameError::Empty;
1377 assert_eq!(err.to_string(), "name cannot be empty");
1378 }
1379
1380 #[test]
1381 fn name_error_display_too_long() {
1382 let err = NameError::TooLong(50);
1383 assert_eq!(
1384 err.to_string(),
1385 "name exceeds maximum length of 32 characters: 50 characters"
1386 );
1387 }
1388
1389 #[test]
1390 fn name_error_display_non_ascii() {
1391 let err = NameError::NonAscii;
1392 assert_eq!(err.to_string(), "name contains non-ASCII characters");
1393 }
1394
1395 #[test]
1396 fn region_name_deserialize_valid() {
1397 let json = "\"aws-us-east-1\"";
1398 let name: RegionName = serde_json::from_str(json).unwrap();
1399 assert_eq!(name.as_str(), "aws-us-east-1");
1400 }
1401
1402 #[test]
1403 fn region_name_deserialize_empty() {
1404 let json = "\"\"";
1405 let result: Result<RegionName, _> = serde_json::from_str(json);
1406 assert!(result.is_err());
1407 let err_msg = result.unwrap_err().to_string();
1408 println!("region_name_deserialize_empty error: {}", err_msg);
1409 assert!(
1410 err_msg.contains("name cannot be empty"),
1411 "Expected error message to contain 'name cannot be empty', got: {}",
1412 err_msg
1413 );
1414 }
1415
1416 #[test]
1417 fn region_name_deserialize_too_long() {
1418 let json = "\"123456789012345678901234567890123\"";
1419 let result: Result<RegionName, _> = serde_json::from_str(json);
1420 assert!(result.is_err());
1421 let err_msg = result.unwrap_err().to_string();
1422 println!("region_name_deserialize_too_long error: {}", err_msg);
1423 assert!(
1424 err_msg.contains("name exceeds maximum length"),
1425 "Expected error message to contain 'name exceeds maximum length', got: {}",
1426 err_msg
1427 );
1428 }
1429
1430 #[test]
1431 fn region_name_deserialize_non_ascii() {
1432 let json = "\"region-🌍\"";
1433 let result: Result<RegionName, _> = serde_json::from_str(json);
1434 assert!(result.is_err());
1435 let err_msg = result.unwrap_err().to_string();
1436 println!("region_name_deserialize_non_ascii error: {}", err_msg);
1437 assert!(
1438 err_msg.contains("non-ASCII"),
1439 "Expected error message to contain 'non-ASCII', got: {}",
1440 err_msg
1441 );
1442 }
1443
1444 #[test]
1445 fn topology_name_deserialize_valid() {
1446 let json = "\"global\"";
1447 let name: TopologyName = serde_json::from_str(json).unwrap();
1448 assert_eq!(name.as_str(), "global");
1449 }
1450
1451 #[test]
1452 fn topology_name_deserialize_empty() {
1453 let json = "\"\"";
1454 let result: Result<TopologyName, _> = serde_json::from_str(json);
1455 assert!(result.is_err());
1456 let err_msg = result.unwrap_err().to_string();
1457 println!("topology_name_deserialize_empty error: {}", err_msg);
1458 assert!(
1459 err_msg.contains("name cannot be empty"),
1460 "Expected error message to contain 'name cannot be empty', got: {}",
1461 err_msg
1462 );
1463 }
1464
1465 #[test]
1466 fn topology_name_deserialize_too_long() {
1467 let json = "\"123456789012345678901234567890123\"";
1468 let result: Result<TopologyName, _> = serde_json::from_str(json);
1469 assert!(result.is_err());
1470 let err_msg = result.unwrap_err().to_string();
1471 println!("topology_name_deserialize_too_long error: {}", err_msg);
1472 assert!(
1473 err_msg.contains("name exceeds maximum length"),
1474 "Expected error message to contain 'name exceeds maximum length', got: {}",
1475 err_msg
1476 );
1477 }
1478
1479 #[test]
1480 fn topology_name_deserialize_non_ascii() {
1481 let json = "\"拓扑名\"";
1482 let result: Result<TopologyName, _> = serde_json::from_str(json);
1483 assert!(result.is_err());
1484 let err_msg = result.unwrap_err().to_string();
1485 println!("topology_name_deserialize_non_ascii error: {}", err_msg);
1486 assert!(
1487 err_msg.contains("non-ASCII"),
1488 "Expected error message to contain 'non-ASCII', got: {}",
1489 err_msg
1490 );
1491 }
1492}