Skip to main content

hopper_manager/
inspect.rs

1//! Header, segment, and field-level inspection of Hopper account bytes.
2//!
3//! Every function here is a pure `&ProgramManifest + &[u8] -> String` (or
4//! `Option<String>`), no I/O, no panics. Consumes the schema truth.
5
6use core::fmt::Write;
7
8use hopper_schema::{
9    decode_account_fields, decode_header, decode_segments, DecodedHeader, DecodedSegment,
10    LayoutManifest, ProgramManifest,
11};
12
13const MAX_FIELDS: usize = 64;
14const MAX_SEGMENTS: usize = 32;
15
16/// Structured identification result.
17///
18/// `Match` means the manifest contains a layout whose `(disc, layout_id)`
19/// matches the data's Hopper header. `NoMatch` means none did.
20#[derive(Debug, Clone, Copy)]
21pub enum IdentifyOutcome<'a> {
22    Match {
23        layout: &'a LayoutManifest,
24        header: DecodedHeader,
25        data_len: usize,
26        size_mismatch: bool,
27    },
28    NoMatch {
29        header: DecodedHeader,
30        data_len: usize,
31    },
32    HeaderTooShort {
33        data_len: usize,
34    },
35}
36
37/// Identify which layout (if any) in the manifest matches the account bytes.
38///
39/// This is the same matching rule used at runtime: `(disc, layout_id)` must
40/// agree with a layout in the manifest. No guessing, no fuzzy matching.
41#[inline]
42pub fn identify_account<'a>(manifest: &'a ProgramManifest, data: &[u8]) -> IdentifyOutcome<'a> {
43    let Some(header) = decode_header(data) else {
44        return IdentifyOutcome::HeaderTooShort {
45            data_len: data.len(),
46        };
47    };
48    match manifest.identify_from_data(data) {
49        Some(layout) => IdentifyOutcome::Match {
50            layout,
51            header,
52            data_len: data.len(),
53            size_mismatch: data.len() != layout.total_size,
54        },
55        None => IdentifyOutcome::NoMatch {
56            header,
57            data_len: data.len(),
58        },
59    }
60}
61
62/// Render the identification outcome as the same "=== Account Identification ==="
63/// block the CLI's `hopper manager identify` emits.
64pub fn identify_report(manifest: &ProgramManifest, data: &[u8]) -> String {
65    let mut out = String::new();
66    match identify_account(manifest, data) {
67        IdentifyOutcome::HeaderTooShort { data_len } => {
68            let _ = writeln!(
69                out,
70                "Data too short for Hopper header (need 16 bytes, got {})",
71                data_len
72            );
73        }
74        IdentifyOutcome::Match {
75            layout,
76            header,
77            data_len,
78            size_mismatch,
79        } => {
80            let _ = writeln!(out, "=== Account Identification ===");
81            let _ = writeln!(out, "  Data size    : {} bytes", data_len);
82            let _ = writeln!(out, "  Header disc  : {}", header.disc);
83            let _ = writeln!(out, "  Header ver   : {}", header.version);
84            let _ = writeln!(out, "  Layout ID    : {}", hex8(&header.layout_id));
85            let _ = writeln!(out);
86            let _ = writeln!(out, "  MATCH: {} v{}", layout.name, layout.version);
87            let _ = writeln!(out, "  Expected size: {} bytes", layout.total_size);
88            let _ = writeln!(out, "  Fields       : {}", layout.field_count);
89            if size_mismatch {
90                let _ = writeln!(
91                    out,
92                    "  WARNING: data size ({}) != expected size ({})",
93                    data_len, layout.total_size
94                );
95            }
96        }
97        IdentifyOutcome::NoMatch { header, data_len } => {
98            let _ = writeln!(out, "=== Account Identification ===");
99            let _ = writeln!(out, "  Data size    : {} bytes", data_len);
100            let _ = writeln!(out, "  Header disc  : {}", header.disc);
101            let _ = writeln!(out, "  Header ver   : {}", header.version);
102            let _ = writeln!(out, "  Layout ID    : {}", hex8(&header.layout_id));
103            let _ = writeln!(out);
104            let _ = writeln!(
105                out,
106                "  NO MATCH: This account does not match any layout in the manifest."
107            );
108            let _ = writeln!(out);
109            let _ = writeln!(out, "Known layouts:");
110            for l in manifest.layouts.iter() {
111                let _ = writeln!(
112                    out,
113                    "    {} v{} (disc={}, id={})",
114                    l.name,
115                    l.version,
116                    l.disc,
117                    hex8(&l.layout_id)
118                );
119            }
120        }
121    }
122    out
123}
124
125/// Render the full table of decoded fields for an account, matching the
126/// CLI's `hopper manager decode` output.
127///
128/// Returns `Err(String)` if the account cannot be identified against the
129/// manifest. The error string is a human-readable diagnostic.
130pub fn decode_account(
131    manifest: &ProgramManifest,
132    data: &[u8],
133    heading: &str,
134) -> Result<String, String> {
135    if data.len() < 16 {
136        return Err(format!(
137            "Data too short for Hopper header (need 16, got {})",
138            data.len()
139        ));
140    }
141    let header =
142        decode_header(data).ok_or_else(|| String::from("Failed to decode Hopper header"))?;
143    let layout = manifest.identify_from_data(data).ok_or_else(|| {
144        format!(
145            "Cannot identify account type (disc={}, layout_id={})",
146            header.disc,
147            hex8(&header.layout_id),
148        )
149    })?;
150
151    let mut out = String::new();
152    let _ = writeln!(
153        out,
154        "=== {}: {} v{} ===",
155        heading, layout.name, layout.version
156    );
157    let _ = writeln!(
158        out,
159        "  Size: {} bytes (expected {})",
160        data.len(),
161        layout.total_size
162    );
163    let _ = writeln!(
164        out,
165        "  Flags: {} (0x{:04x})",
166        format_flags(header.flags),
167        header.flags
168    );
169    let _ = writeln!(out, "  Disc : {}", header.disc);
170    let _ = writeln!(out, "  Wire : {}", hex8(&layout.layout_id));
171    let _ = writeln!(out);
172
173    if layout.field_count == 0 {
174        let _ = writeln!(out, "  (no field descriptors in manifest)");
175        return Ok(out);
176    }
177
178    let (count, fields) = decode_account_fields::<MAX_FIELDS>(data, layout);
179    let mut val_buf = [0u8; 128];
180    let _ = writeln!(
181        out,
182        "  {:>4}  {:>20}  {:>12}  {:>6}  {:>6}  Value",
183        "#", "Field", "Type", "Offset", "Size"
184    );
185    let _ = writeln!(out, "  {}", "-".repeat(76));
186    for (i, slot) in fields.iter().enumerate().take(count) {
187        if let Some(ref field) = slot {
188            let val_len = field.format_value(&mut val_buf);
189            let val_str = core::str::from_utf8(&val_buf[..val_len]).unwrap_or("???");
190            let _ = writeln!(
191                out,
192                "  {:>4}  {:>20}  {:>12}  {:>6}  {:>6}  {}",
193                i, field.name, field.canonical_type, field.offset, field.size, val_str
194            );
195        }
196    }
197    let _ = writeln!(out);
198    let _ = writeln!(out, "  Decoded {}/{} fields.", count, layout.field_count);
199    Ok(out)
200}
201
202/// Render a bare header report (not layout-aware).
203///
204/// Useful when the manifest is unknown or the caller just wants the raw
205/// header bytes interpreted.
206pub fn header_report(data: &[u8]) -> String {
207    let mut out = String::new();
208    match decode_header(data) {
209        Some(h) => {
210            let _ = writeln!(out, "=== Hopper Header ===");
211            let _ = writeln!(out, "  Disc          : {}", h.disc);
212            let _ = writeln!(out, "  Version       : {}", h.version);
213            let _ = writeln!(
214                out,
215                "  Flags         : 0x{:04x} ({})",
216                h.flags,
217                format_flags(h.flags)
218            );
219            let _ = writeln!(out, "  Layout ID     : {}", hex8(&h.layout_id));
220            let _ = writeln!(out, "  Reserved      : {}", hex4(&h.reserved));
221        }
222        None => {
223            let _ = writeln!(
224                out,
225                "Data too short to decode Hopper header (need 16 bytes, got {})",
226                data.len()
227            );
228        }
229    }
230    out
231}
232
233/// Render a segment map report (after the Hopper header) for accounts
234/// that carry segment metadata.
235pub fn segment_map_report(data: &[u8]) -> String {
236    let mut out = String::new();
237    match decode_segments::<MAX_SEGMENTS>(data) {
238        Some((count, segs)) => {
239            let _ = writeln!(out, "=== Segment Map ({} entries) ===", count);
240            for (i, seg) in segs.iter().enumerate().take(count) {
241                render_segment_line(&mut out, i, seg);
242            }
243        }
244        None => {
245            let _ = writeln!(out, "No segment map present (or data too short).");
246        }
247    }
248    out
249}
250
251fn render_segment_line(out: &mut String, index: usize, seg: &DecodedSegment) {
252    let _ = writeln!(
253        out,
254        "  [{}] id={} offset={} size={} flags=0x{:04x} ver={}",
255        index,
256        hex_any(&seg.id),
257        seg.offset,
258        seg.size,
259        seg.flags,
260        seg.version,
261    );
262}
263
264// ── Local formatting helpers ─────────────────────────────────────────
265
266fn format_flags(flags: u16) -> String {
267    let mut parts = Vec::with_capacity(4);
268    if flags & 0x0001 != 0 {
269        parts.push("INITIALIZED");
270    }
271    if flags & 0x0002 != 0 {
272        parts.push("LOCKED");
273    }
274    if flags & 0x0004 != 0 {
275        parts.push("UPGRADED");
276    }
277    if flags & 0x0008 != 0 {
278        parts.push("CLOSED");
279    }
280    if parts.is_empty() {
281        String::from("none")
282    } else {
283        parts.join("|")
284    }
285}
286
287fn hex8(bytes: &[u8; 8]) -> String {
288    let mut out = String::with_capacity(16);
289    for b in bytes {
290        let _ = write!(out, "{:02x}", b);
291    }
292    out
293}
294
295fn hex4(bytes: &[u8; 4]) -> String {
296    let mut out = String::with_capacity(8);
297    for b in bytes {
298        let _ = write!(out, "{:02x}", b);
299    }
300    out
301}
302
303fn hex_any(bytes: &[u8]) -> String {
304    let mut out = String::with_capacity(bytes.len() * 2);
305    for b in bytes {
306        let _ = write!(out, "{:02x}", b);
307    }
308    out
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn header_report_handles_short_data() {
317        let out = header_report(&[0x01, 0x02]);
318        assert!(out.contains("Data too short"));
319    }
320
321    #[test]
322    fn format_flags_all_zero_is_none() {
323        assert_eq!(format_flags(0), "none");
324    }
325
326    #[test]
327    fn format_flags_combines_known_bits() {
328        let s = format_flags(0x0001 | 0x0004);
329        assert!(s.contains("INITIALIZED"));
330        assert!(s.contains("UPGRADED"));
331    }
332}