1use crate::diff::DiffOp;
4use crate::resource::{Catalog, CatalogField};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone)]
8pub struct CatalogSchemaDiff {
9 pub name: String,
10 pub op: DiffOp<Catalog>,
11 pub field_diffs: Vec<DiffOp<CatalogField>>,
12}
13
14impl CatalogSchemaDiff {
15 pub fn has_changes(&self) -> bool {
16 self.op.is_change() || self.field_diffs.iter().any(|d| d.is_change())
17 }
18
19 pub fn has_destructive(&self) -> bool {
20 self.op.is_destructive() || self.field_diffs.iter().any(|d| d.is_destructive())
21 }
22}
23
24pub fn diff_schema(local: Option<&Catalog>, remote: Option<&Catalog>) -> Option<CatalogSchemaDiff> {
29 match (local, remote) {
30 (None, None) => None,
31 (Some(l), None) => Some(CatalogSchemaDiff {
32 name: l.name.clone(),
33 op: DiffOp::Added(l.clone()),
34 field_diffs: vec![],
35 }),
36 (None, Some(r)) => Some(CatalogSchemaDiff {
37 name: r.name.clone(),
38 op: DiffOp::Removed(r.clone()),
39 field_diffs: vec![],
40 }),
41 (Some(l), Some(r)) => {
42 let field_diffs = diff_fields(&l.fields, &r.fields);
43 let op = if field_diffs.is_empty() {
49 DiffOp::Unchanged
50 } else {
51 DiffOp::Modified {
52 from: r.clone(),
53 to: l.clone(),
54 }
55 };
56 Some(CatalogSchemaDiff {
57 name: l.name.clone(),
58 op,
59 field_diffs,
60 })
61 }
62 }
63}
64
65fn diff_fields(local: &[CatalogField], remote: &[CatalogField]) -> Vec<DiffOp<CatalogField>> {
73 use std::collections::BTreeMap;
74 let l: BTreeMap<&String, &CatalogField> = local.iter().map(|f| (&f.name, f)).collect();
75 let r: BTreeMap<&String, &CatalogField> = remote.iter().map(|f| (&f.name, f)).collect();
76
77 let mut ops = Vec::new();
78 for (name, lf) in &l {
79 match r.get(name) {
80 None => ops.push(DiffOp::Added((*lf).clone())),
81 Some(rf) if rf != lf => ops.push(DiffOp::Modified {
82 from: (*rf).clone(),
83 to: (*lf).clone(),
84 }),
85 Some(_) => {} }
87 }
88 for (name, rf) in &r {
89 if !l.contains_key(name) {
90 ops.push(DiffOp::Removed((*rf).clone()));
91 }
92 }
93 ops
94}
95
96#[derive(Debug, Clone)]
97pub struct CatalogItemsDiff {
98 pub catalog_name: String,
99 pub added_ids: Vec<String>,
100 pub modified_ids: Vec<String>,
101 pub removed_ids: Vec<String>,
102 pub unchanged_count: usize,
103}
104
105impl CatalogItemsDiff {
106 pub fn has_changes(&self) -> bool {
107 !self.added_ids.is_empty() || !self.modified_ids.is_empty() || !self.removed_ids.is_empty()
108 }
109
110 pub fn has_destructive(&self) -> bool {
111 !self.removed_ids.is_empty()
112 }
113}
114
115pub fn diff_items(
124 catalog_name: &str,
125 local_hashes: &HashMap<String, String>,
126 remote_hashes: &HashMap<String, String>,
127) -> CatalogItemsDiff {
128 let mut added = Vec::new();
129 let mut modified = Vec::new();
130 let mut removed = Vec::new();
131 let mut unchanged: usize = 0;
132
133 for (id, lhash) in local_hashes {
134 match remote_hashes.get(id) {
135 None => added.push(id.clone()),
136 Some(rhash) if rhash != lhash => modified.push(id.clone()),
137 Some(_) => unchanged += 1,
138 }
139 }
140 for id in remote_hashes.keys() {
141 if !local_hashes.contains_key(id) {
142 removed.push(id.clone());
143 }
144 }
145
146 added.sort();
147 modified.sort();
148 removed.sort();
149
150 CatalogItemsDiff {
151 catalog_name: catalog_name.to_string(),
152 added_ids: added,
153 modified_ids: modified,
154 removed_ids: removed,
155 unchanged_count: unchanged,
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::resource::CatalogFieldType;
163
164 fn field(name: &str, t: CatalogFieldType) -> CatalogField {
165 CatalogField {
166 name: name.into(),
167 field_type: t,
168 }
169 }
170
171 fn cat(name: &str, fields: Vec<CatalogField>) -> Catalog {
172 Catalog {
173 name: name.into(),
174 description: None,
175 fields,
176 }
177 }
178
179 #[test]
180 fn both_absent_returns_none() {
181 assert!(diff_schema(None, None).is_none());
182 }
183
184 #[test]
185 fn local_only_is_added() {
186 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
187 let d = diff_schema(Some(&l), None).unwrap();
188 assert!(matches!(d.op, DiffOp::Added(_)));
189 assert!(d.has_changes());
190 assert!(!d.has_destructive());
191 }
192
193 #[test]
194 fn remote_only_is_removed_and_destructive() {
195 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
196 let d = diff_schema(None, Some(&r)).unwrap();
197 assert!(matches!(d.op, DiffOp::Removed(_)));
198 assert!(d.has_changes());
199 assert!(d.has_destructive());
200 }
201
202 #[test]
203 fn equal_catalogs_are_unchanged() {
204 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
205 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
206 let d = diff_schema(Some(&l), Some(&r)).unwrap();
207 assert!(matches!(d.op, DiffOp::Unchanged));
208 assert!(d.field_diffs.is_empty());
209 assert!(!d.has_changes());
210 assert!(!d.has_destructive());
211 }
212
213 #[test]
214 fn added_field_is_non_destructive() {
215 let l = cat(
216 "c",
217 vec![
218 field("id", CatalogFieldType::String),
219 field("score", CatalogFieldType::Number),
220 ],
221 );
222 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
223 let d = diff_schema(Some(&l), Some(&r)).unwrap();
224 assert!(matches!(d.op, DiffOp::Modified { .. }));
225 assert_eq!(d.field_diffs.len(), 1);
226 assert!(matches!(d.field_diffs[0], DiffOp::Added(_)));
227 assert!(d.has_changes());
228 assert!(!d.has_destructive());
229 }
230
231 #[test]
232 fn removed_field_is_destructive() {
233 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
234 let r = cat(
235 "c",
236 vec![
237 field("id", CatalogFieldType::String),
238 field("legacy", CatalogFieldType::String),
239 ],
240 );
241 let d = diff_schema(Some(&l), Some(&r)).unwrap();
242 assert_eq!(d.field_diffs.len(), 1);
243 assert!(matches!(d.field_diffs[0], DiffOp::Removed(_)));
244 assert!(d.has_destructive());
245 }
246
247 #[test]
248 fn type_change_is_modified_field() {
249 let l = cat("c", vec![field("v", CatalogFieldType::Number)]);
250 let r = cat("c", vec![field("v", CatalogFieldType::String)]);
251 let d = diff_schema(Some(&l), Some(&r)).unwrap();
252 assert_eq!(d.field_diffs.len(), 1);
253 assert!(matches!(d.field_diffs[0], DiffOp::Modified { .. }));
254 assert!(d.has_changes());
255 assert!(!d.has_destructive());
257 }
258
259 #[test]
260 fn unchanged_fields_are_not_recorded() {
261 let l = cat(
262 "c",
263 vec![
264 field("id", CatalogFieldType::String),
265 field("score", CatalogFieldType::Number),
266 ],
267 );
268 let r = cat(
269 "c",
270 vec![
271 field("id", CatalogFieldType::String),
272 field("score", CatalogFieldType::Number),
273 ],
274 );
275 let d = diff_schema(Some(&l), Some(&r)).unwrap();
276 assert!(d.field_diffs.is_empty());
277 }
278
279 #[test]
280 fn field_order_difference_is_not_drift() {
281 let l = cat(
282 "c",
283 vec![
284 field("a", CatalogFieldType::String),
285 field("b", CatalogFieldType::Number),
286 ],
287 );
288 let r = cat(
289 "c",
290 vec![
291 field("b", CatalogFieldType::Number),
292 field("a", CatalogFieldType::String),
293 ],
294 );
295 let d = diff_schema(Some(&l), Some(&r)).unwrap();
296 assert!(matches!(d.op, DiffOp::Unchanged));
299 assert!(d.field_diffs.is_empty());
300 assert!(!d.has_changes());
301 }
302
303 #[test]
304 fn description_only_difference_is_not_drift() {
305 let l = Catalog {
306 name: "c".into(),
307 description: Some("local description".into()),
308 fields: vec![field("id", CatalogFieldType::String)],
309 };
310 let r = Catalog {
311 name: "c".into(),
312 description: Some("remote description".into()),
313 fields: vec![field("id", CatalogFieldType::String)],
314 };
315 let d = diff_schema(Some(&l), Some(&r)).unwrap();
316 assert!(matches!(d.op, DiffOp::Unchanged));
317 assert!(d.field_diffs.is_empty());
318 assert!(!d.has_changes());
319 }
320
321 #[test]
322 fn items_diff_stub_destructive_when_removed() {
323 let d = CatalogItemsDiff {
324 catalog_name: "c".into(),
325 added_ids: vec![],
326 modified_ids: vec![],
327 removed_ids: vec!["x".into()],
328 unchanged_count: 0,
329 };
330 assert!(d.has_changes());
331 assert!(d.has_destructive());
332 }
333
334 fn hashes(pairs: &[(&str, &str)]) -> HashMap<String, String> {
335 pairs
336 .iter()
337 .map(|(id, h)| (id.to_string(), h.to_string()))
338 .collect()
339 }
340
341 #[test]
342 fn diff_items_both_empty() {
343 let d = diff_items("c", &hashes(&[]), &hashes(&[]));
344 assert!(!d.has_changes());
345 assert_eq!(d.unchanged_count, 0);
346 }
347
348 #[test]
349 fn diff_items_all_added() {
350 let local = hashes(&[("a", "h1"), ("b", "h2")]);
351 let remote = hashes(&[]);
352 let d = diff_items("c", &local, &remote);
353 assert_eq!(d.added_ids, vec!["a", "b"]);
354 assert!(d.modified_ids.is_empty());
355 assert!(d.removed_ids.is_empty());
356 assert_eq!(d.unchanged_count, 0);
357 }
358
359 #[test]
360 fn diff_items_all_removed() {
361 let local = hashes(&[]);
362 let remote = hashes(&[("a", "h1"), ("b", "h2")]);
363 let d = diff_items("c", &local, &remote);
364 assert!(d.added_ids.is_empty());
365 assert!(d.modified_ids.is_empty());
366 assert_eq!(d.removed_ids, vec!["a", "b"]);
367 assert!(d.has_destructive());
368 }
369
370 #[test]
371 fn diff_items_all_unchanged() {
372 let local = hashes(&[("a", "h1"), ("b", "h2")]);
373 let remote = hashes(&[("a", "h1"), ("b", "h2")]);
374 let d = diff_items("c", &local, &remote);
375 assert!(!d.has_changes());
376 assert_eq!(d.unchanged_count, 2);
377 }
378
379 #[test]
380 fn diff_items_mixed() {
381 let local = hashes(&[("a", "h1"), ("b", "h2_new"), ("d", "h4")]);
382 let remote = hashes(&[("a", "h1"), ("b", "h2_old"), ("c", "h3")]);
383 let d = diff_items("c", &local, &remote);
384 assert_eq!(d.added_ids, vec!["d"]);
385 assert_eq!(d.modified_ids, vec!["b"]);
386 assert_eq!(d.removed_ids, vec!["c"]);
387 assert_eq!(d.unchanged_count, 1);
388 }
389
390 #[test]
391 fn diff_items_ids_are_sorted() {
392 let local = hashes(&[("z", "h"), ("a", "h"), ("m", "h")]);
393 let remote = hashes(&[]);
394 let d = diff_items("c", &local, &remote);
395 assert_eq!(d.added_ids, vec!["a", "m", "z"]);
396 }
397
398 #[test]
399 fn diff_items_uses_explicit_catalog_name() {
400 let local = hashes(&[]);
401 let remote = hashes(&[("a", "h1")]);
402 let d = diff_items("remote_only", &local, &remote);
403 assert_eq!(d.catalog_name, "remote_only");
404 assert_eq!(d.removed_ids, vec!["a"]);
405 }
406}