ScaffoldReverter.java
package network.ike.plugin.scaffold;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Undo a previous scaffold publish.
*
* <p>Revert semantics by tier:
* <ul>
* <li>{@link ScaffoldTier#TOOL_OWNED}: delete the file if it still
* matches the applied hash, else warn and skip (unexpected
* divergence).</li>
* <li>{@link ScaffoldTier#TRACKED}: delete the file if it still
* matches {@code appliedSha}, else skip (user-edited).</li>
* <li>{@link ScaffoldTier#TRACKED_BLOCK}: not yet implemented —
* the reverter reports a skip with a message. A proper impl
* would remove just the managed block and leave the rest of
* the file.</li>
* <li>{@link ScaffoldTier#MODEL_MANAGED}: not yet implemented —
* proper impl would remove exactly the managed elements.</li>
* </ul>
*
* <p>This class is deliberately conservative: anything the user might
* have touched is left alone and reported, not destroyed.
*/
public final class ScaffoldReverter {
private final Clock clock;
/** Construct with {@link Clock#systemUTC()}. */
public ScaffoldReverter() {
this(Clock.systemUTC());
}
/**
* Construct with an explicit clock. Tests supply a fixed clock so
* the {@code generated-at} timestamp recorded in the updated
* lockfile after revert is deterministic.
*
* @param clock clock for revert timestamps
*/
public ScaffoldReverter(Clock clock) {
this.clock = clock;
}
/**
* Revert every entry in {@code currentLockfile} that lives in
* the given scope.
*
* @param currentLockfile the current lockfile
* @param manifest the manifest (used to look up each
* entry's scope and tier)
* @param scope the scope to revert
* @param pathResolver path resolver
* @return a {@link RevertResult} with the new lockfile (with
* reverted entries removed) and a per-entry report
*/
public RevertResult revert(
ScaffoldLockfile currentLockfile,
ScaffoldManifest manifest,
ScaffoldScope scope,
PathResolver pathResolver) {
List<Outcome> outcomes = new ArrayList<>();
Map<String, LockfileEntry> remaining =
new LinkedHashMap<>(currentLockfile.files());
for (ManifestEntry entry : manifest.entriesInScope(scope)) {
LockfileEntry prior = remaining.get(entry.dest());
if (prior == null) {
continue; // nothing to revert
}
Path dest = pathResolver.resolve(entry);
Outcome o = revertOne(entry, dest, prior);
outcomes.add(o);
if (o.removedFromLockfile()) {
remaining.remove(entry.dest());
}
}
ScaffoldLockfile updated = new ScaffoldLockfile(
ScaffoldLockfile.CURRENT_SCHEMA,
manifest.standardsVersion(),
Instant.now(clock),
remaining);
return new RevertResult(updated, outcomes);
}
private static Outcome revertOne(
ManifestEntry entry, Path dest, LockfileEntry prior) {
return switch (entry.tier()) {
case TOOL_OWNED -> revertWholeFile(
entry, dest, prior, "tool-owned");
case TRACKED -> revertWholeFile(
entry, dest, prior, "tracked");
case TRACKED_BLOCK -> new Outcome(
entry.dest(),
Outcome.Kind.SKIPPED,
"tracked-block revert not yet implemented; "
+ "remove the block between markers "
+ "manually",
false);
case MODEL_MANAGED -> new Outcome(
entry.dest(),
Outcome.Kind.SKIPPED,
"model-managed revert not yet implemented; "
+ "remove managed elements manually",
false);
};
}
private static Outcome revertWholeFile(
ManifestEntry entry,
Path dest,
LockfileEntry prior,
String tierLabel) {
if (!Files.exists(dest)) {
return new Outcome(
entry.dest(),
Outcome.Kind.REMOVED_FROM_LOCKFILE,
"file already absent",
true);
}
String currentSha = Sha256.ofFile(dest);
String expected = prior.appliedSha() != null
? prior.appliedSha()
: prior.templateSha();
if (!expected.equals(currentSha)) {
return new Outcome(
entry.dest(),
Outcome.Kind.SKIPPED,
tierLabel + " file edited since publish "
+ "(expected " + expected
+ ", on disk " + currentSha + "); "
+ "leaving file as-is",
false);
}
try {
Files.delete(dest);
} catch (IOException e) {
throw new ScaffoldException(
"cannot delete " + dest, e);
}
return new Outcome(
entry.dest(),
Outcome.Kind.DELETED,
"deleted",
true);
}
/**
* Per-entry result of a revert.
*
* @param dest the manifest dest string
* @param kind what happened to the entry
* @param message human-readable detail
* @param removedFromLockfile whether the lockfile entry was
* dropped
*/
public record Outcome(
String dest, Kind kind, String message,
boolean removedFromLockfile) {
/** Kinds of outcome. */
public enum Kind {
/** File was deleted. */
DELETED,
/** File was already gone; lockfile entry dropped. */
REMOVED_FROM_LOCKFILE,
/** File was left alone (user-edited, or not supported). */
SKIPPED
}
}
/**
* Aggregate revert result.
*
* @param updatedLockfile the new lockfile (with reverted entries
* removed)
* @param outcomes per-entry outcomes
*/
public record RevertResult(
ScaffoldLockfile updatedLockfile,
List<Outcome> outcomes) {
/** Canonical constructor with defensive copying. */
public RevertResult {
outcomes = outcomes == null
? List.of()
: List.copyOf(outcomes);
}
}
}