diff --git a/AGENTS.md b/AGENTS.md index cc030573..31307cde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Agents may run smaller, targeted checks such as `./mvnw test`, specific Maven te ## Coding Style & Naming Conventions -Follow `.editorconfig`: 2-space indentation, LF line endings, UTF-8, and final newlines. Prettier is the formatter of record, including Java via `prettier-plugin-java`; run it instead of hand-formatting. Checkstyle enforces import hygiene, visibility, declaration order, and standard Java naming. Match current naming patterns: `*Command`, `*Configuration`, `*Exception`, and package names in lowercase. Domain interfaces that represent ports should be named by business capability, such as `RuntimeExtensionInstaller`, `RuntimeDisplayReader`, or `RuntimeModeConfigurationRepository`; do not use the `Port` suffix for domain ports. Secondary implementations of domain ports should use mechanism, source, or context prefix plus capability, such as `FileSystemProjectsRepository`, `BootstrapRuntimeDisplayReader`, or `BootstrapRuntimeExtensionInstaller`. Reserve `Adapter` for technical wrappers that do not implement a domain port, such as adapters around APIs or frameworks. Prefer one public type per file. Do not add overloaded constructors, overloaded methods, factories, or defaulting shortcuts only to make tests shorter or more convenient. Tests should exercise the same intentional API shape used by production code. If a test needs deterministic or alternate behavior, introduce a meaningful production concept that is also connected to the runtime path, or test through the observable public behavior. In production code, order private helper methods immediately after their first use; when a helper is used from multiple places, place it after the earliest caller that introduces it. Avoid raw boolean parameters when they select a business behavior, mode, status, or strategy. Model that choice with a dedicated type, usually a small enum with named states and a predicate method, following patterns such as `BootstrapDebugMode`, `RuntimeProcessMode`, or `RuntimeExtensionReplacementStatus`. Avoid `is*` prefixes for boolean methods; prefer names that express the business predicate directly, such as `atLeast(...)`, `missingBootInfClasses(...)`, or `standardMode(...)`. Prefer `Optional` over direct `null` comparisons when modeling optional values. Do not use `@Autowired`; prefer a single explicit constructor and direct wiring. Do not use `var` in Java; prefer explicit types. When validating mandatory references in Java, do not use `Objects.requireNonNull`; use `Assert.notNull("fieldName", value)` from `src/main/java/com/seed4j/cli/shared/error/domain/Assert.java`. +Follow `.editorconfig`: 2-space indentation, LF line endings, UTF-8, and final newlines. Prettier is the formatter of record, including Java via `prettier-plugin-java`; run it instead of hand-formatting. Checkstyle enforces import hygiene, visibility, declaration order, and standard Java naming. Match current naming patterns: `*Command`, `*Configuration`, `*Exception`, and package names in lowercase. Domain interfaces that represent ports should be named by business capability, such as `RuntimeExtensionInstaller`, `RuntimeDisplayReader`, or `RuntimeModeConfigurationRepository`; do not use the `Port` suffix for domain ports. Secondary implementations of domain ports should use mechanism, source, or context prefix plus capability, such as `FileSystemProjectsRepository`, `BootstrapRuntimeDisplayReader`, or `BootstrapRuntimeExtensionInstaller`. Reserve `Adapter` for technical wrappers that do not implement a domain port, such as adapters around APIs or frameworks. Prefer one public type per file. Do not add overloaded constructors, overloaded methods, factories, or defaulting shortcuts only to make tests shorter or more convenient. Tests should exercise the same intentional API shape used by production code. If a test needs deterministic or alternate behavior, introduce a meaningful production concept that is also connected to the runtime path, or test through the observable public behavior. In production code, order private helper methods immediately after their first use; when a helper is used from multiple places, place it after the earliest caller that introduces it. Do not use mutable out-parameters, such as passing `List`, `Set`, or holder objects into helpers so they can append progress by side effect. Java passes object references by value; the issue is hidden mutation of shared accumulators. Prefer returning an explicit result or progress type and threading it through loops or recursion. Use private records for implementation-local progress/context types, and promote them only when they represent real domain concepts. When a helper needs to apply an ordered sequence of transformations to an immutable progress/result object, prefer a named fold over `forEach` with external state. A stream can be appropriate when each element is mapped to a clearly named `Function` and composed with `Function::andThen` or an equally explicit fold. Use this only when the helper names make the data flow clearer than an explicit loop; do not use streams to hide mutation. Avoid raw boolean parameters when they select a business behavior, mode, status, or strategy. Model that choice with a dedicated type, usually a small enum with named states and a predicate method, following patterns such as `BootstrapDebugMode`, `RuntimeProcessMode`, or `RuntimeExtensionReplacementStatus`. Avoid `is*` prefixes for boolean methods; prefer names that express the business predicate directly, such as `atLeast(...)`, `missingBootInfClasses(...)`, or `standardMode(...)`. Prefer `Optional` over direct `null` comparisons when modeling optional values. Do not use `@Autowired`; prefer a single explicit constructor and direct wiring. Do not use `var` in Java; prefer explicit types. When validating mandatory references in Java, do not use `Objects.requireNonNull`; use `Assert.notNull("fieldName", value)` from `src/main/java/com/seed4j/cli/shared/error/domain/Assert.java`. ## Testing Guidelines diff --git a/README.md b/README.md index bbd4ac00..2c45fcdd 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ To inspect module parameters before applying a module, add `--plan`: seed4j apply init --plan ``` -The plan prints the resolved text values and shows whether each value came from the current CLI input, project history, or a module metadata default. If required values are missing, it prints a `Missing required parameters` section and still does not apply files, write history, or create commits. +The plan first prints a `Dependency plan`, then the resolved text values and whether each value came from the current CLI input, project history, or a module metadata default. Lines marked with `✓` are resolved, and lines marked with `○` are pending or missing. Dependency statuses show direct module dependencies as `already applied` or `pending`; feature dependencies show either `satisfied by ` or `pending choice` with visible candidate modules. If required values are missing, it prints a `Missing required parameters` section and still does not apply files, write history, or create commits. To install Bash completion for the active runtime: diff --git a/documentation/Commands.md b/documentation/Commands.md index 20ff5c8c..37d86396 100644 --- a/documentation/Commands.md +++ b/documentation/Commands.md @@ -119,32 +119,41 @@ seed4j apply init --plan The plan is text-only and exits without generated files, history entries, or commits. Required parameters can come from the current command or project history. When required parameters are still missing, the plan exits successfully and shows -which options to pass before applying the module. +which options to pass before applying the module. Lines marked with `✓` are resolved, and lines marked with `○` are +pending or missing. ```text Plan for module: init Project path: . +Dependency plan: + +✓ No dependencies. + Resolved parameters: -endOfLine: lf +✓ endOfLine: lf Source: default CLI option: --end-of-line -indentSize: 2 +✓ indentSize: 2 Source: default CLI option: --indent-size Missing required parameters: -projectName: +○ projectName: CLI option: --project-name Note: pass this option or apply a module that records it in project history. -baseName: +○ baseName: CLI option: --base-name Note: pass this option or apply a module that records it in project history. +○ nodePackageManager: + CLI option: --node-package-manager + Note: pass this option or apply a module that records it in project history. + No changes were applied. ``` @@ -164,21 +173,25 @@ seed4j apply maven-java --plan Plan for module: maven-java Project path: . +Dependency plan: + +✓ module:init - already applied + Resolved parameters: -projectName: Seed4J Sample Application +✓ projectName: Seed4J Sample Application Source: project history CLI option: --project-name Note: already selected by project history; omit this option to keep it. -baseName: seed4jSampleApplication +✓ baseName: seed4jSampleApplication Source: project history CLI option: --base-name Note: already selected by project history; omit this option to keep it. Missing required parameters: -packageName: +○ packageName: CLI option: --package-name Note: pass this option or apply a module that records it in project history. @@ -191,6 +204,24 @@ Pass the missing option when applying the module: seed4j apply maven-java --package-name com.mycompany.myapp ``` +Dependency status labels mean: + +- `✓ module: - already applied`: the module dependency is already recorded in project history +- `○ module: - pending`: the module dependency is not recorded in project history +- `✓ feature: - satisfied by `: an applied module provides the required feature +- `○ feature: - pending choice: , `: no applied module provides the feature yet; choose one of + the sorted visible candidates + +For example, after applying `maven-java`, planning `sonarqube-java-backend` can show one satisfied feature and one feature +that still needs a choice: + +```text +Dependency plan: + +○ feature:code-coverage-java - pending choice: jacoco, jacoco-with-min-coverage-check +✓ feature:java-build-tool - satisfied by maven-java +``` + Plan source labels mean: - `explicit CLI input`: the option was passed in the current command diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlan.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlan.java new file mode 100644 index 00000000..409fdda0 --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlan.java @@ -0,0 +1,9 @@ +package com.seed4j.cli.command.infrastructure.primary; + +import java.util.List; + +record ApplyModuleDependencyPlan(List lines) { + boolean empty() { + return lines.isEmpty(); + } +} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java new file mode 100644 index 00000000..edfa5111 --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanLine.java @@ -0,0 +1,3 @@ +package com.seed4j.cli.command.infrastructure.primary; + +record ApplyModuleDependencyPlanLine(String dependency, ApplyModuleDependencyStatus status) {} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java new file mode 100644 index 00000000..03ee3c5d --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyPlanner.java @@ -0,0 +1,203 @@ +package com.seed4j.cli.command.infrastructure.primary; + +import com.seed4j.module.domain.landscape.Seed4JLandscapeDependency; +import com.seed4j.module.domain.resource.Seed4JModuleResource; +import com.seed4j.module.domain.resource.Seed4JModulesResources; +import com.seed4j.project.domain.ModuleSlug; +import com.seed4j.project.domain.history.ProjectAction; +import com.seed4j.project.domain.history.ProjectHistory; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +class ApplyModuleDependencyPlanner { + + ApplyModuleDependencyPlan plan(Seed4JModuleResource module, Seed4JModulesResources resources, ProjectHistory history) { + DependencyPlanningProgress progress = appendDependencies( + module, + DependencyPlanningContext.from(resources, history), + DependencyPlanningProgress.empty() + ); + + return new ApplyModuleDependencyPlan(progress.lines()); + } + + private static Set appliedModules(ProjectHistory history) { + return history.actions().stream().map(ProjectAction::module).map(ModuleSlug::get).collect(Collectors.toUnmodifiableSet()); + } + + private static Comparator byModuleSlug() { + return Comparator.comparing(module -> module.slug().get()); + } + + private static DependencyPlanningProgress appendDependencies( + Seed4JModuleResource module, + DependencyPlanningContext context, + DependencyPlanningProgress progress + ) { + String moduleSlug = module.slug().get(); + if (progress.visitedModules().contains(moduleSlug)) { + return progress; + } + + return module + .organization() + .dependencies() + .stream() + .sorted(byDependencyToken()) + .map(dependency -> dependencyPlanningStep(dependency, context)) + .reduce(Function.identity(), Function::andThen) + .apply(progress.withVisitedModule(moduleSlug)); + } + + private static Comparator byDependencyToken() { + return Comparator.comparing(ApplyModuleDependencyPlanner::dependencyToken); + } + + private static Function dependencyPlanningStep( + Seed4JLandscapeDependency dependency, + DependencyPlanningContext context + ) { + return progress -> appendDependency(dependency, context, progress); + } + + private static DependencyPlanningProgress appendDependency( + Seed4JLandscapeDependency dependency, + DependencyPlanningContext context, + DependencyPlanningProgress progress + ) { + return switch (dependency.type()) { + case MODULE -> appendModuleDependency(dependency, context, progress); + case FEATURE -> appendFeatureDependency(dependency, context, progress); + }; + } + + private static DependencyPlanningProgress appendModuleDependency( + Seed4JLandscapeDependency dependency, + DependencyPlanningContext context, + DependencyPlanningProgress progress + ) { + String dependencySlug = dependency.slug().get(); + DependencyPlanningProgress nextProgress = progress.withPlanLineIfMissing( + new ApplyModuleDependencyPlanLine(dependencyToken(dependency), moduleStatus(dependencySlug, context.appliedModules())) + ); + + return context + .module(dependencySlug) + .map(resource -> appendDependencies(resource, context, nextProgress)) + .orElse(nextProgress); + } + + private static ApplyModuleDependencyStatus moduleStatus(String dependencySlug, Set appliedModules) { + if (appliedModules.contains(dependencySlug)) { + return ApplyModuleDependencyStatus.alreadyApplied(); + } + + return ApplyModuleDependencyStatus.pending(); + } + + private static DependencyPlanningProgress appendFeatureDependency( + Seed4JLandscapeDependency dependency, + DependencyPlanningContext context, + DependencyPlanningProgress progress + ) { + return progress.withPlanLineIfMissing( + new ApplyModuleDependencyPlanLine(dependencyToken(dependency), featureStatus(dependency, context)) + ); + } + + private static ApplyModuleDependencyStatus featureStatus(Seed4JLandscapeDependency dependency, DependencyPlanningContext context) { + List candidates = featureCandidates(dependency.slug().get(), context.visibleModules()); + return candidates + .stream() + .filter(context.appliedModules()::contains) + .findFirst() + .map(ApplyModuleDependencyStatus::satisfiedBy) + .orElseGet(() -> ApplyModuleDependencyStatus.pendingChoice(candidates)); + } + + private static List featureCandidates(String featureSlug, List visibleModules) { + return visibleModules + .stream() + .filter(module -> + module + .organization() + .feature() + .map(feature -> feature.get().equals(featureSlug)) + .orElse(false) + ) + .map(module -> module.slug().get()) + .sorted() + .toList(); + } + + private static String dependencyToken(Seed4JLandscapeDependency dependency) { + return dependency.type().name().toLowerCase() + ":" + dependency.slug().get(); + } + + private record DependencyPlanningContext( + Map modulesBySlug, + List visibleModules, + Set appliedModules + ) { + private DependencyPlanningContext { + modulesBySlug = Map.copyOf(modulesBySlug); + visibleModules = List.copyOf(visibleModules); + appliedModules = Set.copyOf(appliedModules); + } + + private static DependencyPlanningContext from(Seed4JModulesResources resources, ProjectHistory history) { + List visibleModules = resources.stream().sorted(byModuleSlug()).toList(); + return new DependencyPlanningContext( + visibleModules.stream().collect(Collectors.toMap(resource -> resource.slug().get(), Function.identity())), + visibleModules, + ApplyModuleDependencyPlanner.appliedModules(history) + ); + } + + private Optional module(String slug) { + return Optional.ofNullable(modulesBySlug.get(slug)); + } + } + + private record DependencyPlanningProgress( + List lines, + Set plannedDependencies, + Set visitedModules + ) { + private DependencyPlanningProgress { + lines = List.copyOf(lines); + plannedDependencies = Collections.unmodifiableSet(new LinkedHashSet<>(plannedDependencies)); + visitedModules = Collections.unmodifiableSet(new LinkedHashSet<>(visitedModules)); + } + + private static DependencyPlanningProgress empty() { + return new DependencyPlanningProgress(List.of(), Set.of(), Set.of()); + } + + private DependencyPlanningProgress withVisitedModule(String moduleSlug) { + Set nextVisitedModules = new LinkedHashSet<>(visitedModules); + nextVisitedModules.add(moduleSlug); + return new DependencyPlanningProgress(lines, plannedDependencies, nextVisitedModules); + } + + private DependencyPlanningProgress withPlanLineIfMissing(ApplyModuleDependencyPlanLine line) { + if (plannedDependencies.contains(line.dependency())) { + return this; + } + + List nextLines = new ArrayList<>(lines); + Set nextPlannedDependencies = new LinkedHashSet<>(plannedDependencies); + nextLines.add(line); + nextPlannedDependencies.add(line.dependency()); + return new DependencyPlanningProgress(nextLines, nextPlannedDependencies, visitedModules); + } + } +} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java new file mode 100644 index 00000000..e499f05d --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleDependencyStatus.java @@ -0,0 +1,51 @@ +package com.seed4j.cli.command.infrastructure.primary; + +import com.seed4j.cli.shared.error.domain.Assert; +import java.util.List; + +record ApplyModuleDependencyStatus(StatusKind kind, String moduleSlug, List candidates) { + ApplyModuleDependencyStatus { + candidates = List.copyOf(candidates); + } + + static ApplyModuleDependencyStatus alreadyApplied() { + return new ApplyModuleDependencyStatus(StatusKind.ALREADY_APPLIED, "", List.of()); + } + + static ApplyModuleDependencyStatus pending() { + return new ApplyModuleDependencyStatus(StatusKind.PENDING, "", List.of()); + } + + static ApplyModuleDependencyStatus satisfiedBy(String moduleSlug) { + return new ApplyModuleDependencyStatus(StatusKind.SATISFIED_BY, moduleSlug, List.of()); + } + + static ApplyModuleDependencyStatus pendingChoice(List candidates) { + Assert.notEmpty("candidates", candidates); + + return new ApplyModuleDependencyStatus(StatusKind.PENDING_CHOICE, "", candidates); + } + + String displayLabel() { + return switch (kind) { + case ALREADY_APPLIED -> "already applied"; + case PENDING -> "pending"; + case SATISFIED_BY -> "satisfied by " + moduleSlug; + case PENDING_CHOICE -> "pending choice: " + String.join(", ", candidates); + }; + } + + ApplyModulePlanItemMarker planMarker() { + return switch (kind) { + case ALREADY_APPLIED, SATISFIED_BY -> ApplyModulePlanItemMarker.RESOLVED; + case PENDING, PENDING_CHOICE -> ApplyModulePlanItemMarker.PENDING; + }; + } + + private enum StatusKind { + ALREADY_APPLIED, + PENDING, + SATISFIED_BY, + PENDING_CHOICE, + } +} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanItemMarker.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanItemMarker.java new file mode 100644 index 00000000..c3503014 --- /dev/null +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanItemMarker.java @@ -0,0 +1,16 @@ +package com.seed4j.cli.command.infrastructure.primary; + +enum ApplyModulePlanItemMarker { + RESOLVED("✓"), + PENDING("○"); + + private final String symbol; + + ApplyModulePlanItemMarker(String symbol) { + this.symbol = symbol; + } + + String prefix() { + return symbol + " "; + } +} diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java index f0bdba8c..b435e058 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModulePlanRenderer.java @@ -5,17 +5,40 @@ class ApplyModulePlanRenderer { private static final String PROJECT_HISTORY_NOTE = "already selected by project history; omit this option to keep it."; private static final String MISSING_REQUIRED_NOTE = "pass this option or apply a module that records it in project history."; - String render(String moduleSlug, String projectPath, ResolvedModuleParameters parameters) { + String render(String moduleSlug, String projectPath, ApplyModuleDependencyPlan dependencyPlan, ResolvedModuleParameters parameters) { StringBuilder plan = new StringBuilder(); plan.append("Plan for module: ").append(moduleSlug).append('\n'); plan.append("Project path: ").append(projectPath).append('\n'); + plan.append('\n'); + plan.append("Dependency plan:").append('\n'); + + if (dependencyPlan.empty()) { + plan.append('\n'); + plan.append(ApplyModulePlanItemMarker.RESOLVED.prefix()).append("No dependencies.").append('\n'); + } else { + plan.append('\n'); + for (ApplyModuleDependencyPlanLine line : dependencyPlan.lines()) { + plan + .append(line.status().planMarker().prefix()) + .append(line.dependency()) + .append(" - ") + .append(line.status().displayLabel()) + .append('\n'); + } + } + plan.append('\n'); plan.append("Resolved parameters:").append('\n'); if (!parameters.empty()) { for (ResolvedModuleParameter parameter : parameters.parameters()) { plan.append('\n'); - plan.append(parameter.name()).append(": ").append(parameter.value()).append('\n'); + plan + .append(ApplyModulePlanItemMarker.RESOLVED.prefix()) + .append(parameter.name()) + .append(": ") + .append(parameter.value()) + .append('\n'); plan.append(" Source: ").append(parameter.source().displayLabel()).append('\n'); plan.append(" CLI option: ").append(parameter.cliOption()).append('\n'); if (parameter.source().projectHistory()) { @@ -29,7 +52,7 @@ String render(String moduleSlug, String projectPath, ResolvedModuleParameters pa plan.append("Missing required parameters:").append('\n'); for (MissingRequiredModuleParameter parameter : parameters.missingRequiredParameters()) { plan.append('\n'); - plan.append(parameter.name()).append(":").append('\n'); + plan.append(ApplyModulePlanItemMarker.PENDING.prefix()).append(parameter.name()).append(":").append('\n'); plan.append(" CLI option: ").append(parameter.cliOption()).append('\n'); plan.append(" Note: ").append(MISSING_REQUIRED_NOTE).append('\n'); } diff --git a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleSubCommand.java b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleSubCommand.java index cb7c512d..1bba40c6 100644 --- a/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleSubCommand.java +++ b/src/main/java/com/seed4j/cli/command/infrastructure/primary/ApplyModuleSubCommand.java @@ -39,6 +39,7 @@ class ApplyModuleSubCommand implements Callable { private final ProjectsApplicationService projects; private final KnownModulePropertyCompletionCandidates knownCompletionCandidates = new KnownModulePropertyCompletionCandidates(); private final ApplyModuleParameterResolver parameterResolver = new ApplyModuleParameterResolver(); + private final ApplyModuleDependencyPlanner dependencyPlanner = new ApplyModuleDependencyPlanner(); private final ApplyModulePlanRenderer planRenderer = new ApplyModulePlanRenderer(); public ApplyModuleSubCommand(Seed4JModulesApplicationService modules, Seed4JModuleResource module, ProjectsApplicationService projects) { @@ -171,7 +172,12 @@ public Integer call() { explicitParameters, historyParameters.get() ); - System.out.print(planRenderer.render(module.slug().get(), projectPath, resolvedParameters)); + ApplyModuleDependencyPlan dependencyPlan = dependencyPlanner.plan( + module, + modules.resources(), + projects.getHistory(new ProjectPath(projectPath)) + ); + System.out.print(planRenderer.render(module.slug().get(), projectPath, dependencyPlan, resolvedParameters)); return ExitCode.OK; } @@ -241,7 +247,10 @@ private void validateRequiredOptions(ModuleParameters moduleParameters) { throw new MissingParameterException( commandSpec.commandLine(), - missingOptions.stream().map(ArgSpec.class::cast).toList(), + missingOptions + .stream() + .map(ArgSpec.class::cast) + .toList(), "Missing required options: %s".formatted(missingOptionsDescription) ); } diff --git a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java index a1c95bcf..06d20070 100644 --- a/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java +++ b/src/test/java/com/seed4j/cli/command/infrastructure/primary/Seed4JCommandsFactoryTest.java @@ -343,24 +343,28 @@ void shouldPlanInitModuleWithExplicitAndDefaultParameterSources(CapturedOutput o Plan for module: init Project path: %s + Dependency plan: + + ✓ No dependencies. + Resolved parameters: - projectName: Seed4J Sample Application + ✓ projectName: Seed4J Sample Application Source: explicit CLI input CLI option: --project-name - baseName: seed4jSampleApplication + ✓ baseName: seed4jSampleApplication Source: explicit CLI input CLI option: --base-name - nodePackageManager: pnpm + ✓ nodePackageManager: pnpm Source: explicit CLI input CLI option: --node-package-manager """.formatted(projectPath) ) .contains( """ - endOfLine: lf + ✓ endOfLine: lf Source: default CLI option: --end-of-line """ @@ -398,19 +402,19 @@ void shouldPlanInitModuleWithHistorySourcesAndExplicitOverrides(CapturedOutput o assertThat(output) .contains( """ - projectName: Seed4J Sample Application + ✓ projectName: Seed4J Sample Application Source: project history CLI option: --project-name Note: already selected by project history; omit this option to keep it. - baseName: explicitOverride + ✓ baseName: explicitOverride Source: explicit CLI input CLI option: --base-name """ ) .contains( """ - nodePackageManager: npm + ✓ nodePackageManager: npm Source: project history CLI option: --node-package-manager Note: already selected by project history; omit this option to keep it. @@ -420,6 +424,85 @@ void shouldPlanInitModuleWithHistorySourcesAndExplicitOverrides(CapturedOutput o .doesNotContain("Missing required parameters:"); } + @Test + void shouldPlanModuleDependencyStatuses(CapturedOutput output) throws IOException { + Path projectPath = setupProjectTestFolder(); + String[] applyArgs = { + "apply", + "init", + "--project-path", + projectPath.toString(), + "--base-name", + "seed4jSampleApplication", + "--project-name", + "Seed4J Sample Application", + "--node-package-manager", + "npm", + }; + int applyExitCode = commandLine(modules, projects).execute(applyArgs); + assertThat(applyExitCode).isZero(); + int historyActionsBeforePlan = projects.getHistory(new ProjectPath(projectPath.toString())).actions().size(); + String commitsBeforePlan = GitTestUtil.getCommits(projectPath); + String[] planArgs = { "apply", "angular-core", "--project-path", projectPath.toString(), "--plan" }; + + int exitCode = commandLine(modules, projects).execute(planArgs); + + assertThat(exitCode).isZero(); + assertThat(projects.getHistory(new ProjectPath(projectPath.toString())).actions()).hasSize(historyActionsBeforePlan); + assertThat(GitTestUtil.getCommits(projectPath)).isEqualTo(commitsBeforePlan); + assertThat(output).contains( + """ + Dependency plan: + + ✓ module:init - already applied + ○ module:prettier - pending + + Resolved parameters: + """ + ); + } + + @Test + void shouldPlanFeatureDependencyStatuses(CapturedOutput output) throws IOException { + Path projectPath = setupProjectTestFolder(); + String[] initArgs = { + "apply", + "init", + "--project-path", + projectPath.toString(), + "--base-name", + "seed4jSampleApplication", + "--project-name", + "Seed4J Sample Application", + "--node-package-manager", + "npm", + }; + int initExitCode = commandLine(modules, projects).execute(initArgs); + assertThat(initExitCode).isZero(); + String[] mavenJavaArgs = { "apply", "maven-java", "--project-path", projectPath.toString(), "--package-name", "com.mycompany.myapp" }; + int mavenJavaExitCode = commandLine(modules, projects).execute(mavenJavaArgs); + assertThat(mavenJavaExitCode).isZero(); + int historyActionsBeforePlan = projects.getHistory(new ProjectPath(projectPath.toString())).actions().size(); + String commitsBeforePlan = GitTestUtil.getCommits(projectPath); + String[] planArgs = { "apply", "sonarqube-java-backend", "--project-path", projectPath.toString(), "--plan" }; + + int exitCode = commandLine(modules, projects).execute(planArgs); + + assertThat(exitCode).isZero(); + assertThat(projects.getHistory(new ProjectPath(projectPath.toString())).actions()).hasSize(historyActionsBeforePlan); + assertThat(GitTestUtil.getCommits(projectPath)).isEqualTo(commitsBeforePlan); + assertThat(output).contains( + """ + Dependency plan: + + ○ feature:code-coverage-java - pending choice: jacoco, jacoco-with-min-coverage-check + ✓ feature:java-build-tool - satisfied by maven-java + + Resolved parameters: + """ + ); + } + @Test void shouldNotApplyInitModuleMissingRequiredOptions(CapturedOutput output) throws IOException { Path projectPath = setupProjectTestFolder(); @@ -453,6 +536,10 @@ void shouldPlanInitModuleMissingRequiredOptions(CapturedOutput output) throws IO Plan for module: init Project path: %s + Dependency plan: + + ✓ No dependencies. + Resolved parameters: """.formatted(projectPath) ) @@ -460,11 +547,11 @@ void shouldPlanInitModuleMissingRequiredOptions(CapturedOutput output) throws IO """ Missing required parameters: - projectName: + ○ projectName: CLI option: --project-name Note: pass this option or apply a module that records it in project history. - baseName: + ○ baseName: CLI option: --base-name Note: pass this option or apply a module that records it in project history. """ @@ -475,7 +562,7 @@ void shouldPlanInitModuleMissingRequiredOptions(CapturedOutput output) throws IO @Test void shouldPlanModuleWithoutResolvedParameters(CapturedOutput output) throws IOException { Path projectPath = setupProjectTestFolder(); - String[] args = { "apply", "checkstyle", "--project-path", projectPath.toString(), "--plan" }; + String[] args = { "apply", "front-hexagonal-architecture", "--project-path", projectPath.toString(), "--plan" }; int exitCode = commandLine(modules, projects).execute(args); @@ -485,9 +572,13 @@ void shouldPlanModuleWithoutResolvedParameters(CapturedOutput output) throws IOE assertThat(output) .contains( """ - Plan for module: checkstyle + Plan for module: front-hexagonal-architecture Project path: %s + Dependency plan: + + ✓ No dependencies. + Resolved parameters: No changes were applied.