Skip to content

Commit aa2c497

Browse files
oskardudyczoskardudycz
authored andcommitted
Separated snapshot reviews from SNapshots storage
1 parent 6f9c6f5 commit aa2c497

15 files changed

Lines changed: 389 additions & 195 deletions

src/jvm/src/main/java/io/eventdriven/strictland/ContractContext.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@
44
* The shared context between {@code given(...)} through to a {@code then} step: <br/>
55
* - the serializer that turns a message into bytes, <br/>
66
* - the storage that reads and writes snapshots, <br/>
7-
* - the type mapper that names a message type.
7+
* - the type mapper that names a message type, <br/>
8+
* - the reviewer that decides what a stored snapshot's outcome means.
89
*
910
* @param serializer turns a message into the bytes the contract compares, and back
1011
* @param storage reads and writes approved snapshots
1112
* @param typeMapper names a message type for locating its snapshot
13+
* @param reviewer reacts to a store outcome: pass, re-baseline, or open a diff and fail
1214
*/
13-
record ContractContext(MessageSerializer serializer, SnapshotStorage storage, MessageTypeMapper typeMapper) {}
15+
record ContractContext(
16+
MessageSerializer serializer,
17+
SnapshotStorage storage,
18+
MessageTypeMapper typeMapper,
19+
SnapshotReviewer reviewer) {}

