Skip to main content

affected_core/resolvers/
maven.rs

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
10/// MavenResolver detects Maven multi-module projects via `pom.xml` with `<modules>`.
11///
12/// Uses `quick-xml` for XML parsing. Walks the XML events manually to extract
13/// `<modules>/<module>`, `<groupId>`, `<artifactId>`, and `<dependencies>/<dependency>`.
14pub 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        // Build dependency edges
90        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/// Parsed information from a pom.xml file.
136#[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/// A single `<dependency>` entry from a pom.xml.
146#[derive(Debug, Default)]
147struct MavenDep {
148    group_id: Option<String>,
149    artifact_id: Option<String>,
150}
151
152/// Parse a pom.xml string, extracting groupId, artifactId, version, modules, and dependencies.
153///
154/// Walks XML events manually with quick_xml::Reader.
155fn 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    // Track nested element context.
162    // We care about:
163    //   /project/groupId, /project/artifactId, /project/version
164    //   /project/modules/module
165    //   /project/dependencies/dependency/groupId
166    //   /project/dependencies/dependency/artifactId
167    // We need to ignore <parent>/<groupId> etc.
168    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 we just closed a <dependency>, save the accumulated dep
181                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                // Top-level project fields (depth 2: project > fieldName)
205                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                // Module entries: project > modules > module
214                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                // Dependency fields: project > dependencies > dependency > (groupId|artifactId)
221                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
244/// Check if the tag stack ends with the given path segments.
245fn is_in_path(stack: &[String], path: &[&str]) -> bool {
246    if stack.len() < path.len() {
247        return false;
248    }
249    // Check from the beginning of the stack
250    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        // Root pom.xml
377        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        // Core module
393        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        // Web module depends on core
406        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        // web depends on core
431        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        // Core has its own groupId
506        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        // Api inherits groupId from root (no groupId specified)
518        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        // api depends on core
537        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}