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