1use crate::common::ResolvedIdentity;
23use crate::identity_string;
24use crate::snapshot_replication::{IdentityMatcher, component_glob_matches};
25
26pub fn replication_identity_overlap(
52 include: &[IdentityMatcher],
53 exclude: &[IdentityMatcher],
54 identities: &[ResolvedIdentity],
55) -> Vec<String> {
56 let mut out: Vec<String> = identities
57 .iter()
58 .filter(|id| {
59 let included =
60 include.is_empty() || include.iter().any(|m| overlap_matcher_matches(m, id));
61 included && !exclude.iter().any(|m| overlap_matcher_matches(m, id))
62 })
63 .map(identity_string)
64 .collect();
65 out.sort();
66 out.dedup();
67 out
68}
69
70fn overlap_matcher_matches(m: &IdentityMatcher, id: &ResolvedIdentity) -> bool {
76 if m.username.is_none() && m.hostname.is_none() && m.source_path.is_none() {
77 return false;
78 }
79 m.username
80 .as_deref()
81 .is_none_or(|p| component_glob_matches(p, &id.username))
82 && m.hostname
83 .as_deref()
84 .is_none_or(|p| component_glob_matches(p, &id.hostname))
85 && m.source_path
86 .as_deref()
87 .is_none_or(|p| component_glob_matches(p, id.source_path.as_deref().unwrap_or("")))
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn id(username: &str, hostname: &str, path: Option<&str>) -> ResolvedIdentity {
95 ResolvedIdentity {
96 username: username.into(),
97 hostname: hostname.into(),
98 source_path: path.map(str::to_string),
99 }
100 }
101
102 fn matcher(
103 username: Option<&str>,
104 hostname: Option<&str>,
105 source_path: Option<&str>,
106 ) -> IdentityMatcher {
107 IdentityMatcher {
108 username: username.map(str::to_string),
109 hostname: hostname.map(str::to_string),
110 source_path: source_path.map(str::to_string),
111 }
112 }
113
114 #[test]
115 fn empty_include_selects_every_identity() {
116 let ids = [
117 id("pg", "billing", Some("/pvc/data")),
118 id("redis", "cache", Some("/pvc/redis")),
119 ];
120 assert_eq!(
121 replication_identity_overlap(&[], &[], &ids),
122 vec![
123 "pg@billing:/pvc/data".to_string(),
124 "redis@cache:/pvc/redis".to_string(),
125 ],
126 );
127 }
128
129 #[test]
130 fn include_glob_narrows_and_all_set_components_must_match() {
131 let ids = [
132 id("pg-main", "billing", Some("/pvc/data")),
133 id("pg-replica", "other", Some("/pvc/data")),
134 id("redis", "billing", Some("/pvc/redis")),
135 ];
136 let include = [matcher(Some("pg-*"), Some("billing"), None)];
137 assert_eq!(
138 replication_identity_overlap(&include, &[], &ids),
139 vec!["pg-main@billing:/pvc/data".to_string()],
140 );
141 }
142
143 #[test]
144 fn exclude_wins_over_include() {
145 let ids = [
146 id("pg", "billing", Some("/pvc/data")),
147 id("pg", "staging", Some("/pvc/data")),
148 ];
149 let include = [matcher(Some("pg"), None, None)];
150 let exclude = [matcher(None, Some("staging"), None)];
151 assert_eq!(
152 replication_identity_overlap(&include, &exclude, &ids),
153 vec!["pg@billing:/pvc/data".to_string()],
154 );
155 }
156
157 #[test]
158 fn all_empty_matcher_matches_nothing_in_either_list() {
159 let ids = [id("pg", "billing", Some("/pvc/data"))];
160 let empty = [matcher(None, None, None)];
161 assert!(replication_identity_overlap(&empty, &[], &ids).is_empty());
163 assert_eq!(
165 replication_identity_overlap(&[], &empty, &ids).len(),
166 1,
167 "a defensively-inert empty matcher must not exclude the world"
168 );
169 }
170
171 #[test]
172 fn absent_source_path_matches_only_patterns_covering_empty() {
173 let ids = [id("cfg", "ns", None)];
174 let star = [matcher(None, None, Some("*"))];
175 assert_eq!(replication_identity_overlap(&star, &[], &ids).len(), 1);
176 let concrete = [matcher(None, None, Some("/pvc/*"))];
177 assert!(replication_identity_overlap(&concrete, &[], &ids).is_empty());
178 }
179
180 #[test]
181 fn result_is_sorted_and_deduplicated() {
182 let ids = [
183 id("z", "h", Some("/p")),
184 id("a", "h", Some("/p")),
185 id("a", "h", Some("/p")),
186 ];
187 assert_eq!(
188 replication_identity_overlap(&[], &[], &ids),
189 vec!["a@h:/p".to_string(), "z@h:/p".to_string()],
190 );
191 }
192}