src/jvm/src/main/java/io/eventdriven/strictland/FileSnapshotStorage.java

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,43 +15,28 @@
1515
* lays out (defaulting to {@code test/resources/contract-registry}).
1616
* It stores approved snapshots in relative {@link SnapshotLocation} path under the root.
1717
*
18-
* <p>The first run writes the approved file for you to review and commit (with {@code .snap.approved} marker). A later run compares the
19-
* payload against it: a match passes, a difference is reviewed according to the {@link SnapshotReview}
20-
* this store was built with. In {@link ReviewMode#APPROVE} a difference re-baselines the approved file;
21-
* otherwise it writes a sibling {@code .snap.received} file, opens a diff tool when the run is local and
22-
* interactive, and fails the check with a message carrying an inline diff of what moved.</p>
18+
* <p>The first run writes the approved file for you to review and commit (with {@code .snap.approved}
19+
* marker) and reports {@link SnapshotResult.Approved}. A later run compares the payload against it: a
20+
* match reports {@link SnapshotResult.Unchanged}, a difference writes a sibling {@code .snap.received}
21+
* file and reports {@link SnapshotResult.Drifted}. Storage only reads and writes; what a drift means is
22+
* decided by the caller.</p>
2323
*/
2424
class FileSnapshotStorage implements SnapshotStorage {
2525

2626
private static final String APPROVED_MARKER = ".approved.";
2727
private static final String RECEIVED_MARKER = ".received.";
28-
static final String SNAP_APPROVED_MARKER = ".snap.approved.";
2928

3029
private final Path root;
31-
private final SnapshotReview review;
32-
private final DiffReview diffReview;
3330

3431
FileSnapshotStorage(SnapshotLayout layout) {
35-
this(layout, SnapshotReview.auto());
36-
}
37-
38-
FileSnapshotStorage(SnapshotLayout layout, SnapshotReview review) {
39-
this(layout, review, DiffReview.forReview(review));
40-
}
41-
42-
FileSnapshotStorage(SnapshotLayout layout, SnapshotReview review, DiffReview diffReview) {
4332
this.root = Path.of(layout.rootPath(), layout.wrapperFolder());
44-
this.review = review;
45-
this.diffReview = diffReview;
4633
}
4734

4835
@Override
49-
public void store(SnapshotLocation location, SnapshotData data) {
36+
public SnapshotResult store(SnapshotLocation location, SnapshotData data) {
5037
var approved = approvedPath(location);
51-
var fileName = approved.getFileName().toString();
52-
var received = approved.resolveSibling(fileName.replace(APPROVED_MARKER, RECEIVED_MARKER));
38+
var received = receivedSibling(approved);
5339
var payload = data.bytes();
54-
byte[] approvedBytes;
5540
try {
5641
var parent = approved.getParent();
5742
if (!Files.exists(approved)) {
@@ -60,36 +45,36 @@ public void store(SnapshotLocation location, SnapshotData data) {
6045
}
6146
Files.write(approved, payload);
6247
Files.deleteIfExists(received);
63-
return;
48+
return new SnapshotResult.Approved(location);
6449
}
65-
approvedBytes = Files.readAllBytes(approved);
50+
var approvedBytes = Files.readAllBytes(approved);
6651
if (Arrays.equals(approvedBytes, payload)) {
6752
Files.deleteIfExists(received);
68-
return;
69-
}
70-
if (review.mode() == ReviewMode.APPROVE) {
71-
SnapshotApproval.writeApproved(approved, received, payload);
72-
return;
53+
return new SnapshotResult.Unchanged(location);
7354
}
7455
var receivedParent = received.getParent();
7556
if (receivedParent != null) {
7657
Files.createDirectories(receivedParent);
7758
}
7859
Files.write(received, payload);
60+
return new SnapshotResult.Drifted(location, new SnapshotData(approvedBytes), data, approved, received);
7961
} catch (IOException e) {
8062
throw new UncheckedIOException("Failed to store snapshot at " + approved, e);
8163
}
82-
diffReview.open(received, approved);
83-
throw new AssertionError(driftMessage(approved, received, approvedBytes, payload));
8464
}
8565

86-
private static String driftMessage(Path approved, Path received, byte[] approvedBytes, byte[] payload) {
87-
return "Snapshot drift: " + approved + " differs from the approved snapshot.\n\n"
88-
+ SnapshotDiff.render(approvedBytes, payload) + "\n"
89-
+ "received: " + received + "\n"
90-
+ "approved: " + approved + "\n\n"
91-
+ "To accept this change, re-run with -Dstrictland.review.mode=approve, "
92-
+ "or save the received payload over the approved file in the diff tool.";
66+
@Override
67+
public void approve(SnapshotLocation location, SnapshotData data) {
68+
var approved = approvedPath(location);
69+
try {
70+
SnapshotApproval.writeApproved(approved, receivedSibling(approved), data.bytes());
71+
} catch (IOException e) {
72+
throw new UncheckedIOException("Failed to approve snapshot at " + approved, e);
73+
}
74+
}
75+
76+
private static Path receivedSibling(Path approved) {
77+
return approved.resolveSibling(approved.getFileName().toString().replace(APPROVED_MARKER, RECEIVED_MARKER));
9378
}
9479

9580
@Override

src/jvm/src/main/java/io/eventdriven/strictland/MessageContract.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,11 @@ private MessageContract(SpecificationOptions options) {
3636
var configuredStorage = options.storage();
3737
var storage = configuredStorage != null
3838
? configuredStorage
39-
: new FileSnapshotStorage(
40-
SnapshotLayoutResolver.resolve(options.snapshotLayout()),
41-
SnapshotReviewResolver.resolve(options.review()));
39+
: new FileSnapshotStorage(SnapshotLayoutResolver.resolve(options.snapshotLayout()));
4240
var configuredTypeMapper = options.typeMapper();
4341
var typeMapper = configuredTypeMapper != null ? configuredTypeMapper : MessageTypeMapper.fullyQualifiedName();
44-
this.context = new ContractContext(serializer, storage, typeMapper);
42+
var reviewer = SnapshotReviewer.forReview(SnapshotReviewResolver.resolve(options.review()));
43+
this.context = new ContractContext(serializer, storage, typeMapper, reviewer);
4544
}
4645

4746
/**
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package io.eventdriven.strictland;
2+
3+
import java.nio.file.Path;
4+
5+
/**
6+
* What {@link SnapshotStorage#store storing} a snapshot did: a first-run approval, an unchanged match,
7+
* or a drift from the approved snapshot. Storage only reports the outcome; deciding what a drift means
8+
* - open a diff tool, fail the check, or re-baseline - is the caller's job, so a custom store never has
9+
* to know about diffs, assertions, or review tools.
10+
*/
11+
public sealed interface SnapshotResult {
12+
13+
/**
14+
* The location this outcome is for.
15+
*
16+
* @return the snapshot location that was stored
17+
*/
18+
SnapshotLocation location();
19+
20+
/**
21+
* The first run for a location: no approved snapshot existed, so the payload was written as the
22+
* approved snapshot for you to review and commit.
23+
*
24+
* @param location the location the approved snapshot was written at
25+
*/
26+
record Approved(SnapshotLocation location) implements SnapshotResult {}
27+
28+
/**
29+
* A later run whose payload matched the approved snapshot: the contract still holds.
30+
*
31+
* @param location the location that matched
32+
*/
33+
record Unchanged(SnapshotLocation location) implements SnapshotResult {}
34+
35+
/**
36+
* A later run whose payload differs from the approved snapshot. The received payload has been
37+
* written beside the approved one so the two can be compared and, if the change is intended,
38+
* accepted with {@link SnapshotStorage#approve}.
39+
*
40+
* @param location the location that drifted
41+
* @param approved the approved payload the check compared against
42+
* @param received the drifted payload this run produced
43+
* @param approvedFile the approved snapshot file
44+
* @param receivedFile the received snapshot file written for review
45+
*/
46+
record Drifted(
47+
SnapshotLocation location,
48+
SnapshotData approved,
49+
SnapshotData received,
50+
Path approvedFile,
51+
Path receivedFile)
52+
implements SnapshotResult {}
53+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.eventdriven.strictland;
2+
3+
/**
4+
* Decides what a {@link SnapshotResult} means. Storage only reports; this is the imperative shell that
5+
* reacts: a match or a first-run approval passes, and a drift either re-baselines (in {@code approve}
6+
* mode) or opens a diff tool and fails the check with an inline diff of what moved.
7+
*/
8+
final class SnapshotReviewer {
9+
10+
private final SnapshotReview review;
11+
private final DiffReview diffReview;
12+
13+
SnapshotReviewer(SnapshotReview review, DiffReview diffReview) {
14+
this.review = review;
15+
this.diffReview = diffReview;
16+
}
17+
18+
static SnapshotReviewer forReview(SnapshotReview review) {
19+
return new SnapshotReviewer(review, DiffReview.forReview(review));
20+
}
21+
22+
void review(SnapshotStorage storage, SnapshotResult result) {
23+
if (!(result instanceof SnapshotResult.Drifted drift)) {
24+
return;
25+
}
26+
if (review.mode() == ReviewMode.APPROVE) {
27+
storage.approve(drift.location(), drift.received());
28+
return;
29+
}
30+
diffReview.open(drift.receivedFile(), drift.approvedFile());
31+
throw new AssertionError(driftMessage(drift));
32+
}
33+
34+
private static String driftMessage(SnapshotResult.Drifted drift) {
35+
return "Snapshot drift: " + drift.approvedFile() + " differs from the approved snapshot.\n\n"
36+
+ SnapshotDiff.render(drift.approved().bytes(), drift.received().bytes()) + "\n"
37+
+ "received: " + drift.receivedFile() + "\n"
38+
+ "approved: " + drift.approvedFile() + "\n\n"
39+
+ "To accept this change, re-run with -Dstrictland.review.mode=approve, "
40+
+ "or save the received payload over the approved file in the diff tool.";
41+
}
42+
}

src/jvm/src/main/java/io/eventdriven/strictland/SnapshotStorage.java

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,28 @@
2929
public interface SnapshotStorage {
3030

3131
/**
32-
* Approves and persists a snapshot at one exact location. The first run saves it for you to review
33-
* and commit; on a later run a matching payload passes, and one that has drifted throws an {@link
34-
* AssertionError} to fail the check.
32+
* Stores a snapshot at one exact location and reports what happened, without deciding what a drift
33+
* means. The first run writes the approved snapshot ({@link SnapshotResult.Approved}); a later run
34+
* that matches passes ({@link SnapshotResult.Unchanged}); one that differs writes the received
35+
* payload beside the approved one and reports the drift ({@link SnapshotResult.Drifted}) for the
36+
* caller to open, fail, or accept. It never throws to fail a check - only wraps a genuine I/O
37+
* failure.
3538
*
3639
* @param location the exact file the snapshot is stored at
37-
* @param data the serialized message to approve and persist
38-
* @throws AssertionError when the payload differs from the approved snapshot
40+
* @param data the serialized message to store
41+
* @return what storing did: approved, unchanged, or drifted
3942
*/
40-
void store(SnapshotLocation location, SnapshotData data) throws AssertionError;
43+
SnapshotResult store(SnapshotLocation location, SnapshotData data);
44+
45+
/**
46+
* Re-baselines a location by overwriting its approved snapshot with a payload you've accepted, and
47+
* clearing any received file. This is how {@code approve} review mode accepts an intended change; it
48+
* assumes the caller has already decided the drift is wanted.
49+
*
50+
* @param location the exact file to re-baseline
51+
* @param data the accepted payload to store as the new approved snapshot
52+
*/
53+
void approve(SnapshotLocation location, SnapshotData data);
4154

4255
/**
4356
* Reads back the one snapshot approved at an exact location, for a compatibility check that replays a

src/jvm/src/main/java/io/eventdriven/strictland/ThenContractStep.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ public class ThenContractStep<S> {
4141
*/
4242
public void thenContractIsUnchanged() {
4343
var data = new SnapshotData(context.serializer().serialize(instance));
44-
context.storage().store(snapshotLocation(), data);
44+
var result = context.storage().store(snapshotLocation(), data);
45+
context.reviewer().review(context.storage(), result);
4546
}
4647

4748
private SnapshotLocation snapshotLocation() {

src/jvm/src/test/java/io/eventdriven/strictland/Base64SnapshotStorage.java

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
@NullMarked
1818
final class Base64SnapshotStorage implements SnapshotStorage {
1919
private static final String APPROVED_SUFFIX = ".approved.txt";
20+
private static final String RECEIVED_SUFFIX = ".received.txt";
2021

2122
private final Path dir;
2223

@@ -25,7 +26,7 @@ final class Base64SnapshotStorage implements SnapshotStorage {
2526
}
2627

2728
@Override
28-
public void store(SnapshotLocation location, SnapshotData data) {
29+
public SnapshotResult store(SnapshotLocation location, SnapshotData data) {
2930
var payload = data.bytes();
3031
var path = approvedPath(location.name());
3132
try {
@@ -34,19 +35,40 @@ public void store(SnapshotLocation location, SnapshotData data) {
3435
if (parent != null) {
3536
Files.createDirectories(parent);
3637
}
37-
Files.writeString(path, Base64.getEncoder().encodeToString(payload), UTF_8);
38-
return;
38+
Files.writeString(path, encode(payload), UTF_8);
39+
return new SnapshotResult.Approved(location);
3940
}
4041
var approved = Base64.getDecoder().decode(Files.readString(path, UTF_8));
41-
if (!Arrays.equals(approved, payload)) {
42-
throw new AssertionError("Snapshot drift for '" + location.name()
43-
+ "': stored payload differs from the approved snapshot");
42+
if (Arrays.equals(approved, payload)) {
43+
return new SnapshotResult.Unchanged(location);
4444
}
45+
var received = receivedPath(location.name());
46+
Files.writeString(received, encode(payload), UTF_8);
47+
return new SnapshotResult.Drifted(location, new SnapshotData(approved), data, path, received);
4548
} catch (IOException e) {
4649
throw new UncheckedIOException(e);
4750
}
4851
}
4952

53+
@Override
54+
public void approve(SnapshotLocation location, SnapshotData data) {
55+
var path = approvedPath(location.name());
56+
try {
57+
var parent = path.getParent();
58+
if (parent != null) {
59+
Files.createDirectories(parent);
60+
}
61+
Files.writeString(path, encode(data.bytes()), UTF_8);
62+
Files.deleteIfExists(receivedPath(location.name()));
63+
} catch (IOException e) {
64+
throw new UncheckedIOException(e);
65+
}
66+
}
67+
68+
private static String encode(byte[] payload) {
69+
return Base64.getEncoder().encodeToString(payload);
70+
}
71+
5072
@Override
5173
public SnapshotData read(SnapshotLocation location) {
5274
var path = approvedPath(location.name());
@@ -82,4 +104,8 @@ public List<SnapshotData> readAll(SnapshotFilter filter) {
82104
private Path approvedPath(String name) {
83105
return dir.resolve(name + APPROVED_SUFFIX);
84106
}
107+
108+
private Path receivedPath(String name) {
109+
return dir.resolve(name + RECEIVED_SUFFIX);
110+
}
85111
}

0 commit comments

Comments
 (0)