FoundationBaker.java

package network.ike.plugin.scaffold;

import network.ike.plugin.support.version.CandidateVersionResolver;
import network.ike.plugin.support.version.MavenVersionComparator;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Release-time refresh of the scaffold manifest's {@code foundation:}
 * block (IKE-Network/ike-issues#414).
 *
 * <p>The {@code foundation:} block in
 * {@code ike-build-standards/src/main/scaffold/scaffold-manifest.yaml}
 * is the tested-together compatibility snapshot a consumer picks up
 * with a given {@code ike-tooling} scaffold. {@code ike-tooling.version}
 * is filtered from {@code ${project.version}} and so is always correct;
 * {@code ike-parent}, {@code ike-docs}, and {@code ike-platform} were
 * hand-maintained literals. This class resolves their latest released
 * (GA) versions from the configured remote repositories and rewrites
 * the block, so the scaffold zip always ships a current snapshot
 * without a manual edit.
 *
 * <p>This is the <em>discovery</em> half of foundation currency — the
 * scaffold {@code foundation:} block can only propagate a snapshot, it
 * cannot find out a newer upstream version exists. Moving discovery to
 * release-time bake here is what lets the scaffold mechanism own
 * foundation currency end to end (the precondition for retiring the
 * {@code versions-upgrade} subsystem, ike-issues#415).
 */
public final class FoundationBaker {

    /**
     * A foundation artifact whose pin is baked at release time.
     *
     * @param label      short human-readable name, e.g. {@code "ike-parent"}
     * @param groupId    the Maven groupId
     * @param artifactId the Maven artifactId
     */
    public record Coordinate(String label, String groupId,
                              String artifactId) {
    }

    /**
     * {@code ike-parent} — carried in the {@code foundation.parent}
     * block. {@code ike-tooling.version} is deliberately absent: it is
     * filtered from {@code ${project.version}} and never baked.
     */
    public static final Coordinate IKE_PARENT = new Coordinate(
            "ike-parent", "network.ike.platform", "ike-parent");

    /** {@code ike-docs} — the {@code ike-docs.version} property pin. */
    public static final Coordinate IKE_DOCS = new Coordinate(
            "ike-docs", "network.ike.docs", "ike-docs");

    /**
     * {@code ike-platform} — the {@code ike-platform.version} pin.
     *
     * <p>{@code ike-parent}, {@code ike-bom}, {@code ike-workspace-maven-plugin}
     * and the {@code network.ike.platform:ike-platform} reactor root all
     * share one unified version, so this pin and {@link #IKE_PARENT}
     * always resolve to the same release. {@link #assess} queries
     * {@code ike-parent} once and answers both — the {@code groupId} /
     * {@code artifactId} here document what the pin tracks but are not
     * resolved independently.
     */
    public static final Coordinate IKE_PLATFORM = new Coordinate(
            "ike-platform", "network.ike.platform", "ike-platform");

    /**
     * {@code ike-tooling} — tracks the {@code ike-tooling.version}
     * property pin (the version line {@code ike-build-standards},
     * {@code ike-maven-plugin} and the rest of the ike-tooling reactor
     * share). Resolved only for the consumer-side
     * {@link #latestFoundation} path; the release-time {@link #assess}
     * never touches it — there it is {@code ${project.version}},
     * correct by construction.
     */
    public static final Coordinate IKE_TOOLING = new Coordinate(
            "ike-tooling", "network.ike.tooling", "ike-tooling");

    /** Classification of a foundation pin against its latest GA. */
    public enum Status {
        /** Latest GA is newer than the pin — a bake is needed. */
        AHEAD,
        /** Pin already equals the latest GA. */
        CURRENT,
        /** Pin is newer than any GA — a backward bake; release fails. */
        BEHIND,
        /** No released version could be resolved for the coordinate. */
        UNRESOLVED
    }

    /**
     * The assessment of one foundation pin.
     *
     * @param coordinate the artifact this pin tracks
     * @param current    the version currently pinned in the manifest
     * @param latest     the latest resolved GA, or {@code null} when
     *                   {@link Status#UNRESOLVED}
     * @param status     how {@code latest} relates to {@code current}
     */
    public record Finding(Coordinate coordinate, String current,
                           String latest, Status status) {
    }

    private FoundationBaker() {
    }

    /**
     * Assess every baked foundation pin against the latest GA versions
     * the resolver can see.
     *
     * <p>Two coordinates are resolved, not three: {@code ike-parent} and
     * {@code ike-docs}. The {@code ike-platform.version} pin shares the
     * unified ike-platform reactor version with {@code ike-parent}, so
     * its {@link Finding} is derived from the same resolution rather
     * than queried separately.
     *
     * @param foundation the manifest's current {@code foundation:} block
     * @param resolver   resolves released versions for a coordinate
     * @return one {@link Finding} per pin, in
     *         {@code ike-parent, ike-docs, ike-platform} order
     */
    public static List<Finding> assess(ScaffoldManifest.Foundation foundation,
                                        CandidateVersionResolver resolver) {
        String platformLatest = resolveLatest(IKE_PARENT, resolver);
        String docsLatest = resolveLatest(IKE_DOCS, resolver);
        List<Finding> findings = new ArrayList<>(3);
        findings.add(classify(IKE_PARENT,
                foundation.parent().version(), platformLatest));
        findings.add(classify(IKE_DOCS,
                foundation.properties().get("ike-docs.version"), docsLatest));
        findings.add(classify(IKE_PLATFORM,
                foundation.properties().get("ike-platform.version"),
                platformLatest));
        return findings;
    }

    /**
     * Resolve the highest released (GA) version of a coordinate, or
     * {@code null} when nothing can be resolved.
     */
    private static String resolveLatest(Coordinate coord,
                                        CandidateVersionResolver resolver) {
        // resolveCandidates returns ascending GA-only versions.
        List<String> candidates = resolver.resolveCandidates(
                coord.groupId(), coord.artifactId(), null);
        return candidates.isEmpty() ? null
                : candidates.get(candidates.size() - 1);
    }

    /**
     * Resolve the latest released versions for every foundation pin and
     * return a {@link ScaffoldManifest.Foundation} carrying them — the
     * input snapshot with each version advanced to the latest GA the
     * resolver can see.
     *
     * <p>The consumer-side counterpart to {@link #assess}: it lets
     * {@code ike:scaffold-publish}'s opt-in resolve-latest mode apply
     * <em>current</em> foundation pins instead of the (possibly stale,
     * parent-pinned) snapshot baked into the scaffold zip — the escape
     * hatch for the bootstrap loop where a consumer's scaffold tooling
     * is itself gated by the {@code ike-parent} it needs to bump.
     *
     * <p>A pin is only advanced, never lowered: when the resolved GA is
     * not newer than the baked value, or cannot be resolved, the baked
     * value is kept. A value containing {@code ${...}} is left verbatim.
     *
     * @param baked    the foundation snapshot from the scaffold zip
     * @param resolver resolves released versions for a coordinate
     * @return a foundation with each pin at the latest released version
     */
    public static ScaffoldManifest.Foundation latestFoundation(
            ScaffoldManifest.Foundation baked,
            CandidateVersionResolver resolver) {
        String platform = resolveLatest(IKE_PARENT, resolver);
        String docs = resolveLatest(IKE_DOCS, resolver);
        String tooling = resolveLatest(IKE_TOOLING, resolver);

        ScaffoldManifest.ParentRef p = baked.parent();
        ScaffoldManifest.ParentRef parent = new ScaffoldManifest.ParentRef(
                p.groupId(), p.artifactId(), higher(p.version(), platform));

        Map<String, String> props = new LinkedHashMap<>(baked.properties());
        props.computeIfPresent("ike-tooling.version",
                (k, v) -> higher(v, tooling));
        props.computeIfPresent("ike-docs.version",
                (k, v) -> higher(v, docs));
        props.computeIfPresent("ike-platform.version",
                (k, v) -> higher(v, platform));
        return new ScaffoldManifest.Foundation(parent, props);
    }

    /**
     * The higher of the baked and resolved versions. The baked value
     * wins when the resolved one is {@code null}, when the baked value
     * is an unresolved {@code ${...}} placeholder, or when the resolved
     * one is not strictly newer — a pin is advanced, never lowered.
     */
    private static String higher(String baked, String resolved) {
        if (resolved == null || baked == null || baked.contains("${")) {
            return baked;
        }
        return MavenVersionComparator.INSTANCE.compare(resolved, baked) > 0
                ? resolved : baked;
    }

    /** Classify a pin's current value against the resolved latest GA. */
    private static Finding classify(Coordinate coord, String current,
                                    String latest) {
        if (latest == null) {
            return new Finding(coord, current, null, Status.UNRESOLVED);
        }
        int cmp = current == null ? 1
                : MavenVersionComparator.INSTANCE.compare(latest, current);
        Status status = cmp > 0 ? Status.AHEAD
                : cmp == 0 ? Status.CURRENT
                : Status.BEHIND;
        return new Finding(coord, current, latest, status);
    }

    /**
     * Rewrite the {@code foundation:} block of a scaffold-manifest YAML
     * document, applying every {@link Status#AHEAD} finding. Only the
     * version values change — comments, indentation, key order, and the
     * rest of the document are byte-preserved.
     *
     * @param manifestYaml the full scaffold-manifest.yaml content
     * @param findings      the assessment from {@link #assess}
     * @return the rewritten content; identical to the input when no
     *         finding is {@link Status#AHEAD}
     */
    public static String rewrite(String manifestYaml,
                                 List<Finding> findings) {
        String parentTarget = aheadTarget(findings, IKE_PARENT);
        String docsTarget = aheadTarget(findings, IKE_DOCS);
        String platformTarget = aheadTarget(findings, IKE_PLATFORM);
        if (parentTarget == null && docsTarget == null
                && platformTarget == null) {
            return manifestYaml;
        }

        String[] lines = manifestYaml.split("\n", -1);
        boolean inFoundation = false;
        boolean parentDone = false;
        for (int i = 0; i < lines.length; i++) {
            String line = lines[i];
            String trimmed = line.strip();

            if (trimmed.equals("foundation:")) {
                inFoundation = true;
                continue;
            }
            if (!inFoundation) {
                continue;
            }
            // Foundation block ends at the next column-0 key.
            if (!line.isBlank() && !line.startsWith(" ")
                    && !trimmed.startsWith("#")) {
                break;
            }

            if (parentTarget != null && !parentDone
                    && trimmed.startsWith("version:")) {
                // The only bare `version:` key in the block is the
                // parent's — property pins are `<name>.version:`.
                lines[i] = replaceValue(line, parentTarget);
                parentDone = true;
            } else if (docsTarget != null
                    && trimmed.startsWith("ike-docs.version:")) {
                lines[i] = replaceValue(line, docsTarget);
            } else if (platformTarget != null
                    && trimmed.startsWith("ike-platform.version:")) {
                lines[i] = replaceValue(line, platformTarget);
            }
        }
        return String.join("\n", lines);
    }

    /**
     * The latest version for {@code coord} when its finding is
     * {@link Status#AHEAD}, else {@code null}.
     */
    private static String aheadTarget(List<Finding> findings,
                                      Coordinate coord) {
        for (Finding f : findings) {
            if (f.coordinate().equals(coord) && f.status() == Status.AHEAD) {
                return f.latest();
            }
        }
        return null;
    }

    /**
     * Replace the quoted value of a {@code key: "value"} YAML line,
     * preserving the leading indentation and the key.
     */
    private static String replaceValue(String line, String newValue) {
        int colon = line.indexOf(':');
        return line.substring(0, colon + 1) + " \"" + newValue + "\"";
    }
}