1use crate::diff::DiffOp;
4use crate::resource::{Catalog, CatalogField};
5
6#[derive(Debug, Clone)]
7pub struct CatalogSchemaDiff {
8 pub name: String,
9 pub op: DiffOp<Catalog>,
10 pub field_diffs: Vec<DiffOp<CatalogField>>,
11}
12
13impl CatalogSchemaDiff {
14 pub fn has_changes(&self) -> bool {
15 self.op.is_change() || self.field_diffs.iter().any(|d| d.is_change())
16 }
17
18 pub fn has_destructive(&self) -> bool {
19 self.op.is_destructive() || self.field_diffs.iter().any(|d| d.is_destructive())
20 }
21}
22
23pub fn diff_schema(local: Option<&Catalog>, remote: Option<&Catalog>) -> Option<CatalogSchemaDiff> {
28 match (local, remote) {
29 (None, None) => None,
30 (Some(l), None) => Some(CatalogSchemaDiff {
31 name: l.name.clone(),
32 op: DiffOp::Added(l.clone()),
33 field_diffs: vec![],
34 }),
35 (None, Some(r)) => Some(CatalogSchemaDiff {
36 name: r.name.clone(),
37 op: DiffOp::Removed(r.clone()),
38 field_diffs: vec![],
39 }),
40 (Some(l), Some(r)) => {
41 let field_diffs = diff_fields(&l.fields, &r.fields);
42 let op = if field_diffs.is_empty() {
48 DiffOp::Unchanged
49 } else {
50 DiffOp::Modified {
51 from: r.clone(),
52 to: l.clone(),
53 }
54 };
55 Some(CatalogSchemaDiff {
56 name: l.name.clone(),
57 op,
58 field_diffs,
59 })
60 }
61 }
62}
63
64fn diff_fields(local: &[CatalogField], remote: &[CatalogField]) -> Vec<DiffOp<CatalogField>> {
72 use std::collections::BTreeMap;
73 let l: BTreeMap<&String, &CatalogField> = local.iter().map(|f| (&f.name, f)).collect();
74 let r: BTreeMap<&String, &CatalogField> = remote.iter().map(|f| (&f.name, f)).collect();
75
76 let mut ops = Vec::new();
77 for (name, lf) in &l {
78 match r.get(name) {
79 None => ops.push(DiffOp::Added((*lf).clone())),
80 Some(rf) if rf != lf => ops.push(DiffOp::Modified {
81 from: (*rf).clone(),
82 to: (*lf).clone(),
83 }),
84 Some(_) => {} }
86 }
87 for (name, rf) in &r {
88 if !l.contains_key(name) {
89 ops.push(DiffOp::Removed((*rf).clone()));
90 }
91 }
92 ops
93}
94
95#[derive(Debug, Clone)]
96pub struct CatalogItemsDiff {
97 pub catalog_name: String,
98 pub added_ids: Vec<String>,
99 pub modified_ids: Vec<String>,
100 pub removed_ids: Vec<String>,
101 pub unchanged_count: usize,
102}
103
104impl CatalogItemsDiff {
105 pub fn has_changes(&self) -> bool {
106 !self.added_ids.is_empty() || !self.modified_ids.is_empty() || !self.removed_ids.is_empty()
107 }
108
109 pub fn has_destructive(&self) -> bool {
110 !self.removed_ids.is_empty()
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::resource::CatalogFieldType;
118
119 fn field(name: &str, t: CatalogFieldType) -> CatalogField {
120 CatalogField {
121 name: name.into(),
122 field_type: t,
123 }
124 }
125
126 fn cat(name: &str, fields: Vec<CatalogField>) -> Catalog {
127 Catalog {
128 name: name.into(),
129 description: None,
130 fields,
131 }
132 }
133
134 #[test]
135 fn both_absent_returns_none() {
136 assert!(diff_schema(None, None).is_none());
137 }
138
139 #[test]
140 fn local_only_is_added() {
141 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
142 let d = diff_schema(Some(&l), None).unwrap();
143 assert!(matches!(d.op, DiffOp::Added(_)));
144 assert!(d.has_changes());
145 assert!(!d.has_destructive());
146 }
147
148 #[test]
149 fn remote_only_is_removed_and_destructive() {
150 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
151 let d = diff_schema(None, Some(&r)).unwrap();
152 assert!(matches!(d.op, DiffOp::Removed(_)));
153 assert!(d.has_changes());
154 assert!(d.has_destructive());
155 }
156
157 #[test]
158 fn equal_catalogs_are_unchanged() {
159 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
160 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
161 let d = diff_schema(Some(&l), Some(&r)).unwrap();
162 assert!(matches!(d.op, DiffOp::Unchanged));
163 assert!(d.field_diffs.is_empty());
164 assert!(!d.has_changes());
165 assert!(!d.has_destructive());
166 }
167
168 #[test]
169 fn added_field_is_non_destructive() {
170 let l = cat(
171 "c",
172 vec![
173 field("id", CatalogFieldType::String),
174 field("score", CatalogFieldType::Number),
175 ],
176 );
177 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
178 let d = diff_schema(Some(&l), Some(&r)).unwrap();
179 assert!(matches!(d.op, DiffOp::Modified { .. }));
180 assert_eq!(d.field_diffs.len(), 1);
181 assert!(matches!(d.field_diffs[0], DiffOp::Added(_)));
182 assert!(d.has_changes());
183 assert!(!d.has_destructive());
184 }
185
186 #[test]
187 fn removed_field_is_destructive() {
188 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
189 let r = cat(
190 "c",
191 vec![
192 field("id", CatalogFieldType::String),
193 field("legacy", CatalogFieldType::String),
194 ],
195 );
196 let d = diff_schema(Some(&l), Some(&r)).unwrap();
197 assert_eq!(d.field_diffs.len(), 1);
198 assert!(matches!(d.field_diffs[0], DiffOp::Removed(_)));
199 assert!(d.has_destructive());
200 }
201
202 #[test]
203 fn type_change_is_modified_field() {
204 let l = cat("c", vec![field("v", CatalogFieldType::Number)]);
205 let r = cat("c", vec![field("v", CatalogFieldType::String)]);
206 let d = diff_schema(Some(&l), Some(&r)).unwrap();
207 assert_eq!(d.field_diffs.len(), 1);
208 assert!(matches!(d.field_diffs[0], DiffOp::Modified { .. }));
209 assert!(d.has_changes());
210 assert!(!d.has_destructive());
212 }
213
214 #[test]
215 fn unchanged_fields_are_not_recorded() {
216 let l = cat(
217 "c",
218 vec![
219 field("id", CatalogFieldType::String),
220 field("score", CatalogFieldType::Number),
221 ],
222 );
223 let r = cat(
224 "c",
225 vec![
226 field("id", CatalogFieldType::String),
227 field("score", CatalogFieldType::Number),
228 ],
229 );
230 let d = diff_schema(Some(&l), Some(&r)).unwrap();
231 assert!(d.field_diffs.is_empty());
232 }
233
234 #[test]
235 fn field_order_difference_is_not_drift() {
236 let l = cat(
237 "c",
238 vec![
239 field("a", CatalogFieldType::String),
240 field("b", CatalogFieldType::Number),
241 ],
242 );
243 let r = cat(
244 "c",
245 vec![
246 field("b", CatalogFieldType::Number),
247 field("a", CatalogFieldType::String),
248 ],
249 );
250 let d = diff_schema(Some(&l), Some(&r)).unwrap();
251 assert!(matches!(d.op, DiffOp::Unchanged));
254 assert!(d.field_diffs.is_empty());
255 assert!(!d.has_changes());
256 }
257
258 #[test]
259 fn description_only_difference_is_not_drift() {
260 let l = Catalog {
261 name: "c".into(),
262 description: Some("local description".into()),
263 fields: vec![field("id", CatalogFieldType::String)],
264 };
265 let r = Catalog {
266 name: "c".into(),
267 description: Some("remote description".into()),
268 fields: vec![field("id", CatalogFieldType::String)],
269 };
270 let d = diff_schema(Some(&l), Some(&r)).unwrap();
271 assert!(matches!(d.op, DiffOp::Unchanged));
272 assert!(d.field_diffs.is_empty());
273 assert!(!d.has_changes());
274 }
275
276 #[test]
277 fn items_diff_stub_destructive_when_removed() {
278 let d = CatalogItemsDiff {
279 catalog_name: "c".into(),
280 added_ids: vec![],
281 modified_ids: vec![],
282 removed_ids: vec!["x".into()],
283 unchanged_count: 0,
284 };
285 assert!(d.has_changes());
286 assert!(d.has_destructive());
287 }
288}