1use anyhow::{Context, Result};
2use quick_xml::events::Event;
3use quick_xml::Reader;
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::resolvers::{file_to_package, Resolver};
8use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
9
10pub struct MavenResolver;
15
16impl Resolver for MavenResolver {
17 fn ecosystem(&self) -> Ecosystem {
18 Ecosystem::Maven
19 }
20
21 fn detect(&self, root: &Path) -> bool {
22 let pom = root.join("pom.xml");
23 if !pom.exists() {
24 return false;
25 }
26 std::fs::read_to_string(&pom)
27 .map(|c| c.contains("<modules>"))
28 .unwrap_or(false)
29 }
30
31 fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
32 let root_pom_path = root.join("pom.xml");
33 let root_content =
34 std::fs::read_to_string(&root_pom_path).context("Failed to read root pom.xml")?;
35
36 let root_info = parse_pom(&root_content)?;
37
38 tracing::debug!(
39 "Maven: root groupId={:?}, artifactId={:?}, {} modules",
40 root_info.group_id,
41 root_info.artifact_id,
42 root_info.modules.len()
43 );
44
45 let root_group_id = root_info.group_id.clone().unwrap_or_default();
46
47 let mut packages = HashMap::new();
48 let mut coord_to_id: HashMap<String, PackageId> = HashMap::new();
49
50 for module_name in &root_info.modules {
51 let module_dir = root.join(module_name);
52 let module_pom_path = module_dir.join("pom.xml");
53 if !module_pom_path.exists() {
54 tracing::debug!("Maven: module '{}' has no pom.xml, skipping", module_name);
55 continue;
56 }
57
58 let content = std::fs::read_to_string(&module_pom_path)
59 .with_context(|| format!("Failed to read {}", module_pom_path.display()))?;
60 let info = parse_pom(&content)?;
61
62 let artifact_id = info
63 .artifact_id
64 .clone()
65 .unwrap_or_else(|| module_name.clone());
66 let group_id = info
67 .group_id
68 .clone()
69 .unwrap_or_else(|| root_group_id.clone());
70
71 let pkg_id = PackageId(module_name.clone());
72 let coord = format!("{}:{}", group_id, artifact_id);
73
74 tracing::debug!("Maven: discovered module '{}' ({})", module_name, coord);
75
76 coord_to_id.insert(coord, pkg_id.clone());
77 packages.insert(
78 pkg_id.clone(),
79 Package {
80 id: pkg_id,
81 name: artifact_id,
82 version: info.version.clone(),
83 path: module_dir.clone(),
84 manifest_path: module_pom_path,
85 },
86 );
87 }
88
89 let mut edges = Vec::new();
91
92 for module_name in &root_info.modules {
93 let module_pom_path = root.join(module_name).join("pom.xml");
94 if !module_pom_path.exists() {
95 continue;
96 }
97
98 let content = std::fs::read_to_string(&module_pom_path)?;
99 let info = parse_pom(&content)?;
100
101 let from_id = PackageId(module_name.clone());
102
103 for dep in &info.dependencies {
104 let dep_group = dep.group_id.as_deref().unwrap_or("");
105 let dep_artifact = dep.artifact_id.as_deref().unwrap_or("");
106 let dep_coord = format!("{}:{}", dep_group, dep_artifact);
107
108 if let Some(to_id) = coord_to_id.get(&dep_coord) {
109 edges.push((from_id.clone(), to_id.clone()));
110 }
111 }
112 }
113
114 Ok(ProjectGraph {
115 packages,
116 edges,
117 root: root.to_path_buf(),
118 })
119 }
120
121 fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
122 file_to_package(graph, file)
123 }
124
125 fn test_command(&self, package_id: &PackageId) -> Vec<String> {
126 vec![
127 "mvn".into(),
128 "test".into(),
129 "-pl".into(),
130 package_id.0.clone(),
131 ]
132 }
133}
134
135#[derive(Debug, Default)]
137struct PomInfo {
138 group_id: Option<String>,
139 artifact_id: Option<String>,
140 version: Option<String>,
141 modules: Vec<String>,
142 dependencies: Vec<MavenDep>,
143}
144
145#[derive(Debug, Default)]
147struct MavenDep {
148 group_id: Option<String>,
149 artifact_id: Option<String>,
150}
151
152fn parse_pom(xml: &str) -> Result<PomInfo> {
156 let mut reader = Reader::from_str(xml);
157
158 let mut info = PomInfo::default();
159 let mut buf = Vec::new();
160
161 let mut tag_stack: Vec<String> = Vec::new();
169 let mut current_dep = MavenDep::default();
170
171 loop {
172 match reader.read_event_into(&mut buf) {
173 Ok(Event::Start(ref e)) => {
174 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
175 tag_stack.push(tag_name);
176 }
177 Ok(Event::End(_)) => {
178 let ended_tag = tag_stack.pop().unwrap_or_default();
179
180 if ended_tag == "dependency" && is_in_path(&tag_stack, &["project", "dependencies"])
182 {
183 let dep = std::mem::take(&mut current_dep);
184 if dep.group_id.is_some() || dep.artifact_id.is_some() {
185 info.dependencies.push(dep);
186 }
187 }
188 }
189 Ok(Event::Text(ref e)) => {
190 let text = e.unescape().unwrap_or_default().trim().to_string();
191 if text.is_empty() {
192 buf.clear();
193 continue;
194 }
195
196 let depth = tag_stack.len();
197 if depth == 0 {
198 buf.clear();
199 continue;
200 }
201
202 let current_tag = &tag_stack[depth - 1];
203
204 if depth == 2 && tag_stack[0] == "project" {
206 match current_tag.as_str() {
207 "groupId" => info.group_id = Some(text),
208 "artifactId" => info.artifact_id = Some(text),
209 "version" => info.version = Some(text),
210 _ => {}
211 }
212 }
213 else if depth == 3
215 && is_in_path(&tag_stack[..depth - 1], &["project", "modules"])
216 && current_tag == "module"
217 {
218 info.modules.push(text);
219 }
220 else if depth == 4
222 && is_in_path(
223 &tag_stack[..depth - 1],
224 &["project", "dependencies", "dependency"],
225 )
226 {
227 match current_tag.as_str() {
228 "groupId" => current_dep.group_id = Some(text),
229 "artifactId" => current_dep.artifact_id = Some(text),
230 _ => {}
231 }
232 }
233 }
234 Ok(Event::Eof) => break,
235 Err(e) => anyhow::bail!("Error parsing pom.xml: {}", e),
236 _ => {}
237 }
238 buf.clear();
239 }
240
241 Ok(info)
242}
243
244fn is_in_path(stack: &[String], path: &[&str]) -> bool {
246 if stack.len() < path.len() {
247 return false;
248 }
249 path.iter()
251 .enumerate()
252 .all(|(i, &expected)| stack[i] == expected)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_detect_maven_with_modules() {
261 let dir = tempfile::tempdir().unwrap();
262 std::fs::write(
263 dir.path().join("pom.xml"),
264 r#"<?xml version="1.0"?>
265<project>
266 <groupId>com.example</groupId>
267 <artifactId>parent</artifactId>
268 <modules>
269 <module>core</module>
270 <module>web</module>
271 </modules>
272</project>"#,
273 )
274 .unwrap();
275
276 assert!(MavenResolver.detect(dir.path()));
277 }
278
279 #[test]
280 fn test_detect_maven_no_modules() {
281 let dir = tempfile::tempdir().unwrap();
282 std::fs::write(
283 dir.path().join("pom.xml"),
284 r#"<?xml version="1.0"?>
285<project>
286 <groupId>com.example</groupId>
287 <artifactId>single</artifactId>
288</project>"#,
289 )
290 .unwrap();
291
292 assert!(!MavenResolver.detect(dir.path()));
293 }
294
295 #[test]
296 fn test_detect_no_pom() {
297 let dir = tempfile::tempdir().unwrap();
298 assert!(!MavenResolver.detect(dir.path()));
299 }
300
301 #[test]
302 fn test_parse_pom_root() {
303 let xml = r#"<?xml version="1.0"?>
304<project>
305 <groupId>com.example</groupId>
306 <artifactId>parent</artifactId>
307 <version>1.0.0</version>
308 <modules>
309 <module>core</module>
310 <module>web</module>
311 </modules>
312</project>"#;
313
314 let info = parse_pom(xml).unwrap();
315 assert_eq!(info.group_id.as_deref(), Some("com.example"));
316 assert_eq!(info.artifact_id.as_deref(), Some("parent"));
317 assert_eq!(info.version.as_deref(), Some("1.0.0"));
318 assert_eq!(info.modules, vec!["core", "web"]);
319 assert!(info.dependencies.is_empty());
320 }
321
322 #[test]
323 fn test_parse_pom_with_dependencies() {
324 let xml = r#"<?xml version="1.0"?>
325<project>
326 <groupId>com.example</groupId>
327 <artifactId>web</artifactId>
328 <dependencies>
329 <dependency>
330 <groupId>com.example</groupId>
331 <artifactId>core</artifactId>
332 <version>1.0.0</version>
333 </dependency>
334 <dependency>
335 <groupId>org.external</groupId>
336 <artifactId>lib</artifactId>
337 </dependency>
338 </dependencies>
339</project>"#;
340
341 let info = parse_pom(xml).unwrap();
342 assert_eq!(info.dependencies.len(), 2);
343 assert_eq!(
344 info.dependencies[0].group_id.as_deref(),
345 Some("com.example")
346 );
347 assert_eq!(info.dependencies[0].artifact_id.as_deref(), Some("core"));
348 assert_eq!(
349 info.dependencies[1].group_id.as_deref(),
350 Some("org.external")
351 );
352 assert_eq!(info.dependencies[1].artifact_id.as_deref(), Some("lib"));
353 }
354
355 #[test]
356 fn test_parse_pom_ignores_parent_group_id() {
357 let xml = r#"<?xml version="1.0"?>
358<project>
359 <parent>
360 <groupId>com.parent</groupId>
361 <artifactId>parent-pom</artifactId>
362 </parent>
363 <groupId>com.example</groupId>
364 <artifactId>mymodule</artifactId>
365</project>"#;
366
367 let info = parse_pom(xml).unwrap();
368 assert_eq!(info.group_id.as_deref(), Some("com.example"));
369 assert_eq!(info.artifact_id.as_deref(), Some("mymodule"));
370 }
371
372 #[test]
373 fn test_resolve_maven_project() {
374 let dir = tempfile::tempdir().unwrap();
375
376 std::fs::write(
378 dir.path().join("pom.xml"),
379 r#"<?xml version="1.0"?>
380<project>
381 <groupId>com.example</groupId>
382 <artifactId>parent</artifactId>
383 <version>1.0.0</version>
384 <modules>
385 <module>core</module>
386 <module>web</module>
387 </modules>
388</project>"#,
389 )
390 .unwrap();
391
392 std::fs::create_dir_all(dir.path().join("core")).unwrap();
394 std::fs::write(
395 dir.path().join("core/pom.xml"),
396 r#"<?xml version="1.0"?>
397<project>
398 <groupId>com.example</groupId>
399 <artifactId>core</artifactId>
400 <version>1.0.0</version>
401</project>"#,
402 )
403 .unwrap();
404
405 std::fs::create_dir_all(dir.path().join("web")).unwrap();
407 std::fs::write(
408 dir.path().join("web/pom.xml"),
409 r#"<?xml version="1.0"?>
410<project>
411 <groupId>com.example</groupId>
412 <artifactId>web</artifactId>
413 <version>1.0.0</version>
414 <dependencies>
415 <dependency>
416 <groupId>com.example</groupId>
417 <artifactId>core</artifactId>
418 <version>1.0.0</version>
419 </dependency>
420 </dependencies>
421</project>"#,
422 )
423 .unwrap();
424
425 let graph = MavenResolver.resolve(dir.path()).unwrap();
426 assert_eq!(graph.packages.len(), 2);
427 assert!(graph.packages.contains_key(&PackageId("core".into())));
428 assert!(graph.packages.contains_key(&PackageId("web".into())));
429
430 assert!(graph
432 .edges
433 .contains(&(PackageId("web".into()), PackageId("core".into()),)));
434 }
435
436 #[test]
437 fn test_resolve_maven_no_internal_deps() {
438 let dir = tempfile::tempdir().unwrap();
439
440 std::fs::write(
441 dir.path().join("pom.xml"),
442 r#"<?xml version="1.0"?>
443<project>
444 <groupId>com.example</groupId>
445 <artifactId>parent</artifactId>
446 <modules>
447 <module>alpha</module>
448 <module>beta</module>
449 </modules>
450</project>"#,
451 )
452 .unwrap();
453
454 std::fs::create_dir_all(dir.path().join("alpha")).unwrap();
455 std::fs::write(
456 dir.path().join("alpha/pom.xml"),
457 r#"<?xml version="1.0"?>
458<project>
459 <groupId>com.example</groupId>
460 <artifactId>alpha</artifactId>
461</project>"#,
462 )
463 .unwrap();
464
465 std::fs::create_dir_all(dir.path().join("beta")).unwrap();
466 std::fs::write(
467 dir.path().join("beta/pom.xml"),
468 r#"<?xml version="1.0"?>
469<project>
470 <groupId>com.example</groupId>
471 <artifactId>beta</artifactId>
472 <dependencies>
473 <dependency>
474 <groupId>org.external</groupId>
475 <artifactId>something</artifactId>
476 </dependency>
477 </dependencies>
478</project>"#,
479 )
480 .unwrap();
481
482 let graph = MavenResolver.resolve(dir.path()).unwrap();
483 assert_eq!(graph.packages.len(), 2);
484 assert!(graph.edges.is_empty());
485 }
486
487 #[test]
488 fn test_resolve_maven_inherits_group_id() {
489 let dir = tempfile::tempdir().unwrap();
490
491 std::fs::write(
492 dir.path().join("pom.xml"),
493 r#"<?xml version="1.0"?>
494<project>
495 <groupId>com.example</groupId>
496 <artifactId>parent</artifactId>
497 <modules>
498 <module>core</module>
499 <module>api</module>
500 </modules>
501</project>"#,
502 )
503 .unwrap();
504
505 std::fs::create_dir_all(dir.path().join("core")).unwrap();
507 std::fs::write(
508 dir.path().join("core/pom.xml"),
509 r#"<?xml version="1.0"?>
510<project>
511 <groupId>com.example</groupId>
512 <artifactId>core</artifactId>
513</project>"#,
514 )
515 .unwrap();
516
517 std::fs::create_dir_all(dir.path().join("api")).unwrap();
519 std::fs::write(
520 dir.path().join("api/pom.xml"),
521 r#"<?xml version="1.0"?>
522<project>
523 <artifactId>api</artifactId>
524 <dependencies>
525 <dependency>
526 <groupId>com.example</groupId>
527 <artifactId>core</artifactId>
528 </dependency>
529 </dependencies>
530</project>"#,
531 )
532 .unwrap();
533
534 let graph = MavenResolver.resolve(dir.path()).unwrap();
535 assert_eq!(graph.packages.len(), 2);
536 assert!(graph
538 .edges
539 .contains(&(PackageId("api".into()), PackageId("core".into()),)));
540 }
541
542 #[test]
543 fn test_test_command() {
544 let cmd = MavenResolver.test_command(&PackageId("core".into()));
545 assert_eq!(cmd, vec!["mvn", "test", "-pl", "core"]);
546 }
547}