ScaffoldApplier.java

package network.ike.plugin.scaffold;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Clock;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Execute a {@link ScaffoldPlan}: write out Write-actions to disk and
 * compose the updated {@link ScaffoldLockfile}.
 *
 * <p>Write actions are executed in order; the applier carefully
 * creates parent directories and uses
 * {@link StandardCopyOption#REPLACE_EXISTING} so existing files are
 * atomically replaced.
 *
 * <p>{@link TierAction.Skip} actions are recorded in the returned
 * lockfile as-is (the existing entry stays put). {@link TierAction.UpToDate}
 * and {@link TierAction.UserManaged} actions refresh the
 * {@code standards-version} on model-managed elements but are
 * otherwise no-ops.
 */
public final class ScaffoldApplier {

    private final Clock clock;

    /** Construct with {@link Clock#systemUTC()}. */
    public ScaffoldApplier() {
        this(Clock.systemUTC());
    }

    /**
     * Construct with an explicit clock. Tests supply a fixed clock so
     * the {@code generated-at} timestamp recorded in the updated
     * lockfile is deterministic.
     *
     * @param clock clock used for timestamps written into the lockfile
     */
    public ScaffoldApplier(Clock clock) {
        this.clock = clock;
    }

    /**
     * Carry out a plan.
     *
     * @param plan            the plan to execute
     * @param currentLockfile the current lockfile (so entries outside
     *                        the plan's scope are preserved verbatim)
     * @return the updated lockfile
     * @throws ScaffoldException if any Write fails
     */
    public ScaffoldLockfile apply(
            ScaffoldPlan plan,
            ScaffoldLockfile currentLockfile) {
        Map<String, LockfileEntry> files =
                new LinkedHashMap<>(currentLockfile.files());
        for (PlannedEntry pe : plan.entries()) {
            LockfileEntry updated = applyOne(pe);
            if (updated != null) {
                files.put(pe.manifest().dest(), updated);
            }
            // Skip actions retain whatever the lockfile already had.
        }
        return new ScaffoldLockfile(
                ScaffoldLockfile.CURRENT_SCHEMA,
                plan.manifestStandardsVersion(),
                Instant.now(clock),
                files);
    }

    /**
     * Remove orphaned scaffold files and drop their lockfile entries.
     *
     * <p>Run after {@link #apply} on the lockfile that {@code apply}
     * returned. For each {@link OrphanEntry.Disposition#REMOVE} orphan
     * the on-disk file is deleted; for {@code REMOVE} and
     * {@link OrphanEntry.Disposition#ALREADY_ABSENT} orphans the stale
     * lockfile entry is dropped. {@link OrphanEntry.Disposition#SKIP_USER_EDITED}
     * orphans are left entirely alone — file and lockfile entry both
     * stay, so the orphan keeps surfacing until the operator resolves
     * it.
     *
     * @param orphans  orphans found by {@link OrphanScanner}
     * @param lockfile the lockfile to prune (typically the result of
     *                 {@link #apply})
     * @return the lockfile with removed/absent orphan entries dropped
     * @throws ScaffoldException if a file deletion fails
     */
    public ScaffoldLockfile removeOrphans(
            List<OrphanEntry> orphans,
            ScaffoldLockfile lockfile) {
        ScaffoldLockfile result = lockfile;
        for (OrphanEntry orphan : orphans) {
            switch (orphan.disposition()) {
                case REMOVE -> {
                    deleteFile(orphan.resolvedDest());
                    result = result.withoutEntry(orphan.dest());
                }
                case ALREADY_ABSENT ->
                        result = result.withoutEntry(orphan.dest());
                case SKIP_USER_EDITED -> {
                    // Leave the file and the lockfile entry in place.
                }
            }
        }
        return result;
    }

    private static void deleteFile(Path dest) {
        try {
            Files.delete(dest);
        } catch (IOException e) {
            throw new ScaffoldException(
                    "cannot delete orphaned scaffold file " + dest, e);
        }
    }

    private LockfileEntry applyOne(PlannedEntry pe) {
        TierAction action = pe.action();
        if (action instanceof TierAction.Write w) {
            writeBytes(w.resolvedDest(), w.newContent());
            return lockfileEntryFor(pe, w.templateSha(), w.appliedSha());
        }
        if (action instanceof TierAction.UpToDate u) {
            // Refresh standards-version in the lockfile entry.
            return lockfileEntryFor(pe, u.templateSha(), u.appliedSha());
        }
        if (action instanceof TierAction.UserManaged m) {
            // No write; refresh lockfile provenance like UpToDate.
            return lockfileEntryFor(pe, m.templateSha(), m.appliedSha());
        }
        // Skip: leave lockfile unchanged.
        return null;
    }

    private static LockfileEntry lockfileEntryFor(
            PlannedEntry pe,
            String templateSha,
            String appliedSha) {
        ScaffoldTier tier = pe.manifest().tier();
        return switch (tier) {
            case TOOL_OWNED ->
                    LockfileEntry.toolOwned(templateSha);
            case TRACKED, TRACKED_BLOCK ->
                    LockfileEntry.tracked(
                            tier, templateSha, appliedSha);
            case MODEL_MANAGED ->
                    LockfileEntry.modelManaged(pe.managedElements());
        };
    }

    private static void writeBytes(Path dest, byte[] content) {
        try {
            Path parent = dest.getParent();
            if (parent != null) {
                Files.createDirectories(parent);
            }
            Path tmp = Files.createTempFile(
                    parent == null ? dest.toAbsolutePath().getParent()
                            : parent,
                    dest.getFileName().toString(), ".tmp");
            try {
                Files.write(tmp, content);
                Files.move(tmp, dest,
                        StandardCopyOption.REPLACE_EXISTING,
                        StandardCopyOption.ATOMIC_MOVE);
            } catch (IOException e) {
                try {
                    Files.deleteIfExists(tmp);
                } catch (IOException ignored) {
                    // ignore — best-effort cleanup
                }
                throw e;
            }
        } catch (IOException e) {
            throw new ScaffoldException(
                    "cannot write " + dest, e);
        }
    }
}