Skip to main content

sdkt_wasm/
spec_diff.rs

1//! Offline contract comparison (ABI / WASM diff).
2//!
3//! Compares two compiled Soroban WASM binaries by extracting their
4//! `ContractSpec` (functions, events, custom types) via the existing
5//! [`crate::parse_contract_spec`] parser, then classifying the deltas.
6//!
7//! Everything is offline: no RPC, no network. The diff operates purely on the
8//! declared ABI, so it is a safe pre-upgrade check (detect breaking changes
9//! before `sdkt deploy` of a new WASM).
10
11use serde::{Deserialize, Serialize};
12
13use crate::{
14    parse_contract_spec, parse_metadata, ContractEvent, ContractFunction, ContractSpec,
15    ContractType, WasmError,
16};
17
18/// The full comparison result between two contract WASM binaries.
19///
20/// `Eq` is not derived because `size_delta_pct` is an `Option<f64>`.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
22pub struct SpecDiff {
23    /// Metadata of the "old" (baseline) WASM.
24    pub old: WasmSummary,
25    /// Metadata of the "new" (candidate) WASM.
26    pub new: WasmSummary,
27    /// Functions present in `new` but absent from `old`.
28    pub added_functions: Vec<ContractFunction>,
29    /// Functions present in `old` but absent from `new`.
30    pub removed_functions: Vec<ContractFunction>,
31    /// Functions present in both whose signature (inputs + outputs) changed.
32    pub changed_functions: Vec<FunctionSignatureChange>,
33    /// Events present in `new` but absent from `old`.
34    pub added_events: Vec<String>,
35    /// Events present in `old` but absent from `new`.
36    pub removed_events: Vec<String>,
37    /// Events present in both whose signature (prefix topics, params, data
38    /// format) changed.
39    pub changed_events: Vec<EventSignatureChange>,
40    /// Custom types present in `new` but absent from `old`.
41    pub added_types: Vec<String>,
42    /// Custom types present in `old` but absent from `new`.
43    pub removed_types: Vec<String>,
44    /// Custom types present in both whose definition (kind or members) changed.
45    pub changed_types: Vec<TypeDefinitionChange>,
46    /// Signed size change: `new.size_bytes - old.size_bytes` (negative when the
47    /// candidate shrank). Always present; independent of any policy flag.
48    pub size_delta_bytes: i64,
49    /// Percentage growth relative to the old artifact, rounded to one decimal
50    /// place (e.g. `2157.6`). `null` when the old artifact is zero bytes and
51    /// the new one is not — a relative change against an empty baseline is
52    /// mathematically undefined, and is never coerced to 0 or infinity.
53    /// Both artifacts empty ⇒ `Some(0.0)`.
54    #[serde(default)]
55    pub size_delta_pct: Option<f64>,
56}
57
58/// Lightweight WASM identity summary for diff context.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
60pub struct WasmSummary {
61    /// SHA-256 hex of the raw WASM bytes.
62    pub hash: String,
63    /// Size in bytes.
64    pub size_bytes: usize,
65}
66
67/// A function whose signature changed between old and new.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct FunctionSignatureChange {
70    pub name: String,
71    pub old: ContractFunction,
72    pub new: ContractFunction,
73}
74
75/// An event whose signature changed while keeping its name.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub struct EventSignatureChange {
78    pub name: String,
79    pub old: ContractEvent,
80    pub new: ContractEvent,
81}
82
83/// A custom type whose definition changed while keeping its name.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct TypeDefinitionChange {
86    pub name: String,
87    pub old: ContractType,
88    pub new: ContractType,
89}
90
91impl SpecDiff {
92    /// True when the two WASM binaries declare an identical ABI surface
93    /// (same functions, signatures, events, and custom types).
94    pub fn is_identical(&self) -> bool {
95        self.added_functions.is_empty()
96            && self.removed_functions.is_empty()
97            && self.changed_functions.is_empty()
98            && self.added_events.is_empty()
99            && self.removed_events.is_empty()
100            && self.changed_events.is_empty()
101            && self.added_types.is_empty()
102            && self.removed_types.is_empty()
103            && self.changed_types.is_empty()
104    }
105
106    /// Total number of breaking/non-breaking deltas (a single count used for
107    /// quick pass/fail triage in CI).
108    pub fn total_changes(&self) -> usize {
109        self.added_functions.len()
110            + self.removed_functions.len()
111            + self.changed_functions.len()
112            + self.added_events.len()
113            + self.removed_events.len()
114            + self.changed_events.len()
115            + self.added_types.len()
116            + self.removed_types.len()
117            + self.changed_types.len()
118    }
119}
120
121/// Convenience: build a [`WasmSummary`] from raw WASM bytes.
122fn summarize(raw: &[u8]) -> Result<WasmSummary, WasmError> {
123    let meta = parse_metadata(raw)?;
124    Ok(WasmSummary {
125        hash: meta.hash,
126        size_bytes: meta.size_bytes,
127    })
128}
129
130/// Diff two raw WASM binaries (offline).
131///
132/// Each side is parsed independently; if either fails to yield a
133/// `ContractSpec`, the error is returned. Missing entries are treated as
134/// empty lists, so a contract with no events still diffs cleanly against one
135/// that declares events.
136pub fn diff_wasm(old_raw: &[u8], new_raw: &[u8]) -> Result<SpecDiff, WasmError> {
137    let old_spec = parse_contract_spec(old_raw)?;
138    let new_spec = parse_contract_spec(new_raw)?;
139    diff_specs(
140        &old_spec,
141        &new_spec,
142        summarize(old_raw)?,
143        summarize(new_raw)?,
144    )
145}
146
147/// Diff two already-parsed [`ContractSpec`] values.
148///
149/// `old_summary` / `new_summary` carry WASM identity (hash/size) for context in
150/// reports; pass `WasmSummary::default()` when not available.
151pub fn diff_specs(
152    old: &ContractSpec,
153    new: &ContractSpec,
154    old_summary: WasmSummary,
155    new_summary: WasmSummary,
156) -> Result<SpecDiff, WasmError> {
157    let mut diff = SpecDiff {
158        old: old_summary,
159        new: new_summary,
160        ..Default::default()
161    };
162    diff.size_delta_bytes = size_delta_bytes(diff.old.size_bytes, diff.new.size_bytes);
163    diff.size_delta_pct = size_delta_pct(diff.old.size_bytes, diff.new.size_bytes);
164
165    // Index old by name for O(n) lookups.
166    let old_fns: std::collections::BTreeMap<&str, &ContractFunction> =
167        old.functions.iter().map(|f| (f.name.as_str(), f)).collect();
168    let new_fns: std::collections::BTreeMap<&str, &ContractFunction> =
169        new.functions.iter().map(|f| (f.name.as_str(), f)).collect();
170
171    for (name, nf) in &new_fns {
172        match old_fns.get(name) {
173            None => diff.added_functions.push((**nf).clone()),
174            Some(of) => {
175                if of.parameters != nf.parameters || of.outputs != nf.outputs {
176                    diff.changed_functions.push(FunctionSignatureChange {
177                        name: (*name).to_string(),
178                        old: (*of).clone(),
179                        new: (**nf).clone(),
180                    });
181                }
182            }
183        }
184    }
185    for (_name, of) in &old_fns {
186        if !new_fns.contains_key(_name) {
187            diff.removed_functions.push((**of).clone());
188        }
189    }
190
191    // Events and custom types: name-set comparison for add/remove, then a
192    // definition comparison for the ones present on both sides. Comparing
193    // names alone would report an event/type that changed its shape in place
194    // as unchanged.
195    let old_events = by_name(&old.events, |e| e.name.as_str());
196    let new_events = by_name(&new.events, |e| e.name.as_str());
197    diff_set(
198        &old.events
199            .iter()
200            .map(|e| e.name.clone())
201            .collect::<Vec<_>>(),
202        &new.events
203            .iter()
204            .map(|e| e.name.clone())
205            .collect::<Vec<_>>(),
206        &mut diff.added_events,
207        &mut diff.removed_events,
208    );
209    for (name, ne) in &new_events {
210        if let Some(oe) = old_events.get(name) {
211            if !event_signature_eq(oe, ne) {
212                diff.changed_events.push(EventSignatureChange {
213                    name: (*name).to_string(),
214                    old: (*oe).clone(),
215                    new: (**ne).clone(),
216                });
217            }
218        }
219    }
220
221    let old_types = by_name(&old.custom_types, |t| t.name.as_str());
222    let new_types = by_name(&new.custom_types, |t| t.name.as_str());
223    diff_set(
224        &old.custom_types
225            .iter()
226            .map(|t| t.name.clone())
227            .collect::<Vec<_>>(),
228        &new.custom_types
229            .iter()
230            .map(|t| t.name.clone())
231            .collect::<Vec<_>>(),
232        &mut diff.added_types,
233        &mut diff.removed_types,
234    );
235    for (name, nt) in &new_types {
236        if let Some(ot) = old_types.get(name) {
237            if !type_definition_eq(ot, nt) {
238                diff.changed_types.push(TypeDefinitionChange {
239                    name: (*name).to_string(),
240                    old: (*ot).clone(),
241                    new: (**nt).clone(),
242                });
243            }
244        }
245    }
246
247    Ok(diff)
248}
249
250/// Index a list of named ABI entries by name for O(n) lookups.
251fn by_name<T, F>(items: &[T], name_of: F) -> std::collections::BTreeMap<&str, &T>
252where
253    F: Fn(&T) -> &str,
254{
255    items.iter().map(|i| (name_of(i), i)).collect()
256}
257
258/// Compare two [`ContractType`] references by identity, ignoring docs.
259///
260/// For compound types (`option<T>`, `vec<T>`, `map<K,V>`, `result<O,E>`,
261/// `tuple<...>`, `bytesn<N>`) the recursive inner structure is encoded into
262/// `name` (e.g. `"vec<u32>"` vs `"vec<i128>"`), so a name + kind comparison
263/// is sufficient to distinguish e.g. `Vec<u32>` from `Vec<i128>`. The members
264/// field is compared as well for defence-in-depth.
265fn type_ref_eq(a: &ContractType, b: &ContractType) -> bool {
266    if a.name != b.name || a.kind != b.kind {
267        return false;
268    }
269    // For compound types the `members` field carries the inner ContractTypes
270    // that were recursively mapped.  Comparing them here ensures that nested
271    // compound structures (e.g. Option<Vec<u32>>) are compared deeply even
272    // if, hypothetically, the name encoding were ever changed.
273    if a.kind == "compound" {
274        return a.members.len() == b.members.len()
275            && a.members
276                .iter()
277                .zip(b.members.iter())
278                .all(|(x, y)| x.name == y.name && member_types_eq(&x.types, &y.types));
279    }
280    true
281}
282
283/// Compare two member type lists by identity (name + kind), ignoring docs.
284fn member_types_eq(a: &[ContractType], b: &[ContractType]) -> bool {
285    a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| type_ref_eq(x, y))
286}
287
288/// Compare two event signatures, ignoring doc comments (docs are not ABI).
289///
290/// A consumer decodes the topic/data payload layout, so any change to the
291/// prefix topics, the parameter list (name, type, or topic-vs-data location),
292/// or the data format is a signature change.
293fn event_signature_eq(a: &ContractEvent, b: &ContractEvent) -> bool {
294    a.prefix_topics == b.prefix_topics
295        && a.data_format == b.data_format
296        && a.params.len() == b.params.len()
297        && a.params.iter().zip(b.params.iter()).all(|(x, y)| {
298            x.name == y.name && x.location == y.location && type_ref_eq(&x.type_, &y.type_)
299        })
300}
301
302/// Compare two custom type definitions, ignoring doc comments (docs are not
303/// ABI). A change of kind (struct -> enum), of any member's name or types, or
304/// of an enum case's discriminant is a definition change.
305fn type_definition_eq(a: &ContractType, b: &ContractType) -> bool {
306    a.kind == b.kind
307        && a.members.len() == b.members.len()
308        && a.members.iter().zip(b.members.iter()).all(|(x, y)| {
309            x.name == y.name && x.value == y.value && member_types_eq(&x.types, &y.types)
310        })
311}
312
313/// Classify named items present in `new` but not `old` (`added`) and `old` but
314/// not `new` (`removed`). Names are the identity key.
315fn diff_set(old: &[String], new: &[String], added: &mut Vec<String>, removed: &mut Vec<String>) {
316    let old_set: std::collections::BTreeSet<&str> = old.iter().map(String::as_str).collect();
317    let new_set: std::collections::BTreeSet<&str> = new.iter().map(String::as_str).collect();
318
319    for n in &new_set {
320        if !old_set.contains(n) {
321            added.push((*n).to_string());
322        }
323    }
324    for n in &old_set {
325        if !new_set.contains(n) {
326            removed.push((*n).to_string());
327        }
328    }
329}
330
331/// Kind of ABI delta observed during an upgrade-safety check.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(rename_all = "snake_case")]
334pub enum ChangeKind {
335    RemovedFunction,
336    ChangedSignature,
337    RemovedEvent,
338    RemovedType,
339    AddedFunction,
340    AddedEvent,
341    AddedType,
342    ChangedEvent,
343    ChangedTypeDefinition,
344}
345
346/// A single classified delta, used for both breaking and non-breaking lists.
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348pub struct VerdictChange {
349    pub kind: ChangeKind,
350    /// Item name (function/event/type). Functions are stored bare; the
351    /// human label adds `()` for call-style rendering.
352    pub name: String,
353    /// Optional human-readable detail (e.g. old/new signature for a change).
354    #[serde(default, skip_serializing_if = "String::is_empty")]
355    pub detail: String,
356}
357
358impl VerdictChange {
359    /// Human-readable `Kind: name()` label for pretty output.
360    pub fn label(&self) -> String {
361        let kind = match self.kind {
362            ChangeKind::RemovedFunction => "Removed function",
363            ChangeKind::ChangedSignature => "Changed signature",
364            ChangeKind::RemovedEvent => "Removed event",
365            ChangeKind::RemovedType => "Removed type",
366            ChangeKind::AddedFunction => "Added function",
367            ChangeKind::AddedEvent => "Added event",
368            ChangeKind::AddedType => "Added type",
369            ChangeKind::ChangedEvent => "Changed event",
370            ChangeKind::ChangedTypeDefinition => "Changed type definition",
371        };
372        match self.kind {
373            ChangeKind::RemovedFunction
374            | ChangeKind::AddedFunction
375            | ChangeKind::ChangedSignature => {
376                format!("{}: {}()", kind, self.name)
377            }
378            _ => format!("{}: {}", kind, self.name),
379        }
380    }
381}
382
383/// An actionable upgrade-safety verdict derived from a [`SpecDiff`].
384///
385/// `compatible` is `false` when any *breaking* change is present (removed
386/// function, changed signature, removed event, changed event signature,
387/// removed type, changed type definition). Additions are non-breaking and
388/// recorded separately.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
390pub struct UpgradeVerdict {
391    pub compatible: bool,
392    pub breaking_changes: Vec<VerdictChange>,
393    pub non_breaking_changes: Vec<VerdictChange>,
394}
395
396impl UpgradeVerdict {
397    /// Classify a [`SpecDiff`] into an upgrade-safety verdict.
398    ///
399    /// This deliberately reuses the existing diff output; no comparison logic
400    /// is duplicated.
401    pub fn from_diff(diff: &SpecDiff) -> Self {
402        let mut breaking = Vec::new();
403        let mut non_breaking = Vec::new();
404
405        for f in &diff.removed_functions {
406            breaking.push(VerdictChange {
407                kind: ChangeKind::RemovedFunction,
408                name: f.name.clone(),
409                detail: String::new(),
410            });
411        }
412        for c in &diff.changed_functions {
413            breaking.push(VerdictChange {
414                kind: ChangeKind::ChangedSignature,
415                name: c.name.clone(),
416                detail: format!("old: {}\n      new: {}", sig_of(&c.old), sig_of(&c.new)),
417            });
418        }
419        for e in &diff.removed_events {
420            breaking.push(VerdictChange {
421                kind: ChangeKind::RemovedEvent,
422                name: e.clone(),
423                detail: String::new(),
424            });
425        }
426        for c in &diff.changed_events {
427            breaking.push(VerdictChange {
428                kind: ChangeKind::ChangedEvent,
429                name: c.name.clone(),
430                detail: format!(
431                    "old: {}\n      new: {}",
432                    event_sig(&c.old),
433                    event_sig(&c.new)
434                ),
435            });
436        }
437        for t in &diff.removed_types {
438            breaking.push(VerdictChange {
439                kind: ChangeKind::RemovedType,
440                name: t.clone(),
441                detail: String::new(),
442            });
443        }
444        for c in &diff.changed_types {
445            breaking.push(VerdictChange {
446                kind: ChangeKind::ChangedTypeDefinition,
447                name: c.name.clone(),
448                detail: format!("old: {}\n      new: {}", type_sig(&c.old), type_sig(&c.new)),
449            });
450        }
451        for f in &diff.added_functions {
452            non_breaking.push(VerdictChange {
453                kind: ChangeKind::AddedFunction,
454                name: f.name.clone(),
455                detail: String::new(),
456            });
457        }
458        for e in &diff.added_events {
459            non_breaking.push(VerdictChange {
460                kind: ChangeKind::AddedEvent,
461                name: e.clone(),
462                detail: String::new(),
463            });
464        }
465        for t in &diff.added_types {
466            non_breaking.push(VerdictChange {
467                kind: ChangeKind::AddedType,
468                name: t.clone(),
469                detail: String::new(),
470            });
471        }
472
473        let compatible = breaking.is_empty();
474        Self {
475            compatible,
476            breaking_changes: breaking,
477            non_breaking_changes: non_breaking,
478        }
479    }
480}
481
482/// Signed size delta in bytes between two artifacts.
483pub fn size_delta_bytes(old_bytes: usize, new_bytes: usize) -> i64 {
484    new_bytes as i64 - old_bytes as i64
485}
486
487/// Percentage growth of `new_bytes` relative to `old_bytes`, rounded to one
488/// decimal place.
489///
490/// Zero-byte baseline semantics (documented, no division by zero):
491/// - old == 0, new == 0 → `Some(0.0)` (nothing grew);
492/// - old == 0, new  > 0 → `None` — undefined relative change (rendered as
493///   `null` in JSON); absolute policy (`--max-size-bytes`) still applies.
494pub fn size_delta_pct(old_bytes: usize, new_bytes: usize) -> Option<f64> {
495    if old_bytes == 0 {
496        return if new_bytes == 0 { Some(0.0) } else { None };
497    }
498    let pct = (new_bytes as f64 - old_bytes as f64) * 100.0 / old_bytes as f64;
499    Some((pct * 10.0).round() / 10.0)
500}
501
502/// Build a `name(params) -> outputs` signature string for a function.
503fn sig_of(f: &ContractFunction) -> String {
504    let params = f
505        .parameters
506        .iter()
507        .map(|p| p.type_.name.clone())
508        .collect::<Vec<_>>()
509        .join(", ");
510    let outputs = f
511        .outputs
512        .iter()
513        .map(|o| o.name.clone())
514        .collect::<Vec<_>>()
515        .join(", ");
516    format!("{}({}) -> ({})", f.name, params, outputs)
517}
518
519/// Build a `name(param: type, ...)` signature string for an event, annotating
520/// each parameter with whether it is carried in a topic or in the data
521/// payload — the two are decoded differently by consumers.
522pub fn event_sig(e: &ContractEvent) -> String {
523    let mut parts: Vec<String> = e
524        .prefix_topics
525        .iter()
526        .map(|t| format!("{}: symbol [prefix]", t))
527        .collect();
528    parts.extend(
529        e.params
530            .iter()
531            .map(|p| format!("{}: {} [{}]", p.name, p.type_.name, p.location)),
532    );
533    format!("{}({}) -> {}", e.name, parts.join(", "), e.data_format)
534}
535
536/// Build a `kind name { member, ... }` definition string for a custom type.
537///
538/// An enum or error-enum case renders as `Name=0`, showing the discriminant
539/// that decodes the case; a struct field or union case renders as
540/// `name: type` (or `name: void` for a void union case).
541pub fn type_sig(t: &ContractType) -> String {
542    let members = t
543        .members
544        .iter()
545        .map(|m| {
546            let Some(value) = m.value else {
547                let types = m.types.iter().map(|ty| ty.name.clone()).collect::<Vec<_>>();
548                let shape = match types.len() {
549                    0 => "void".to_string(),
550                    1 => types.into_iter().next().unwrap_or_default(),
551                    _ => format!("({})", types.join(", ")),
552                };
553                return format!("{}: {}", m.name, shape);
554            };
555            let types = m.types.iter().map(|ty| ty.name.clone()).collect::<Vec<_>>();
556            if types.is_empty() {
557                format!("{}={}", m.name, value)
558            } else {
559                format!("{}={}: ({})", m.name, value, types.join(", "))
560            }
561        })
562        .collect::<Vec<_>>();
563    if members.is_empty() {
564        format!("{} {}", t.kind, t.name)
565    } else {
566        format!("{} {} {{ {} }}", t.kind, t.name, members.join(", "))
567    }
568}
569
570/// Compute an upgrade-safety verdict from two already-parsed [`ContractSpec`]s.
571///
572/// Reuses [`diff_specs`] (no duplicated comparison logic).
573pub fn upgrade_safety(old: &ContractSpec, new: &ContractSpec) -> UpgradeVerdict {
574    let diff =
575        diff_specs(old, new, WasmSummary::default(), WasmSummary::default()).unwrap_or_default();
576    UpgradeVerdict::from_diff(&diff)
577}
578
579/// Compute an upgrade-safety verdict directly from two raw WASM binaries.
580pub fn upgrade_safety_wasm(old_raw: &[u8], new_raw: &[u8]) -> Result<UpgradeVerdict, WasmError> {
581    let diff = diff_wasm(old_raw, new_raw)?;
582    Ok(UpgradeVerdict::from_diff(&diff))
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    // ---- size delta / growth policy math (WASM size policy) ----
590
591    #[test]
592    fn size_delta_bytes_is_signed_and_sums_correctly() {
593        assert_eq!(size_delta_bytes(467, 10543), 10076); // growth
594        assert_eq!(size_delta_bytes(10543, 467), -10076); // shrink
595        assert_eq!(size_delta_bytes(530, 530), 0); // equal
596        assert_eq!(size_delta_bytes(0, 0), 0);
597        assert_eq!(size_delta_bytes(0, 4096), 4096);
598    }
599
600    #[test]
601    fn size_delta_pct_rounds_to_one_decimal() {
602        assert_eq!(size_delta_pct(467, 10543), Some(2157.6));
603        assert_eq!(size_delta_pct(198, 530), Some(167.7)); // 167.676… -> 167.7
604        assert_eq!(size_delta_pct(467, 467), Some(0.0)); // equal
605        assert_eq!(size_delta_pct(1000, 900), Some(-10.0)); // negative growth
606        assert_eq!(size_delta_pct(10, 10), Some(0.0));
607        // 1.44 -> 1.4 (half-away-from-zero), 1.45 -> 1.5
608        assert_eq!(size_delta_pct(1000, 1014), Some(1.4));
609        assert_eq!(size_delta_pct(1000, 1015), Some(1.5));
610    }
611
612    #[test]
613    fn size_delta_pct_zero_baseline_is_none_unless_both_zero() {
614        // Division by zero must never happen: undefined relative change -> None.
615        assert_eq!(size_delta_pct(0, 1), None);
616        assert_eq!(size_delta_pct(0, 4096), None);
617        assert_eq!(size_delta_pct(0, 0), Some(0.0)); // nothing grew
618    }
619
620    #[test]
621    fn diff_specs_reports_size_fields() {
622        let old = ContractSpec {
623            env_meta: None,
624            functions: vec![],
625            custom_types: vec![],
626            events: vec![],
627        };
628        let new = old.clone();
629        let diff = diff_specs(
630            &old,
631            &new,
632            WasmSummary {
633                hash: "o".into(),
634                size_bytes: 467,
635            },
636            WasmSummary {
637                hash: "n".into(),
638                size_bytes: 530,
639            },
640        )
641        .unwrap();
642        assert_eq!(diff.size_delta_bytes, 63);
643        assert_eq!(diff.size_delta_pct, Some(13.5));
644        // JSON key presence/shape is asserted from the real CLI output in
645        // `sdkt-cli` integration tests (this crate has no serde_json dep).
646    }
647
648    #[test]
649    fn diff_specs_zero_baseline_serializes_null_pct() {
650        let spec = ContractSpec {
651            env_meta: None,
652            functions: vec![],
653            custom_types: vec![],
654            events: vec![],
655        };
656        let diff = diff_specs(
657            &spec,
658            &spec,
659            WasmSummary::default(),
660            WasmSummary {
661                hash: "n".into(),
662                size_bytes: 100,
663            },
664        )
665        .unwrap();
666        assert_eq!(diff.size_delta_bytes, 100);
667        assert_eq!(diff.size_delta_pct, None);
668    }
669    use crate::spec::tests::func_entry;
670    use crate::spec::tests::spec_section;
671    use stellar_xdr::ScSpecTypeDef;
672
673    // Re-create small WASM blobs with the helper from spec.rs tests.
674
675    #[test]
676    fn identical_specs_diff_empty() {
677        let wasm = spec_section(&[func_entry(
678            "greet",
679            vec![("name".into(), ScSpecTypeDef::String)],
680        )]);
681        let d = diff_wasm(&wasm, &wasm).unwrap();
682        assert!(d.is_identical());
683        assert_eq!(d.total_changes(), 0);
684    }
685
686    #[test]
687    fn detects_added_function() {
688        let old = spec_section(&[func_entry("a", vec![])]);
689        let new = spec_section(&[
690            func_entry("a", vec![]),
691            func_entry("b", vec![("x".into(), ScSpecTypeDef::U32)]),
692        ]);
693        let d = diff_wasm(&old, &new).unwrap();
694        assert_eq!(d.added_functions.len(), 1);
695        assert_eq!(d.added_functions[0].name, "b");
696        assert!(d.removed_functions.is_empty());
697        assert!(d.changed_functions.is_empty());
698    }
699
700    #[test]
701    fn detects_removed_function() {
702        let old = spec_section(&[func_entry("a", vec![]), func_entry("b", vec![])]);
703        let new = spec_section(&[func_entry("a", vec![])]);
704        let d = diff_wasm(&old, &new).unwrap();
705        assert_eq!(d.removed_functions.len(), 1);
706        assert_eq!(d.removed_functions[0].name, "b");
707    }
708
709    #[test]
710    fn detects_changed_signature() {
711        let old = spec_section(&[func_entry("f", vec![("x".into(), ScSpecTypeDef::U32)])]);
712        let new = spec_section(&[func_entry("f", vec![("x".into(), ScSpecTypeDef::U64)])]);
713        let d = diff_wasm(&old, &new).unwrap();
714        assert_eq!(d.changed_functions.len(), 1);
715        assert_eq!(d.changed_functions[0].name, "f");
716        assert_eq!(d.changed_functions[0].old.parameters[0].type_.name, "u32");
717        assert_eq!(d.changed_functions[0].new.parameters[0].type_.name, "u64");
718    }
719
720    #[test]
721    fn detects_added_removed_events() {
722        use crate::spec::tests::event_entry;
723        let old = spec_section(&[event_entry("Transfer")]);
724        let new = spec_section(&[event_entry("Mint")]);
725        let d = diff_wasm(&old, &new).unwrap();
726        assert_eq!(d.added_events, vec!["Mint".to_string()]);
727        assert_eq!(d.removed_events, vec!["Transfer".to_string()]);
728    }
729
730    #[test]
731    fn detects_added_removed_types() {
732        use crate::spec::tests::udt_struct_entry;
733        let old = spec_section(&[udt_struct_entry("Point")]);
734        let new = spec_section(&[udt_struct_entry("Circle")]);
735        let d = diff_wasm(&old, &new).unwrap();
736        assert_eq!(d.added_types, vec!["Circle".to_string()]);
737        assert_eq!(d.removed_types, vec!["Point".to_string()]);
738    }
739
740    #[test]
741    fn detects_changed_event_params() {
742        use crate::spec::tests::event_entry_with_params;
743        // Same event name, different params: `amount` widens and `memo` is added.
744        let old = spec_section(&[event_entry_with_params(
745            "Transfer",
746            vec![
747                ("from".into(), ScSpecTypeDef::Address),
748                ("amount".into(), ScSpecTypeDef::I128),
749            ],
750        )]);
751        let new = spec_section(&[event_entry_with_params(
752            "Transfer",
753            vec![
754                ("from".into(), ScSpecTypeDef::Address),
755                ("amount".into(), ScSpecTypeDef::U64),
756                ("memo".into(), ScSpecTypeDef::String),
757            ],
758        )]);
759        let d = diff_wasm(&old, &new).unwrap();
760        assert_eq!(d.changed_events.len(), 1);
761        assert_eq!(d.changed_events[0].name, "Transfer");
762        assert_eq!(d.changed_events[0].old.params.len(), 2);
763        assert_eq!(d.changed_events[0].old.params[1].type_.name, "i128");
764        assert_eq!(d.changed_events[0].new.params.len(), 3);
765        assert_eq!(d.changed_events[0].new.params[1].type_.name, "u64");
766        // A changed event is not an add or a remove.
767        assert!(d.added_events.is_empty());
768        assert!(d.removed_events.is_empty());
769    }
770
771    #[test]
772    fn identical_events_produce_no_change() {
773        use crate::spec::tests::event_entry_with_params;
774        let wasm = spec_section(&[event_entry_with_params(
775            "Transfer",
776            vec![("from".into(), ScSpecTypeDef::Address)],
777        )]);
778        let d = diff_wasm(&wasm, &wasm).unwrap();
779        assert!(d.changed_events.is_empty());
780        assert!(d.is_identical());
781        assert_eq!(d.total_changes(), 0);
782    }
783
784    #[test]
785    fn detects_changed_type_members() {
786        use crate::spec::tests::udt_struct_entry_with_fields;
787        // Same type name, different members: `x` widens and `z` is added.
788        let old = spec_section(&[udt_struct_entry_with_fields(
789            "Point",
790            vec![("x".into(), ScSpecTypeDef::I32)],
791        )]);
792        let new = spec_section(&[udt_struct_entry_with_fields(
793            "Point",
794            vec![
795                ("x".into(), ScSpecTypeDef::I64),
796                ("z".into(), ScSpecTypeDef::I32),
797            ],
798        )]);
799        let d = diff_wasm(&old, &new).unwrap();
800        assert_eq!(d.changed_types.len(), 1);
801        assert_eq!(d.changed_types[0].name, "Point");
802        assert_eq!(d.changed_types[0].old.members.len(), 1);
803        assert_eq!(d.changed_types[0].old.members[0].types[0].name, "i32");
804        assert_eq!(d.changed_types[0].new.members.len(), 2);
805        assert_eq!(d.changed_types[0].new.members[0].types[0].name, "i64");
806        // A changed type is not an add or a remove.
807        assert!(d.added_types.is_empty());
808        assert!(d.removed_types.is_empty());
809    }
810
811    #[test]
812    fn detects_type_kind_change() {
813        use stellar_xdr::{
814            ScSpecEntry, ScSpecUdtEnumCaseV0, ScSpecUdtEnumV0, ScSpecUdtStructFieldV0,
815            ScSpecUdtStructV0,
816        };
817        let struct_entry = ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 {
818            doc: "".try_into().unwrap(),
819            lib: "soroban_sdk".try_into().unwrap(),
820            name: "Status".try_into().unwrap(),
821            fields: vec![ScSpecUdtStructFieldV0 {
822                doc: "".try_into().unwrap(),
823                name: "active".try_into().unwrap(),
824                type_: ScSpecTypeDef::Bool,
825            }]
826            .try_into()
827            .unwrap(),
828        });
829        let enum_entry = ScSpecEntry::UdtEnumV0(ScSpecUdtEnumV0 {
830            doc: "".try_into().unwrap(),
831            lib: "soroban_sdk".try_into().unwrap(),
832            name: "Status".try_into().unwrap(),
833            cases: vec![ScSpecUdtEnumCaseV0 {
834                doc: "".try_into().unwrap(),
835                name: "Active".try_into().unwrap(),
836                value: 0,
837            }]
838            .try_into()
839            .unwrap(),
840        });
841        let d = diff_wasm(&spec_section(&[struct_entry]), &spec_section(&[enum_entry])).unwrap();
842        assert_eq!(d.changed_types.len(), 1);
843        assert_eq!(d.changed_types[0].name, "Status");
844        assert_eq!(d.changed_types[0].old.kind, "struct");
845        assert_eq!(d.changed_types[0].new.kind, "enum");
846    }
847
848    #[test]
849    fn identical_types_produce_no_change() {
850        use crate::spec::tests::udt_struct_entry_with_fields;
851        let wasm = spec_section(&[udt_struct_entry_with_fields(
852            "Point",
853            vec![("x".into(), ScSpecTypeDef::I32)],
854        )]);
855        let d = diff_wasm(&wasm, &wasm).unwrap();
856        assert!(d.changed_types.is_empty());
857        assert!(d.is_identical());
858        assert_eq!(d.total_changes(), 0);
859    }
860
861    #[test]
862    fn parse_error_propagates() {
863        // "old" is valid but has no contract spec; "new" is a valid spec.
864        let old = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
865        let new = spec_section(&[func_entry("a", vec![])]);
866        let err = diff_wasm(&old, &new);
867        assert!(matches!(err, Err(WasmError::NoContractSpec)));
868    }
869
870    // ---- Upgrade-safety verdict tests (reuse diff_specs, no new comparison) ----
871
872    #[test]
873    fn verdict_flags_removed_function_breaking() {
874        let old = spec_section(&[func_entry("a", vec![]), func_entry("b", vec![])]);
875        let new = spec_section(&[func_entry("a", vec![])]);
876        let v = upgrade_safety_wasm(&old, &new).unwrap();
877        assert!(!v.compatible);
878        assert_eq!(v.breaking_changes.len(), 1);
879        assert_eq!(v.breaking_changes[0].kind, ChangeKind::RemovedFunction);
880        assert_eq!(v.breaking_changes[0].name, "b");
881    }
882
883    #[test]
884    fn verdict_flags_changed_signature_breaking() {
885        let old = spec_section(&[func_entry("f", vec![("x".into(), ScSpecTypeDef::U32)])]);
886        let new = spec_section(&[func_entry("f", vec![("x".into(), ScSpecTypeDef::U64)])]);
887        let v = upgrade_safety_wasm(&old, &new).unwrap();
888        assert!(!v.compatible);
889        assert_eq!(v.breaking_changes.len(), 1);
890        assert_eq!(v.breaking_changes[0].kind, ChangeKind::ChangedSignature);
891        assert_eq!(v.breaking_changes[0].name, "f");
892    }
893
894    #[test]
895    fn verdict_flags_changed_event_breaking() {
896        use crate::spec::tests::event_entry_with_params;
897        let old = spec_section(&[event_entry_with_params(
898            "Transfer",
899            vec![("amount".into(), ScSpecTypeDef::I128)],
900        )]);
901        let new = spec_section(&[event_entry_with_params(
902            "Transfer",
903            vec![("amount".into(), ScSpecTypeDef::U64)],
904        )]);
905        let v = upgrade_safety_wasm(&old, &new).unwrap();
906        assert!(!v.compatible);
907        assert_eq!(v.breaking_changes.len(), 1);
908        assert_eq!(v.breaking_changes[0].kind, ChangeKind::ChangedEvent);
909        assert_eq!(v.breaking_changes[0].name, "Transfer");
910        // The detail shows both shapes so the change is actionable.
911        let d = &v.breaking_changes[0].detail;
912        assert!(d.contains("i128"), "detail should show the old type: {}", d);
913        assert!(d.contains("u64"), "detail should show the new type: {}", d);
914    }
915
916    #[test]
917    fn detects_changed_enum_discriminant() {
918        use stellar_xdr::{ScSpecEntry, ScSpecUdtEnumCaseV0, ScSpecUdtEnumV0};
919        // Same case names, different discriminants: stored data that decoded
920        // as `Active` would now resolve to a different case.
921        let enum_with = |value: u32| {
922            ScSpecEntry::UdtEnumV0(ScSpecUdtEnumV0 {
923                doc: "".try_into().unwrap(),
924                lib: "soroban_sdk".try_into().unwrap(),
925                name: "Status".try_into().unwrap(),
926                cases: vec![
927                    ScSpecUdtEnumCaseV0 {
928                        doc: "".try_into().unwrap(),
929                        name: "Active".try_into().unwrap(),
930                        value: 0,
931                    },
932                    ScSpecUdtEnumCaseV0 {
933                        doc: "".try_into().unwrap(),
934                        name: "Paused".try_into().unwrap(),
935                        value,
936                    },
937                ]
938                .try_into()
939                .unwrap(),
940            })
941        };
942        let d = diff_wasm(
943            &spec_section(&[enum_with(1)]),
944            &spec_section(&[enum_with(2)]),
945        )
946        .unwrap();
947        assert_eq!(d.changed_types.len(), 1);
948        assert_eq!(d.changed_types[0].name, "Status");
949        assert_eq!(
950            d.changed_types[0].old.members[1].value,
951            Some(1),
952            "old discriminant must be retained"
953        );
954        assert_eq!(d.changed_types[0].new.members[1].value, Some(2));
955        // Breaking, with both discriminants visible in the detail.
956        let v = upgrade_safety(
957            &parse_contract_spec(&spec_section(&[enum_with(1)])).unwrap(),
958            &parse_contract_spec(&spec_section(&[enum_with(2)])).unwrap(),
959        );
960        assert!(!v.compatible);
961        assert_eq!(
962            v.breaking_changes[0].kind,
963            ChangeKind::ChangedTypeDefinition
964        );
965        assert!(
966            v.breaking_changes[0].detail.contains("Paused=1"),
967            "{}",
968            v.breaking_changes[0].detail
969        );
970        assert!(
971            v.breaking_changes[0].detail.contains("Paused=2"),
972            "{}",
973            v.breaking_changes[0].detail
974        );
975    }
976
977    #[test]
978    fn identical_enum_discriminants_produce_no_change() {
979        use stellar_xdr::{ScSpecEntry, ScSpecUdtEnumCaseV0, ScSpecUdtEnumV0};
980        let entry = ScSpecEntry::UdtEnumV0(ScSpecUdtEnumV0 {
981            doc: "".try_into().unwrap(),
982            lib: "soroban_sdk".try_into().unwrap(),
983            name: "Status".try_into().unwrap(),
984            cases: vec![ScSpecUdtEnumCaseV0 {
985                doc: "".try_into().unwrap(),
986                name: "Active".try_into().unwrap(),
987                value: 7,
988            }]
989            .try_into()
990            .unwrap(),
991        });
992        let wasm = spec_section(&[entry]);
993        let d = diff_wasm(&wasm, &wasm).unwrap();
994        assert!(d.changed_types.is_empty());
995        assert!(d.is_identical());
996    }
997
998    #[test]
999    fn verdict_flags_changed_type_definition_breaking() {
1000        use crate::spec::tests::udt_struct_entry_with_fields;
1001        let old = spec_section(&[udt_struct_entry_with_fields(
1002            "Point",
1003            vec![("x".into(), ScSpecTypeDef::I32)],
1004        )]);
1005        let new = spec_section(&[udt_struct_entry_with_fields(
1006            "Point",
1007            vec![("x".into(), ScSpecTypeDef::I64)],
1008        )]);
1009        let v = upgrade_safety_wasm(&old, &new).unwrap();
1010        assert!(!v.compatible);
1011        assert_eq!(v.breaking_changes.len(), 1);
1012        assert_eq!(
1013            v.breaking_changes[0].kind,
1014            ChangeKind::ChangedTypeDefinition
1015        );
1016        assert_eq!(v.breaking_changes[0].name, "Point");
1017        let d = &v.breaking_changes[0].detail;
1018        assert!(d.contains("i32"), "detail should show the old type: {}", d);
1019        assert!(d.contains("i64"), "detail should show the new type: {}", d);
1020    }
1021
1022    #[test]
1023    fn verdict_flags_removed_event_breaking() {
1024        use crate::spec::tests::event_entry;
1025        let old = spec_section(&[event_entry("Transfer")]);
1026        let new = spec_section(&[]);
1027        let v = upgrade_safety_wasm(&old, &new).unwrap();
1028        assert!(!v.compatible);
1029        assert_eq!(v.breaking_changes.len(), 1);
1030        assert_eq!(v.breaking_changes[0].kind, ChangeKind::RemovedEvent);
1031        assert_eq!(v.breaking_changes[0].name, "Transfer");
1032    }
1033
1034    #[test]
1035    fn verdict_flags_removed_type_breaking() {
1036        use crate::spec::tests::udt_struct_entry;
1037        let old = spec_section(&[udt_struct_entry("Point")]);
1038        let new = spec_section(&[]);
1039        let v = upgrade_safety_wasm(&old, &new).unwrap();
1040        assert!(!v.compatible);
1041        assert_eq!(v.breaking_changes.len(), 1);
1042        assert_eq!(v.breaking_changes[0].kind, ChangeKind::RemovedType);
1043        assert_eq!(v.breaking_changes[0].name, "Point");
1044    }
1045
1046    #[test]
1047    fn verdict_only_additions_is_compatible() {
1048        use crate::spec::tests::event_entry;
1049        let old = spec_section(&[func_entry("a", vec![])]);
1050        let new = spec_section(&[
1051            func_entry("a", vec![]),
1052            func_entry("b", vec![("x".into(), ScSpecTypeDef::U32)]),
1053        ]);
1054        let old2 = spec_section(&[func_entry("a", vec![])]);
1055        let new2 = spec_section(&[func_entry("a", vec![]), event_entry("Mint")]);
1056        let v = upgrade_safety_wasm(&old, &new).unwrap();
1057        assert!(v.compatible);
1058        let v2 = upgrade_safety_wasm(&old2, &new2).unwrap();
1059        assert!(v2.compatible);
1060        assert!(v2.breaking_changes.is_empty());
1061        assert!(!v2.non_breaking_changes.is_empty());
1062        assert!(v2
1063            .non_breaking_changes
1064            .iter()
1065            .any(|c| c.kind == ChangeKind::AddedEvent && c.name == "Mint"));
1066    }
1067
1068    #[test]
1069    fn verdict_identical_is_compatible() {
1070        let wasm = spec_section(&[func_entry(
1071            "greet",
1072            vec![("name".into(), ScSpecTypeDef::String)],
1073        )]);
1074        let v = upgrade_safety_wasm(&wasm, &wasm).unwrap();
1075        assert!(v.compatible);
1076        assert!(v.breaking_changes.is_empty());
1077        assert!(v.non_breaking_changes.is_empty());
1078    }
1079}