1use ghidra_mcp::config::RawConfig;
15use std::path::{Path, PathBuf};
16
17pub fn settings_from_markdown(source: &str) -> RawConfig {
21 let mut config = RawConfig::default();
22 let Some(frontmatter) = extract_frontmatter(source) else {
23 return config;
24 };
25
26 for line in frontmatter.lines() {
27 let line = line.trim();
28 if line.is_empty() || line.starts_with('#') {
29 continue;
30 }
31 let Some((key, value)) = line.split_once(':') else {
32 continue;
33 };
34 let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
35 if value.is_empty() {
36 continue;
37 }
38 let value = Some(value.to_string());
39 match key.trim() {
40 "ghidra_install_dir" => config.ghidra_install_dir = value,
41 "project_dir" => config.project_dir = value,
42 "project_name" => config.project_name = value,
43 "bootstrap_program" => config.bootstrap_program = value,
44 "bootstrap_program_path" => config.bootstrap_program_path = value,
45 "max_heap" => config.max_heap = value,
46 _ => {}
47 }
48 }
49 config
50}
51
52pub fn settings_path(project_dir: &Path) -> PathBuf {
54 project_dir
55 .join(".agents")
56 .join("re-ghidra-mcp-agy.local.md")
57}
58
59pub fn load_settings(project_dir: &Path) -> RawConfig {
63 match std::fs::read_to_string(settings_path(project_dir)) {
64 Ok(text) => settings_from_markdown(&text),
65 Err(_) => RawConfig::default(),
66 }
67}
68
69fn extract_frontmatter(source: &str) -> Option<&str> {
71 let source = source.strip_prefix('\u{feff}').unwrap_or(source);
72 let rest = source.strip_prefix("---")?;
73 let rest = rest.strip_prefix('\r').unwrap_or(rest);
74 let rest = rest.strip_prefix('\n')?;
75 let end = rest.find("\n---")?;
76 Some(&rest[..end])
77}
78
79#[derive(Debug, Clone, Default)]
82pub struct Probe {
83 pub launcher_present: bool,
84 pub java_major: Option<u32>,
85}
86
87pub const MIN_JAVA_MAJOR: u32 = 21;
88
89pub fn preflight(cfg: &RawConfig, probe: &Probe) -> Vec<String> {
90 let mut findings = Vec::new();
91
92 match cfg.ghidra_install_dir.as_deref() {
93 None => findings.push(
94 "GHIDRA_INSTALL_DIR is not set. Point it at your Ghidra install root \
95 (the directory containing `support/`)."
96 .to_string(),
97 ),
98 Some(dir) if !probe.launcher_present => findings.push(format!(
99 "GHIDRA_INSTALL_DIR is set to `{dir}` but there is no \
100 `support/analyzeHeadless` there, so it is not a Ghidra install root."
101 )),
102 Some(_) => {}
103 }
104
105 match probe.java_major {
106 None => findings.push(
107 "No `java` on PATH. Ghidra 12.1.2 requires JDK 21 \
108 (`application.java.min=21`)."
109 .to_string(),
110 ),
111 Some(v) if v < MIN_JAVA_MAJOR => findings.push(format!(
112 "`java` on PATH reports version {v}; Ghidra 12.1.2 requires \
113 JDK {MIN_JAVA_MAJOR} or newer."
114 )),
115 Some(_) => {}
116 }
117
118 let missing: Vec<&str> = [
119 ("project_dir", cfg.project_dir.is_none()),
120 ("project_name", cfg.project_name.is_none()),
121 ("bootstrap_program", cfg.bootstrap_program.is_none()),
122 ]
123 .into_iter()
124 .filter_map(|(name, absent)| absent.then_some(name))
125 .collect();
126
127 if !missing.is_empty() {
128 findings.push(format!(
129 "Unset: {}. Set them in `.agents/re-ghidra-mcp-agy.local.md` \
130 or as GHIDRA_MCP_PROJECT_DIR / GHIDRA_MCP_PROJECT_NAME / \
131 GHIDRA_MCP_BOOTSTRAP_PROGRAM.",
132 missing.join(", ")
133 ));
134 }
135
136 findings
137}
138
139pub fn parse_java_major(version_output: &str) -> Option<u32> {
140 let quoted = version_output.split('"').nth(1)?;
141 let mut parts = quoted.split(['.', '_', '-']);
142 let first: u32 = parts.next()?.parse().ok()?;
143 if first == 1 {
144 parts.next()?.parse().ok()
145 } else {
146 Some(first)
147 }
148}
149
150#[cfg(test)]
151mod preflight_tests {
152 use super::*;
153
154 fn complete() -> RawConfig {
155 RawConfig {
156 ghidra_install_dir: Some("C:/ghidra".into()),
157 project_dir: Some("C:/re".into()),
158 project_name: Some("p".into()),
159 bootstrap_program: Some("add.exe".into()),
160 ..Default::default()
161 }
162 }
163
164 fn good_probe() -> Probe {
165 Probe {
166 launcher_present: true,
167 java_major: Some(21),
168 }
169 }
170
171 #[test]
174 fn fully_configured_reports_nothing() {
175 assert!(preflight(&complete(), &good_probe()).is_empty());
176 }
177
178 #[test]
179 fn install_dir_set_but_not_a_ghidra_root_is_reported() {
180 let probe = Probe {
181 launcher_present: false,
182 ..good_probe()
183 };
184 let f = preflight(&complete(), &probe);
185 assert_eq!(f.len(), 1);
186 assert!(f[0].contains("analyzeHeadless"), "{}", f[0]);
187 }
188
189 #[test]
190 fn old_java_is_reported_with_its_version() {
191 let probe = Probe {
192 java_major: Some(17),
193 ..good_probe()
194 };
195 let f = preflight(&complete(), &probe);
196 assert_eq!(f.len(), 1);
197 assert!(f[0].contains("17") && f[0].contains("21"), "{}", f[0]);
198 }
199
200 #[test]
201 fn absent_java_is_reported() {
202 let probe = Probe {
203 java_major: None,
204 ..good_probe()
205 };
206 assert!(preflight(&complete(), &probe)[0].contains("No `java` on PATH"));
207 }
208
209 #[test]
212 fn missing_project_values_are_one_finding_naming_each() {
213 let cfg = RawConfig {
214 project_name: None,
215 bootstrap_program: None,
216 ..complete()
217 };
218 let f = preflight(&cfg, &good_probe());
219 assert_eq!(f.len(), 1);
220 assert!(f[0].contains("project_name"), "{}", f[0]);
221 assert!(f[0].contains("bootstrap_program"), "{}", f[0]);
222 assert!(!f[0].contains("project_dir,"), "{}", f[0]);
223 }
224
225 #[test]
228 fn missing_project_values_name_the_agy_settings_file() {
229 let cfg = RawConfig {
230 project_dir: None,
231 ..complete()
232 };
233 let f = preflight(&cfg, &good_probe());
234 assert_eq!(f.len(), 1);
235 assert!(
236 f[0].contains(".agents/re-ghidra-mcp-agy.local.md"),
237 "{}",
238 f[0]
239 );
240 }
241
242 #[test]
243 fn parses_modern_and_legacy_java_version_strings() {
244 assert_eq!(
245 parse_java_major("openjdk version \"21.0.2\" 2024-01-16"),
246 Some(21)
247 );
248 assert_eq!(
249 parse_java_major("java version \"1.8.0_412\"\nJava(TM) SE Runtime"),
250 Some(8)
251 );
252 assert_eq!(
253 parse_java_major("openjdk version \"25\" 2025-09-16"),
254 Some(25)
255 );
256 }
257
258 #[test]
261 fn unrecognized_version_output_is_none() {
262 assert_eq!(parse_java_major("command not found"), None);
263 assert_eq!(parse_java_major("openjdk version \"weird\""), None);
264 assert_eq!(parse_java_major(""), None);
265 }
266}
267
268#[cfg(test)]
269mod settings_tests {
270 use super::*;
271
272 const SAMPLE: &str = "---\nproject_dir: C:\\re\\projects\nproject_name: crackme\nbootstrap_program: add.exe\n---\n\n# notes\n";
273
274 #[test]
275 fn reads_the_frontmatter_keys() {
276 let c = settings_from_markdown(SAMPLE);
277 assert_eq!(c.project_dir.as_deref(), Some("C:\\re\\projects"));
278 assert_eq!(c.project_name.as_deref(), Some("crackme"));
279 assert_eq!(c.bootstrap_program.as_deref(), Some("add.exe"));
280 assert_eq!(c.max_heap, None);
281 }
282
283 #[test]
284 fn crlf_and_bom_still_parse() {
285 let crlf = format!("\u{feff}{}", SAMPLE.replace('\n', "\r\n"));
286 let c = settings_from_markdown(&crlf);
287 assert_eq!(c.project_name.as_deref(), Some("crackme"));
288 }
289
290 #[test]
293 fn no_frontmatter_yields_nothing() {
294 assert!(settings_from_markdown("# just a heading\n")
295 .project_name
296 .is_none());
297 assert!(settings_from_markdown("---\nproject_name: x\n")
298 .project_name
299 .is_none());
300 }
301
302 #[test]
305 fn blank_value_is_not_a_value() {
306 assert!(settings_from_markdown("---\nproject_name: \n---\n")
307 .project_name
308 .is_none());
309 }
310
311 #[test]
312 fn unknown_keys_are_ignored() {
313 let c = settings_from_markdown("---\nfuture_key: 1\nproject_name: p\n---\n");
314 assert_eq!(c.project_name.as_deref(), Some("p"));
315 }
316
317 #[test]
319 fn settings_path_is_the_agy_file() {
320 let p = settings_path(Path::new("C:/work/proj"));
321 assert_eq!(
322 p,
323 Path::new("C:/work/proj/.agents/re-ghidra-mcp-agy.local.md")
324 );
325 }
326}