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