1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
11pub enum ErrorCode {
12 ProgramNotOpen,
13 ProgramNotFound,
14 ProjectNotFound,
15 ProjectLocked,
16 AddressNotFound,
17 NotAFunction,
18 AmbiguousSymbol,
19 SymbolNotFound,
20 VariableNotFound,
21 DatatypeNotFound,
22 AmbiguousDatatype,
23 RenameConflict,
24 StateConflict,
25 InvalidParams,
26 WorkerWarming,
27 WorkerUnavailable,
28 WorkerRestarted,
29 WorkerIncompatible,
30 TransactionFailed,
31 DecompileTimeout,
32 GhidraNotFound,
33 JdkNotFound,
34 DeprecatedTool,
35 FrameTooLarge,
36 DecompilationFailed,
37 ServerBusy,
38 ParseError,
39}
40
41impl ErrorCode {
42 pub fn suggested_action(self) -> &'static str {
45 match self {
46 ErrorCode::ProgramNotOpen => "attach the program first with attach_program, then retry",
47 ErrorCode::ProgramNotFound => "the program VFS path was not found in the project; call list_project_programs to list valid paths",
48 ErrorCode::ProjectNotFound => "check project_path points at an existing Ghidra project (.gpr / project dir)",
49 ErrorCode::ProjectLocked => "close this project in the Ghidra GUI, then retry",
50 ErrorCode::AddressNotFound => "the address is not mapped in this program; verify it with a read tool",
51 ErrorCode::NotAFunction => "no function is defined at this address; use describe_address to identify it or find_functions to search",
52 ErrorCode::AmbiguousSymbol => "the name resolves to multiple symbols; call resolve_symbol and pass an address",
53 ErrorCode::SymbolNotFound => "no symbol matches that name; call resolve_symbol or list_symbols",
54 ErrorCode::VariableNotFound => "no local variable matches that storage or name in this function; call inspect_function to re-read its locals[], then retry with a listed storage handle",
55 ErrorCode::DatatypeNotFound => "no data type matches that name; check the exact type name",
56 ErrorCode::AmbiguousDatatype => "several data types share that name; re-call get_datatype with the category-path-qualified name shown in details.candidates",
57 ErrorCode::RenameConflict => "the symbol's current name differs from expected_name; re-read with describe_address or resolve_symbol and retry with the actual current name shown in details.actual",
58 ErrorCode::StateConflict => "the target's current value differs from your expected_ guard; re-read (describe_address / inspect_function / get_datatype), then retry with the actual value shown in details.actual, or omit the guard",
59 ErrorCode::InvalidParams => "one or more parameters are invalid; check the tool's parameter schema",
60 ErrorCode::WorkerWarming => "the Ghidra worker is still starting; wait at least 10 s before retrying",
61 ErrorCode::WorkerUnavailable => "the Ghidra worker is temporarily unavailable; retry shortly",
62 ErrorCode::WorkerRestarted => "worker crashed and restarted; unsaved edits since last save were lost — re-verify state",
63 ErrorCode::WorkerIncompatible => "the worker script is incompatible with this host; reinstall the plugin",
64 ErrorCode::TransactionFailed => "the edit was rolled back and the program is unchanged; re-read state and retry",
65 ErrorCode::DecompileTimeout => "decompilation exceeded the time budget; raise the timeout or treat the function as opaque",
66 ErrorCode::GhidraNotFound => "GHIDRA_INSTALL_DIR is unset or invalid; point it at your Ghidra install root",
67 ErrorCode::JdkNotFound => "a JDK 21 runtime for Ghidra was not found; install JDK 21 and retry",
68 ErrorCode::DeprecatedTool => "this tool is deprecated; call the replacement named in the message",
69 ErrorCode::FrameTooLarge => "the response exceeded the 16 MiB frame limit; lower max_c_lines and retry",
70 ErrorCode::DecompilationFailed => "the decompiler produced no output for this function; treat it as opaque or inspect a caller instead",
71 ErrorCode::ServerBusy => "the worker is single-threaded and at capacity; issue your tool calls one at a time and retry",
72 ErrorCode::ParseError => "the worker returned malformed data; the connection was reset — retry the call",
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ErrorPayload {
80 pub code: ErrorCode,
81 pub message: String,
82 pub suggested_action: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub details: Option<serde_json::Value>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ErrorEnvelope {
90 pub error: ErrorPayload,
91}
92
93impl ErrorEnvelope {
94 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
96 Self {
97 error: ErrorPayload {
98 code,
99 message: message.into(),
100 suggested_action: code.suggested_action().to_string(),
101 details: None,
102 },
103 }
104 }
105
106 pub fn with_details(mut self, details: serde_json::Value) -> Self {
107 self.error.details = Some(details);
108 self
109 }
110
111 pub fn with_action(mut self, action: impl Into<String>) -> Self {
115 self.error.suggested_action = action.into();
116 self
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 const ALL: [ErrorCode; 27] = [
126 ErrorCode::ProgramNotOpen,
127 ErrorCode::ProgramNotFound,
128 ErrorCode::ProjectNotFound,
129 ErrorCode::ProjectLocked,
130 ErrorCode::AddressNotFound,
131 ErrorCode::NotAFunction,
132 ErrorCode::AmbiguousSymbol,
133 ErrorCode::SymbolNotFound,
134 ErrorCode::VariableNotFound,
135 ErrorCode::DatatypeNotFound,
136 ErrorCode::AmbiguousDatatype,
137 ErrorCode::RenameConflict,
138 ErrorCode::StateConflict,
139 ErrorCode::InvalidParams,
140 ErrorCode::WorkerWarming,
141 ErrorCode::WorkerUnavailable,
142 ErrorCode::WorkerRestarted,
143 ErrorCode::WorkerIncompatible,
144 ErrorCode::TransactionFailed,
145 ErrorCode::DecompileTimeout,
146 ErrorCode::GhidraNotFound,
147 ErrorCode::JdkNotFound,
148 ErrorCode::DeprecatedTool,
149 ErrorCode::FrameTooLarge,
150 ErrorCode::DecompilationFailed,
151 ErrorCode::ServerBusy,
152 ErrorCode::ParseError,
153 ];
154
155 #[test]
156 fn codes_serialize_screaming_snake_case() {
157 assert_eq!(
158 serde_json::to_string(&ErrorCode::ProgramNotOpen).unwrap(),
159 "\"PROGRAM_NOT_OPEN\""
160 );
161 assert_eq!(
162 serde_json::to_string(&ErrorCode::DecompileTimeout).unwrap(),
163 "\"DECOMPILE_TIMEOUT\""
164 );
165 assert_eq!(
166 serde_json::to_string(&ErrorCode::JdkNotFound).unwrap(),
167 "\"JDK_NOT_FOUND\""
168 );
169 }
170
171 #[test]
172 fn every_code_has_a_nonempty_suggested_action() {
173 for code in ALL {
174 assert!(
175 !code.suggested_action().is_empty(),
176 "empty action for {code:?}"
177 );
178 }
179 }
180
181 #[test]
182 fn spec_fixed_actions_match_verbatim() {
183 assert_eq!(
184 ErrorCode::ProjectLocked.suggested_action(),
185 "close this project in the Ghidra GUI, then retry"
186 );
187 assert_eq!(
188 ErrorCode::WorkerRestarted.suggested_action(),
189 "worker crashed and restarted; unsaved edits since last save were lost — re-verify state"
190 );
191 assert!(ErrorCode::WorkerWarming.suggested_action().contains("wait"));
193 }
194
195 #[test]
196 fn envelope_carries_code_action_and_omits_absent_details() {
197 let env = ErrorEnvelope::new(ErrorCode::ProjectLocked, "project /p is locked");
198 let json = serde_json::to_value(&env).unwrap();
199 assert_eq!(json["error"]["code"], "PROJECT_LOCKED");
200 assert_eq!(json["error"]["message"], "project /p is locked");
201 assert_eq!(
202 json["error"]["suggested_action"],
203 "close this project in the Ghidra GUI, then retry"
204 );
205 assert!(json["error"].get("details").is_none());
206 }
207
208 #[test]
209 fn envelope_with_details_roundtrips() {
210 let env = ErrorEnvelope::new(ErrorCode::AmbiguousSymbol, "main matches 3 symbols")
211 .with_details(serde_json::json!({ "candidates": 3 }));
212 let s = serde_json::to_string(&env).unwrap();
213 let back: ErrorEnvelope = serde_json::from_str(&s).unwrap();
214 assert_eq!(back.error.code, ErrorCode::AmbiguousSymbol);
215 assert_eq!(back.error.details.unwrap()["candidates"], 3);
216 }
217
218 #[test]
219 fn envelope_snapshot() {
220 let env = ErrorEnvelope::new(
221 ErrorCode::DecompileTimeout,
222 "decompile of FUN_00401230 timed out",
223 );
224 insta::assert_json_snapshot!(env);
225 }
226
227 #[test]
228 fn new_m1a_variants_serialize_screaming_snake_case() {
229 assert_eq!(
230 serde_json::to_string(&ErrorCode::FrameTooLarge).unwrap(),
231 "\"FRAME_TOO_LARGE\""
232 );
233 assert_eq!(
234 serde_json::to_string(&ErrorCode::DecompilationFailed).unwrap(),
235 "\"DECOMPILATION_FAILED\""
236 );
237 assert_eq!(
238 serde_json::to_string(&ErrorCode::ServerBusy).unwrap(),
239 "\"SERVER_BUSY\""
240 );
241 assert_eq!(
242 serde_json::to_string(&ErrorCode::ParseError).unwrap(),
243 "\"PARSE_ERROR\""
244 );
245 assert_eq!(
246 serde_json::to_string(&ErrorCode::AmbiguousDatatype).unwrap(),
247 "\"AMBIGUOUS_DATATYPE\""
248 );
249 }
250
251 #[test]
252 fn with_action_overrides_the_pinned_action() {
253 let env = ErrorEnvelope::new(ErrorCode::NotAFunction, "no function 'foo'").with_action(
254 "supply a known address or exact name, or navigate callers/callees from a known entry point",
255 );
256 let json = serde_json::to_value(&env).unwrap();
257 assert_eq!(
258 json["error"]["suggested_action"],
259 "supply a known address or exact name, or navigate callers/callees from a known entry point"
260 );
261 }
262
263 #[test]
264 fn retired_bridge_actions_name_shipped_tools() {
265 assert!(ErrorCode::AmbiguousSymbol
266 .suggested_action()
267 .contains("resolve_symbol"));
268 assert!(ErrorCode::NotAFunction
269 .suggested_action()
270 .contains("find_functions"));
271 }
272
273 #[test]
274 fn rename_conflict_serializes_screaming_snake() {
275 assert_eq!(
276 serde_json::to_string(&ErrorCode::RenameConflict).unwrap(),
277 "\"RENAME_CONFLICT\""
278 );
279 }
280
281 #[test]
282 fn state_conflict_serializes_screaming_snake() {
283 assert_eq!(
284 serde_json::to_string(&ErrorCode::StateConflict).unwrap(),
285 "\"STATE_CONFLICT\""
286 );
287 assert!(!ErrorCode::StateConflict.suggested_action().is_empty());
288 }
289
290 #[test]
291 fn variable_not_found_serializes_screaming_snake() {
292 assert_eq!(
293 serde_json::to_string(&ErrorCode::VariableNotFound).unwrap(),
294 "\"VARIABLE_NOT_FOUND\""
295 );
296 assert!(!ErrorCode::VariableNotFound.suggested_action().is_empty());
297 }
298}