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;
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        // Build dependency edges
91        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/// Parsed information from a pom.xml file.
137#[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/// A single `<dependency>` entry from a pom.xml.
147#[derive(Debug, Default)]
148struct MavenDep {
149    group_id: Option<String>,
150    artifact_id: Option<String>,
151}
152
153/// Parse a pom.xml string, extracting groupId, artifactId, version, modules, and dependencies.
154///
155/// Walks XML events manually with quick_xml::Reader.
156fn 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    // Track nested element context.
163    // We care about:
164    //   /project/groupId, /project/artifactId, /project/version
165    //   /project/modules/module
166    //   /project/dependencies/dependency/groupId
167    //   /project/dependencies/dependency/artifactId
168    // We need to ignore <parent>/<groupId> etc.
169    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 we just closed a <dependency>, save the accumulated dep
182                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                // Top-level project fields (depth 2: project > fieldName)
206                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                // Module entries: project > modules > module
215                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                // Dependency fields: project > dependencies > dependency > (groupId|artifactId)
222                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
245/// Check if the tag stack ends with the given path segments.
246fn is_in_path(stack: &[String], path: &[&str]) -> bool {
247    if stack.len() < path.len() {
248        return false;
249    }
250    // Check from the beginning of the stack
251    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        // Root pom.xml
378        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        // Core module
394        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        // Web module depends on core
407        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        // web depends on core
432        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        // Core has its own groupId
507        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        // Api inherits groupId from root (no groupId specified)
519        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        // api depends on core
538        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}