1use ghidra_mcp::config::RawConfig;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Host {
26 Claude,
27 Opencode,
28}
29
30impl Host {
31 pub fn settings_rel_path(self) -> &'static str {
33 match self {
34 Host::Claude => ".claude/re-ghidra-mcp-cc.local.md",
35 Host::Opencode => ".opencode/re-ghidra-mcp-opencode.local.md",
36 }
37 }
38}
39
40pub fn host_from_str(value: Option<&str>) -> Host {
46 match value {
47 Some("opencode") => Host::Opencode,
48 _ => Host::Claude,
49 }
50}
51
52pub fn host_from_env() -> Host {
54 host_from_str(std::env::var("RE_GHIDRA_HOST").ok().as_deref())
55}
56
57pub fn settings_from_markdown(source: &str) -> RawConfig {
65 let mut config = RawConfig::default();
66 let Some(frontmatter) = extract_frontmatter(source) else {
67 return config;
68 };
69
70 for line in frontmatter.lines() {
71 let line = line.trim();
72 if line.is_empty() || line.starts_with('#') {
73 continue;
74 }
75 let Some((key, value)) = line.split_once(':') else {
76 continue;
77 };
78 let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
79 if value.is_empty() {
80 continue;
81 }
82 let value = Some(value.to_string());
83 match key.trim() {
84 "ghidra_install_dir" => config.ghidra_install_dir = value,
85 "project_dir" => config.project_dir = value,
86 "project_name" => config.project_name = value,
87 "bootstrap_program" => config.bootstrap_program = value,
88 "bootstrap_program_path" => config.bootstrap_program_path = value,
89 "max_heap" => config.max_heap = value,
90 _ => {}
93 }
94 }
95 config
96}
97
98pub fn settings_path(project_dir: &Path, host: Host) -> PathBuf {
100 project_dir.join(host.settings_rel_path())
101}
102
103pub fn load_settings(project_dir: &Path, host: Host) -> RawConfig {
110 match std::fs::read_to_string(settings_path(project_dir, host)) {
111 Ok(text) => settings_from_markdown(&text),
112 Err(_) => RawConfig::default(),
113 }
114}
115
116fn extract_frontmatter(source: &str) -> Option<&str> {
122 let source = source.strip_prefix('\u{feff}').unwrap_or(source);
123 let rest = source.strip_prefix("---")?;
124 let rest = rest.strip_prefix('\r').unwrap_or(rest);
125 let rest = rest.strip_prefix('\n')?;
126 let end = rest.find("\n---")?;
127 Some(&rest[..end])
128}
129
130#[derive(Debug, Clone, Default)]
138pub struct Probe {
139 pub launcher_present: bool,
141 pub java_major: Option<u32>,
143}
144
145pub const MIN_JAVA_MAJOR: u32 = 21;
148
149pub fn preflight(cfg: &RawConfig, probe: &Probe, host: Host) -> Vec<String> {
155 let mut findings = Vec::new();
156
157 match cfg.ghidra_install_dir.as_deref() {
158 None => findings.push(
159 "GHIDRA_INSTALL_DIR is not set. Point it at your Ghidra install root \
160 (the directory containing `support/`)."
161 .to_string(),
162 ),
163 Some(dir) if !probe.launcher_present => findings.push(format!(
164 "GHIDRA_INSTALL_DIR is set to `{dir}` but there is no \
165 `support/analyzeHeadless` there, so it is not a Ghidra install root."
166 )),
167 Some(_) => {}
168 }
169
170 match probe.java_major {
171 None => findings.push(
172 "No `java` on PATH. Ghidra 12.1.2 requires JDK 21 \
173 (`application.java.min=21`)."
174 .to_string(),
175 ),
176 Some(v) if v < MIN_JAVA_MAJOR => findings.push(format!(
177 "`java` on PATH reports version {v}; Ghidra 12.1.2 requires \
178 JDK {MIN_JAVA_MAJOR} or newer."
179 )),
180 Some(_) => {}
181 }
182
183 let missing: Vec<&str> = [
186 ("project_dir", cfg.project_dir.is_none()),
187 ("project_name", cfg.project_name.is_none()),
188 ("bootstrap_program", cfg.bootstrap_program.is_none()),
189 ]
190 .into_iter()
191 .filter_map(|(name, absent)| absent.then_some(name))
192 .collect();
193 if !missing.is_empty() {
194 findings.push(format!(
195 "Unset: {}. Set them in `{}` (see the plugin's examples/ directory) or as \
196 GHIDRA_MCP_PROJECT_DIR / GHIDRA_MCP_PROJECT_NAME / \
197 GHIDRA_MCP_BOOTSTRAP_PROGRAM.",
198 missing.join(", "),
199 host.settings_rel_path()
200 ));
201 }
202
203 findings
204}
205
206pub fn parse_java_major(version_output: &str) -> Option<u32> {
213 let quoted = version_output.split('"').nth(1)?;
214 let mut parts = quoted.split(['.', '_', '-']);
215 let first: u32 = parts.next()?.parse().ok()?;
216 if first == 1 {
217 parts.next()?.parse().ok()
218 } else {
219 Some(first)
220 }
221}
222
223#[cfg(test)]
224mod preflight_tests {
225 use super::*;
226
227 fn complete() -> RawConfig {
228 RawConfig {
229 ghidra_install_dir: Some("C:/ghidra".into()),
230 project_dir: Some("C:/re".into()),
231 project_name: Some("p".into()),
232 bootstrap_program: Some("add.exe".into()),
233 ..Default::default()
234 }
235 }
236
237 fn good_probe() -> Probe {
238 Probe {
239 launcher_present: true,
240 java_major: Some(21),
241 }
242 }
243
244 #[test]
247 fn fully_configured_reports_nothing() {
248 assert!(preflight(&complete(), &good_probe(), Host::Claude).is_empty());
249 }
250
251 #[test]
252 fn install_dir_set_but_not_a_ghidra_root_is_reported() {
253 let probe = Probe {
254 launcher_present: false,
255 ..good_probe()
256 };
257 let f = preflight(&complete(), &probe, Host::Claude);
258 assert_eq!(f.len(), 1);
259 assert!(f[0].contains("analyzeHeadless"), "{}", f[0]);
260 }
261
262 #[test]
263 fn guidance_names_the_active_hosts_file() {
264 let cfg = RawConfig {
265 project_dir: None,
266 project_name: None,
267 bootstrap_program: None,
268 ..complete()
269 };
270 let f = preflight(&cfg, &good_probe(), Host::Opencode);
271 assert_eq!(f.len(), 1);
272 assert!(
273 f[0].contains(".opencode/re-ghidra-mcp-opencode.local.md"),
274 "{}",
275 f[0]
276 );
277 let f = preflight(&cfg, &good_probe(), Host::Claude);
278 assert_eq!(f.len(), 1);
279 assert!(
280 f[0].contains(".claude/re-ghidra-mcp-cc.local.md"),
281 "{}",
282 f[0]
283 );
284 }
285
286 #[test]
287 fn old_java_is_reported_with_its_version() {
288 let probe = Probe {
289 java_major: Some(17),
290 ..good_probe()
291 };
292 let f = preflight(&complete(), &probe, Host::Claude);
293 assert_eq!(f.len(), 1);
294 assert!(f[0].contains("17") && f[0].contains("21"), "{}", f[0]);
295 }
296
297 #[test]
298 fn absent_java_is_reported() {
299 let probe = Probe {
300 java_major: None,
301 ..good_probe()
302 };
303 assert!(preflight(&complete(), &probe, Host::Claude)[0].contains("No `java` on PATH"));
304 }
305
306 #[test]
309 fn missing_project_values_are_one_finding_naming_each() {
310 let cfg = RawConfig {
311 project_name: None,
312 bootstrap_program: None,
313 ..complete()
314 };
315 let f = preflight(&cfg, &good_probe(), Host::Claude);
316 assert_eq!(f.len(), 1);
317 assert!(f[0].contains("project_name"), "{}", f[0]);
318 assert!(f[0].contains("bootstrap_program"), "{}", f[0]);
319 assert!(!f[0].contains("project_dir,"), "{}", f[0]);
320 }
321
322 #[test]
323 fn parses_modern_and_legacy_java_version_strings() {
324 assert_eq!(
325 parse_java_major("openjdk version \"21.0.2\" 2024-01-16"),
326 Some(21)
327 );
328 assert_eq!(
329 parse_java_major("java version \"1.8.0_412\"\nJava(TM) SE Runtime"),
330 Some(8)
331 );
332 assert_eq!(
333 parse_java_major("openjdk version \"25\" 2025-09-16"),
334 Some(25)
335 );
336 }
337
338 #[test]
341 fn unrecognized_version_output_is_none() {
342 assert_eq!(parse_java_major("command not found"), None);
343 assert_eq!(parse_java_major("openjdk version \"weird\""), None);
344 assert_eq!(parse_java_major(""), None);
345 }
346}
347
348#[cfg(test)]
349mod settings_tests {
350 use super::*;
351
352 const SAMPLE: &str = "---\nproject_dir: C:\\re\\projects\nproject_name: crackme\nbootstrap_program: add.exe\n---\n\n# notes\n";
353
354 #[test]
355 fn reads_the_frontmatter_keys() {
356 let c = settings_from_markdown(SAMPLE);
357 assert_eq!(c.project_dir.as_deref(), Some("C:\\re\\projects"));
358 assert_eq!(c.project_name.as_deref(), Some("crackme"));
359 assert_eq!(c.bootstrap_program.as_deref(), Some("add.exe"));
360 assert_eq!(c.max_heap, None);
361 }
362
363 #[test]
364 fn crlf_and_bom_still_parse() {
365 let crlf = format!("\u{feff}{}", SAMPLE.replace('\n', "\r\n"));
366 let c = settings_from_markdown(&crlf);
367 assert_eq!(c.project_name.as_deref(), Some("crackme"));
368 }
369
370 #[test]
373 fn no_frontmatter_yields_nothing() {
374 assert!(settings_from_markdown("# just a heading\n")
375 .project_name
376 .is_none());
377 assert!(settings_from_markdown("---\nproject_name: x\n")
378 .project_name
379 .is_none());
380 }
381
382 #[test]
385 fn blank_value_is_not_a_value() {
386 assert!(settings_from_markdown("---\nproject_name: \n---\n")
387 .project_name
388 .is_none());
389 }
390
391 #[test]
392 fn unknown_keys_are_ignored() {
393 let c = settings_from_markdown("---\nfuture_key: 1\nproject_name: p\n---\n");
394 assert_eq!(c.project_name.as_deref(), Some("p"));
395 }
396
397 #[test]
398 fn settings_path_is_per_host() {
399 let dir = Path::new("/proj");
400 assert!(settings_path(dir, Host::Claude).ends_with(".claude/re-ghidra-mcp-cc.local.md"));
401 assert!(settings_path(dir, Host::Opencode)
402 .ends_with(".opencode/re-ghidra-mcp-opencode.local.md"));
403 }
404
405 #[test]
406 fn host_from_str_defaults_to_claude() {
407 assert_eq!(host_from_str(None), Host::Claude);
408 assert_eq!(host_from_str(Some("")), Host::Claude);
409 assert_eq!(host_from_str(Some("claude")), Host::Claude);
410 assert_eq!(host_from_str(Some("OPENCODE")), Host::Claude); assert_eq!(host_from_str(Some("opencode")), Host::Opencode);
412 }
413}