1use std::collections::BTreeMap;
9use std::collections::BTreeSet;
10
11use crate::diff::destructiveness::Destructiveness;
12use crate::identifier::Identifier;
13use crate::ir::cluster::catalog::ClusterCatalog;
14use crate::ir::cluster::role::{Role, RoleAttributes};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ClusterChange {
19 CreateRole(Role),
21 DropRole {
23 name: Identifier,
25 },
26 AlterRoleAttributes {
31 name: Identifier,
33 from: RoleAttributes,
35 to: RoleAttributes,
37 },
38 GrantRoleMembership {
40 member: Identifier,
42 role: Identifier,
44 },
45 RevokeRoleMembership {
47 member: Identifier,
49 role: Identifier,
51 },
52 CommentOnRole {
54 name: Identifier,
56 comment: Option<String>,
58 },
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ClusterChangeEntry {
64 pub change: ClusterChange,
66 pub destructiveness: Destructiveness,
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct ClusterChangeSet {
73 pub entries: Vec<ClusterChangeEntry>,
75}
76
77impl ClusterChangeSet {
78 #[must_use]
80 pub const fn is_empty(&self) -> bool {
81 self.entries.is_empty()
82 }
83}
84
85#[must_use]
89pub fn diff_cluster(target: &ClusterCatalog, source: &ClusterCatalog) -> ClusterChangeSet {
90 let mut entries = Vec::new();
91
92 let target_map: BTreeMap<&Identifier, &Role> =
93 target.roles.iter().map(|r| (&r.name, r)).collect();
94 let source_map: BTreeMap<&Identifier, &Role> =
95 source.roles.iter().map(|r| (&r.name, r)).collect();
96
97 for (name, source_role) in &source_map {
99 if !target_map.contains_key(name) {
100 entries.push(ClusterChangeEntry {
101 change: ClusterChange::CreateRole((*source_role).clone()),
102 destructiveness: Destructiveness::Safe,
103 });
104 }
105 }
106
107 for (name, target_role) in &target_map {
109 match source_map.get(name) {
110 None => entries.push(ClusterChangeEntry {
111 change: ClusterChange::DropRole {
112 name: (*name).clone(),
113 },
114 destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
115 reason: format!("drops role {name} — may orphan grants in other DBs"),
116 },
117 }),
118 Some(source_role) => diff_role(target_role, source_role, &mut entries),
119 }
120 }
121
122 ClusterChangeSet { entries }
123}
124
125fn diff_role(target: &Role, source: &Role, out: &mut Vec<ClusterChangeEntry>) {
126 if target.attributes != source.attributes {
127 out.push(ClusterChangeEntry {
128 change: ClusterChange::AlterRoleAttributes {
129 name: target.name.clone(),
130 from: target.attributes.clone(),
131 to: source.attributes.clone(),
132 },
133 destructiveness: Destructiveness::Safe,
134 });
135 }
136
137 let target_membership: BTreeSet<&Identifier> = target.member_of.iter().collect();
139 let source_membership: BTreeSet<&Identifier> = source.member_of.iter().collect();
140
141 for added in source_membership.difference(&target_membership) {
142 out.push(ClusterChangeEntry {
143 change: ClusterChange::GrantRoleMembership {
144 member: target.name.clone(),
145 role: (*added).clone(),
146 },
147 destructiveness: Destructiveness::Safe,
148 });
149 }
150 for removed in target_membership.difference(&source_membership) {
151 out.push(ClusterChangeEntry {
152 change: ClusterChange::RevokeRoleMembership {
153 member: target.name.clone(),
154 role: (*removed).clone(),
155 },
156 destructiveness: Destructiveness::Safe,
157 });
158 }
159
160 if target.comment != source.comment {
161 out.push(ClusterChangeEntry {
162 change: ClusterChange::CommentOnRole {
163 name: target.name.clone(),
164 comment: source.comment.clone(),
165 },
166 destructiveness: Destructiveness::Safe,
167 });
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 fn id(s: &str) -> Identifier {
176 Identifier::from_unquoted(s).unwrap()
177 }
178
179 fn role(name: &str) -> Role {
180 Role {
181 name: id(name),
182 attributes: RoleAttributes::default(),
183 member_of: vec![],
184 comment: None,
185 }
186 }
187
188 #[test]
189 fn equal_catalogs_yield_no_changes() {
190 let c = ClusterCatalog {
191 roles: vec![role("a")],
192 };
193 let cs = diff_cluster(&c, &c);
194 assert!(cs.is_empty());
195 }
196
197 #[test]
198 fn added_role_creates() {
199 let target = ClusterCatalog::empty();
200 let source = ClusterCatalog {
201 roles: vec![role("a")],
202 };
203 let cs = diff_cluster(&target, &source);
204 assert_eq!(cs.entries.len(), 1);
205 assert!(matches!(cs.entries[0].change, ClusterChange::CreateRole(_)));
206 assert_eq!(cs.entries[0].destructiveness, Destructiveness::Safe);
207 }
208
209 #[test]
210 fn removed_role_drops_with_intent_gate() {
211 let target = ClusterCatalog {
212 roles: vec![role("a")],
213 };
214 let source = ClusterCatalog::empty();
215 let cs = diff_cluster(&target, &source);
216 assert_eq!(cs.entries.len(), 1);
217 assert!(matches!(
218 cs.entries[0].change,
219 ClusterChange::DropRole { .. }
220 ));
221 assert!(cs.entries[0].destructiveness.requires_approval());
222 assert!(cs.entries[0].destructiveness.data_loss_risk());
223 }
224
225 #[test]
226 fn attribute_change_emits_alter() {
227 let t = role("a");
228 let mut s = role("a");
229 s.attributes.login = true;
230 let cs = diff_cluster(
231 &ClusterCatalog { roles: vec![t] },
232 &ClusterCatalog { roles: vec![s] },
233 );
234 assert_eq!(cs.entries.len(), 1);
235 assert!(matches!(
236 cs.entries[0].change,
237 ClusterChange::AlterRoleAttributes { .. }
238 ));
239 }
240
241 #[test]
242 fn added_membership_emits_grant() {
243 let t = role("a");
244 let mut s = role("a");
245 s.member_of.push(id("readers"));
246 let cs = diff_cluster(
247 &ClusterCatalog { roles: vec![t] },
248 &ClusterCatalog { roles: vec![s] },
249 );
250 assert_eq!(cs.entries.len(), 1);
251 match &cs.entries[0].change {
252 ClusterChange::GrantRoleMembership { member, role } => {
253 assert_eq!(member.as_str(), "a");
254 assert_eq!(role.as_str(), "readers");
255 }
256 other => panic!("got {other:?}"),
257 }
258 }
259
260 #[test]
261 fn removed_membership_emits_revoke() {
262 let mut t = role("a");
263 let s = role("a");
264 t.member_of.push(id("readers"));
265 let cs = diff_cluster(
266 &ClusterCatalog { roles: vec![t] },
267 &ClusterCatalog { roles: vec![s] },
268 );
269 assert_eq!(cs.entries.len(), 1);
270 assert!(matches!(
271 cs.entries[0].change,
272 ClusterChange::RevokeRoleMembership { .. }
273 ));
274 }
275
276 #[test]
277 fn comment_change_emits_comment_op() {
278 let t = role("a");
279 let mut s = role("a");
280 s.comment = Some("hello".into());
281 let cs = diff_cluster(
282 &ClusterCatalog { roles: vec![t] },
283 &ClusterCatalog { roles: vec![s] },
284 );
285 assert_eq!(cs.entries.len(), 1);
286 assert!(matches!(
287 cs.entries[0].change,
288 ClusterChange::CommentOnRole { .. }
289 ));
290 }
291}