Skip to main content

panproto_check/
diff.rs

1//! Structural diffing of two schemas.
2//!
3//! [`diff`] compares an old and new schema, producing a [`SchemaDiff`]
4//! that records every added, removed, or modified element. The diff is
5//! purely structural; it does not yet classify changes as breaking or
6//! non-breaking (that is handled by [`crate::classify()`]).
7
8use std::collections::HashMap;
9
10use panproto_gat::Name;
11use panproto_schema::{Constraint, Edge, RecursionPoint, Schema, UsageMode, Variant};
12use rustc_hash::FxHashSet;
13use serde::{Deserialize, Serialize};
14
15/// A structural diff between two schemas.
16///
17/// Each field captures a specific category of change between the old
18/// and new schema revisions.
19#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub struct SchemaDiff {
21    // --- Vertices ---
22    /// Vertex IDs present in the new schema but absent from the old.
23    pub added_vertices: Vec<String>,
24    /// Vertex IDs present in the old schema but absent from the new.
25    pub removed_vertices: Vec<String>,
26    /// Vertices whose `kind` changed between old and new.
27    pub kind_changes: Vec<KindChange>,
28
29    // --- Edges ---
30    /// Edges present in the new schema but absent from the old.
31    pub added_edges: Vec<Edge>,
32    /// Edges present in the old schema but absent from the new.
33    pub removed_edges: Vec<Edge>,
34
35    // --- Constraints ---
36    /// Constraints that changed between old and new, keyed by vertex ID.
37    pub modified_constraints: HashMap<String, ConstraintDiff>,
38
39    // --- Hyper-edges ---
40    /// Hyper-edge IDs added in the new schema.
41    pub added_hyper_edges: Vec<String>,
42    /// Hyper-edge IDs removed from the old schema.
43    pub removed_hyper_edges: Vec<String>,
44    /// Hyper-edges whose kind, signature, or parent label changed.
45    pub modified_hyper_edges: Vec<HyperEdgeChange>,
46
47    // --- Required edges ---
48    /// Per-vertex: required edges added in the new schema.
49    pub added_required: HashMap<String, Vec<Edge>>,
50    /// Per-vertex: required edges removed from the old schema.
51    pub removed_required: HashMap<String, Vec<Edge>>,
52
53    // --- NSIDs ---
54    /// Vertex-to-NSID mappings added in the new schema.
55    pub added_nsids: HashMap<String, String>,
56    /// Vertex IDs whose NSID mapping was removed.
57    pub removed_nsids: Vec<String>,
58    /// NSID mappings that changed: `(vertex_id, old_nsid, new_nsid)`.
59    pub changed_nsids: Vec<(String, String, String)>,
60
61    // --- Variants ---
62    /// Variants added in the new schema.
63    pub added_variants: Vec<Variant>,
64    /// Variants removed from the old schema.
65    pub removed_variants: Vec<Variant>,
66    /// Variants whose tag changed (same ID, different tag).
67    pub modified_variants: Vec<VariantChange>,
68
69    // --- Orderings ---
70    /// Edge ordering changes: `(edge, old_position, new_position)`.
71    pub order_changes: Vec<(Edge, Option<u32>, Option<u32>)>,
72
73    // --- Recursion points ---
74    //
75    // Each carries the marker vertex alongside the point, because the marker is
76    // the key in `Schema::recursion_points` and a bare `RecursionPoint` names
77    // only what it unfolds to. Reporting an added or removed marker needs both.
78    /// Recursion points added in the new schema, as `(marker vertex, point)`.
79    pub added_recursion_points: Vec<(Name, RecursionPoint)>,
80    /// Recursion points removed from the old schema, as `(marker vertex, point)`.
81    pub removed_recursion_points: Vec<(Name, RecursionPoint)>,
82    /// Recursion points whose target vertex changed.
83    pub modified_recursion_points: Vec<RecursionPointChange>,
84
85    // --- Usage modes ---
86    /// Usage mode changes: `(edge, old_mode, new_mode)`.
87    pub usage_mode_changes: Vec<(Edge, UsageMode, UsageMode)>,
88
89    // --- Spans ---
90    /// Span IDs added in the new schema.
91    pub added_spans: Vec<String>,
92    /// Span IDs removed from the old schema.
93    pub removed_spans: Vec<String>,
94    /// Spans whose left or right vertex changed.
95    pub modified_spans: Vec<SpanChange>,
96
97    // --- Nominal ---
98    /// Nominal flag changes: `(vertex_id, old_value, new_value)`.
99    pub nominal_changes: Vec<(String, bool, bool)>,
100
101    // --- Enrichment maps ---
102    /// Coercion keys `(source_kind, target_kind)` added in the new schema.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub added_coercions: Vec<(String, String)>,
105    /// Coercion keys `(source_kind, target_kind)` removed from the old schema.
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub removed_coercions: Vec<(String, String)>,
108    /// Coercion keys `(source_kind, target_kind)` whose expression changed.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub modified_coercions: Vec<(String, String)>,
111
112    /// Merger keys (vertex ID) added in the new schema.
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub added_mergers: Vec<String>,
115    /// Merger keys (vertex ID) removed from the old schema.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub removed_mergers: Vec<String>,
118    /// Merger keys (vertex ID) whose expression changed.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub modified_mergers: Vec<String>,
121
122    /// Default keys (vertex ID) added in the new schema.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub added_defaults: Vec<String>,
125    /// Default keys (vertex ID) removed from the old schema.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub removed_defaults: Vec<String>,
128    /// Default keys (vertex ID) whose expression changed.
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub modified_defaults: Vec<String>,
131
132    /// Policy keys (sort name) added in the new schema.
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub added_policies: Vec<String>,
135    /// Policy keys (sort name) removed from the old schema.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub removed_policies: Vec<String>,
138    /// Policy keys (sort name) whose expression changed.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub modified_policies: Vec<String>,
141
142    // --- Renames ---
143    /// Vertex renames as `(old_id, new_id)` pairs, populated by
144    /// [`apply_renames`] from an external rename detector. When set, the
145    /// classifier reports a single rename instead of a removal plus an
146    /// addition for the pair.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub renamed_vertices: Vec<(String, String)>,
149}
150
151/// Rewrite a [`SchemaDiff`] to recognise vertex renames.
152///
153/// For each `(old_id, new_id)` pair where `old_id` appears in
154/// [`removed_vertices`](SchemaDiff::removed_vertices) and `new_id` in
155/// [`added_vertices`](SchemaDiff::added_vertices), the pair is removed
156/// from those two lists and recorded in
157/// [`renamed_vertices`](SchemaDiff::renamed_vertices) instead, so a
158/// rename is reported once rather than as a deletion plus an insertion.
159///
160/// Pairs that do not correspond to a removed/added vertex pair are
161/// ignored. The rename detector itself lives in `panproto-vcs` (which
162/// depends on this crate); this hook lets callers feed detection results
163/// back into the diff without a dependency cycle.
164pub fn apply_renames(diff: &mut SchemaDiff, renames: &[(String, String)]) {
165    for (old_id, new_id) in renames {
166        let has_removed = diff.removed_vertices.iter().any(|v| v == old_id);
167        let has_added = diff.added_vertices.iter().any(|v| v == new_id);
168        if has_removed && has_added {
169            diff.removed_vertices.retain(|v| v != old_id);
170            diff.added_vertices.retain(|v| v != new_id);
171            diff.renamed_vertices.push((old_id.clone(), new_id.clone()));
172        }
173    }
174}
175
176/// Describes how constraints on a single vertex changed.
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ConstraintDiff {
179    /// Constraints added in the new schema.
180    pub added: Vec<Constraint>,
181    /// Constraints removed from the old schema.
182    pub removed: Vec<Constraint>,
183    /// Constraints whose value changed.
184    pub changed: Vec<ConstraintChange>,
185}
186
187/// A single constraint that changed its value.
188#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
189pub struct ConstraintChange {
190    /// The constraint sort (e.g., `"maxLength"`).
191    pub sort: String,
192    /// The value in the old schema.
193    pub old_value: String,
194    /// The value in the new schema.
195    pub new_value: String,
196}
197
198/// Records a vertex whose kind changed between schema versions.
199#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200pub struct KindChange {
201    /// The vertex ID.
202    pub vertex_id: String,
203    /// The kind in the old schema.
204    pub old_kind: String,
205    /// The kind in the new schema.
206    pub new_kind: String,
207}
208
209/// Records changes to a hyper-edge's kind, signature, or parent label.
210#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
211pub struct HyperEdgeChange {
212    /// The hyper-edge ID.
213    pub id: String,
214    /// Kind change: `(old_kind, new_kind)`, or `None` if unchanged.
215    pub kind_change: Option<(String, String)>,
216    /// Signature labels added: label → `vertex_id`.
217    pub signature_added: HashMap<String, String>,
218    /// Signature labels removed: label → `vertex_id`.
219    pub signature_removed: HashMap<String, String>,
220    /// Signature labels whose vertex changed: label → (`old_vid`, `new_vid`).
221    pub signature_changed: HashMap<String, (String, String)>,
222    /// Parent label change: `(old, new)`, or `None` if unchanged.
223    pub parent_label_change: Option<(String, String)>,
224}
225
226/// Records a variant whose tag changed between schema versions.
227#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
228pub struct VariantChange {
229    /// The variant ID.
230    pub id: String,
231    /// The parent coproduct vertex ID.
232    pub parent_vertex: String,
233    /// The old tag.
234    pub old_tag: Option<String>,
235    /// The new tag.
236    pub new_tag: Option<String>,
237}
238
239/// Records a recursion point whose target vertex changed.
240#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
241pub struct RecursionPointChange {
242    /// The fixpoint marker vertex ID.
243    pub mu_id: String,
244    /// The old target vertex.
245    pub old_target: String,
246    /// The new target vertex.
247    pub new_target: String,
248}
249
250/// Records a span whose left or right vertex changed.
251#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
252pub struct SpanChange {
253    /// The span ID.
254    pub id: String,
255    /// Left vertex change: `(old, new)`, or `None` if unchanged.
256    pub left_change: Option<(String, String)>,
257    /// Right vertex change: `(old, new)`, or `None` if unchanged.
258    pub right_change: Option<(String, String)>,
259}
260
261/// Compute a structural diff between two schemas.
262///
263/// Compares every schema field: vertices, edges, constraints, hyper-edges,
264/// required edges, NSIDs, variants, orderings, recursion points, usage modes,
265/// spans, and nominal flags.
266// Structural diff walks every field of `Schema` in turn; each branch
267// contributes one field to `SchemaDiff` and is naturally co-located.
268#[must_use]
269#[allow(clippy::too_many_lines)]
270pub fn diff(old: &Schema, new: &Schema) -> SchemaDiff {
271    let mut result = SchemaDiff::default();
272
273    // --- Vertices ---
274    let old_verts: FxHashSet<&panproto_gat::Name> = old.vertices.keys().collect();
275    let new_verts: FxHashSet<&panproto_gat::Name> = new.vertices.keys().collect();
276
277    for v in &new_verts {
278        if !old_verts.contains(*v) {
279            result.added_vertices.push(v.to_string());
280        }
281    }
282    for v in &old_verts {
283        if !new_verts.contains(*v) {
284            result.removed_vertices.push(v.to_string());
285        }
286    }
287    result.added_vertices.sort();
288    result.removed_vertices.sort();
289
290    // --- Kind changes ---
291    for v in old_verts.intersection(&new_verts) {
292        if let (Some(old_v), Some(new_v)) = (old.vertices.get(*v), new.vertices.get(*v)) {
293            if old_v.kind != new_v.kind {
294                result.kind_changes.push(KindChange {
295                    vertex_id: v.to_string(),
296                    old_kind: old_v.kind.to_string(),
297                    new_kind: new_v.kind.to_string(),
298                });
299            }
300        }
301    }
302    result
303        .kind_changes
304        .sort_by(|a, b| a.vertex_id.cmp(&b.vertex_id));
305
306    // --- Edges ---
307    let old_edges: FxHashSet<&Edge> = old.edges.keys().collect();
308    let new_edges: FxHashSet<&Edge> = new.edges.keys().collect();
309
310    for e in &new_edges {
311        if !old_edges.contains(*e) {
312            result.added_edges.push((*e).clone());
313        }
314    }
315    for e in &old_edges {
316        if !new_edges.contains(*e) {
317            result.removed_edges.push((*e).clone());
318        }
319    }
320    result.added_edges.sort();
321    result.removed_edges.sort();
322
323    // --- Constraints ---
324    let all_constraint_vids: FxHashSet<&panproto_gat::Name> = old
325        .constraints
326        .keys()
327        .chain(new.constraints.keys())
328        .collect();
329
330    for vid in all_constraint_vids {
331        // A constraint change is a *modification* only when its vertex
332        // exists in both schemas. Constraints on a newly-added vertex ride
333        // along with the addition (non-breaking), and constraints on a
334        // removed vertex vanish with it; neither is a modification of an
335        // existing vertex, which is what `modified_constraints` records.
336        if !old_verts.contains(vid) || !new_verts.contains(vid) {
337            continue;
338        }
339
340        let old_cs = old.constraints.get(vid).cloned().unwrap_or_default();
341        let new_cs = new.constraints.get(vid).cloned().unwrap_or_default();
342
343        let cdiff = diff_constraints(&old_cs, &new_cs);
344        if !cdiff.added.is_empty() || !cdiff.removed.is_empty() || !cdiff.changed.is_empty() {
345            result.modified_constraints.insert(vid.to_string(), cdiff);
346        }
347    }
348
349    // --- Hyper-edges ---
350    diff_hyper_edges(old, new, &mut result);
351
352    // --- Required edges ---
353    diff_required(old, new, &mut result);
354
355    // --- NSIDs ---
356    diff_nsids(old, new, &mut result);
357
358    // --- Variants ---
359    diff_variants(old, new, &mut result);
360
361    // --- Orderings ---
362    let all_order_edges: FxHashSet<&Edge> =
363        old.orderings.keys().chain(new.orderings.keys()).collect();
364    for edge in all_order_edges {
365        let old_pos = old.orderings.get(edge).copied();
366        let new_pos = new.orderings.get(edge).copied();
367        if old_pos != new_pos {
368            result
369                .order_changes
370                .push(((*edge).clone(), old_pos, new_pos));
371        }
372    }
373
374    // --- Recursion Points ---
375    diff_recursion_points(old, new, &mut result);
376
377    // --- Usage Modes ---
378    let all_usage_edges: FxHashSet<&Edge> = old
379        .usage_modes
380        .keys()
381        .chain(new.usage_modes.keys())
382        .collect();
383    for edge in all_usage_edges {
384        let old_mode = old.usage_modes.get(edge).cloned().unwrap_or_default();
385        let new_mode = new.usage_modes.get(edge).cloned().unwrap_or_default();
386        if old_mode != new_mode {
387            result
388                .usage_mode_changes
389                .push(((*edge).clone(), old_mode, new_mode));
390        }
391    }
392
393    // --- Spans ---
394    diff_spans(old, new, &mut result);
395
396    // --- Nominal ---
397    diff_nominal(old, new, &mut result);
398
399    // --- Enrichment maps ---
400    diff_coercions(old, new, &mut result);
401    diff_name_keyed_exprs(
402        &old.mergers,
403        &new.mergers,
404        &mut result.added_mergers,
405        &mut result.removed_mergers,
406        &mut result.modified_mergers,
407    );
408    diff_name_keyed_exprs(
409        &old.defaults,
410        &new.defaults,
411        &mut result.added_defaults,
412        &mut result.removed_defaults,
413        &mut result.modified_defaults,
414    );
415    diff_name_keyed_exprs(
416        &old.policies,
417        &new.policies,
418        &mut result.added_policies,
419        &mut result.removed_policies,
420        &mut result.modified_policies,
421    );
422
423    result
424}
425
426// ---------------------------------------------------------------------------
427// Per-field diff helpers
428// ---------------------------------------------------------------------------
429
430/// Diff two constraint lists for a single vertex.
431fn diff_constraints(old: &[Constraint], new: &[Constraint]) -> ConstraintDiff {
432    let mut added = Vec::new();
433    let mut removed = Vec::new();
434    let mut changed = Vec::new();
435
436    let old_by_sort: HashMap<&str, &Constraint> =
437        old.iter().map(|c| (c.sort.as_str(), c)).collect();
438    let new_by_sort: HashMap<&str, &Constraint> =
439        new.iter().map(|c| (c.sort.as_str(), c)).collect();
440
441    for (sort, nc) in &new_by_sort {
442        match old_by_sort.get(sort) {
443            Some(oc) if oc.value != nc.value => {
444                changed.push(ConstraintChange {
445                    sort: sort.to_string(),
446                    old_value: oc.value.clone(),
447                    new_value: nc.value.clone(),
448                });
449            }
450            None => {
451                added.push((*nc).clone());
452            }
453            _ => {}
454        }
455    }
456
457    for (sort, oc) in &old_by_sort {
458        if !new_by_sort.contains_key(sort) {
459            removed.push((*oc).clone());
460        }
461    }
462
463    ConstraintDiff {
464        added,
465        removed,
466        changed,
467    }
468}
469
470/// Diff hyper-edges: additions, removals, and modifications.
471fn diff_hyper_edges(old: &Schema, new: &Schema, result: &mut SchemaDiff) {
472    let old_ids: FxHashSet<&panproto_gat::Name> = old.hyper_edges.keys().collect();
473    let new_ids: FxHashSet<&panproto_gat::Name> = new.hyper_edges.keys().collect();
474
475    for id in &new_ids {
476        if !old_ids.contains(*id) {
477            result.added_hyper_edges.push(id.to_string());
478        }
479    }
480    for id in &old_ids {
481        if !new_ids.contains(*id) {
482            result.removed_hyper_edges.push(id.to_string());
483        }
484    }
485    result.added_hyper_edges.sort();
486    result.removed_hyper_edges.sort();
487
488    // Modifications on surviving hyper-edges.
489    for id in old_ids.intersection(&new_ids) {
490        let old_he = &old.hyper_edges[*id];
491        let new_he = &new.hyper_edges[*id];
492
493        if old_he == new_he {
494            continue;
495        }
496
497        let kind_change = if old_he.kind == new_he.kind {
498            None
499        } else {
500            Some((old_he.kind.to_string(), new_he.kind.to_string()))
501        };
502
503        let parent_label_change = if old_he.parent_label == new_he.parent_label {
504            None
505        } else {
506            Some((
507                old_he.parent_label.to_string(),
508                new_he.parent_label.to_string(),
509            ))
510        };
511
512        let mut sig_added = HashMap::new();
513        let mut sig_removed = HashMap::new();
514        let mut sig_changed = HashMap::new();
515
516        for (label, new_vid) in &new_he.signature {
517            match old_he.signature.get(label) {
518                Some(old_vid) if old_vid != new_vid => {
519                    sig_changed.insert(
520                        label.to_string(),
521                        (old_vid.to_string(), new_vid.to_string()),
522                    );
523                }
524                None => {
525                    sig_added.insert(label.to_string(), new_vid.to_string());
526                }
527                _ => {}
528            }
529        }
530        for (label, old_vid) in &old_he.signature {
531            if !new_he.signature.contains_key(label) {
532                sig_removed.insert(label.to_string(), old_vid.to_string());
533            }
534        }
535
536        result.modified_hyper_edges.push(HyperEdgeChange {
537            id: id.to_string(),
538            kind_change,
539            signature_added: sig_added,
540            signature_removed: sig_removed,
541            signature_changed: sig_changed,
542            parent_label_change,
543        });
544    }
545    result.modified_hyper_edges.sort_by(|a, b| a.id.cmp(&b.id));
546}
547
548/// Diff required-edge maps.
549fn diff_required(old: &Schema, new: &Schema, result: &mut SchemaDiff) {
550    let all_vids: FxHashSet<&panproto_gat::Name> =
551        old.required.keys().chain(new.required.keys()).collect();
552
553    for vid in all_vids {
554        let old_edges: FxHashSet<&Edge> = old
555            .required
556            .get(vid)
557            .map(|v| v.iter().collect())
558            .unwrap_or_default();
559        let new_edges: FxHashSet<&Edge> = new
560            .required
561            .get(vid)
562            .map(|v| v.iter().collect())
563            .unwrap_or_default();
564
565        let added: Vec<Edge> = new_edges
566            .difference(&old_edges)
567            .map(|e| (*e).clone())
568            .collect();
569        let removed: Vec<Edge> = old_edges
570            .difference(&new_edges)
571            .map(|e| (*e).clone())
572            .collect();
573
574        if !added.is_empty() {
575            result.added_required.insert(vid.to_string(), added);
576        }
577        if !removed.is_empty() {
578            result.removed_required.insert(vid.to_string(), removed);
579        }
580    }
581}
582
583/// Diff NSID maps.
584fn diff_nsids(old: &Schema, new: &Schema, result: &mut SchemaDiff) {
585    for (vid, new_nsid) in &new.nsids {
586        match old.nsids.get(vid) {
587            Some(old_nsid) if old_nsid != new_nsid => {
588                result.changed_nsids.push((
589                    vid.to_string(),
590                    old_nsid.to_string(),
591                    new_nsid.to_string(),
592                ));
593            }
594            None => {
595                result
596                    .added_nsids
597                    .insert(vid.to_string(), new_nsid.to_string());
598            }
599            _ => {}
600        }
601    }
602    for vid in old.nsids.keys() {
603        if !new.nsids.contains_key(vid) {
604            result.removed_nsids.push(vid.to_string());
605        }
606    }
607    result.removed_nsids.sort();
608    result.changed_nsids.sort_by(|a, b| a.0.cmp(&b.0));
609}
610
611/// Diff variants: additions, removals, and tag modifications.
612fn diff_variants(old: &Schema, new: &Schema, result: &mut SchemaDiff) {
613    // Build a flat lookup: (parent_vertex, variant_id) → Variant
614    let mut old_flat: HashMap<(&str, &str), &Variant> = HashMap::new();
615    let mut new_flat: HashMap<(&str, &str), &Variant> = HashMap::new();
616
617    for (parent, variants) in &old.variants {
618        for v in variants {
619            old_flat.insert((parent.as_str(), v.id.as_str()), v);
620        }
621    }
622    for (parent, variants) in &new.variants {
623        for v in variants {
624            new_flat.insert((parent.as_str(), v.id.as_str()), v);
625        }
626    }
627
628    // Additions and modifications.
629    for (&(parent, vid), new_v) in &new_flat {
630        match old_flat.get(&(parent, vid)) {
631            Some(old_v) => {
632                if old_v.tag != new_v.tag {
633                    result.modified_variants.push(VariantChange {
634                        id: vid.to_string(),
635                        parent_vertex: parent.to_string(),
636                        old_tag: old_v.tag.as_ref().map(ToString::to_string),
637                        new_tag: new_v.tag.as_ref().map(ToString::to_string),
638                    });
639                }
640            }
641            None => {
642                result.added_variants.push((*new_v).clone());
643            }
644        }
645    }
646
647    // Removals.
648    for (&(parent, vid), old_v) in &old_flat {
649        if !new_flat.contains_key(&(parent, vid)) {
650            result.removed_variants.push((*old_v).clone());
651        }
652    }
653
654    result
655        .modified_variants
656        .sort_by(|a, b| (&a.parent_vertex, &a.id).cmp(&(&b.parent_vertex, &b.id)));
657}
658
659/// Diff recursion points: additions, removals, and target changes.
660fn diff_recursion_points(old: &Schema, new: &Schema, result: &mut SchemaDiff) {
661    for (id, new_rp) in &new.recursion_points {
662        match old.recursion_points.get(id) {
663            Some(old_rp) => {
664                if old_rp.target_vertex != new_rp.target_vertex {
665                    result.modified_recursion_points.push(RecursionPointChange {
666                        mu_id: id.to_string(),
667                        old_target: old_rp.target_vertex.to_string(),
668                        new_target: new_rp.target_vertex.to_string(),
669                    });
670                }
671            }
672            None => {
673                result
674                    .added_recursion_points
675                    .push((id.clone(), new_rp.clone()));
676            }
677        }
678    }
679    for (id, old_rp) in &old.recursion_points {
680        if !new.recursion_points.contains_key(id) {
681            result
682                .removed_recursion_points
683                .push((id.clone(), old_rp.clone()));
684        }
685    }
686}
687
688/// Diff spans: additions, removals, and left/right changes.
689fn diff_spans(old: &Schema, new: &Schema, result: &mut SchemaDiff) {
690    let old_ids: FxHashSet<&panproto_gat::Name> = old.spans.keys().collect();
691    let new_ids: FxHashSet<&panproto_gat::Name> = new.spans.keys().collect();
692
693    for id in &new_ids {
694        if !old_ids.contains(*id) {
695            result.added_spans.push(id.to_string());
696        }
697    }
698    for id in &old_ids {
699        if !new_ids.contains(*id) {
700            result.removed_spans.push(id.to_string());
701        }
702    }
703    result.added_spans.sort();
704    result.removed_spans.sort();
705
706    for id in old_ids.intersection(&new_ids) {
707        let old_span = &old.spans[*id];
708        let new_span = &new.spans[*id];
709
710        if old_span == new_span {
711            continue;
712        }
713
714        let left_change = if old_span.left == new_span.left {
715            None
716        } else {
717            Some((old_span.left.to_string(), new_span.left.to_string()))
718        };
719        let right_change = if old_span.right == new_span.right {
720            None
721        } else {
722            Some((old_span.right.to_string(), new_span.right.to_string()))
723        };
724
725        result.modified_spans.push(SpanChange {
726            id: id.to_string(),
727            left_change,
728            right_change,
729        });
730    }
731    result.modified_spans.sort_by(|a, b| a.id.cmp(&b.id));
732}
733
734/// Diff nominal flags.
735fn diff_nominal(old: &Schema, new: &Schema, result: &mut SchemaDiff) {
736    let all_vids: FxHashSet<&panproto_gat::Name> =
737        old.nominal.keys().chain(new.nominal.keys()).collect();
738
739    for vid in all_vids {
740        let old_val = old.nominal.get(vid).copied().unwrap_or(false);
741        let new_val = new.nominal.get(vid).copied().unwrap_or(false);
742        if old_val != new_val {
743            result
744                .nominal_changes
745                .push((vid.to_string(), old_val, new_val));
746        }
747    }
748    result.nominal_changes.sort_by(|a, b| a.0.cmp(&b.0));
749}
750
751/// Diff coercion maps: keyed by `(Name, Name)`.
752fn diff_coercions(old: &Schema, new: &Schema, result: &mut SchemaDiff) {
753    for (key, new_expr) in &new.coercions {
754        match old.coercions.get(key) {
755            Some(old_expr) => {
756                if old_expr != new_expr {
757                    result
758                        .modified_coercions
759                        .push((key.0.to_string(), key.1.to_string()));
760                }
761            }
762            None => {
763                result
764                    .added_coercions
765                    .push((key.0.to_string(), key.1.to_string()));
766            }
767        }
768    }
769    for key in old.coercions.keys() {
770        if !new.coercions.contains_key(key) {
771            result
772                .removed_coercions
773                .push((key.0.to_string(), key.1.to_string()));
774        }
775    }
776    result.added_coercions.sort();
777    result.removed_coercions.sort();
778    result.modified_coercions.sort();
779}
780
781/// Diff `HashMap<Name, V>` maps where `V: PartialEq` (mergers, defaults, policies).
782fn diff_name_keyed_exprs<V: PartialEq>(
783    old: &std::collections::HashMap<panproto_gat::Name, V>,
784    new: &std::collections::HashMap<panproto_gat::Name, V>,
785    added: &mut Vec<String>,
786    removed: &mut Vec<String>,
787    modified: &mut Vec<String>,
788) {
789    for (key, new_expr) in new {
790        match old.get(key) {
791            Some(old_expr) => {
792                if old_expr != new_expr {
793                    modified.push(key.to_string());
794                }
795            }
796            None => {
797                added.push(key.to_string());
798            }
799        }
800    }
801    for key in old.keys() {
802        if !new.contains_key(key) {
803            removed.push(key.to_string());
804        }
805    }
806    added.sort();
807    removed.sort();
808    modified.sort();
809}
810
811impl SchemaDiff {
812    /// Returns `true` if this diff contains no changes.
813    #[must_use]
814    pub fn is_empty(&self) -> bool {
815        self.added_vertices.is_empty()
816            && self.removed_vertices.is_empty()
817            && self.kind_changes.is_empty()
818            && self.added_edges.is_empty()
819            && self.removed_edges.is_empty()
820            && self.modified_constraints.is_empty()
821            && self.added_hyper_edges.is_empty()
822            && self.removed_hyper_edges.is_empty()
823            && self.modified_hyper_edges.is_empty()
824            && self.added_required.is_empty()
825            && self.removed_required.is_empty()
826            && self.added_nsids.is_empty()
827            && self.removed_nsids.is_empty()
828            && self.changed_nsids.is_empty()
829            && self.added_variants.is_empty()
830            && self.removed_variants.is_empty()
831            && self.modified_variants.is_empty()
832            && self.order_changes.is_empty()
833            && self.added_recursion_points.is_empty()
834            && self.removed_recursion_points.is_empty()
835            && self.modified_recursion_points.is_empty()
836            && self.usage_mode_changes.is_empty()
837            && self.added_spans.is_empty()
838            && self.removed_spans.is_empty()
839            && self.modified_spans.is_empty()
840            && self.nominal_changes.is_empty()
841            && self.added_coercions.is_empty()
842            && self.removed_coercions.is_empty()
843            && self.modified_coercions.is_empty()
844            && self.added_mergers.is_empty()
845            && self.removed_mergers.is_empty()
846            && self.modified_mergers.is_empty()
847            && self.added_defaults.is_empty()
848            && self.removed_defaults.is_empty()
849            && self.modified_defaults.is_empty()
850            && self.added_policies.is_empty()
851            && self.removed_policies.is_empty()
852            && self.modified_policies.is_empty()
853            && self.renamed_vertices.is_empty()
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use panproto_gat::Name;
861    use panproto_schema::{HyperEdge, RecursionPoint, Span, Variant, Vertex};
862    use smallvec::SmallVec;
863    use std::collections::HashMap;
864
865    /// Helper to build a minimal test schema.
866    fn test_schema(
867        vertices: &[(&str, &str)],
868        edges: &[Edge],
869        constraints: HashMap<Name, Vec<Constraint>>,
870    ) -> Schema {
871        let mut vert_map = HashMap::new();
872        let mut edge_map = HashMap::new();
873        let mut outgoing: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
874        let mut incoming: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
875        let mut between: HashMap<(Name, Name), SmallVec<Edge, 2>> = HashMap::new();
876
877        for (id, kind) in vertices {
878            vert_map.insert(
879                Name::from(*id),
880                Vertex {
881                    id: Name::from(*id),
882                    kind: Name::from(*kind),
883                    nsid: None,
884                },
885            );
886        }
887
888        for edge in edges {
889            edge_map.insert(edge.clone(), edge.kind.clone());
890            outgoing
891                .entry(edge.src.clone())
892                .or_default()
893                .push(edge.clone());
894            incoming
895                .entry(edge.tgt.clone())
896                .or_default()
897                .push(edge.clone());
898            between
899                .entry((edge.src.clone(), edge.tgt.clone()))
900                .or_default()
901                .push(edge.clone());
902        }
903
904        Schema {
905            protocol: "test".into(),
906            vertices: vert_map,
907            edges: edge_map,
908            hyper_edges: HashMap::new(),
909            constraints,
910            required: HashMap::new(),
911            nsids: HashMap::new(),
912            entries: Vec::new(),
913            variants: HashMap::new(),
914            orderings: HashMap::new(),
915            recursion_points: HashMap::new(),
916            spans: HashMap::new(),
917            usage_modes: HashMap::new(),
918            nominal: HashMap::new(),
919            coercions: HashMap::new(),
920            mergers: HashMap::new(),
921            defaults: HashMap::new(),
922            policies: HashMap::new(),
923            outgoing,
924            incoming,
925            between,
926        }
927    }
928
929    /// Build a schema with additional extended fields set.
930    fn test_schema_ext(base: Schema, f: impl FnOnce(&mut Schema)) -> Schema {
931        let mut s = base;
932        f(&mut s);
933        s
934    }
935
936    // -----------------------------------------------------------------------
937    // Existing tests (preserved)
938    // -----------------------------------------------------------------------
939
940    #[test]
941    fn diff_added_and_removed_vertices() {
942        let edge = Edge {
943            src: "a".into(),
944            tgt: "b".into(),
945            kind: "prop".into(),
946            name: None,
947        };
948        let old = test_schema(&[("a", "object"), ("b", "string")], &[edge], HashMap::new());
949        let new = test_schema(&[("a", "object"), ("c", "integer")], &[], HashMap::new());
950
951        let d = diff(&old, &new);
952        assert_eq!(d.added_vertices, vec!["c"]);
953        assert_eq!(d.removed_vertices, vec!["b"]);
954        assert_eq!(d.removed_edges.len(), 1);
955    }
956
957    #[test]
958    fn diff_kind_change() {
959        let old = test_schema(&[("x", "string")], &[], HashMap::new());
960        let new = test_schema(&[("x", "integer")], &[], HashMap::new());
961
962        let d = diff(&old, &new);
963        assert_eq!(d.kind_changes.len(), 1);
964        assert_eq!(d.kind_changes[0].old_kind, "string");
965        assert_eq!(d.kind_changes[0].new_kind, "integer");
966    }
967
968    #[test]
969    fn diff_constraint_changed() {
970        let old_constraints = HashMap::from([(
971            Name::from("x"),
972            vec![Constraint {
973                sort: "maxLength".into(),
974                value: "3000".into(),
975            }],
976        )]);
977        let new_constraints = HashMap::from([(
978            Name::from("x"),
979            vec![Constraint {
980                sort: "maxLength".into(),
981                value: "300".into(),
982            }],
983        )]);
984
985        let old = test_schema(&[("x", "string")], &[], old_constraints);
986        let new = test_schema(&[("x", "string")], &[], new_constraints);
987
988        let d = diff(&old, &new);
989        assert!(d.modified_constraints.contains_key("x"));
990        let cdiff = &d.modified_constraints["x"];
991        assert_eq!(cdiff.changed.len(), 1);
992        assert_eq!(cdiff.changed[0].old_value, "3000");
993        assert_eq!(cdiff.changed[0].new_value, "300");
994    }
995
996    #[test]
997    fn diff_constraint_on_added_vertex_is_not_a_modification() {
998        // `title` is a newly-added vertex whose only constraint arrives
999        // with it. Adding a field cannot invalidate existing data, so the
1000        // constraint must not surface as a *modified* constraint (which
1001        // classify would report as a breaking `ConstraintAdded`).
1002        let old = test_schema(&[("post", "record")], &[], HashMap::new());
1003        let new_constraints = HashMap::from([(
1004            Name::from("title"),
1005            vec![Constraint {
1006                sort: "maxLength".into(),
1007                value: "120".into(),
1008            }],
1009        )]);
1010        let new = test_schema(
1011            &[("post", "record"), ("title", "string")],
1012            &[],
1013            new_constraints,
1014        );
1015
1016        let d = diff(&old, &new);
1017        assert_eq!(d.added_vertices, vec!["title"]);
1018        assert!(
1019            !d.modified_constraints.contains_key("title"),
1020            "a constraint on a newly-added vertex is not a modification"
1021        );
1022    }
1023
1024    #[test]
1025    fn empty_diff_for_identical_schemas() {
1026        let s = test_schema(&[("a", "object")], &[], HashMap::new());
1027        let d = diff(&s, &s);
1028        assert!(d.is_empty());
1029    }
1030
1031    // -----------------------------------------------------------------------
1032    // Hyper-edge diff tests
1033    // -----------------------------------------------------------------------
1034
1035    #[test]
1036    fn diff_hyper_edge_added() {
1037        let base = test_schema(&[("a", "object")], &[], HashMap::new());
1038        let new = test_schema_ext(base.clone(), |s| {
1039            s.hyper_edges.insert(
1040                "he1".into(),
1041                HyperEdge {
1042                    id: "he1".into(),
1043                    kind: "join".into(),
1044                    signature: HashMap::from([("left".into(), "a".into())]),
1045                    parent_label: "left".into(),
1046                },
1047            );
1048        });
1049        let d = diff(&base, &new);
1050        assert_eq!(d.added_hyper_edges, vec!["he1"]);
1051        assert!(d.removed_hyper_edges.is_empty());
1052        assert!(!d.is_empty());
1053    }
1054
1055    #[test]
1056    fn diff_hyper_edge_removed() {
1057        let old = test_schema_ext(test_schema(&[("a", "object")], &[], HashMap::new()), |s| {
1058            s.hyper_edges.insert(
1059                "he1".into(),
1060                HyperEdge {
1061                    id: "he1".into(),
1062                    kind: "join".into(),
1063                    signature: HashMap::from([("left".into(), "a".into())]),
1064                    parent_label: "left".into(),
1065                },
1066            );
1067        });
1068        let new = test_schema(&[("a", "object")], &[], HashMap::new());
1069        let d = diff(&old, &new);
1070        assert_eq!(d.removed_hyper_edges, vec!["he1"]);
1071    }
1072
1073    #[test]
1074    fn diff_hyper_edge_modified_kind() {
1075        let he = HyperEdge {
1076            id: "he1".into(),
1077            kind: "join".into(),
1078            signature: HashMap::from([("left".into(), "a".into())]),
1079            parent_label: "left".into(),
1080        };
1081        let old = test_schema_ext(test_schema(&[("a", "object")], &[], HashMap::new()), |s| {
1082            s.hyper_edges.insert("he1".into(), he.clone());
1083        });
1084        let new = test_schema_ext(test_schema(&[("a", "object")], &[], HashMap::new()), |s| {
1085            let mut he2 = he.clone();
1086            he2.kind = "merge".into();
1087            s.hyper_edges.insert("he1".into(), he2);
1088        });
1089        let d = diff(&old, &new);
1090        assert_eq!(d.modified_hyper_edges.len(), 1);
1091        assert_eq!(
1092            d.modified_hyper_edges[0].kind_change,
1093            Some(("join".into(), "merge".into()))
1094        );
1095    }
1096
1097    #[test]
1098    fn diff_hyper_edge_modified_signature() {
1099        let old = test_schema_ext(
1100            test_schema(&[("a", "object"), ("b", "string")], &[], HashMap::new()),
1101            |s| {
1102                s.hyper_edges.insert(
1103                    "he1".into(),
1104                    HyperEdge {
1105                        id: "he1".into(),
1106                        kind: "join".into(),
1107                        signature: HashMap::from([("left".into(), "a".into())]),
1108                        parent_label: "left".into(),
1109                    },
1110                );
1111            },
1112        );
1113        let new = test_schema_ext(
1114            test_schema(&[("a", "object"), ("b", "string")], &[], HashMap::new()),
1115            |s| {
1116                s.hyper_edges.insert(
1117                    "he1".into(),
1118                    HyperEdge {
1119                        id: "he1".into(),
1120                        kind: "join".into(),
1121                        signature: HashMap::from([
1122                            ("left".into(), "a".into()),
1123                            ("right".into(), "b".into()),
1124                        ]),
1125                        parent_label: "left".into(),
1126                    },
1127                );
1128            },
1129        );
1130        let d = diff(&old, &new);
1131        assert_eq!(d.modified_hyper_edges.len(), 1);
1132        assert_eq!(
1133            d.modified_hyper_edges[0].signature_added.get("right"),
1134            Some(&"b".to_string())
1135        );
1136    }
1137
1138    // -----------------------------------------------------------------------
1139    // Required-edge diff tests
1140    // -----------------------------------------------------------------------
1141
1142    #[test]
1143    fn diff_required_edge_added() {
1144        let edge = Edge {
1145            src: "a".into(),
1146            tgt: "b".into(),
1147            kind: "prop".into(),
1148            name: Some("x".into()),
1149        };
1150        let base = test_schema(
1151            &[("a", "object"), ("b", "string")],
1152            std::slice::from_ref(&edge),
1153            HashMap::new(),
1154        );
1155        let new = test_schema_ext(base.clone(), |s| {
1156            s.required.insert("a".into(), vec![edge.clone()]);
1157        });
1158        let d = diff(&base, &new);
1159        assert_eq!(d.added_required.len(), 1);
1160        assert_eq!(d.added_required["a"].len(), 1);
1161    }
1162
1163    #[test]
1164    fn diff_required_edge_removed() {
1165        let edge = Edge {
1166            src: "a".into(),
1167            tgt: "b".into(),
1168            kind: "prop".into(),
1169            name: Some("x".into()),
1170        };
1171        let old = test_schema_ext(
1172            test_schema(
1173                &[("a", "object"), ("b", "string")],
1174                std::slice::from_ref(&edge),
1175                HashMap::new(),
1176            ),
1177            |s| {
1178                s.required.insert("a".into(), vec![edge.clone()]);
1179            },
1180        );
1181        let new = test_schema(&[("a", "object"), ("b", "string")], &[edge], HashMap::new());
1182        let d = diff(&old, &new);
1183        assert_eq!(d.removed_required.len(), 1);
1184        assert_eq!(d.removed_required["a"].len(), 1);
1185    }
1186
1187    // -----------------------------------------------------------------------
1188    // NSID diff tests
1189    // -----------------------------------------------------------------------
1190
1191    #[test]
1192    fn diff_nsid_added() {
1193        let base = test_schema(&[("a", "object")], &[], HashMap::new());
1194        let new = test_schema_ext(base.clone(), |s| {
1195            s.nsids.insert("a".into(), "com.example.thing".into());
1196        });
1197        let d = diff(&base, &new);
1198        assert_eq!(
1199            d.added_nsids.get("a"),
1200            Some(&"com.example.thing".to_string())
1201        );
1202    }
1203
1204    #[test]
1205    fn diff_nsid_removed() {
1206        let old = test_schema_ext(test_schema(&[("a", "object")], &[], HashMap::new()), |s| {
1207            s.nsids.insert("a".into(), "com.example.thing".into());
1208        });
1209        let new = test_schema(&[("a", "object")], &[], HashMap::new());
1210        let d = diff(&old, &new);
1211        assert_eq!(d.removed_nsids, vec!["a"]);
1212    }
1213
1214    #[test]
1215    fn diff_nsid_changed() {
1216        let old = test_schema_ext(test_schema(&[("a", "object")], &[], HashMap::new()), |s| {
1217            s.nsids.insert("a".into(), "com.example.old".into());
1218        });
1219        let new = test_schema_ext(test_schema(&[("a", "object")], &[], HashMap::new()), |s| {
1220            s.nsids.insert("a".into(), "com.example.new".into());
1221        });
1222        let d = diff(&old, &new);
1223        assert_eq!(d.changed_nsids.len(), 1);
1224        assert_eq!(
1225            d.changed_nsids[0],
1226            (
1227                "a".into(),
1228                "com.example.old".into(),
1229                "com.example.new".into()
1230            )
1231        );
1232    }
1233
1234    // -----------------------------------------------------------------------
1235    // Variant diff tests
1236    // -----------------------------------------------------------------------
1237
1238    #[test]
1239    fn diff_variant_tag_modified() {
1240        let old = test_schema_ext(test_schema(&[("u", "union")], &[], HashMap::new()), |s| {
1241            s.variants.insert(
1242                "u".into(),
1243                vec![Variant {
1244                    id: "v1".into(),
1245                    parent_vertex: "u".into(),
1246                    tag: Some("a".into()),
1247                }],
1248            );
1249        });
1250        let new = test_schema_ext(test_schema(&[("u", "union")], &[], HashMap::new()), |s| {
1251            s.variants.insert(
1252                "u".into(),
1253                vec![Variant {
1254                    id: "v1".into(),
1255                    parent_vertex: "u".into(),
1256                    tag: Some("b".into()),
1257                }],
1258            );
1259        });
1260        let d = diff(&old, &new);
1261        assert!(d.added_variants.is_empty());
1262        assert!(d.removed_variants.is_empty());
1263        assert_eq!(d.modified_variants.len(), 1);
1264        assert_eq!(d.modified_variants[0].old_tag, Some("a".into()));
1265        assert_eq!(d.modified_variants[0].new_tag, Some("b".into()));
1266    }
1267
1268    // -----------------------------------------------------------------------
1269    // Recursion point diff tests
1270    // -----------------------------------------------------------------------
1271
1272    #[test]
1273    fn diff_recursion_point_target_modified() {
1274        let old = test_schema_ext(
1275            test_schema(
1276                &[("a", "object"), ("b", "string"), ("c", "integer")],
1277                &[],
1278                HashMap::new(),
1279            ),
1280            |s| {
1281                s.recursion_points.insert(
1282                    "mu1".into(),
1283                    RecursionPoint {
1284                        target_vertex: "b".into(),
1285                    },
1286                );
1287            },
1288        );
1289        let new = test_schema_ext(
1290            test_schema(
1291                &[("a", "object"), ("b", "string"), ("c", "integer")],
1292                &[],
1293                HashMap::new(),
1294            ),
1295            |s| {
1296                s.recursion_points.insert(
1297                    "mu1".into(),
1298                    RecursionPoint {
1299                        target_vertex: "c".into(),
1300                    },
1301                );
1302            },
1303        );
1304        let d = diff(&old, &new);
1305        assert!(d.added_recursion_points.is_empty());
1306        assert!(d.removed_recursion_points.is_empty());
1307        assert_eq!(d.modified_recursion_points.len(), 1);
1308        assert_eq!(d.modified_recursion_points[0].old_target, "b");
1309        assert_eq!(d.modified_recursion_points[0].new_target, "c");
1310    }
1311
1312    // -----------------------------------------------------------------------
1313    // Span diff tests
1314    // -----------------------------------------------------------------------
1315
1316    #[test]
1317    fn diff_span_added() {
1318        let base = test_schema(&[("a", "object"), ("b", "string")], &[], HashMap::new());
1319        let new = test_schema_ext(base.clone(), |s| {
1320            s.spans.insert(
1321                "s1".into(),
1322                Span {
1323                    id: "s1".into(),
1324                    left: "a".into(),
1325                    right: "b".into(),
1326                },
1327            );
1328        });
1329        let d = diff(&base, &new);
1330        assert_eq!(d.added_spans, vec!["s1"]);
1331    }
1332
1333    #[test]
1334    fn diff_span_modified() {
1335        let old = test_schema_ext(
1336            test_schema(
1337                &[("a", "object"), ("b", "string"), ("c", "integer")],
1338                &[],
1339                HashMap::new(),
1340            ),
1341            |s| {
1342                s.spans.insert(
1343                    "s1".into(),
1344                    Span {
1345                        id: "s1".into(),
1346                        left: "a".into(),
1347                        right: "b".into(),
1348                    },
1349                );
1350            },
1351        );
1352        let new = test_schema_ext(
1353            test_schema(
1354                &[("a", "object"), ("b", "string"), ("c", "integer")],
1355                &[],
1356                HashMap::new(),
1357            ),
1358            |s| {
1359                s.spans.insert(
1360                    "s1".into(),
1361                    Span {
1362                        id: "s1".into(),
1363                        left: "a".into(),
1364                        right: "c".into(),
1365                    },
1366                );
1367            },
1368        );
1369        let d = diff(&old, &new);
1370        assert_eq!(d.modified_spans.len(), 1);
1371        assert_eq!(
1372            d.modified_spans[0].right_change,
1373            Some(("b".into(), "c".into()))
1374        );
1375        assert_eq!(d.modified_spans[0].left_change, None);
1376    }
1377
1378    // -----------------------------------------------------------------------
1379    // Nominal diff tests
1380    // -----------------------------------------------------------------------
1381
1382    #[test]
1383    fn diff_nominal_changed() {
1384        let old = test_schema_ext(test_schema(&[("a", "object")], &[], HashMap::new()), |s| {
1385            s.nominal.insert("a".into(), false);
1386        });
1387        let new = test_schema_ext(test_schema(&[("a", "object")], &[], HashMap::new()), |s| {
1388            s.nominal.insert("a".into(), true);
1389        });
1390        let d = diff(&old, &new);
1391        assert_eq!(d.nominal_changes.len(), 1);
1392        assert_eq!(d.nominal_changes[0], ("a".into(), false, true));
1393    }
1394
1395    // -----------------------------------------------------------------------
1396    // is_empty comprehensive test
1397    // -----------------------------------------------------------------------
1398
1399    #[test]
1400    #[allow(clippy::too_many_lines)]
1401    fn is_empty_false_for_each_field() {
1402        let base = test_schema(&[("a", "object")], &[], HashMap::new());
1403
1404        // Each of these should make is_empty() return false.
1405        let cases: Vec<SchemaDiff> = vec![
1406            SchemaDiff {
1407                added_vertices: vec!["x".into()],
1408                ..Default::default()
1409            },
1410            SchemaDiff {
1411                removed_vertices: vec!["x".into()],
1412                ..Default::default()
1413            },
1414            SchemaDiff {
1415                kind_changes: vec![KindChange {
1416                    vertex_id: "a".into(),
1417                    old_kind: "x".into(),
1418                    new_kind: "y".into(),
1419                }],
1420                ..Default::default()
1421            },
1422            SchemaDiff {
1423                added_edges: vec![Edge {
1424                    src: "a".into(),
1425                    tgt: "b".into(),
1426                    kind: "p".into(),
1427                    name: None,
1428                }],
1429                ..Default::default()
1430            },
1431            SchemaDiff {
1432                removed_edges: vec![Edge {
1433                    src: "a".into(),
1434                    tgt: "b".into(),
1435                    kind: "p".into(),
1436                    name: None,
1437                }],
1438                ..Default::default()
1439            },
1440            SchemaDiff {
1441                modified_constraints: HashMap::from([(
1442                    "a".into(),
1443                    ConstraintDiff {
1444                        added: vec![Constraint {
1445                            sort: "s".into(),
1446                            value: "v".into(),
1447                        }],
1448                        removed: vec![],
1449                        changed: vec![],
1450                    },
1451                )]),
1452                ..Default::default()
1453            },
1454            SchemaDiff {
1455                added_hyper_edges: vec!["he".into()],
1456                ..Default::default()
1457            },
1458            SchemaDiff {
1459                removed_hyper_edges: vec!["he".into()],
1460                ..Default::default()
1461            },
1462            SchemaDiff {
1463                modified_hyper_edges: vec![HyperEdgeChange {
1464                    id: "he".into(),
1465                    kind_change: None,
1466                    signature_added: HashMap::new(),
1467                    signature_removed: HashMap::new(),
1468                    signature_changed: HashMap::new(),
1469                    parent_label_change: Some(("a".into(), "b".into())),
1470                }],
1471                ..Default::default()
1472            },
1473            SchemaDiff {
1474                added_required: HashMap::from([("a".into(), vec![])]),
1475                ..Default::default()
1476            },
1477            SchemaDiff {
1478                removed_required: HashMap::from([("a".into(), vec![])]),
1479                ..Default::default()
1480            },
1481            SchemaDiff {
1482                added_nsids: HashMap::from([("a".into(), "x".into())]),
1483                ..Default::default()
1484            },
1485            SchemaDiff {
1486                removed_nsids: vec!["a".into()],
1487                ..Default::default()
1488            },
1489            SchemaDiff {
1490                changed_nsids: vec![("a".into(), "x".into(), "y".into())],
1491                ..Default::default()
1492            },
1493            SchemaDiff {
1494                added_variants: vec![Variant {
1495                    id: "v".into(),
1496                    parent_vertex: "u".into(),
1497                    tag: None,
1498                }],
1499                ..Default::default()
1500            },
1501            SchemaDiff {
1502                removed_variants: vec![Variant {
1503                    id: "v".into(),
1504                    parent_vertex: "u".into(),
1505                    tag: None,
1506                }],
1507                ..Default::default()
1508            },
1509            SchemaDiff {
1510                modified_variants: vec![VariantChange {
1511                    id: "v".into(),
1512                    parent_vertex: "u".into(),
1513                    old_tag: None,
1514                    new_tag: Some("t".into()),
1515                }],
1516                ..Default::default()
1517            },
1518            SchemaDiff {
1519                order_changes: vec![(
1520                    Edge {
1521                        src: "a".into(),
1522                        tgt: "b".into(),
1523                        kind: "p".into(),
1524                        name: None,
1525                    },
1526                    Some(0),
1527                    Some(1),
1528                )],
1529                ..Default::default()
1530            },
1531            SchemaDiff {
1532                added_recursion_points: vec![(
1533                    "m".into(),
1534                    RecursionPoint {
1535                        target_vertex: "t".into(),
1536                    },
1537                )],
1538                ..Default::default()
1539            },
1540            SchemaDiff {
1541                removed_recursion_points: vec![(
1542                    "m".into(),
1543                    RecursionPoint {
1544                        target_vertex: "t".into(),
1545                    },
1546                )],
1547                ..Default::default()
1548            },
1549            SchemaDiff {
1550                modified_recursion_points: vec![RecursionPointChange {
1551                    mu_id: "m".into(),
1552                    old_target: "a".into(),
1553                    new_target: "b".into(),
1554                }],
1555                ..Default::default()
1556            },
1557            SchemaDiff {
1558                usage_mode_changes: vec![(
1559                    Edge {
1560                        src: "a".into(),
1561                        tgt: "b".into(),
1562                        kind: "p".into(),
1563                        name: None,
1564                    },
1565                    UsageMode::Structural,
1566                    UsageMode::Linear,
1567                )],
1568                ..Default::default()
1569            },
1570            SchemaDiff {
1571                added_spans: vec!["s".into()],
1572                ..Default::default()
1573            },
1574            SchemaDiff {
1575                removed_spans: vec!["s".into()],
1576                ..Default::default()
1577            },
1578            SchemaDiff {
1579                modified_spans: vec![SpanChange {
1580                    id: "s".into(),
1581                    left_change: Some(("a".into(), "b".into())),
1582                    right_change: None,
1583                }],
1584                ..Default::default()
1585            },
1586            SchemaDiff {
1587                nominal_changes: vec![("a".into(), false, true)],
1588                ..Default::default()
1589            },
1590            SchemaDiff {
1591                added_coercions: vec![("a".into(), "b".into())],
1592                ..Default::default()
1593            },
1594            SchemaDiff {
1595                removed_coercions: vec![("a".into(), "b".into())],
1596                ..Default::default()
1597            },
1598            SchemaDiff {
1599                modified_coercions: vec![("a".into(), "b".into())],
1600                ..Default::default()
1601            },
1602            SchemaDiff {
1603                added_mergers: vec!["a".into()],
1604                ..Default::default()
1605            },
1606            SchemaDiff {
1607                removed_mergers: vec!["a".into()],
1608                ..Default::default()
1609            },
1610            SchemaDiff {
1611                modified_mergers: vec!["a".into()],
1612                ..Default::default()
1613            },
1614            SchemaDiff {
1615                added_defaults: vec!["a".into()],
1616                ..Default::default()
1617            },
1618            SchemaDiff {
1619                removed_defaults: vec!["a".into()],
1620                ..Default::default()
1621            },
1622            SchemaDiff {
1623                modified_defaults: vec!["a".into()],
1624                ..Default::default()
1625            },
1626            SchemaDiff {
1627                added_policies: vec!["a".into()],
1628                ..Default::default()
1629            },
1630            SchemaDiff {
1631                removed_policies: vec!["a".into()],
1632                ..Default::default()
1633            },
1634            SchemaDiff {
1635                modified_policies: vec!["a".into()],
1636                ..Default::default()
1637            },
1638            SchemaDiff {
1639                renamed_vertices: vec![("a".into(), "b".into())],
1640                ..Default::default()
1641            },
1642        ];
1643
1644        let _ = base; // suppress unused warning
1645        for (i, d) in cases.iter().enumerate() {
1646            assert!(!d.is_empty(), "case {i} should not be empty: {d:?}");
1647        }
1648    }
1649
1650    #[test]
1651    fn apply_renames_moves_pair_into_renamed_vertices() {
1652        let mut d = SchemaDiff {
1653            removed_vertices: vec!["root.text".into(), "root.gone".into()],
1654            added_vertices: vec!["root.body".into(), "root.new".into()],
1655            ..SchemaDiff::default()
1656        };
1657        apply_renames(&mut d, &[("root.text".into(), "root.body".into())]);
1658
1659        assert_eq!(
1660            d.renamed_vertices,
1661            vec![("root.text".into(), "root.body".into())]
1662        );
1663        assert_eq!(d.removed_vertices, vec!["root.gone".to_string()]);
1664        assert_eq!(d.added_vertices, vec!["root.new".to_string()]);
1665    }
1666
1667    #[test]
1668    fn apply_renames_ignores_unmatched_pairs() {
1669        let mut d = SchemaDiff {
1670            removed_vertices: vec!["root.text".into()],
1671            added_vertices: vec!["root.body".into()],
1672            ..SchemaDiff::default()
1673        };
1674        // new id is not actually an added vertex: no rename recorded.
1675        apply_renames(&mut d, &[("root.text".into(), "root.other".into())]);
1676        assert!(d.renamed_vertices.is_empty());
1677        assert_eq!(d.removed_vertices, vec!["root.text".to_string()]);
1678    }
1679}