ReleaseRecordFile.java
package network.ike.workspace;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Reads and writes {@code releases/release-<cycle>.yaml} — the on-disk
* form of a {@link ReleaseRecord} (IKE-Network/ike-issues#973).
*
* <p>Unlike {@link ManifestWriter}, which performs comment-preserving
* surgical edits on the hand-maintained {@code workspace.yaml}, the
* release record is machine-owned ({@code ws:record-release} writes it,
* nothing hand-edits it), so this class renders the whole file from the
* model on every write — symmetric read/write over one canonical form,
* in the style of the checkpoint YAML files.
*
* <p>Scalar values that could parse as non-string YAML types (versions,
* shas, dates) are written quoted so a read-back always yields strings.
*/
public final class ReleaseRecordFile {
private ReleaseRecordFile() {}
/**
* The canonical record path for a cycle:
* {@code <root>/releases/release-<cycle>.yaml}.
*
* @param workspaceRoot the workspace root directory
* @param cycle the cycle label, e.g. {@code komet-wsr-1}
* @return the record file path
* @throws ManifestException if {@code cycle} is null or blank
*/
public static Path pathFor(Path workspaceRoot, String cycle) {
if (cycle == null || cycle.isBlank()) {
throw new ManifestException("cycle must not be null or blank");
}
return workspaceRoot.resolve("releases")
.resolve("release-" + cycle + ".yaml");
}
/**
* Read a release record from disk.
*
* @param path the record file path
* @return the parsed record
* @throws ManifestException if the file cannot be read, is not a YAML
* map, or fails {@link ReleaseRecord}
* validation
*/
public static ReleaseRecord read(Path path) {
Map<String, Object> raw;
try (Reader reader = Files.newBufferedReader(path,
StandardCharsets.UTF_8)) {
Object loaded = new Yaml().load(reader);
if (!(loaded instanceof Map)) {
throw new ManifestException("Release record " + path
+ " is not a YAML map");
}
@SuppressWarnings("unchecked")
Map<String, Object> cast = (Map<String, Object>) loaded;
raw = cast;
} catch (IOException e) {
throw new ManifestException("Cannot read release record "
+ path + ": " + e.getMessage(), e);
}
String cycle = stringValue(raw.get("cycle"));
String started = stringValue(raw.get("started"));
Map<String, ReleaseRecord.MemberRelease> members = new LinkedHashMap<>();
Object membersRaw = raw.get("members");
if (membersRaw instanceof Map<?, ?> membersMap) {
for (Map.Entry<?, ?> entry : membersMap.entrySet()) {
String name = stringValue(entry.getKey());
if (!(entry.getValue() instanceof Map<?, ?> row)) {
throw new ManifestException("Release record " + path
+ ": member '" + name + "' is not a YAML map");
}
members.put(name, new ReleaseRecord.MemberRelease(
stringValue(row.get("version")),
stringValue(row.get("tag")),
stringValue(row.get("sha")),
stringValue(row.get("recorded"))));
}
} else if (membersRaw != null) {
throw new ManifestException("Release record " + path
+ ": members is not a YAML map");
}
return new ReleaseRecord(cycle, started, members);
}
/**
* Write a release record to disk, creating the {@code releases/}
* directory when absent. The rendered form is deterministic for a
* given record, so re-writing an unchanged record is byte-stable.
*
* @param path the record file path
* @param record the record to render
* @throws IOException if the directory or file cannot be written
*/
public static void write(Path path, ReleaseRecord record)
throws IOException {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(path, render(record), StandardCharsets.UTF_8);
}
/**
* Render a record to its canonical YAML text.
*
* @param record the record to render
* @return the YAML document text
*/
static String render(ReleaseRecord record) {
StringBuilder sb = new StringBuilder();
sb.append("# release-").append(record.cycle()).append(".yaml — ")
.append("one release cycle of this working set.\n");
sb.append("# Written by ws:record-release ")
.append("(IKE-Network/ike-issues#973); one file per cycle,\n");
sb.append("# one row per released member. Do not hand-edit.\n");
sb.append("\n");
sb.append("cycle: ").append(quoted(record.cycle())).append("\n");
if (record.started() != null) {
sb.append("started: ").append(quoted(record.started()))
.append("\n");
}
sb.append("\n");
if (record.members().isEmpty()) {
sb.append("members: {}\n");
return sb.toString();
}
sb.append("members:\n");
for (Map.Entry<String, ReleaseRecord.MemberRelease> entry
: record.members().entrySet()) {
ReleaseRecord.MemberRelease row = entry.getValue();
sb.append(" ").append(entry.getKey()).append(":\n");
sb.append(" version: ").append(quoted(row.version()))
.append("\n");
sb.append(" tag: ").append(quoted(row.tag())).append("\n");
sb.append(" sha: ").append(quoted(row.sha())).append("\n");
if (row.recorded() != null) {
sb.append(" recorded: ").append(quoted(row.recorded()))
.append("\n");
}
}
return sb.toString();
}
/**
* Quote a scalar for the rendered form so read-back always yields a
* string regardless of YAML's scalar typing rules.
*
* @param value the scalar text
* @return the double-quoted form
*/
private static String quoted(String value) {
return "\"" + value + "\"";
}
/**
* Normalize a loaded YAML scalar to its string form. SnakeYAML types
* bare scalars (dates, integers); the record model is string-typed.
*
* @param value the loaded scalar, possibly null
* @return the string form, or null when {@code value} is null
*/
private static String stringValue(Object value) {
return value == null ? null : value.toString();
}
}