Skip to main content

hopper_manager/
analyze.rs

1//! Cross-version analysis: compatibility verdicts and semantic field diffs.
2//!
3//! These routines ingest two `LayoutManifest`s (or two versions of the same
4//! account's bytes) and explain whether upgrades are safe, what fields
5//! moved, and what downstream migration steps are implied, all from the
6//! schema truth, never re-derived.
7
8use core::fmt::Write;
9
10use hopper_schema::{
11    compare_fields, decode_header, is_append_compatible, is_backward_readable, requires_migration,
12    CompatibilityVerdict, FieldCompat, LayoutManifest, ProgramManifest,
13};
14
15/// Render a compatibility report between two versions of the same layout
16/// name declared in the manifest.
17///
18/// `from_version` is the current on-chain version; `to_version` is the
19/// target version. Returns `Err(String)` if either version is missing.
20pub fn compatibility_report(
21    manifest: &ProgramManifest,
22    layout_name: &str,
23    from_version: u8,
24    to_version: u8,
25) -> Result<String, String> {
26    let older = find_layout_version(manifest, layout_name, from_version)
27        .ok_or_else(|| format!("layout {} v{} not in manifest", layout_name, from_version))?;
28    let newer = find_layout_version(manifest, layout_name, to_version)
29        .ok_or_else(|| format!("layout {} v{} not in manifest", layout_name, to_version))?;
30
31    let verdict = CompatibilityVerdict::between(older, newer);
32
33    let mut out = String::new();
34    let _ = writeln!(
35        out,
36        "=== Compatibility: {} v{} -> v{} ===",
37        layout_name, from_version, to_version
38    );
39    let _ = writeln!(
40        out,
41        "  {} v{}  ({} bytes, {} fields)",
42        older.name, older.version, older.total_size, older.field_count
43    );
44    let _ = writeln!(
45        out,
46        "  {} v{}  ({} bytes, {} fields)",
47        newer.name, newer.version, newer.total_size, newer.field_count
48    );
49    let _ = writeln!(out);
50    let _ = writeln!(out, "  Verdict            : {}", verdict.name());
51    let _ = writeln!(out, "  Description        : {}", describe_verdict(verdict));
52    let _ = writeln!(
53        out,
54        "  Append compatible  : {}",
55        is_append_compatible(older, newer)
56    );
57    let _ = writeln!(
58        out,
59        "  Backward readable  : {}",
60        is_backward_readable(older, newer)
61    );
62    let _ = writeln!(
63        out,
64        "  Requires migration : {}",
65        requires_migration(older, newer)
66    );
67
68    // Field-level detail.
69    let report = compare_fields::<64>(older, newer);
70    if report.count > 0 {
71        let _ = writeln!(out);
72        let _ = writeln!(out, "  Field changes:");
73        for entry in report.entries.iter().take(report.count) {
74            let _ = writeln!(
75                out,
76                "    {:<20}  {}",
77                entry.name,
78                describe_field_compat(&entry.status)
79            );
80        }
81    }
82    Ok(out)
83}
84
85/// Render a byte-level comparison between two account data blobs whose
86/// layouts may be at different versions. Identifies each side, then
87/// delegates to `compatibility_report` if layouts differ.
88pub fn field_diff_report(
89    manifest: &ProgramManifest,
90    before: &[u8],
91    after: &[u8],
92) -> Result<String, String> {
93    let Some(before_hdr) = decode_header(before) else {
94        return Err(String::from("'before' data is too short to decode"));
95    };
96    let Some(after_hdr) = decode_header(after) else {
97        return Err(String::from("'after' data is too short to decode"));
98    };
99
100    let older = manifest.identify_from_data(before).ok_or_else(|| {
101        format!(
102            "cannot identify 'before' layout (disc={}, id={})",
103            before_hdr.disc,
104            hex8(&before_hdr.layout_id)
105        )
106    })?;
107    let newer = manifest.identify_from_data(after).ok_or_else(|| {
108        format!(
109            "cannot identify 'after' layout (disc={}, id={})",
110            after_hdr.disc,
111            hex8(&after_hdr.layout_id)
112        )
113    })?;
114
115    let mut out = String::new();
116    let _ = writeln!(out, "=== Semantic Field Diff ===");
117    let _ = writeln!(
118        out,
119        "  before: {} v{} ({} bytes)",
120        older.name,
121        older.version,
122        before.len()
123    );
124    let _ = writeln!(
125        out,
126        "  after : {} v{} ({} bytes)",
127        newer.name,
128        newer.version,
129        after.len()
130    );
131    let _ = writeln!(out);
132
133    if older.name != newer.name {
134        let _ = writeln!(
135            out,
136            "  layouts have different names, treating as full replacement"
137        );
138        return Ok(out);
139    }
140
141    if older.version == newer.version {
142        diff_same_version(&mut out, older, before, after);
143        return Ok(out);
144    }
145
146    // Cross-version: reuse the compatibility report, then append a concrete
147    // per-field byte diff for fields that exist in both versions.
148    let compat = compatibility_report(manifest, older.name, older.version, newer.version)?;
149    out.push_str(&compat);
150    out.push('\n');
151    diff_cross_version(&mut out, older, newer, before, after);
152    Ok(out)
153}
154
155fn diff_same_version(out: &mut String, layout: &LayoutManifest, before: &[u8], after: &[u8]) {
156    let _ = writeln!(out, "  (same version, showing per-field byte deltas)");
157    for field in layout.fields.iter().take(layout.field_count) {
158        let end = field.offset as usize + field.size as usize;
159        let before_slice = before.get(field.offset as usize..end);
160        let after_slice = after.get(field.offset as usize..end);
161        match (before_slice, after_slice) {
162            (Some(b), Some(a)) if b != a => {
163                let _ = writeln!(
164                    out,
165                    "    {:<20}  CHANGED  before={}  after={}",
166                    field.name,
167                    hex_any(b),
168                    hex_any(a)
169                );
170            }
171            (Some(_), Some(_)) => {}
172            _ => {
173                let _ = writeln!(out, "    {:<20}  SKIPPED (out of bounds)", field.name);
174            }
175        }
176    }
177}
178
179fn diff_cross_version(
180    out: &mut String,
181    older: &LayoutManifest,
182    newer: &LayoutManifest,
183    before: &[u8],
184    after: &[u8],
185) {
186    let _ = writeln!(out, "  Per-field byte deltas:");
187    for older_field in older.fields.iter().take(older.field_count) {
188        let Some(newer_field) = newer
189            .fields
190            .iter()
191            .take(newer.field_count)
192            .find(|f| f.name == older_field.name)
193        else {
194            let _ = writeln!(
195                out,
196                "    {:<20}  REMOVED in v{}",
197                older_field.name, newer.version
198            );
199            continue;
200        };
201        let ob_end = older_field.offset as usize + older_field.size as usize;
202        let nb_end = newer_field.offset as usize + newer_field.size as usize;
203        let b = before.get(older_field.offset as usize..ob_end);
204        let a = after.get(newer_field.offset as usize..nb_end);
205        match (b, a) {
206            (Some(b), Some(a)) => {
207                if b == a {
208                    continue;
209                }
210                let _ = writeln!(
211                    out,
212                    "    {:<20}  CHANGED  v{}: {}  v{}: {}",
213                    older_field.name,
214                    older.version,
215                    hex_any(b),
216                    newer.version,
217                    hex_any(a),
218                );
219            }
220            _ => {
221                let _ = writeln!(out, "    {:<20}  SKIPPED (out of bounds)", older_field.name);
222            }
223        }
224    }
225    for newer_field in newer.fields.iter().take(newer.field_count) {
226        if !older
227            .fields
228            .iter()
229            .take(older.field_count)
230            .any(|f| f.name == newer_field.name)
231        {
232            let _ = writeln!(
233                out,
234                "    {:<20}  ADDED in v{}",
235                newer_field.name, newer.version
236            );
237        }
238    }
239}
240
241fn describe_verdict(v: CompatibilityVerdict) -> &'static str {
242    match v {
243        CompatibilityVerdict::Identical => "byte-identical layouts, no transition required",
244        CompatibilityVerdict::WireCompatible => "wire-compatible; only semantic metadata differs",
245        CompatibilityVerdict::AppendSafe => "append-safe; old readers can still decode new data",
246        CompatibilityVerdict::MigrationRequired => {
247            "migration required; existing bytes must be rewritten"
248        }
249        CompatibilityVerdict::Incompatible => "incompatible; discriminators diverge",
250    }
251}
252
253fn describe_field_compat(compat: &FieldCompat) -> &'static str {
254    match compat {
255        FieldCompat::Identical => "identical",
256        FieldCompat::Changed => "changed (type or size)",
257        FieldCompat::Added => "added",
258        FieldCompat::Removed => "removed",
259    }
260}
261
262fn find_layout_version<'a>(
263    manifest: &'a ProgramManifest,
264    name: &str,
265    version: u8,
266) -> Option<&'a LayoutManifest> {
267    manifest
268        .layouts
269        .iter()
270        .find(|l| l.name == name && l.version == version)
271}
272
273fn hex8(bytes: &[u8; 8]) -> String {
274    let mut out = String::with_capacity(16);
275    for b in bytes {
276        let _ = write!(out, "{:02x}", b);
277    }
278    out
279}
280
281fn hex_any(bytes: &[u8]) -> String {
282    let mut out = String::with_capacity(bytes.len() * 2);
283    for b in bytes {
284        let _ = write!(out, "{:02x}", b);
285    }
286    out
287}