Skip to main content

ghidra_ipc/
protocol.rs

1//! Cross-language wire DTOs for the M1a read tools (spec §3). These are the worker's response shapes;
2//! the server canonicalizes address strings (via `address::AddressCanonicalizer`) before returning
3//! them to the MCP client, but the wire form carries whatever `space:offset` the worker emits.
4//!
5//! Schema-stability contract (spec §3.5): these shapes are a BASELINE. Later milestones may only
6//! ADD keys; renaming/retyping/removing an M1a field requires a NEW MCP tool name.
7
8use serde::{Deserialize, Serialize};
9
10/// One call-graph neighbor of a function (spec §3.3). `address` is a raw `space:offset` string as
11/// emitted by the worker; the server re-renders it through `AddressCanonicalizer` before it reaches
12/// the agent. `external: true` marks an imported-DLL thunk with no decompilable body (spec §3.3
13/// round-7 seat-3) — the agent identifies it by name, never feeds it back to `inspect_function`.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct CallEdge {
16    pub name: String,
17    pub address: String,
18    pub external: bool,
19}
20
21/// The result of `inspect_function` (spec §3.3). `c` is capped from the top by `max_c_lines`
22/// (signature + decls always intact — spec §3.4); `c_truncated` signals the body was longer.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct FunctionContext {
25    pub name: String,
26    pub entry_point: String,
27    pub signature: String,
28    pub calling_convention: String,
29    pub is_thunk: bool,
30    /// Address when `is_thunk`, else null.
31    pub thunked_to: Option<String>,
32    pub c: String,
33    pub c_truncated: bool,
34    pub callers: Vec<CallEdge>,
35    pub callees: Vec<CallEdge>,
36    #[serde(default)]
37    pub data_refs: Vec<DataRef>,
38    #[serde(default)]
39    pub data_refs_truncated: bool,
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub comments: Vec<CommentEntry>,
42    #[serde(default)]
43    pub comments_truncated: bool,
44    /// Structured current signature (M2c Task 0.2). `Option` + `serde(default)` so an older worker's
45    /// `FunctionContext` (no `prototype`) still deserializes (panel R3-Seat3, matches the M2b
46    /// serde-default-on-Option lesson). The flat `signature` string above remains for the human form
47    /// and the `expected_signature` CAS value.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub prototype: Option<Prototype>,
50    /// Structured decompiler-visible locals (M2d). `Option` + `serde(default)` so an older worker's
51    /// `FunctionContext` (no `locals`) still deserializes, mirroring `prototype`. Excludes parameters
52    /// (those live in `prototype`). Bounded (~100); `locals_truncated` flags the cap was hit.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub locals: Option<Vec<LocalEntry>>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub locals_truncated: Option<bool>,
57}
58
59/// One from-data reference gathered from `inspectFunction`'s body (Task 16). `address` is a raw
60/// `space:offset` string as emitted by the worker; the handler canonicalizes it alongside the other
61/// `FunctionContext` addresses. `name` is the primary symbol at the target address when one exists.
62/// `ref_type` is the Ghidra `RefType` name (e.g. "DATA", "READ", "WRITE").
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct DataRef {
65    pub address: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub name: Option<String>,
68    pub ref_type: String,
69}
70
71/// One comment surfaced by `inspect_function` (M2b read-back tidy). `address` is a raw `space:offset`
72/// string as emitted by the worker; the handler canonicalizes it alongside the other `FunctionContext`
73/// addresses. `kind` is the comment channel (`eol|pre|post|plate|repeatable`); `comment` is the text.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct CommentEntry {
76    pub address: String,
77    #[serde(rename = "type")]
78    pub kind: String,
79    pub comment: String,
80}
81
82/// One parameter of a function's structured signature (read side of `inspect_function.prototype`;
83/// M2c Task 0.2). `name` is Ghidra's DB parameter name (auto-named `param_1…` when the binary has no
84/// symbol); `type_` serializes as the wire key `type`. Mirrors the write-side `SetPrototypeArgs.params`
85/// element so a read prototype can be edited and bounced straight back to `set_prototype`.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct ParamEntry {
88    pub name: String,
89    #[serde(rename = "type")]
90    pub type_: String,
91}
92
93/// The current signature of a function, structured (read side; M2c Task 0.2). Sourced from the Program
94/// DB `Function.getSignature()` — NOT the decompiler `HighFunction`, which infers params not locked in
95/// the DB (the CAS/write only operate on the DB signature). Mirrors `SetPrototypeArgs` exactly so the
96/// write-loop closes from one read: inspect → mutate one field → set_prototype.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct Prototype {
99    pub return_type: String,
100    pub calling_convention: String,
101    pub params: Vec<ParamEntry>,
102    pub variadic: bool,
103}
104
105/// One local variable of a function, sourced from the decompiler `HighFunction` (so the `name` matches
106/// the `c` body the agent reads) — NOT the Program-DB listing. `storage` (`stack:-0x18`, `EAX:4`,
107/// `unique:…`) is the STABLE primary handle a `set_local` write keys on; `name` is a convenience selector
108/// that the decompiler may silently re-derive across edits. `kind` ∈ `stack|register|unique|other` tells
109/// the agent which handles are trustworthy across edits. `type_` serializes as the wire key `type`,
110/// mirroring `ParamEntry`.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct LocalEntry {
113    pub name: String,
114    #[serde(rename = "type")]
115    pub type_: String,
116    pub storage: String,
117    pub kind: String,
118}
119
120/// One row of `list_project_programs` (spec §3.1). `program_path` is the absolute `/`-VFS path used
121/// by `attach_program`. `language_id`/`size` come from cheap `DomainFile` metadata (may be absent).
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct ProgramEntry {
124    pub program_path: String,
125    pub name: String,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub language_id: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub size: Option<u64>,
130}
131
132/// The full `list_project_programs` result (spec §3.1, paginated).
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct ListProjectPrograms {
135    pub programs: Vec<ProgramEntry>,
136    pub total: u64,
137    pub truncated: bool,
138}
139
140/// The `attach_program` / `worker_info` shared result: the worker reports the attached program's
141/// address geometry so the server can build an `AddressCanonicalizer` for it (spec §3.2 + §6). This
142/// is strictly-additive to the M0 `{attached}` shape.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct AttachResult {
145    /// The VFS path now attached (echoes the request); null in a `worker_info` reply if nothing is
146    /// attached yet (empty project cannot boot, so in practice always Some for M1a).
147    pub attached: Option<String>,
148    pub default_space: String,
149    pub pointer_size: u8,
150}
151
152/// One ambiguous-symbol candidate, carried in an `AMBIGUOUS_SYMBOL` error's `details.candidates`
153/// (spec §6). The agent re-calls `inspect_function` with the disambiguating `address`.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct SymbolCandidate {
156    pub name: String,
157    pub address: String,
158}
159
160/// One row of `find_functions` (spec §3.1): the cheap metadata subset of `FunctionContext` — NO
161/// decompiled C, NO edges. `address` fields are raw `space:offset`; the handler canonicalizes them.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct FunctionSummary {
164    pub name: String,
165    pub entry_point: String,
166    pub signature: String,
167    pub calling_convention: String,
168    pub is_thunk: bool,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub thunked_to: Option<String>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub size: Option<u64>,
173    pub external: bool,
174}
175
176/// `find_functions` result (spec §3.1). `total` present only for cheap exact counts (§3.0).
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ListFunctions {
179    pub functions: Vec<FunctionSummary>,
180    pub has_more: bool,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub total: Option<u64>,
183}
184
185/// One symbol row, shared by resolve_symbol and list_symbols (spec §3.2/§3.3). Uniform shape; optional
186/// fields absent when N/A. `kind` is a lowercase Ghidra classification string (NOT a Rust enum).
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct Symbol {
189    pub name: String,
190    pub address: String,
191    pub kind: String,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub namespace: Option<String>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub library: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub size: Option<u64>,
198}
199
200/// `resolve_symbol` result (spec §3.2): every symbol matching the requested name.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct ResolveSymbol {
203    pub candidates: Vec<Symbol>,
204}
205
206/// `list_symbols` result (spec §3.3, paginated). `total` present only for cheap exact counts (§3.0).
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct ListSymbols {
209    pub symbols: Vec<Symbol>,
210    pub has_more: bool,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub total: Option<u64>,
213}
214
215/// One row of `list_strings` (spec §3.4). `address` is a raw `space:offset` string as emitted by the
216/// worker; the handler canonicalizes it. `encoding` is the Ghidra data type name (e.g. "string",
217/// "unicode").
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219pub struct StringItem {
220    pub address: String,
221    pub value: String,
222    pub length: u64,
223    pub encoding: String,
224}
225
226/// `list_strings` result (spec §3.4, paginated). `total` present only for cheap exact counts (§3.0).
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct ListStrings {
229    pub strings: Vec<StringItem>,
230    pub has_more: bool,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub total: Option<u64>,
233}
234
235/// One cross-reference row from `get_xrefs` (spec §3.x). `from_address`/`to_address` are raw
236/// `space:offset` strings as emitted by the worker; the handler canonicalizes both. `ref_type` is the
237/// Ghidra `RefType` name (e.g. "CALL", "DATA", "READ"). `from_function` is present only when the
238/// reference's source address falls inside a defined function.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct Reference {
241    pub from_address: String,
242    pub to_address: String,
243    pub ref_type: String,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub from_function: Option<String>,
246}
247
248/// `get_xrefs` result (paginated). `total` present only for cheap exact counts (§3.0).
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct Xrefs {
251    pub xrefs: Vec<Reference>,
252    pub has_more: bool,
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub total: Option<u64>,
255}
256
257/// One instruction row from `get_disassembly`. `address` is a raw `space:offset` string as emitted by
258/// the worker; the handler canonicalizes it. `bytes` is lowercase hex. `comments` mirrors the wire
259/// object keyed by comment channel (`eol|pre|post|plate|repeatable`), present only when a comment exists
260/// at this address (M2b read-back tidy — aligned to the wire in M2c Task 0.1).
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262pub struct Instruction {
263    pub address: String,
264    pub bytes: String,
265    pub mnemonic: String,
266    pub operands: String,
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub comments: Option<std::collections::BTreeMap<String, String>>,
269}
270
271/// `get_disassembly` result (paginated). `total` is the EXACT instruction count of the resolved
272/// function's body (cheap to compute for one bounded function — unlike the filtered-table tools).
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274pub struct Disassembly {
275    pub instructions: Vec<Instruction>,
276    pub has_more: bool,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub total: Option<u64>,
279}
280
281/// `read_bytes` result (spec §3.x): a raw byte dump at `address` (which echoes the address actually
282/// read, i.e. `address + offset`). `bytes` is lowercase hex, `length` the number of bytes actually
283/// returned (may be less than requested if clamped). `truncated` is true when the requested length was
284/// clamped down to the 4096-byte cap.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct ReadBytes {
287    pub address: String,
288    pub length: u64,
289    pub bytes: String,
290    pub truncated: bool,
291}
292
293/// One field of a struct/union data type, from `get_datatype` (spec §3.x). `type_` serializes as the
294/// wire key `type` (a Rust keyword-adjacent rename, not a shape change).
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct DataTypeMember {
297    pub name: String,
298    #[serde(rename = "type")]
299    pub type_: String,
300    pub offset: u64,
301    pub size: u64,
302}
303
304/// One named value of an enum data type, from `get_datatype`. `value` is a **String** (not a number):
305/// Ghidra enum constants can be full 64-bit values, and a bare JSON number would lose precision for the
306/// high end of that range (spec §10.8) — the agent must parse it itself.
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct EnumValue {
309    pub name: String,
310    pub value: String,
311}
312
313/// `get_datatype` result (spec §3.x): looks up a data type DEFINITION by name. `members` is present for
314/// struct/union kinds, `values` for enum kinds; both absent for any other kind (e.g. a typedef or
315/// built-in scalar), which reports only name/kind/size.
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct DataTypeInfo {
318    pub name: String,
319    pub kind: String,
320    pub size: u64,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub members: Option<Vec<DataTypeMember>>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub values: Option<Vec<EnumValue>>,
325}
326
327/// One memory block from `list_segments` (spec §3.x). `start`/`end` are raw `space:offset` strings as
328/// emitted by the worker; the handler canonicalizes both.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct Segment {
331    pub name: String,
332    pub start: String,
333    pub end: String,
334    pub size: u64,
335    pub readable: bool,
336    pub writable: bool,
337    pub executable: bool,
338    pub initialized: bool,
339}
340
341/// `list_segments` result (spec §3.x): the full memory map. NOT paginated — segment counts are small.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct ListSegments {
344    pub segments: Vec<Segment>,
345}
346
347/// One defined data instance from `list_data_items` (spec §3.x). `address` is a raw `space:offset`
348/// string as emitted by the worker; the handler canonicalizes it. `name` is the listing label when one
349/// is present (else absent); `value` is present only when the worker could cheaply render it.
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351pub struct DataItem {
352    pub address: String,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub name: Option<String>,
355    pub data_type: String,
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub value: Option<String>,
358    pub size: u64,
359}
360
361/// `list_data_items` result (paginated). `total` present only for cheap exact counts (§3.0) — the M1b
362/// worker does not emit it today, so it always deserializes as absent/None.
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364pub struct ListDataItems {
365    pub data: Vec<DataItem>,
366    pub has_more: bool,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub total: Option<u64>,
369}
370
371/// The function containing an address, from `describe_address` (spec §3.x). `entry_point` is a raw
372/// `space:offset` string as emitted by the worker; the handler canonicalizes it.
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374pub struct ContainingFunction {
375    pub name: String,
376    pub entry_point: String,
377}
378
379/// The primary symbol at an address, from `describe_address`. `kind` is a lowercase Ghidra
380/// classification string (NOT a Rust enum), mirroring `Symbol::kind`.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct AddressSymbol {
383    pub name: String,
384    pub kind: String,
385}
386
387/// `describe_address` result (spec §3.x): a TOTAL address -> what-is-here point lookup, the dual of
388/// `resolve_symbol`. An unmapped/unparseable address is a valid answer (`mapped: false`), never an
389/// error. `address` echoes the request (raw `space:offset` when mapped; the handler canonicalizes it).
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct AddressInfo {
392    pub address: String,
393    pub mapped: bool,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub segment: Option<String>,
396    pub is_code: bool,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub containing_function: Option<ContainingFunction>,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub symbol: Option<AddressSymbol>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub data_type: Option<String>,
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub comments: Option<std::collections::BTreeMap<String, String>>,
405}
406
407/// Result of the `rename` write tool (spec §3.4). `previous_name` is present only for `status="renamed"`.
408/// The `address` is the raw worker string; the rmcp handler canonicalizes it (spec §3.4).
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410pub struct RenameResult {
411    pub status: String,
412    pub address: String,
413    pub name: String,
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub previous_name: Option<String>,
416    pub durable: bool,
417}
418
419/// Result of the `comment` write tool (M2b §3.1). `previous_comment` is present only when a prior
420/// non-empty comment was overwritten or cleared. `address` is canonicalized by the rmcp handler.
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422pub struct CommentResult {
423    pub status: String,
424    pub address: String,
425    pub comment_type: String,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub previous_comment: Option<String>,
428    pub durable: bool,
429}
430
431/// Result of the `set_datatype` write tool (M2b §3.2). `status` is `"set"` | `"cleared"` |
432/// `"already_applied"`; `datatype` is the final resolved display name (`"undefined"` after a clear).
433/// `previous_datatype` is present only when a prior type was replaced/cleared. `address` is
434/// canonicalized by the rmcp handler.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct SetDatatypeResult {
437    pub status: String,
438    pub address: String,
439    pub datatype: String,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub previous_datatype: Option<String>,
442    pub length: i64,
443    pub durable: bool,
444}
445
446/// Result of the `set_prototype` write tool (M2c §3). `status` is `"set"` | `"already_applied"`;
447/// `signature` is the NEW canonical DB prototype string; `previous_signature` is present only when a
448/// prior signature was overwritten. `address` (the function entry point) is canonicalized by the rmcp
449/// handler. `name` echoes the function name.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451pub struct SetPrototypeResult {
452    pub status: String,
453    pub address: String,
454    pub name: String,
455    pub signature: String,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub previous_signature: Option<String>,
458    pub durable: bool,
459}
460
461/// Result of the `set_local` write tool (M2d §4.1). `status` ∈ `set` | `renamed` | `retyped` |
462/// `already_applied` (a combined type+name change ⇒ `set`). `storage` is the canonical handle that was
463/// edited (retry-stable for stack locals); `name`/`type` are the NEW current values. `previous_name` is
464/// present only when `new_name` was given AND the name actually changed; `previous_type` only when
465/// `new_type` was given AND the type actually changed (panel R1-Seat2). `function` echoes the function
466/// name. `type_` serializes as the wire key `type`.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
468pub struct SetLocalResult {
469    pub status: String,
470    pub function: String,
471    pub storage: String,
472    pub name: String,
473    #[serde(rename = "type")]
474    pub type_: String,
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub previous_name: Option<String>,
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub previous_type: Option<String>,
479    pub durable: bool,
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn sample_fc() -> FunctionContext {
487        FunctionContext {
488            name: "add_two".into(),
489            entry_point: "0x00401000".into(),
490            signature: "uint __cdecl add_two(uint param_1, uint param_2)".into(),
491            calling_convention: "__cdecl".into(),
492            is_thunk: false,
493            thunked_to: None,
494            c: "uint add_two(...){ ... }".into(),
495            c_truncated: false,
496            callers: vec![CallEdge {
497                name: "main".into(),
498                address: "0x00401040".into(),
499                external: false,
500            }],
501            callees: vec![CallEdge {
502                name: "MessageBoxW".into(),
503                address: "EXTERNAL:0x00000001".into(),
504                external: true,
505            }],
506            data_refs: vec![],
507            data_refs_truncated: false,
508            comments: vec![],
509            comments_truncated: false,
510            prototype: None,
511            locals: None,
512            locals_truncated: None,
513        }
514    }
515
516    #[test]
517    fn function_context_locals_roundtrip_uses_wire_key_type() {
518        let le = LocalEntry {
519            name: "uVar1".into(),
520            type_: "uint".into(),
521            storage: "EAX:4".into(),
522            kind: "register".into(),
523        };
524        let j = serde_json::to_value(&le).unwrap();
525        assert_eq!(j["type"], "uint"); // serializes as wire key `type`, not `type_`
526        assert_eq!(j["storage"], "EAX:4");
527        // Empty locals -> omitted (skip_serializing_if), but round-trips to None via serde(default).
528        let mut fc = sample_fc();
529        assert!(serde_json::to_value(&fc).unwrap().get("locals").is_none());
530        fc.locals = Some(vec![le]);
531        fc.locals_truncated = Some(false);
532        let back: FunctionContext =
533            serde_json::from_str(&serde_json::to_string(&fc).unwrap()).unwrap();
534        assert_eq!(back, fc);
535    }
536
537    #[test]
538    fn function_context_roundtrips_with_spec_shape() {
539        let fc = FunctionContext {
540            name: "add_two".into(),
541            entry_point: "0x00401000".into(),
542            signature: "uint __cdecl add_two(uint param_1, uint param_2)".into(),
543            calling_convention: "__cdecl".into(),
544            is_thunk: false,
545            thunked_to: None,
546            c: "uint add_two(...){ ... }".into(),
547            c_truncated: false,
548            callers: vec![CallEdge {
549                name: "main".into(),
550                address: "0x00401040".into(),
551                external: false,
552            }],
553            callees: vec![CallEdge {
554                name: "MessageBoxW".into(),
555                address: "EXTERNAL:0x00000001".into(),
556                external: true,
557            }],
558            data_refs: vec![],
559            data_refs_truncated: false,
560            comments: vec![],
561            comments_truncated: false,
562            prototype: None,
563            locals: None,
564            locals_truncated: None,
565        };
566        let s = serde_json::to_string(&fc).unwrap();
567        let back: FunctionContext = serde_json::from_str(&s).unwrap();
568        assert_eq!(back, fc);
569        // thunked_to serializes as JSON null when None.
570        assert_eq!(
571            serde_json::to_value(&fc).unwrap()["thunked_to"],
572            serde_json::Value::Null
573        );
574        // comments is empty -> omitted (skip_serializing_if), but still round-trips to empty via #[serde(default)].
575        assert!(serde_json::to_value(&fc).unwrap().get("comments").is_none());
576    }
577
578    #[test]
579    fn function_context_comments_roundtrip_uses_wire_key_type() {
580        let fc = FunctionContext {
581            name: "add_two".into(),
582            entry_point: "0x00401000".into(),
583            signature: "uint __cdecl add_two(uint param_1, uint param_2)".into(),
584            calling_convention: "__cdecl".into(),
585            is_thunk: false,
586            thunked_to: None,
587            c: "uint add_two(...){ ... }".into(),
588            c_truncated: false,
589            callers: vec![CallEdge {
590                name: "main".into(),
591                address: "0x00401040".into(),
592                external: false,
593            }],
594            callees: vec![CallEdge {
595                name: "MessageBoxW".into(),
596                address: "EXTERNAL:0x00000001".into(),
597                external: true,
598            }],
599            data_refs: vec![],
600            data_refs_truncated: false,
601            comments: vec![
602                CommentEntry {
603                    address: "ram:00401000".into(),
604                    kind: "plate".into(),
605                    comment: "hdr".into(),
606                },
607                CommentEntry {
608                    address: "ram:00401004".into(),
609                    kind: "eol".into(),
610                    comment: "note".into(),
611                },
612            ],
613            comments_truncated: false,
614            prototype: None,
615            locals: None,
616            locals_truncated: None,
617        };
618        let v = serde_json::to_value(&fc).unwrap();
619        // wire uses "type", not "kind"
620        assert_eq!(v["comments"][0]["type"], "plate");
621        assert!(v["comments"][0].get("kind").is_none());
622        let back: FunctionContext = serde_json::from_value(v).unwrap();
623        assert_eq!(back, fc);
624    }
625
626    #[test]
627    fn function_context_prototype_roundtrips_and_omits_when_none() {
628        let proto = Prototype {
629            return_type: "uint".into(),
630            calling_convention: "__cdecl".into(),
631            params: vec![
632                ParamEntry {
633                    name: "param_1".into(),
634                    type_: "uint".into(),
635                },
636                ParamEntry {
637                    name: "param_2".into(),
638                    type_: "uint".into(),
639                },
640            ],
641            variadic: false,
642        };
643        let v = serde_json::to_value(&proto).unwrap();
644        assert_eq!(v["params"][0]["type"], "uint"); // wire key is `type`, not `type_`
645        assert!(v["params"][0].get("type_").is_none());
646        // absent prototype omitted, but a shape without it still deserializes (serde default).
647        let missing = serde_json::json!({
648            "name":"f","entry_point":"ram:1","signature":"s","calling_convention":"__cdecl",
649            "is_thunk":false,"thunked_to":null,"c":"","c_truncated":false,"callers":[],"callees":[]
650        });
651        let fc: FunctionContext = serde_json::from_value(missing).unwrap();
652        assert_eq!(fc.prototype, None);
653    }
654
655    #[test]
656    fn program_entry_omits_absent_optional_metadata() {
657        let e = ProgramEntry {
658            program_path: "/add.exe".into(),
659            name: "add.exe".into(),
660            language_id: None,
661            size: None,
662        };
663        let v = serde_json::to_value(&e).unwrap();
664        assert!(v.get("language_id").is_none());
665        assert!(v.get("size").is_none());
666    }
667
668    #[test]
669    fn attach_result_carries_address_geometry() {
670        let a = AttachResult {
671            attached: Some("/add.exe".into()),
672            default_space: "ram".into(),
673            pointer_size: 4,
674        };
675        let back: AttachResult = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
676        assert_eq!(back, a);
677    }
678
679    #[test]
680    fn function_summary_omits_absent_optionals_and_roundtrips() {
681        let s = FunctionSummary {
682            name: "add_two".into(),
683            entry_point: "ram:401000".into(),
684            signature: "uint add_two(uint,uint)".into(),
685            calling_convention: "__cdecl".into(),
686            is_thunk: false,
687            thunked_to: None,
688            size: Some(42),
689            external: false,
690        };
691        let v = serde_json::to_value(&s).unwrap();
692        assert!(v.get("thunked_to").is_none());
693        let lf = ListFunctions {
694            functions: vec![s.clone()],
695            has_more: false,
696            total: Some(1),
697        };
698        assert_eq!(
699            serde_json::from_str::<ListFunctions>(&serde_json::to_string(&lf).unwrap())
700                .unwrap()
701                .functions[0],
702            s
703        );
704    }
705
706    #[test]
707    fn symbol_omits_absent_optionals_and_resolve_symbol_roundtrips() {
708        let s = Symbol {
709            name: "api_entry".into(),
710            address: "ram:401000".into(),
711            kind: "function".into(),
712            namespace: None,
713            library: None,
714            size: None,
715        };
716        let v = serde_json::to_value(&s).unwrap();
717        assert!(v.get("namespace").is_none());
718        assert!(v.get("library").is_none());
719        assert!(v.get("size").is_none());
720        let rs = ResolveSymbol {
721            candidates: vec![s.clone()],
722        };
723        let back: ResolveSymbol =
724            serde_json::from_str(&serde_json::to_string(&rs).unwrap()).unwrap();
725        assert_eq!(back, rs);
726    }
727
728    #[test]
729    fn list_symbols_roundtrips() {
730        let s = Symbol {
731            name: "printf".into(),
732            address: "EXTERNAL:1".into(),
733            kind: "label".into(),
734            namespace: Some("ns".into()),
735            library: Some("msvcrt.dll".into()),
736            size: Some(4),
737        };
738        let ls = ListSymbols {
739            symbols: vec![s.clone()],
740            has_more: true,
741            total: None,
742        };
743        let v = serde_json::to_value(&ls).unwrap();
744        assert!(v.get("total").is_none());
745        let back: ListSymbols = serde_json::from_str(&serde_json::to_string(&ls).unwrap()).unwrap();
746        assert_eq!(back, ls);
747        assert_eq!(back.symbols[0], s);
748    }
749
750    #[test]
751    fn list_strings_roundtrips() {
752        let item = StringItem {
753            address: "ram:402000".into(),
754            value: "https://example.test".into(),
755            length: 21,
756            encoding: "string".into(),
757        };
758        let ls = ListStrings {
759            strings: vec![item.clone()],
760            has_more: false,
761            total: Some(1),
762        };
763        let back: ListStrings = serde_json::from_str(&serde_json::to_string(&ls).unwrap()).unwrap();
764        assert_eq!(back, ls);
765        assert_eq!(back.strings[0], item);
766    }
767
768    #[test]
769    fn xrefs_omits_absent_from_function_and_roundtrips() {
770        let r = Reference {
771            from_address: "ram:401040".into(),
772            to_address: "ram:401000".into(),
773            ref_type: "CALL".into(),
774            from_function: Some("compute".into()),
775        };
776        let x = Xrefs {
777            xrefs: vec![r.clone()],
778            has_more: false,
779            total: None,
780        };
781        let v = serde_json::to_value(&x).unwrap();
782        assert!(v.get("total").is_none());
783        let back: Xrefs = serde_json::from_str(&serde_json::to_string(&x).unwrap()).unwrap();
784        assert_eq!(back, x);
785        assert_eq!(back.xrefs[0], r);
786
787        let r2 = Reference {
788            from_address: "ram:500000".into(),
789            to_address: "ram:401000".into(),
790            ref_type: "DATA".into(),
791            from_function: None,
792        };
793        let v2 = serde_json::to_value(&r2).unwrap();
794        assert!(v2.get("from_function").is_none());
795    }
796
797    #[test]
798    fn disassembly_omits_absent_comments_and_roundtrips_map() {
799        let i = Instruction {
800            address: "ram:401000".into(),
801            bytes: "8b442404".into(),
802            mnemonic: "MOV".into(),
803            operands: "EAX, dword ptr [ESP + 0x4]".into(),
804            comments: None,
805        };
806        let v = serde_json::to_value(&i).unwrap();
807        assert!(v.get("comments").is_none(), "absent comments omitted");
808        let mut m = std::collections::BTreeMap::new();
809        m.insert("eol".to_string(), "note".to_string());
810        let i2 = Instruction {
811            comments: Some(m),
812            ..i.clone()
813        };
814        let v2 = serde_json::to_value(&i2).unwrap();
815        assert_eq!(v2["comments"]["eol"], "note");
816        let d = Disassembly {
817            instructions: vec![i2.clone()],
818            has_more: false,
819            total: Some(1),
820        };
821        let back: Disassembly = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
822        assert_eq!(back.instructions[0], i2);
823    }
824
825    #[test]
826    fn read_bytes_roundtrips() {
827        let rb = ReadBytes {
828            address: "ram:401000".into(),
829            length: 4,
830            bytes: "8b442404".into(),
831            truncated: false,
832        };
833        let back: ReadBytes = serde_json::from_str(&serde_json::to_string(&rb).unwrap()).unwrap();
834        assert_eq!(back, rb);
835    }
836
837    #[test]
838    fn data_type_info_struct_roundtrips_and_serializes_type_key() {
839        let m = DataTypeMember {
840            name: "left".into(),
841            type_: "int".into(),
842            offset: 0,
843            size: 4,
844        };
845        let v = serde_json::to_value(&m).unwrap();
846        // The `type_` field MUST serialize under the wire key `type` (rename, not a shape change).
847        assert_eq!(v["type"], "int");
848        assert!(v.get("type_").is_none());
849        let dt = DataTypeInfo {
850            name: "Rect".into(),
851            kind: "struct".into(),
852            size: 16,
853            members: Some(vec![m.clone()]),
854            values: None,
855        };
856        let back: DataTypeInfo =
857            serde_json::from_str(&serde_json::to_string(&dt).unwrap()).unwrap();
858        assert_eq!(back, dt);
859        assert_eq!(back.members.unwrap()[0], m);
860    }
861
862    #[test]
863    fn data_type_info_enum_roundtrips_with_string_values() {
864        let ev = EnumValue {
865            name: "RED".into(),
866            value: "0".into(),
867        };
868        let v = serde_json::to_value(&ev).unwrap();
869        // Enum value is a STRING on the wire (64-bit safety) — never a bare JSON number.
870        assert!(v["value"].is_string());
871        let dt = DataTypeInfo {
872            name: "Color".into(),
873            kind: "enum".into(),
874            size: 4,
875            members: None,
876            values: Some(vec![ev.clone()]),
877        };
878        let v2 = serde_json::to_value(&dt).unwrap();
879        assert!(v2.get("members").is_none());
880        let back: DataTypeInfo =
881            serde_json::from_str(&serde_json::to_string(&dt).unwrap()).unwrap();
882        assert_eq!(back, dt);
883        assert_eq!(back.values.unwrap()[0], ev);
884    }
885
886    #[test]
887    fn list_segments_roundtrips() {
888        let s = Segment {
889            name: ".text".into(),
890            start: "ram:401000".into(),
891            end: "ram:402000".into(),
892            size: 0x1000,
893            readable: true,
894            writable: false,
895            executable: true,
896            initialized: true,
897        };
898        let ls = ListSegments {
899            segments: vec![s.clone()],
900        };
901        let back: ListSegments =
902            serde_json::from_str(&serde_json::to_string(&ls).unwrap()).unwrap();
903        assert_eq!(back, ls);
904        assert_eq!(back.segments[0], s);
905    }
906
907    #[test]
908    fn list_data_items_omits_absent_optionals_and_roundtrips() {
909        let item = DataItem {
910            address: "ram:403000".into(),
911            name: Some("g_rect".into()),
912            data_type: "Rect".into(),
913            value: None,
914            size: 16,
915        };
916        let v = serde_json::to_value(&item).unwrap();
917        assert!(v.get("value").is_none());
918        let ld = ListDataItems {
919            data: vec![item.clone()],
920            has_more: false,
921            total: None,
922        };
923        let v2 = serde_json::to_value(&ld).unwrap();
924        assert!(v2.get("total").is_none());
925        let back: ListDataItems =
926            serde_json::from_str(&serde_json::to_string(&ld).unwrap()).unwrap();
927        assert_eq!(back, ld);
928        assert_eq!(back.data[0], item);
929    }
930
931    #[test]
932    fn address_info_mapped_roundtrips_with_all_optionals_present() {
933        let info = AddressInfo {
934            address: "ram:401000".into(),
935            mapped: true,
936            segment: Some(".text".into()),
937            is_code: true,
938            containing_function: Some(ContainingFunction {
939                name: "add_two".into(),
940                entry_point: "ram:401000".into(),
941            }),
942            symbol: Some(AddressSymbol {
943                name: "add_two".into(),
944                kind: "function".into(),
945            }),
946            data_type: None,
947            comments: None,
948        };
949        let v = serde_json::to_value(&info).unwrap();
950        // data_type is None -> omitted; the rest are present.
951        assert!(v.get("data_type").is_none());
952        assert_eq!(v["containing_function"]["name"], "add_two");
953        assert_eq!(v["symbol"]["kind"], "function");
954        let back: AddressInfo =
955            serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
956        assert_eq!(back, info);
957    }
958
959    #[test]
960    fn rename_result_roundtrips_and_omits_previous_on_already_applied() {
961        let renamed = RenameResult {
962            status: "renamed".into(),
963            address: "ram:00401000".into(),
964            name: "decrypt".into(),
965            previous_name: Some("FUN_00401000".into()),
966            durable: true,
967        };
968        let s = serde_json::to_string(&renamed).unwrap();
969        assert_eq!(serde_json::from_str::<RenameResult>(&s).unwrap(), renamed);
970        let noop = RenameResult {
971            status: "already_applied".into(),
972            address: "ram:00401000".into(),
973            name: "decrypt".into(),
974            previous_name: None,
975            durable: true,
976        };
977        let v = serde_json::to_value(&noop).unwrap();
978        assert!(
979            v.get("previous_name").is_none(),
980            "previous_name omitted when None"
981        );
982    }
983
984    #[test]
985    fn address_info_unmapped_omits_all_optionals_and_roundtrips() {
986        let info = AddressInfo {
987            address: "0xffffffff0".into(),
988            mapped: false,
989            segment: None,
990            is_code: false,
991            containing_function: None,
992            symbol: None,
993            data_type: None,
994            comments: None,
995        };
996        let v = serde_json::to_value(&info).unwrap();
997        assert!(v.get("segment").is_none());
998        assert!(v.get("containing_function").is_none());
999        assert!(v.get("symbol").is_none());
1000        assert!(v.get("data_type").is_none());
1001        assert_eq!(v["mapped"], false);
1002        let back: AddressInfo =
1003            serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
1004        assert_eq!(back, info);
1005    }
1006
1007    #[test]
1008    fn comment_result_roundtrips_and_omits_previous_when_none() {
1009        let set = CommentResult {
1010            status: "set".into(),
1011            address: "ram:00401000".into(),
1012            comment_type: "plate".into(),
1013            previous_comment: Some("old".into()),
1014            durable: true,
1015        };
1016        let s = serde_json::to_string(&set).unwrap();
1017        assert_eq!(serde_json::from_str::<CommentResult>(&s).unwrap(), set);
1018        let fresh = CommentResult {
1019            status: "set".into(),
1020            address: "ram:00401000".into(),
1021            comment_type: "eol".into(),
1022            previous_comment: None,
1023            durable: true,
1024        };
1025        let v = serde_json::to_value(&fresh).unwrap();
1026        assert!(v.get("previous_comment").is_none(), "omitted when None");
1027        let missing = serde_json::json!({"status":"set","address":"ram:1","comment_type":"eol","durable":true});
1028        let d: CommentResult = serde_json::from_value(missing).unwrap();
1029        assert_eq!(d.previous_comment, None);
1030    }
1031
1032    #[test]
1033    fn set_datatype_result_roundtrips_and_omits_previous_when_none() {
1034        let set = SetDatatypeResult {
1035            status: "set".into(),
1036            address: "ram:00402000".into(),
1037            datatype: "int".into(),
1038            previous_datatype: Some("undefined4".into()),
1039            length: 4,
1040            durable: true,
1041        };
1042        let s = serde_json::to_string(&set).unwrap();
1043        assert_eq!(serde_json::from_str::<SetDatatypeResult>(&s).unwrap(), set);
1044        let cleared = SetDatatypeResult {
1045            status: "cleared".into(),
1046            address: "ram:00402000".into(),
1047            datatype: "undefined".into(),
1048            previous_datatype: None,
1049            length: 0,
1050            durable: true,
1051        };
1052        let v = serde_json::to_value(&cleared).unwrap();
1053        assert!(v.get("previous_datatype").is_none(), "omitted when None");
1054        let missing = serde_json::json!({"status":"set","address":"ram:2","datatype":"int","length":4,"durable":true});
1055        let d: SetDatatypeResult = serde_json::from_value(missing).unwrap();
1056        assert_eq!(d.previous_datatype, None);
1057    }
1058
1059    #[test]
1060    fn set_prototype_result_roundtrips_and_omits_previous_when_none() {
1061        let set = SetPrototypeResult {
1062            status: "set".into(),
1063            address: "ram:00401000".into(),
1064            name: "compute".into(),
1065            signature: "int __cdecl compute(Rect * param_1)".into(),
1066            previous_signature: Some("undefined4 __cdecl compute(void)".into()),
1067            durable: true,
1068        };
1069        let s = serde_json::to_string(&set).unwrap();
1070        assert_eq!(serde_json::from_str::<SetPrototypeResult>(&s).unwrap(), set);
1071        let noop = SetPrototypeResult {
1072            status: "already_applied".into(),
1073            address: "ram:00401000".into(),
1074            name: "compute".into(),
1075            signature: "int __cdecl compute(Rect * param_1)".into(),
1076            previous_signature: None,
1077            durable: true,
1078        };
1079        let v = serde_json::to_value(&noop).unwrap();
1080        assert!(v.get("previous_signature").is_none(), "omitted when None");
1081        let missing = serde_json::json!({"status":"set","address":"ram:1","name":"f","signature":"s","durable":true});
1082        let d: SetPrototypeResult = serde_json::from_value(missing).unwrap();
1083        assert_eq!(d.previous_signature, None);
1084    }
1085
1086    #[test]
1087    fn set_local_result_omits_absent_previous_fields() {
1088        let r = SetLocalResult {
1089            status: "retyped".into(),
1090            function: "compute".into(),
1091            storage: "stack:-0x18".into(),
1092            name: "packet_len".into(),
1093            type_: "size_t".into(),
1094            previous_name: None,
1095            previous_type: Some("undefined8".into()),
1096            durable: true,
1097        };
1098        let j = serde_json::to_value(&r).unwrap();
1099        assert_eq!(j["type"], "size_t"); // wire key `type`
1100        assert_eq!(j["previous_type"], "undefined8");
1101        assert!(j.get("previous_name").is_none()); // omitted when None
1102        let back: SetLocalResult =
1103            serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
1104        assert_eq!(back, r);
1105    }
1106}