Repository: jhipster/jhipster-lite (java)

Issue:
Gradle: removeDependency doesn't remove unused version at `libs.versions.toml`
Following #6977 gradle removeDependency needs to be handled.
I generated the [sample app using Gradle build tools](https://github.com/renanfranca/sampleapp-gradle-dont-remove-unused-version). Three versions aren't used anywhere (neither in [`libs.versions.toml`](https://github.com/renanfranca/sampleapp-gradle-dont-remove-unused-version/blob/main/gradle/libs.versions.toml) nor in [`build.gradle.kts`](https://github.com/renanfranca/sampleapp-gradle-dont-remove-unused-version/blob/main/build.gradle.kts)):

- `mockito = "5.12.0"`
- `assertj = "3.26.0"`
- `junit-jupiter = "5.10.2"`

TO-DO: Implement safe removal of versions
- [x] When removing the dependency manager
- [x] When removing a direct dependency
- [x] Profile: When removing the dependency manager
- [x] Profile: When removing a direct dependency


Pull request:
safely remove libs version reference
Fix #10060  (see the TO-DO at issue description)

Patch:
diff --git a/src/main/java/tech/jhipster/lite/module/infrastructure/secondary/javadependency/gradle/VersionsCatalog.java b/src/main/java/tech/jhipster/lite/module/infrastructure/secondary/javadependency/gradle/VersionsCatalog.java
index 44391cfca..a68a22b4f 100644
--- a/src/main/java/tech/jhipster/lite/module/infrastructure/secondary/javadependency/gradle/VersionsCatalog.java
+++ b/src/main/java/tech/jhipster/lite/module/infrastructure/secondary/javadependency/gradle/VersionsCatalog.java
@@ -15,6 +15,7 @@ import org.apache.commons.lang3.StringUtils;
 import tech.jhipster.lite.module.domain.gradleplugin.GradleCommunityPlugin;
 import tech.jhipster.lite.module.domain.gradleplugin.GradlePluginSlug;
 import tech.jhipster.lite.module.domain.javabuild.DependencySlug;
+import tech.jhipster.lite.module.domain.javabuild.VersionSlug;
 import tech.jhipster.lite.module.domain.javadependency.DependencyId;
 import tech.jhipster.lite.module.domain.javadependency.JavaDependency;
 import tech.jhipster.lite.module.domain.javadependency.JavaDependencyVersion;
@@ -25,6 +26,7 @@ public class VersionsCatalog {
   private static final String VERSIONS_TOML_KEY = "versions";
   private static final String LIBRARIES_TOML_KEY = "libraries";
   private static final String PLUGINS_TOML_KEY = "plugins";
+  private static final String VERSION_REF = "version.ref";
 
   private final FileConfig tomlConfigFile;
 
@@ -73,16 +75,55 @@ public class VersionsCatalog {
     Config libraryConfig = Config.inMemory();
     libraryConfig.set("group", dependency.id().groupId().get());
     libraryConfig.set("name", dependency.id().artifactId().get());
-    dependency.version().ifPresent(versionSlug -> libraryConfig.set("version.ref", versionSlug.slug()));
+    dependency.version().ifPresent(versionSlug -> libraryConfig.set(VERSION_REF, versionSlug.slug()));
     String libraryEntryKey = libraryAlias(dependency);
     tomlConfigFile.set(List.of(LIBRARIES_TOML_KEY, libraryEntryKey), libraryConfig);
     save();
   }
 
   public void removeLibrary(DependencyId dependency) {
-    libraryEntriesMatchingDependency(dependency).forEach(
-      libraryConfig -> tomlConfigFile.remove(List.of(LIBRARIES_TOML_KEY, libraryConfig.getKey()))
-    );
+    libraryEntriesMatchingDependency(dependency).forEach(libraryConfig -> {
+      tomlConfigFile.remove(List.of(LIBRARIES_TOML_KEY, libraryConfig.getKey()));
+
+      removeUnusedVersion(libraryConfig);
+    });
+    save();
+  }
+
+  private void removeUnusedVersion(Entry libraryConfig) {
+    VersionSlug.of(versionReference(libraryConfig)).ifPresent(versionSlug -> {
+      if (versionUnused(tomlConfigFile, versionSlug)) {
+        this.removeVersion(versionSlug);
+      }
+    });
+  }
+
+  private static String versionReference(Entry libraryConfig) {
+    return ((Config) libraryConfig.getValue()).get(VERSION_REF);
+  }
+
+  private static boolean versionUnused(FileConfig tomlConfigFile, VersionSlug versionSlug) {
+    return tomlConfigFile
+      .entrySet()
+      .stream()
+      .filter(entry -> entry.getKey().equals(LIBRARIES_TOML_KEY))
+      .map(Entry::getValue)
+      .filter(Config.class::isInstance)
+      .map(Config.class::cast)
+      .map(Config::entrySet)
+      .flatMap(Collection::stream)
+      .noneMatch(versionShouldMatch(versionSlug));
+  }
+
+  private static Predicate<Entry> versionShouldMatch(VersionSlug versionSlug) {
+    return libraryConfig -> {
+      Object versionProperty = versionReference(libraryConfig);
+      return versionProperty.equals(versionSlug.slug());
+    };
+  }
+
+  private void removeVersion(VersionSlug versionSlug) {
+    tomlConfigFile.remove(List.of(VERSIONS_TOML_KEY, versionSlug.slug()));
     save();
   }
 
@@ -140,7 +181,7 @@ public class VersionsCatalog {
   public void addPlugin(GradleCommunityPlugin plugin) {
     Config pluginConfig = Config.inMemory();
     pluginConfig.set("id", plugin.id().get());
-    plugin.versionSlug().ifPresent(versionSlug -> pluginConfig.set("version.ref", versionSlug.slug()));
+    plugin.versionSlug().ifPresent(versionSlug -> pluginConfig.set(VERSION_REF, versionSlug.slug()));
     String pluginEntryKey = pluginAlias(plugin);
     tomlConfigFile.set(List.of(PLUGINS_TOML_KEY, pluginEntryKey), pluginConfig);
     save();


Test patch:
diff --git a/src/test/java/tech/jhipster/lite/module/domain/JHipsterModulesFixture.java b/src/test/java/tech/jhipster/lite/module/domain/JHipsterModulesFixture.java
index 009ea36a9..b236ebab2 100644
--- a/src/test/java/tech/jhipster/lite/module/domain/JHipsterModulesFixture.java
+++ b/src/test/java/tech/jhipster/lite/module/domain/JHipsterModulesFixture.java
@@ -274,6 +274,10 @@ public final class JHipsterModulesFixture {
     return javaDependency().groupId("org.springframework.boot").artifactId("spring-boot-starter").build();
   }
 
+  public static JavaDependency dependencyWithVersion() {
+    return javaDependency().groupId("io.jsonwebtoken").artifactId("jjwt-jackson").versionSlug("json-web-token").build();
+  }
+
   public static JavaDependency dependencyWithVersionAndExclusion() {
     return javaDependency()
       .groupId("io.jsonwebtoken")
diff --git a/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/javadependency/gradle/GradleCommandHandlerTest.java b/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/javadependency/gradle/GradleCommandHandlerTest.java
index c3650ff29..db7e81366 100644
--- a/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/javadependency/gradle/GradleCommandHandlerTest.java
+++ b/src/test/java/tech/jhipster/lite/module/infrastructure/secondary/javadependency/gradle/GradleCommandHandlerTest.java
@@ -762,6 +762,67 @@ class GradleCommandHandlerTest {
         );
     }
 
+    @Test
+    void shouldRemoveEntryInLibrariesSectionAndEntryInVersionsSection() {
+      GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
+        Indentation.DEFAULT,
+        projectFolder,
+        emptyModuleContext(),
+        files,
+        fileReplacer
+      );
+      gradleCommandHandler.handle(new SetVersion(jsonWebTokenVersion()));
+      gradleCommandHandler.handle(new AddDirectJavaDependency(dependencyWithVersion()));
+
+      gradleCommandHandler.handle(new RemoveDirectJavaDependency(dependencyWithVersion().id()));
+
+      assertThat(versionCatalogContent(projectFolder))
+        .doesNotContain("json-web-token = ")
+        .doesNotContain("[libraries.jjwt-jackson]")
+        .doesNotContain(
+          """
+          \t\tname = "jjwt-jackson"
+          \t\tgroup = "io.jsonwebtoken"\
+          """
+        )
+        .doesNotContain("[libraries.jjwt-jackson.version]")
+        .doesNotContain(
+          """
+          \t\t\tref = "json-web-token"
+          """
+        );
+    }
+
+    @Test
+    void shouldRemoveEntryInLibrariesSectionButKeepEntryInVersionsSectionIfStillUsedByAnotherDependency() {
+      GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
+        Indentation.DEFAULT,
+        projectFolder,
+        emptyModuleContext(),
+        files,
+        fileReplacer
+      );
+      gradleCommandHandler.handle(new SetVersion(springBootVersion()));
+      JavaDependency starterWebDependency = javaDependency()
+        .groupId("org.springframework.boot")
+        .artifactId("spring-boot-starter-web")
+        .versionSlug("spring-boot")
+        .build();
+      gradleCommandHandler.handle(new AddDirectJavaDependency(starterWebDependency));
+      gradleCommandHandler.handle(
+        new AddDirectJavaDependency(
+          javaDependency().groupId("org.springframework.boot").artifactId("spring-boot-starter-data-jpa").versionSlug("spring-boot").build()
+        )
+      );
+
+      gradleCommandHandler.handle(new RemoveDirectJavaDependency(starterWebDependency.id()));
+
+      assertThat(versionCatalogContent(projectFolder))
+        .contains("spring-boot = ")
+        .doesNotContain("[libraries.spring-boot-starter-web]")
+        .contains("[libraries.spring-boot-starter-data-jpa]");
+    }
+
     @Test
     void shouldRemoveDependencyInBuildGradleFile() {
       GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
@@ -911,6 +972,74 @@ class GradleCommandHandlerTest {
           """
         );
     }
+
+    @Test
+    void shouldRemoveEntryInLibrariesSectionAndEntryInVersionsSectionWhenRemovedDependencyIsInBuildGradleProfileFile() {
+      JHipsterProjectFolder projectFolder = projectFrom("src/test/resources/projects/gradle-with-local-profile");
+      GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
+        Indentation.DEFAULT,
+        projectFolder,
+        emptyModuleContext(),
+        files,
+        fileReplacer
+      );
+      gradleCommandHandler.handle(new SetVersion(jsonWebTokenVersion()));
+      gradleCommandHandler.handle(new AddDirectJavaDependency(dependencyWithVersion(), localBuildProfile()));
+
+      gradleCommandHandler.handle(new RemoveDirectJavaDependency(dependencyWithVersion().id(), localBuildProfile()));
+
+      assertThat(versionCatalogContent(projectFolder))
+        .doesNotContain("json-web-token = ")
+        .doesNotContain("[libraries.jjwt-jackson]")
+        .doesNotContain(
+          """
+          \t\tname = "jjwt-jackson"
+          \t\tgroup = "io.jsonwebtoken"\
+          """
+        )
+        .doesNotContain("[libraries.jjwt-jackson.version]")
+        .doesNotContain(
+          """
+          \t\t\tref = "json-web-token"
+          """
+        );
+    }
+
+    @Test
+    void shouldRemoveEntryInLibrariesSectionButKeepEntryInVersionsSectionIfStillUsedByDependencyInBuildGradleProfileFile() {
+      JHipsterProjectFolder projectFolder = projectFrom("src/test/resources/projects/gradle-with-local-profile");
+      GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
+        Indentation.DEFAULT,
+        projectFolder,
+        emptyModuleContext(),
+        files,
+        fileReplacer
+      );
+      gradleCommandHandler.handle(new SetVersion(springBootVersion()));
+      JavaDependency starterWebDependency = javaDependency()
+        .groupId("org.springframework.boot")
+        .artifactId("spring-boot-starter-web")
+        .versionSlug("spring-boot")
+        .build();
+      gradleCommandHandler.handle(new AddDirectJavaDependency(starterWebDependency));
+      gradleCommandHandler.handle(
+        new AddDirectJavaDependency(
+          javaDependency()
+            .groupId("org.springframework.boot")
+            .artifactId("spring-boot-starter-data-jpa")
+            .versionSlug("spring-boot")
+            .build(),
+          localBuildProfile()
+        )
+      );
+
+      gradleCommandHandler.handle(new RemoveDirectJavaDependency(starterWebDependency.id()));
+
+      assertThat(versionCatalogContent(projectFolder))
+        .contains("spring-boot = ")
+        .doesNotContain("[libraries.spring-boot-starter-web]")
+        .contains("[libraries.spring-boot-starter-data-jpa]");
+    }
   }
 
   @Nested
@@ -1016,6 +1145,61 @@ class GradleCommandHandlerTest {
         );
     }
 
+    @Test
+    void shouldRemoveEntryInLibrariesSectionAndEntryInVersionsSection() {
+      GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
+        Indentation.DEFAULT,
+        projectFolder,
+        emptyModuleContext(),
+        files,
+        fileReplacer
+      );
+      gradleCommandHandler.handle(new SetVersion(springBootVersion()));
+      gradleCommandHandler.handle(new AddJavaDependencyManagement(springBootDependencyManagement()));
+
+      gradleCommandHandler.handle(new RemoveJavaDependencyManagement(springBootDependencyManagement().id()));
+
+      assertThat(versionCatalogContent(projectFolder))
+        .doesNotContain("spring-boot = ")
+        .doesNotContain("[libraries.spring-boot-dependencies]")
+        .doesNotContain(
+          """
+          \t\tname = "spring-boot-dependencies"
+          \t\tgroup = "org.springframework.boot"
+          """
+        );
+    }
+
+    @Test
+    void shouldRemoveEntryInLibrariesSectionButKeepEntryInVersionsSectionIfStillUsedByAnotherDependencyManagement() {
+      GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
+        Indentation.DEFAULT,
+        projectFolder,
+        emptyModuleContext(),
+        files,
+        fileReplacer
+      );
+      gradleCommandHandler.handle(new SetVersion(springBootVersion()));
+      gradleCommandHandler.handle(new AddJavaDependencyManagement(springBootDependencyManagement()));
+      gradleCommandHandler.handle(
+        new AddJavaDependencyManagement(
+          javaDependency()
+            .groupId("org.springframework.boot")
+            .artifactId("spring-boot-starter-data-jpa")
+            .versionSlug("spring-boot")
+            .scope(JavaDependencyScope.IMPORT)
+            .build()
+        )
+      );
+
+      gradleCommandHandler.handle(new RemoveJavaDependencyManagement(springBootDependencyManagement().id()));
+
+      assertThat(versionCatalogContent(projectFolder))
+        .contains("spring-boot = ")
+        .doesNotContain("[libraries.spring-boot-dependencies]")
+        .contains("[libraries.spring-boot-starter-data-jpa]");
+    }
+
     @Test
     void shouldRemoveDependencyInBuildGradleFile() {
       GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
@@ -1101,6 +1285,64 @@ class GradleCommandHandlerTest {
           """
         );
     }
+
+    @Test
+    void shouldRemoveEntryInLibrariesSectionAndEntryInVersionsSectionWhenRemovedDependencyManagementIsInBuildGradleProfileFile() {
+      JHipsterProjectFolder projectFolder = projectFrom("src/test/resources/projects/gradle-with-local-profile");
+      GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
+        Indentation.DEFAULT,
+        projectFolder,
+        emptyModuleContext(),
+        files,
+        fileReplacer
+      );
+      gradleCommandHandler.handle(new SetVersion(springBootVersion()));
+      gradleCommandHandler.handle(new AddJavaDependencyManagement(springBootDependencyManagement(), localBuildProfile()));
+
+      gradleCommandHandler.handle(new RemoveJavaDependencyManagement(springBootDependencyManagement().id(), localBuildProfile()));
+
+      assertThat(versionCatalogContent(projectFolder))
+        .doesNotContain("spring-boot = ")
+        .doesNotContain("[libraries.spring-boot-dependencies]")
+        .doesNotContain(
+          """
+          \t\tname = "spring-boot-dependencies"
+          \t\tgroup = "org.springframework.boot"
+          """
+        );
+    }
+
+    @Test
+    void shouldRemoveEntryInLibrariesSectionButKeepEntryInVersionsSectionIfStillUsedByDependencyManagementInBuildGradleProfileFile() {
+      JHipsterProjectFolder projectFolder = projectFrom("src/test/resources/projects/gradle-with-local-profile");
+      GradleCommandHandler gradleCommandHandler = new GradleCommandHandler(
+        Indentation.DEFAULT,
+        projectFolder,
+        emptyModuleContext(),
+        files,
+        fileReplacer
+      );
+      gradleCommandHandler.handle(new SetVersion(springBootVersion()));
+      gradleCommandHandler.handle(new AddJavaDependencyManagement(springBootDependencyManagement()));
+      gradleCommandHandler.handle(
+        new AddJavaDependencyManagement(
+          javaDependency()
+            .groupId("org.springframework.boot")
+            .artifactId("spring-boot-starter-data-jpa")
+            .versionSlug("spring-boot")
+            .scope(JavaDependencyScope.IMPORT)
+            .build(),
+          localBuildProfile()
+        )
+      );
+
+      gradleCommandHandler.handle(new RemoveJavaDependencyManagement(springBootDependencyManagement().id()));
+
+      assertThat(versionCatalogContent(projectFolder))
+        .contains("spring-boot = ")
+        .doesNotContain("[libraries.spring-boot-dependencies]")
+        .contains("[libraries.spring-boot-starter-data-jpa]");
+    }
   }
 
   @Nested