ScaffoldLockfile.java

package network.ike.plugin.scaffold;

import java.time.Instant;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;

/**
 * In-memory representation of a scaffold lockfile.
 *
 * <p>Two lockfile instances exist per scaffold run:
 *
 * <ul>
 *   <li>{@code {project.root}/.ike/scaffold.lock} — committed to git,
 *       tracks what the scaffold has installed in a project.</li>
 *   <li>{@code {user.home}/.ike/scaffold.lock} — local to a machine,
 *       tracks what the scaffold has installed in the user-home tier
 *       ({@code ~/.git-hooks/}, {@code ~/.m2/settings.xml}, etc.).</li>
 * </ul>
 *
 * <p>The on-disk format is YAML; see
 * {@code ScaffoldLockfileIo} for parse/emit.
 *
 * <p>This class records only current scaffold-owned state; it never
 * stores user secrets or free-form user content.
 *
 * @param schema           lockfile schema version (currently
 *                         {@link #CURRENT_SCHEMA}); future schema
 *                         bumps will be accompanied by an in-place
 *                         migrator
 * @param standardsVersion {@code ike-build-standards} version that
 *                         produced the last applied state; may be
 *                         {@code null} for a newly created lockfile
 *                         that has not yet had a successful publish
 * @param applied          UTC timestamp of the last successful
 *                         publish; may be {@code null} (never
 *                         published)
 * @param files            per-file entries, keyed by scaffold path.
 *                         Paths are normalised to forward slashes and
 *                         may use the {@code "~/"} prefix for
 *                         user-home scope. Insertion order is
 *                         preserved. The stored map is unmodifiable.
 */
public record ScaffoldLockfile(
        int schema,
        String standardsVersion,
        Instant applied,
        Map<String, LockfileEntry> files) {

    /**
     * The current on-disk schema version. Bumps here must be paired
     * with a migration in {@code ScaffoldLockfileIo}.
     */
    public static final int CURRENT_SCHEMA = 1;

    /**
     * Canonical constructor with validation and defensive copying of
     * the file map.
     */
    public ScaffoldLockfile {
        if (schema <= 0) {
            throw new IllegalArgumentException(
                    "schema must be positive");
        }
        files = files == null
                ? Collections.emptyMap()
                : Collections.unmodifiableMap(
                        new LinkedHashMap<>(files));
        for (Map.Entry<String, LockfileEntry> e : files.entrySet()) {
            Objects.requireNonNull(e.getKey(),
                    "lockfile entry key must not be null");
            Objects.requireNonNull(e.getValue(),
                    "lockfile entry value must not be null");
            if (e.getKey().isBlank()) {
                throw new IllegalArgumentException(
                        "lockfile entry key must not be blank");
            }
        }
    }

    /**
     * Create an empty lockfile at the current schema with no entries
     * and no applied state. Useful for initialising a new scaffold
     * target.
     *
     * @return a blank {@code ScaffoldLockfile} at
     *         {@link #CURRENT_SCHEMA}
     */
    public static ScaffoldLockfile empty() {
        return new ScaffoldLockfile(
                CURRENT_SCHEMA,
                null,
                null,
                Collections.emptyMap());
    }

    /**
     * Return a copy of this lockfile with one entry added or replaced.
     *
     * @param path  scaffold path key
     * @param entry entry to store
     * @return a new lockfile with the entry in place (insertion order
     *         preserved: new keys append; existing keys keep their
     *         position)
     */
    public ScaffoldLockfile withEntry(String path, LockfileEntry entry) {
        Objects.requireNonNull(path, "path");
        Objects.requireNonNull(entry, "entry");
        LinkedHashMap<String, LockfileEntry> next =
                new LinkedHashMap<>(files);
        next.put(path, entry);
        return new ScaffoldLockfile(
                schema, standardsVersion, applied, next);
    }

    /**
     * Return a copy of this lockfile with one entry removed.
     *
     * @param path scaffold path key to drop; missing keys are ignored
     * @return a new lockfile without that entry; the same instance
     *         if the entry was already absent
     */
    public ScaffoldLockfile withoutEntry(String path) {
        Objects.requireNonNull(path, "path");
        if (!files.containsKey(path)) {
            return this;
        }
        LinkedHashMap<String, LockfileEntry> next =
                new LinkedHashMap<>(files);
        next.remove(path);
        return new ScaffoldLockfile(
                schema, standardsVersion, applied, next);
    }

    /**
     * Return a copy with the top-level {@code standardsVersion} and
     * {@code applied} stamps updated (used when publish completes).
     *
     * @param standardsVersion the ike-build-standards version just
     *                         applied; must not be {@code null}
     * @param applied          timestamp of the publish; must not be
     *                         {@code null}
     * @return a new lockfile with updated stamps
     */
    public ScaffoldLockfile withAppliedStamp(
            String standardsVersion, Instant applied) {
        Objects.requireNonNull(standardsVersion, "standardsVersion");
        Objects.requireNonNull(applied, "applied");
        return new ScaffoldLockfile(
                schema, standardsVersion, applied, files);
    }
}