OrphanScanner.java

package network.ike.plugin.scaffold;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Find scaffold lockfile entries the current manifest no longer ships.
 *
 * <p>When a scaffold strategy is retired its template disappears from
 * {@code scaffold-manifest.yaml}. But a {@code dest} a prior
 * {@code scaffold-publish} already wrote stays in
 * {@code .ike/scaffold.lock} and on disk — {@link ScaffoldPlanner}
 * only iterates manifest entries, so an entry with no manifest
 * counterpart is silently ignored. This scanner flags those orphans
 * so {@code scaffold-publish} can clean them up.
 *
 * <p>Read-only: like {@link ScaffoldPlanner} the scanner reads disk to
 * decide a {@link OrphanEntry.Disposition} but never writes.
 */
public final class OrphanScanner {

    private OrphanScanner() {}

    /**
     * Scan one scope for orphaned lockfile entries.
     *
     * @param manifest the current scaffold manifest
     * @param lockfile the lockfile for this scope (project or user)
     * @param scope    the scope being scanned
     * @param resolver resolver for {@code dest} → absolute path
     * @return orphan entries in lockfile order; empty when none
     */
    public static List<OrphanEntry> scan(
            ScaffoldManifest manifest,
            ScaffoldLockfile lockfile,
            ScaffoldScope scope,
            PathResolver resolver) {

        Set<String> manifestDests = new HashSet<>();
        for (ManifestEntry e : manifest.entriesInScope(scope)) {
            manifestDests.add(e.dest());
        }

        List<OrphanEntry> orphans = new ArrayList<>();
        for (Map.Entry<String, LockfileEntry> le
                : lockfile.files().entrySet()) {
            String dest = le.getKey();
            if (manifestDests.contains(dest)) {
                continue; // still shipped — planned normally
            }
            if (!inScope(dest, scope)) {
                continue; // belongs to the other scope's lockfile
            }
            orphans.add(classify(dest, le.getValue(), scope, resolver));
        }
        return orphans;
    }

    /**
     * Whether a {@code dest} string belongs to the given scope. USER
     * entries carry the {@code ~/} prefix; PROJECT entries never do.
     */
    private static boolean inScope(String dest, ScaffoldScope scope) {
        boolean userForm = dest.startsWith("~/");
        return scope == ScaffoldScope.USER ? userForm : !userForm;
    }

    private static OrphanEntry classify(
            String dest,
            LockfileEntry prior,
            ScaffoldScope scope,
            PathResolver resolver) {

        Path resolved = resolver.resolveDest(dest, scope);
        ScaffoldTier tier = prior.tier();

        if (!Files.exists(resolved)) {
            return new OrphanEntry(dest, resolved, tier,
                    OrphanEntry.Disposition.ALREADY_ABSENT,
                    "file already absent — stale lockfile entry");
        }

        // Shared / user-owned tiers: removing the whole file would
        // destroy content the scaffold never owned. Mirror the
        // conservative stance ScaffoldReverter takes.
        if (tier == ScaffoldTier.TRACKED_BLOCK) {
            return new OrphanEntry(dest, resolved, tier,
                    OrphanEntry.Disposition.SKIP_USER_EDITED,
                    "tracked-block orphan — remove the managed block "
                            + "manually");
        }
        if (tier == ScaffoldTier.MODEL_MANAGED) {
            return new OrphanEntry(dest, resolved, tier,
                    OrphanEntry.Disposition.SKIP_USER_EDITED,
                    "model-managed orphan — remove managed elements "
                            + "manually");
        }

        String expected = prior.appliedSha() != null
                ? prior.appliedSha()
                : prior.templateSha();
        String current = Sha256.ofFile(resolved);
        if (expected != null && expected.equals(current)) {
            return new OrphanEntry(dest, resolved, tier,
                    OrphanEntry.Disposition.REMOVE,
                    "no longer shipped by the scaffold — will be "
                            + "deleted");
        }
        return new OrphanEntry(dest, resolved, tier,
                OrphanEntry.Disposition.SKIP_USER_EDITED,
                "no longer shipped, but edited since publish "
                        + "(expected " + expected + ", on disk "
                        + current + ") — leaving file as-is");
    }
}