diff --git a/cli/azd/.gitignore b/cli/azd/.gitignore
index 83a4d0bbddc..03fed358981 100644
--- a/cli/azd/.gitignore
+++ b/cli/azd/.gitignore
@@ -7,3 +7,5 @@ resource.syso
versioninfo.json
azd.sln
+**/target
+
diff --git a/cli/azd/internal/appdetect/appdetect_test.go b/cli/azd/internal/appdetect/appdetect_test.go
index dcff83fdf68..dfc62d45dab 100644
--- a/cli/azd/internal/appdetect/appdetect_test.go
+++ b/cli/azd/internal/appdetect/appdetect_test.go
@@ -41,6 +41,20 @@ func TestDetect(t *testing.T) {
Path: "java",
DetectionRule: "Inferred by presence of: pom.xml",
},
+ {
+ Language: Java,
+ Path: "java-multimodules/application",
+ DetectionRule: "Inferred by presence of: pom.xml",
+ DatabaseDeps: []DatabaseDep{
+ DbMySql,
+ DbPostgres,
+ },
+ },
+ {
+ Language: Java,
+ Path: "java-multimodules/library",
+ DetectionRule: "Inferred by presence of: pom.xml",
+ },
{
Language: JavaScript,
Path: "javascript",
@@ -111,6 +125,20 @@ func TestDetect(t *testing.T) {
Path: "java",
DetectionRule: "Inferred by presence of: pom.xml",
},
+ {
+ Language: Java,
+ Path: "java-multimodules/application",
+ DetectionRule: "Inferred by presence of: pom.xml",
+ DatabaseDeps: []DatabaseDep{
+ DbMySql,
+ DbPostgres,
+ },
+ },
+ {
+ Language: Java,
+ Path: "java-multimodules/library",
+ DetectionRule: "Inferred by presence of: pom.xml",
+ },
},
},
{
@@ -130,6 +158,20 @@ func TestDetect(t *testing.T) {
Path: "java",
DetectionRule: "Inferred by presence of: pom.xml",
},
+ {
+ Language: Java,
+ Path: "java-multimodules/application",
+ DetectionRule: "Inferred by presence of: pom.xml",
+ DatabaseDeps: []DatabaseDep{
+ DbMySql,
+ DbPostgres,
+ },
+ },
+ {
+ Language: Java,
+ Path: "java-multimodules/library",
+ DetectionRule: "Inferred by presence of: pom.xml",
+ },
},
},
{
@@ -152,6 +194,20 @@ func TestDetect(t *testing.T) {
Path: "java",
DetectionRule: "Inferred by presence of: pom.xml",
},
+ {
+ Language: Java,
+ Path: "java-multimodules/application",
+ DetectionRule: "Inferred by presence of: pom.xml",
+ DatabaseDeps: []DatabaseDep{
+ DbMySql,
+ DbPostgres,
+ },
+ },
+ {
+ Language: Java,
+ Path: "java-multimodules/library",
+ DetectionRule: "Inferred by presence of: pom.xml",
+ },
{
Language: Python,
Path: "python",
diff --git a/cli/azd/internal/appdetect/java.go b/cli/azd/internal/appdetect/java.go
index 0a5dfdac870..fe6fec3ea65 100644
--- a/cli/azd/internal/appdetect/java.go
+++ b/cli/azd/internal/appdetect/java.go
@@ -2,11 +2,18 @@ package appdetect
import (
"context"
+ "encoding/xml"
+ "fmt"
"io/fs"
+ "maps"
+ "os"
+ "path/filepath"
+ "slices"
"strings"
)
type javaDetector struct {
+ rootProjects []mavenProject
}
func (jd *javaDetector) Language() Language {
@@ -16,13 +23,121 @@ func (jd *javaDetector) Language() Language {
func (jd *javaDetector) DetectProject(ctx context.Context, path string, entries []fs.DirEntry) (*Project, error) {
for _, entry := range entries {
if strings.ToLower(entry.Name()) == "pom.xml" {
- return &Project{
+ pomFile := filepath.Join(path, entry.Name())
+ project, err := readMavenProject(pomFile)
+ if err != nil {
+ return nil, fmt.Errorf("error reading pom.xml: %w", err)
+ }
+
+ if len(project.Modules) > 0 {
+ // This is a multi-module project, we will capture the analysis, but return nil
+ // to continue recursing
+ jd.rootProjects = append(jd.rootProjects, *project)
+ return nil, nil
+ }
+
+ var currentRoot *mavenProject
+ for _, rootProject := range jd.rootProjects {
+ // we can say that the project is in the root project if the path is under the project
+ if inRoot := strings.HasPrefix(pomFile, rootProject.path); inRoot {
+ currentRoot = &rootProject
+ }
+ }
+
+ _ = currentRoot // use currentRoot here in the analysis
+ result, err := detectDependencies(project, &Project{
Language: Java,
Path: path,
- DetectionRule: "Inferred by presence of: " + entry.Name(),
- }, nil
+ DetectionRule: "Inferred by presence of: pom.xml",
+ })
+ if err != nil {
+ return nil, fmt.Errorf("detecting dependencies: %w", err)
+ }
+
+ return result, nil
}
}
return nil, nil
}
+
+// mavenProject represents the top-level structure of a Maven POM file.
+type mavenProject struct {
+ XmlName xml.Name `xml:"project"`
+ Parent parent `xml:"parent"`
+ Modules []string `xml:"modules>module"` // Capture the modules
+ Dependencies []dependency `xml:"dependencies>dependency"`
+ DependencyManagement dependencyManagement `xml:"dependencyManagement"`
+ Build build `xml:"build"`
+ path string
+}
+
+// Parent represents the parent POM if this project is a module.
+type parent struct {
+ GroupId string `xml:"groupId"`
+ ArtifactId string `xml:"artifactId"`
+ Version string `xml:"version"`
+}
+
+// Dependency represents a single Maven dependency.
+type dependency struct {
+ GroupId string `xml:"groupId"`
+ ArtifactId string `xml:"artifactId"`
+ Version string `xml:"version"`
+ Scope string `xml:"scope,omitempty"`
+}
+
+// DependencyManagement includes a list of dependencies that are managed.
+type dependencyManagement struct {
+ Dependencies []dependency `xml:"dependencies>dependency"`
+}
+
+// Build represents the build configuration which can contain plugins.
+type build struct {
+ Plugins []plugin `xml:"plugins>plugin"`
+}
+
+// Plugin represents a build plugin.
+type plugin struct {
+ GroupId string `xml:"groupId"`
+ ArtifactId string `xml:"artifactId"`
+ Version string `xml:"version"`
+}
+
+func readMavenProject(filePath string) (*mavenProject, error) {
+ bytes, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, err
+ }
+
+ var project mavenProject
+ if err := xml.Unmarshal(bytes, &project); err != nil {
+ return nil, fmt.Errorf("parsing xml: %w", err)
+ }
+
+ project.path = filepath.Dir(filePath)
+
+ return &project, nil
+}
+
+func detectDependencies(mavenProject *mavenProject, project *Project) (*Project, error) {
+ databaseDepMap := map[DatabaseDep]struct{}{}
+ for _, dep := range mavenProject.Dependencies {
+ if dep.GroupId == "com.mysql" && dep.ArtifactId == "mysql-connector-j" {
+ databaseDepMap[DbMySql] = struct{}{}
+ }
+
+ if dep.GroupId == "org.postgresql" && dep.ArtifactId == "postgresql" {
+ databaseDepMap[DbPostgres] = struct{}{}
+ }
+ }
+
+ if len(databaseDepMap) > 0 {
+ project.DatabaseDeps = slices.SortedFunc(maps.Keys(databaseDepMap),
+ func(a, b DatabaseDep) int {
+ return strings.Compare(string(a), string(b))
+ })
+ }
+
+ return project, nil
+}
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml b/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml
new file mode 100644
index 00000000000..e4ddaa858b5
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/pom.xml
@@ -0,0 +1,59 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.3.0
+
+
+ com.example
+ application
+ 0.0.1-SNAPSHOT
+ application
+ Demo project for Spring Boot
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ com.example
+ library
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ com.mysql
+ mysql-connector-j
+
+
+
+ org.postgresql
+ postgresql
+ test
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/java/com/example/multimodule/application/DemoApplication.java b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/java/com/example/multimodule/application/DemoApplication.java
new file mode 100644
index 00000000000..de6d4e0c7ce
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/java/com/example/multimodule/application/DemoApplication.java
@@ -0,0 +1,27 @@
+package com.example.multimodule.application;
+
+import com.example.multimodule.service.MyService;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@SpringBootApplication(scanBasePackages = "com.example.multimodule")
+@RestController
+public class DemoApplication {
+
+ private final MyService myService;
+
+ public DemoApplication(MyService myService) {
+ this.myService = myService;
+ }
+
+ @GetMapping("/")
+ public String home() {
+ return myService.message();
+ }
+
+ public static void main(String[] args) {
+ SpringApplication.run(DemoApplication.class, args);
+ }
+}
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/resources/application.properties b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/resources/application.properties
new file mode 100644
index 00000000000..7c40093f75e
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/main/resources/application.properties
@@ -0,0 +1 @@
+service.message=Hello, World
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/test/java/com/example/multimodule/application/DemoApplicationTest.java b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/test/java/com/example/multimodule/application/DemoApplicationTest.java
new file mode 100644
index 00000000000..7ef7bd2ad19
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/application/src/test/java/com/example/multimodule/application/DemoApplicationTest.java
@@ -0,0 +1,23 @@
+package com.example.multimodule.application;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import com.example.multimodule.service.MyService;
+
+@SpringBootTest
+public class DemoApplicationTest {
+
+ @Autowired
+ private MyService myService;
+
+ @Test
+ public void contextLoads() {
+ assertThat(myService.message()).isNotNull();
+ }
+
+}
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/library/pom.xml b/cli/azd/internal/appdetect/testdata/java-multimodules/library/pom.xml
new file mode 100644
index 00000000000..8a2db935b0f
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/library/pom.xml
@@ -0,0 +1,29 @@
+
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.2
+
+
+ com.example
+ library
+ 0.0.1-SNAPSHOT
+ library
+ Demo project for Spring Boot
+
+
+ org.springframework.boot
+ spring-boot
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/MyService.java b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/MyService.java
new file mode 100644
index 00000000000..06444562963
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/MyService.java
@@ -0,0 +1,19 @@
+package com.example.multimodule.service;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.stereotype.Service;
+
+@Service
+@EnableConfigurationProperties(ServiceProperties.class)
+public class MyService {
+
+ private final ServiceProperties serviceProperties;
+
+ public MyService(ServiceProperties serviceProperties) {
+ this.serviceProperties = serviceProperties;
+ }
+
+ public String message() {
+ return this.serviceProperties.getMessage();
+ }
+}
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/ServiceProperties.java b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/ServiceProperties.java
new file mode 100644
index 00000000000..7dd29b730e0
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/main/java/com/example/multimodule/service/ServiceProperties.java
@@ -0,0 +1,20 @@
+package com.example.multimodule.service;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties("service")
+public class ServiceProperties {
+
+ /**
+ * A message for the service.
+ */
+ private String message;
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+}
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/test/java/com/example/multimodule/service/MyServiceTest.java b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/test/java/com/example/multimodule/service/MyServiceTest.java
new file mode 100644
index 00000000000..0a2a07cfeef
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/library/src/test/java/com/example/multimodule/service/MyServiceTest.java
@@ -0,0 +1,26 @@
+package com.example.multimodule.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest("service.message=Hello")
+public class MyServiceTest {
+
+ @Autowired
+ private MyService myService;
+
+ @Test
+ public void contextLoads() {
+ assertThat(myService.message()).isNotNull();
+ }
+
+ @SpringBootApplication
+ static class TestConfiguration {
+ }
+
+}
diff --git a/cli/azd/internal/appdetect/testdata/java-multimodules/pom.xml b/cli/azd/internal/appdetect/testdata/java-multimodules/pom.xml
new file mode 100644
index 00000000000..fa72a1aa4c8
--- /dev/null
+++ b/cli/azd/internal/appdetect/testdata/java-multimodules/pom.xml
@@ -0,0 +1,16 @@
+
+
+ 4.0.0
+
+ org.springframework
+ gs-multi-module
+ 0.1.0
+ pom
+
+
+ library
+ application
+
+
+