ScaffoldMojoSupport.java
package network.ike.plugin.scaffold;
import org.apache.maven.api.Session;
import org.apache.maven.api.plugin.Log;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Shared plumbing for {@code ike:scaffold-draft},
* {@code ike:scaffold-publish}, and {@code ike:scaffold-revert}.
*
* <p>Resolves the scaffold tree (manifest + templates), loads
* per-scope lockfiles from disk, and offers pure-function helpers
* the three mojos use. Written as a final class with static
* methods so the mojos can share wiring without inheritance.
*/
public final class ScaffoldMojoSupport {
/** File name of the manifest inside a scaffold tree. */
public static final String MANIFEST_FILE = "scaffold-manifest.yaml";
/** Relative path of the per-project lockfile. */
public static final String PROJECT_LOCKFILE_REL =
".ike/scaffold.lock";
/** Relative path of the per-user lockfile. */
public static final String USER_LOCKFILE_REL =
".ike/scaffold.lock";
private ScaffoldMojoSupport() {}
/**
* Resolve the effective project root for a scaffold goal.
*
* <p>Goals annotated {@code projectRequired = false} cannot rely on
* Maven's parameter-default expansion of {@code ${project.basedir}}
* — Maven 4 leaves the placeholder uninterpolated when no project
* is required, even when one is in scope. Callers therefore pass an
* explicit {@code projectRoot} parameter when supplied by the user;
* when blank, this helper derives the directory from
* {@link Session#getTopDirectory()} (the directory Maven was
* launched from).
*
* <p>The result is null when no {@code pom.xml} sits at the
* resolved location, which the calling mojo treats as
* fresh-machine bootstrap (user scope only, no project scope).
*
* @param projectRootOverride raw value of the {@code projectRoot}
* parameter; null/blank means
* "auto-detect"
* @param session Maven 4 session; required for
* auto-detection. May be {@code null}
* in tests that supply an explicit
* override.
* @return absolute project root, or {@code null} when no project
* is in scope at the resolved location
*/
public static Path resolveProjectRoot(
String projectRootOverride, Session session) {
if (projectRootOverride != null
&& !projectRootOverride.isBlank()) {
return Path.of(projectRootOverride);
}
if (session == null) {
return null;
}
Path top = session.getTopDirectory();
if (top == null) {
return null;
}
if (!Files.isRegularFile(top.resolve("pom.xml"))) {
return null;
}
return top;
}
/**
* Resolve the manifest file at the root of a scaffold tree.
*
* @param scaffoldDir the directory containing
* {@value #MANIFEST_FILE} and templates
* @return parsed manifest
* @throws ScaffoldException if the directory or manifest is missing
*/
public static ScaffoldManifest loadManifest(Path scaffoldDir) {
if (scaffoldDir == null) {
throw new ScaffoldException(
"scaffoldDir is required; point it at the "
+ "unpacked ike-build-standards scaffold tree");
}
if (!Files.isDirectory(scaffoldDir)) {
throw new ScaffoldException(
"scaffoldDir is not a directory: " + scaffoldDir);
}
Path manifestPath = scaffoldDir.resolve(MANIFEST_FILE);
if (!Files.isRegularFile(manifestPath)) {
throw new ScaffoldException(
"missing " + MANIFEST_FILE + " under "
+ scaffoldDir);
}
return ScaffoldManifestIo.read(manifestPath);
}
/**
* Resolve the project lockfile path, creating nothing.
*
* @param projectRoot project base directory; may be {@code null}
* when no project is in scope
* @return absolute path to {@code {projectRoot}/.ike/scaffold.lock},
* or {@code null} if {@code projectRoot} is null
*/
public static Path projectLockfilePath(Path projectRoot) {
if (projectRoot == null) {
return null;
}
return projectRoot.resolve(PROJECT_LOCKFILE_REL);
}
/**
* Resolve the user lockfile path, creating nothing.
*
* @param userHome the user's home directory; required
* @return absolute path to {@code {userHome}/.ike/scaffold.lock}
*/
public static Path userLockfilePath(Path userHome) {
return userHome.resolve(USER_LOCKFILE_REL);
}
/**
* Load a lockfile from disk, returning
* {@link ScaffoldLockfile#empty()} when the file is absent.
*
* @param lockfilePath path to the lockfile; may be {@code null}
* (returns empty)
* @return the loaded lockfile, or an empty lockfile
*/
public static ScaffoldLockfile loadLockfileOrEmpty(Path lockfilePath) {
if (lockfilePath == null) {
return ScaffoldLockfile.empty();
}
if (!Files.isRegularFile(lockfilePath)) {
return ScaffoldLockfile.empty();
}
return ScaffoldLockfileIo.read(lockfilePath);
}
/**
* Render a draft-style listing of a plan suitable for log output.
*
* <p>Lines look like:
* <pre>
* [INSTALL] mvnw -> /path/to/mvnw
* [UPDATE] .mvn/maven.config -> refresh
* [SKIP] .gitignore user-edited; +3/-1
* [OK] ~/.m2/settings.xml up-to-date
* [USER] ~/.gitconfig deferred to user value for [core].hooksPath
* </pre>
*
* @param plan the plan to render
* @param scope the scope the plan was built for (used as a header)
* @return multi-line human-readable report (no trailing newline)
*/
public static String renderPlanReport(
ScaffoldPlan plan, ScaffoldScope scope) {
StringBuilder sb = new StringBuilder();
sb.append("─── ").append(scope.manifestValue())
.append(" scope (").append(plan.entries().size())
.append(" entries) ───");
if (plan.entries().isEmpty()) {
sb.append("\n (nothing in this scope)");
return sb.toString();
}
for (PlannedEntry pe : plan.entries()) {
sb.append('\n').append(formatLine(pe));
}
return sb.toString();
}
/**
* Render an orphan listing suitable for log output. Orphans are
* lockfile entries the current manifest no longer ships.
*
* @param orphans the orphans found by {@link OrphanScanner}
* @param scope the scope the scan targeted (used as a header)
* @return multi-line human-readable report (no trailing newline)
*/
public static String renderOrphanReport(
List<OrphanEntry> orphans, ScaffoldScope scope) {
StringBuilder sb = new StringBuilder();
sb.append("─── ").append(scope.manifestValue())
.append(" scope — ").append(orphans.size())
.append(" orphan(s) ───");
for (OrphanEntry o : orphans) {
sb.append('\n').append(" [ORPHAN] ")
.append(padRight(o.dest(), 36))
.append(" ").append(o.reason());
}
return sb.toString();
}
/**
* One-line summary of what the plan would change, for post-publish
* telemetry and draft headers.
*
* @param plan the plan to summarise
* @return counts of each action type
*/
public static Counts countActions(ScaffoldPlan plan) {
int install = 0, update = 0, skip = 0;
int upToDate = 0, userManaged = 0;
for (PlannedEntry pe : plan.entries()) {
TierAction a = pe.action();
if (a instanceof TierAction.Write w) {
if (w.kind() == TierAction.Write.Kind.INSTALL) {
install++;
} else {
update++;
}
} else if (a instanceof TierAction.Skip) {
skip++;
} else if (a instanceof TierAction.UpToDate) {
upToDate++;
} else if (a instanceof TierAction.UserManaged) {
userManaged++;
}
}
return new Counts(install, update, skip, upToDate, userManaged);
}
/**
* Render a revert-outcome listing suitable for log output.
*
* @param result the revert result
* @param scope the scope the revert targeted
* @return multi-line human-readable report (no trailing newline)
*/
public static String renderRevertReport(
ScaffoldReverter.RevertResult result, ScaffoldScope scope) {
StringBuilder sb = new StringBuilder();
sb.append("─── ").append(scope.manifestValue())
.append(" scope (").append(result.outcomes().size())
.append(" entries) ───");
if (result.outcomes().isEmpty()) {
sb.append("\n (nothing to revert)");
return sb.toString();
}
for (ScaffoldReverter.Outcome o : result.outcomes()) {
sb.append('\n').append(formatOutcome(o));
}
return sb.toString();
}
/**
* Write a batch of plan-report lines through the supplied log.
* Each {@code \n}-separated segment becomes a separate
* {@link Log#info(CharSequence) log.info} call so Maven's
* level/colour formatting works on each line.
*
* @param log the mojo log
* @param multiLine multi-line text
*/
public static void logLines(Log log, String multiLine) {
for (String line : multiLine.split("\n")) {
log.info(line);
}
}
// ── Helpers ─────────────────────────────────────────────────────
private static String formatLine(PlannedEntry pe) {
String dest = pe.manifest().dest();
TierAction a = pe.action();
if (a instanceof TierAction.Write w) {
String label = switch (w.kind()) {
case INSTALL -> "[INSTALL]";
case UPDATE -> "[UPDATE] ";
case REVERT -> "[REVERT] ";
};
return " " + label + " " + padRight(dest, 36)
+ " " + w.reason();
}
if (a instanceof TierAction.Skip s) {
StringBuilder line = new StringBuilder();
line.append(" [SKIP] ").append(padRight(dest, 36))
.append(" ").append(s.reason());
if (!s.diff().isBlank()) {
for (String diffLine : s.diff().split("\n")) {
line.append('\n').append(" ").append(diffLine);
}
}
return line.toString();
}
if (a instanceof TierAction.UpToDate u) {
return " [OK] " + padRight(dest, 36)
+ " " + u.reason();
}
if (a instanceof TierAction.UserManaged m) {
return " [USER] " + padRight(dest, 36)
+ " " + m.reason();
}
return " [?] " + dest;
}
private static String formatOutcome(ScaffoldReverter.Outcome o) {
String label = switch (o.kind()) {
case DELETED -> "[DELETED]";
case REMOVED_FROM_LOCKFILE -> "[CLEARED]";
case SKIPPED -> "[SKIP] ";
};
return " " + label + " " + padRight(o.dest(), 36)
+ " " + o.message();
}
private static String padRight(String s, int width) {
if (s.length() >= width) {
return s;
}
StringBuilder sb = new StringBuilder(s);
while (sb.length() < width) {
sb.append(' ');
}
return sb.toString();
}
/**
* Aggregate counts of each action kind in a plan.
*
* @param install number of {@link TierAction.Write} with
* {@link TierAction.Write.Kind#INSTALL}
* @param update number of {@link TierAction.Write} with
* {@link TierAction.Write.Kind#UPDATE}
* @param skip number of {@link TierAction.Skip}
* @param upToDate number of {@link TierAction.UpToDate}
* @param userManaged number of {@link TierAction.UserManaged}
*/
public record Counts(
int install, int update, int skip,
int upToDate, int userManaged) {
/**
* Total number of entries across every action kind.
*
* @return sum of {@code install + update + skip + upToDate +
* userManaged}
*/
public int total() {
return install + update + skip + upToDate + userManaged;
}
/**
* Whether any write action is planned.
*
* @return {@code true} if {@code install + update > 0}
*/
public boolean hasWrites() {
return install + update > 0;
}
/**
* One-line summary like
* {@code "2 install, 1 update, 0 skip, 0 ok, 1 user"}.
*
* @return summary line suitable for draft-output display
*/
public String summary() {
return install + " install, " + update + " update, "
+ skip + " skip, " + upToDate + " ok, "
+ userManaged + " user";
}
}
/**
* Scopes the mojos should run for given a project-required
* context: in a project, both scopes apply; standalone, only
* user.
*
* @param inProject whether the mojo invocation has a project
* @return scopes to process, in order
*/
public static List<ScaffoldScope> scopesToProcess(boolean inProject) {
List<ScaffoldScope> scopes = new ArrayList<>();
scopes.add(ScaffoldScope.USER);
if (inProject) {
scopes.add(ScaffoldScope.PROJECT);
}
return scopes;
}
}