Repository: jhipster/jhipster-lite (java)

Issue:
Manage landscape preset configuration
I propose saving/reading the preset configuration into a JSON file, similar to how `history.json` works. Here is what I think the `.jhipster/modules/preset.json` should look like:

```json
[
  {
    "name": "angular + spring boot",
    "date": "2024-07-09T15:10:00.521252170Z",
    "modules": [
      "init",
      "application-service-hexagonal-architecture-documentation",
      "maven-java",
      "prettier",
      "angular-core",
      "java-base",
      "maven-wrapper",
      "spring-boot",
      "spring-boot-mvc-empty",
      "logs-spy",
      "spring-boot-tomcat"
    ]
  }
]
```

When applying the selected preset, the properties defined by the landscape interface or default will be used, which is why they won't be stored in the json.
```json
    "properties": {
      "packageName": "com.mycompany.myapp",
      "projectName": "JHipster Sample Application",
      "baseName": "jhipsterSampleApplication",
      "serverPort": 8080,
      "endOfLine": "lf",
      "indentSize": 2,
      "springConfigurationFormat": "yaml"
    },
```

Pull request description:
manage landscape preset configuration
Fix #10239 

Patch:
diff --git a/src/main/java/tech/jhipster/lite/project/domain/history/ModuleSlug.java b/src/main/java/tech/jhipster/lite/project/domain/ModuleSlug.java
similarity index 80%
rename from src/main/java/tech/jhipster/lite/project/domain/history/ModuleSlug.java
rename to src/main/java/tech/jhipster/lite/project/domain/ModuleSlug.java
index 2eefaed46..3b1e8ac2a 100644
--- a/src/main/java/tech/jhipster/lite/project/domain/history/ModuleSlug.java
+++ b/src/main/java/tech/jhipster/lite/project/domain/ModuleSlug.java
@@ -1,4 +1,4 @@
-package tech.jhipster.lite.project.domain.history;
+package tech.jhipster.lite.project.domain;
 
 import tech.jhipster.lite.shared.error.domain.Assert;
 
diff --git a/src/main/java/tech/jhipster/lite/project/domain/ModulesSlugs.java b/src/main/java/tech/jhipster/lite/project/domain/ModulesSlugs.java
new file mode 100644
index 000000000..996babf4e
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/project/domain/ModulesSlugs.java
@@ -0,0 +1,14 @@
+package tech.jhipster.lite.project.domain;
+
+import java.util.Collection;
+import tech.jhipster.lite.shared.error.domain.Assert;
+
+public record ModulesSlugs(Collection<ModuleSlug> modules) {
+  public ModulesSlugs {
+    Assert.notEmpty("modules", modules);
+  }
+
+  public static ModulesSlugs from(Collection<String> modules) {
+    return new ModulesSlugs(modules.stream().map(ModuleSlug::new).toList());
+  }
+}
diff --git a/src/main/java/tech/jhipster/lite/project/domain/ProjectsRepository.java b/src/main/java/tech/jhipster/lite/project/domain/ProjectsRepository.java
index f26de1b39..a9f7a1496 100644
--- a/src/main/java/tech/jhipster/lite/project/domain/ProjectsRepository.java
+++ b/src/main/java/tech/jhipster/lite/project/domain/ProjectsRepository.java
@@ -1,8 +1,10 @@
 package tech.jhipster.lite.project.domain;
 
+import java.util.Collection;
 import java.util.Optional;
 import tech.jhipster.lite.project.domain.download.Project;
 import tech.jhipster.lite.project.domain.history.ProjectHistory;
