1use 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
15pub 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 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
85pub 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 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}