+import tech.jhipster.lite.project.domain.preset.Preset;
 
 public interface ProjectsRepository {
   void format(ProjectPath path);
@@ -12,4 +14,6 @@ public interface ProjectsRepository {
   void save(ProjectHistory history);
 
   ProjectHistory getHistory(ProjectPath path);
+
+  Collection<Preset> getPresets();
 }
diff --git a/src/main/java/tech/jhipster/lite/project/domain/history/ProjectAction.java b/src/main/java/tech/jhipster/lite/project/domain/history/ProjectAction.java
index 69b97ad0d..fc89fd454 100644
--- a/src/main/java/tech/jhipster/lite/project/domain/history/ProjectAction.java
+++ b/src/main/java/tech/jhipster/lite/project/domain/history/ProjectAction.java
@@ -2,6 +2,7 @@ package tech.jhipster.lite.project.domain.history;
 
 import java.time.Instant;
 import java.util.Map;
+import tech.jhipster.lite.project.domain.ModuleSlug;
 import tech.jhipster.lite.shared.error.domain.Assert;
 
 public final class ProjectAction {
diff --git a/src/main/java/tech/jhipster/lite/project/domain/preset/Preset.java b/src/main/java/tech/jhipster/lite/project/domain/preset/Preset.java
new file mode 100644
index 000000000..ec0837841
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/project/domain/preset/Preset.java
@@ -0,0 +1,11 @@
+package tech.jhipster.lite.project.domain.preset;
+
+import tech.jhipster.lite.project.domain.ModulesSlugs;
+import tech.jhipster.lite.shared.error.domain.Assert;
+
+public record Preset(PresetName name, ModulesSlugs modules) {
+  public Preset {
+    Assert.notNull("name", name);
+    Assert.notNull("modules", modules);
+  }
+}
diff --git a/src/main/java/tech/jhipster/lite/project/domain/preset/PresetName.java b/src/main/java/tech/jhipster/lite/project/domain/preset/PresetName.java
new file mode 100644
index 000000000..85b036c9f
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/project/domain/preset/PresetName.java
@@ -0,0 +1,13 @@
+package tech.jhipster.lite.project.domain.preset;
+
+import tech.jhipster.lite.shared.error.domain.Assert;
+
+public record PresetName(String name) {
+  public PresetName {
+    Assert.notBlank("name", name);
+  }
+
+  public static PresetName from(String name) {
+    return new PresetName(name);
+  }
+}
diff --git a/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/FileSystemProjectsRepository.java b/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/FileSystemProjectsRepository.java
index f285799cb..cdc5d4373 100644
--- a/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/FileSystemProjectsRepository.java
+++ b/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/FileSystemProjectsRepository.java
@@ -6,12 +6,15 @@ import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Collection;
 import java.util.Optional;
 import org.springframework.stereotype.Repository;
+import tech.jhipster.lite.module.domain.ProjectFiles;
 import tech.jhipster.lite.project.domain.ProjectPath;
 import tech.jhipster.lite.project.domain.ProjectsRepository;
 import tech.jhipster.lite.project.domain.download.Project;
 import tech.jhipster.lite.project.domain.history.ProjectHistory;
+import tech.jhipster.lite.project.domain.preset.Preset;
 import tech.jhipster.lite.shared.error.domain.Assert;
 import tech.jhipster.lite.shared.error.domain.GeneratorException;
 
@@ -22,15 +25,18 @@ class FileSystemProjectsRepository implements ProjectsRepository {
 
   private static final String HISTORY_FOLDER = ".jhipster/modules";
   private static final String HISTORY_FILE = "history.json";
+  private static final String PRESET_FILE = "preset.json";
 
   private final ObjectMapper json;
   private final ProjectFormatter formatter;
+  private final ProjectFiles projectFiles;
   private final ObjectWriter writer;
   private final FileSystemProjectDownloader downloader;
 
-  public FileSystemProjectsRepository(ObjectMapper json, ProjectFormatter formatter) {
+  public FileSystemProjectsRepository(ObjectMapper json, ProjectFormatter formatter, ProjectFiles projectFiles) {
     this.json = json;
     this.formatter = formatter;
+    this.projectFiles = projectFiles;
 
     writer = json.writerWithDefaultPrettyPrinter();
     downloader = new FileSystemProjectDownloader();
@@ -84,4 +90,13 @@ class FileSystemProjectsRepository implements ProjectsRepository {
   private Path historyFilePath(ProjectPath path) {
     return Paths.get(path.get(), HISTORY_FOLDER, HISTORY_FILE);
   }
+
+  @Override
+  public Collection<Preset> getPresets() {
+    try {
+      return json.readValue(projectFiles.readBytes(PRESET_FILE), PersistedPresets.class).toDomain();
+    } catch (IOException e) {
+      throw GeneratorException.technicalError("Can't read presets: " + e.getMessage(), e);
+    }
+  }
 }
diff --git a/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/PersistedPreset.java b/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/PersistedPreset.java
new file mode 100644
index 000000000..ef3295919
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/PersistedPreset.java
@@ -0,0 +1,12 @@
+package tech.jhipster.lite.project.infrastructure.secondary;
+
+import java.util.Collection;
+import tech.jhipster.lite.project.domain.ModulesSlugs;
+import tech.jhipster.lite.project.domain.preset.Preset;
+import tech.jhipster.lite.project.domain.preset.PresetName;
+
+record PersistedPreset(String name, Collection<String> modules) {
+  public Preset toDomain() {
+    return new Preset(PresetName.from(name), ModulesSlugs.from(modules));
+  }
+}
diff --git a/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/PersistedPresets.java b/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/PersistedPresets.java
new file mode 100644
index 000000000..1b385a1be
--- /dev/null
+++ b/src/main/java/tech/jhipster/lite/project/infrastructure/secondary/PersistedPresets.java
@@ -0,0 +1,11 @@
+package tech.jhipster.lite.project.infrastructure.secondary;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.Collection;
+import tech.jhipster.lite.project.domain.preset.Preset;
+
+record PersistedPresets(@JsonProperty("presets") Collection<PersistedPreset> presets) {
+  public Collection<Preset> toDomain() {
+    return presets.stream().map(PersistedPreset::toDomain).toList();
+  }
+}


Test patch:
diff --git a/src/test/java/tech/jhipster/lite/TestFileUtils.java b/src/test/java/tech/jhipster/lite/TestFileUtils.java
index 74719853d..55d1de2f5 100644
--- a/src/test/java/tech/jhipster/lite/TestFileUtils.java
+++ b/src/test/java/tech/jhipster/lite/TestFileUtils.java
@@ -1,6 +1,6 @@
 package tech.jhipster.lite;
 
-import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+import static java.nio.file.StandardCopyOption.*;
 
 import java.io.IOException;
 import java.nio.file.FileSystems;
diff --git a/src/test/java/tech/jhipster/lite/project/infrastructure/secondary/FileSystemProjectsRepositoryTest.java b/src/test/java/tech/jhipster/lite/project/infrastructure/secondary/FileSystemProjectsRepositoryTest.java
index 38ff6d9bd..c5124ffd6 100644
--- a/src/test/java/tech/jhipster/lite/project/infrastructure/secondary/FileSystemProjectsRepositoryTest.java
+++ b/src/test/java/tech/jhipster/lite/project/infrastructure/secondary/FileSystemProjectsRepositoryTest.java
@@ -24,10 +24,16 @@ import org.junit.jupiter.api.Test;
 import tech.jhipster.lite.JsonHelper;
 import tech.jhipster.lite.TestFileUtils;
 import tech.jhipster.lite.UnitTest;
+import tech.jhipster.lite.module.domain.ProjectFiles;
+import tech.jhipster.lite.module.infrastructure.secondary.FileSystemProjectFiles;
+import tech.jhipster.lite.project.domain.ModuleSlug;
+import tech.jhipster.lite.project.domain.ModulesSlugs;
 import tech.jhipster.lite.project.domain.ProjectPath;
 import tech.jhipster.lite.project.domain.download.Project;
 import tech.jhipster.lite.project.domain.download.ProjectName;
 import tech.jhipster.lite.project.domain.history.ProjectHistory;
+import tech.jhipster.lite.project.domain.preset.Preset;
+import tech.jhipster.lite.project.domain.preset.PresetName;
 import tech.jhipster.lite.shared.error.domain.GeneratorException;
 
 @UnitTest
@@ -35,7 +41,8 @@ class FileSystemProjectsRepositoryTest {
 
   private static final FileSystemProjectsRepository projects = new FileSystemProjectsRepository(
     JsonHelper.jsonMapper(),
-    mock(ProjectFormatter.class)
+    mock(ProjectFormatter.class),
+    mock(ProjectFiles.class)
   );
 
   @Nested
@@ -139,7 +146,11 @@ class FileSystemProjectsRepositoryTest {
       ObjectMapper json = mock(ObjectMapper.class);
       when(json.writerWithDefaultPrettyPrinter()).thenReturn(writer);
 
-      FileSystemProjectsRepository fileSystemProjectsRepository = new FileSystemProjectsRepository(json, mock(ProjectFormatter.class));
+      FileSystemProjectsRepository fileSystemProjectsRepository = new FileSystemProjectsRepository(
+        json,
+        mock(ProjectFormatter.class),
+        mock(ProjectFiles.class)
+      );
 
       assertThatThrownBy(() -> fileSystemProjectsRepository.save(projectHistory())).isExactlyInstanceOf(GeneratorException.class);
     }
@@ -178,7 +189,11 @@ class FileSystemProjectsRepositoryTest {
       ObjectMapper json = mock(ObjectMapper.class);
       when(json.readValue(any(byte[].class), eq(PersistedProjectHistory.class))).thenThrow(IOException.class);
 
-      FileSystemProjectsRepository fileSystemProjectsRepository = new FileSystemProjectsRepository(json, mock(ProjectFormatter.class));
+      FileSystemProjectsRepository fileSystemProjectsRepository = new FileSystemProjectsRepository(
+        json,
+        mock(ProjectFormatter.class),
+        mock(ProjectFiles.class)
+      );
 
       assertThatThrownBy(() -> fileSystemProjectsRepository.getHistory(path)).isExactlyInstanceOf(GeneratorException.class);
     }
@@ -204,6 +219,48 @@ class FileSystemProjectsRepositoryTest {
     }
   }
 
+  @Nested
+  @DisplayName("Get preset")
+  class FileSystemProjectsRepositoryGetPresetTest {
+
+    @Test
+    void shouldHandleDeserializationErrors() throws IOException {
+      ObjectMapper json = mock(ObjectMapper.class);
+      when(json.readValue(any(byte[].class), eq(PersistedPresets.class))).thenThrow(IOException.class);
+      FileSystemProjectsRepository fileSystemProjectsRepository = new FileSystemProjectsRepository(
+        json,
+        mock(ProjectFormatter.class),
+        mockProjectFilesWithValidPresetJson()
+      );
+
+      assertThatThrownBy(fileSystemProjectsRepository::getPresets).isExactlyInstanceOf(GeneratorException.class);
+    }
+
+    @Test
+    void shouldNotReturnPresetFromUnknownFile() {
+      FileSystemProjectsRepository fileSystemProjectsRepository = new FileSystemProjectsRepository(
+        JsonHelper.jsonMapper(),
+        mock(ProjectFormatter.class),
+        new FileSystemProjectFiles()
+      );
+
+      assertThatThrownBy(fileSystemProjectsRepository::getPresets).isExactlyInstanceOf(GeneratorException.class);
+    }
+
+    @Test
+    void shouldGetExistingPreset() {
+      FileSystemProjectsRepository fileSystemProjectsRepository = new FileSystemProjectsRepository(
+        JsonHelper.jsonMapper(),
+        mock(ProjectFormatter.class),
+        mockProjectFilesWithValidPresetJson()
+      );
+
+      Collection<Preset> presets = fileSystemProjectsRepository.getPresets();
+
+      assertThat(presets).containsExactly(expectedPreset());
+    }
+  }
+
   private static FolderBuilder folder() {
     return new FolderBuilder();
   }
@@ -248,4 +305,56 @@ class FileSystemProjectsRepositoryTest {
       return new ProjectPath(folder.toString());
     }
   }
+
+  private static ProjectFiles mockProjectFilesWithValidPresetJson() {
+    ProjectFiles projectFiles = mock(ProjectFiles.class);
+
+    String validPresetJson =
+      """
+      {
+        "presets": [
+          {
+            "name": "angular + spring boot",
+            "modules": [
+              "init",
+              "application-service-hexagonal-architecture-documentation",
+              "maven-java",
+              "prettier",
+              "angular-core",
+              "java-base",
+              "maven-wrapper",
+              "spring-boot",
+              "spring-boot-mvc-empty",
+              "logs-spy",
+              "spring-boot-tomcat"
+            ]
+          }
+        ]
+      }
+      """;
+    lenient().when(projectFiles.readBytes("preset.json")).thenReturn(validPresetJson.getBytes());
+
+    return projectFiles;
+  }
+
+  private static Preset expectedPreset() {
+    return new Preset(
+      new PresetName("angular + spring boot"),
+      new ModulesSlugs(
+        List.of(
+          new ModuleSlug("init"),
+          new ModuleSlug("application-service-hexagonal-architecture-documentation"),
+          new ModuleSlug("maven-java"),
+          new ModuleSlug("prettier"),
+          new ModuleSlug("angular-core"),
+          new ModuleSlug("java-base"),
+          new ModuleSlug("maven-wrapper"),
+          new ModuleSlug("spring-boot"),
+          new ModuleSlug("spring-boot-mvc-empty"),
+          new ModuleSlug("logs-spy"),
+          new ModuleSlug("spring-boot-tomcat")
+        )
+      )
+    );
+  }
 }