ManifestWriter.java
package network.ike.workspace;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Updates specific fields in workspace.yaml while preserving
* comments, formatting, and structure.
*
* <p>Uses targeted text replacement rather than YAML serialization
* to avoid stripping comments or reordering keys.
*/
public final class ManifestWriter {
private ManifestWriter() {}
/**
* Update the branch field for one or more subprojects.
*
* @param manifestPath path to workspace.yaml
* @param branchUpdates map of subproject name to new branch value
* @throws IOException if the file cannot be read or written
*/
public static void updateBranches(Path manifestPath, Map<String, String> branchUpdates)
throws IOException {
String content = Files.readString(manifestPath, StandardCharsets.UTF_8);
for (Map.Entry<String, String> entry : branchUpdates.entrySet()) {
String subprojectName = entry.getKey();
String newBranch = entry.getValue();
content = updateSubprojectBranch(content, subprojectName, newBranch);
}
Files.writeString(manifestPath, content, StandardCharsets.UTF_8);
}
/**
* Update the maven-version field in the defaults section.
*
* @param manifestPath path to workspace.yaml
* @param newVersion the new Maven version string
* @throws IOException if the file cannot be read or written
*/
public static void updateDefaultMavenVersion(Path manifestPath, String newVersion)
throws IOException {
String content = Files.readString(manifestPath, StandardCharsets.UTF_8);
content = updateDefaultField(content, "maven-version", newVersion);
Files.writeString(manifestPath, content, StandardCharsets.UTF_8);
}
/**
* Update the maven-version field for one or more subprojects.
*
* @param manifestPath path to workspace.yaml
* @param versionUpdates map of subproject name to new maven-version value
* @throws IOException if the file cannot be read or written
*/
public static void updateMavenVersions(Path manifestPath, Map<String, String> versionUpdates)
throws IOException {
String content = Files.readString(manifestPath, StandardCharsets.UTF_8);
for (Map.Entry<String, String> entry : versionUpdates.entrySet()) {
String subprojectName = entry.getKey();
String newVersion = entry.getValue();
content = updateSubprojectField(content, subprojectName, "maven-version", newVersion);
}
Files.writeString(manifestPath, content, StandardCharsets.UTF_8);
}
/**
* Update the sha field for one or more subprojects. If the sha field
* does not exist in the subproject block, it is inserted after the
* branch field.
*
* @param manifestPath path to workspace.yaml
* @param shaUpdates map of subproject name to SHA value
* @throws IOException if the file cannot be read or written
*/
public static void updateShas(Path manifestPath, Map<String, String> shaUpdates)
throws IOException {
String content = Files.readString(manifestPath, StandardCharsets.UTF_8);
for (Map.Entry<String, String> entry : shaUpdates.entrySet()) {
String subprojectName = entry.getKey();
String sha = entry.getValue();
content = addOrUpdateSubprojectField(content, subprojectName,
"sha", "\"" + sha + "\"", "branch");
}
Files.writeString(manifestPath, content, StandardCharsets.UTF_8);
}
/**
* Update a field in a subproject block, or insert it after a reference
* field if it doesn't exist yet.
*
* <p>If the field exists multiple times in the block (a corrupted state
* from a prior version of this writer, see #387), all duplicates are
* collapsed into a single entry with the new value.
*
* @param yaml full YAML content
* @param subprojectName the subproject key
* @param field the field name to update or insert
* @param newValue the new value (pre-quoted if needed)
* @param afterField insert after this field if the target field is absent
* @return updated YAML content
*/
public static String addOrUpdateSubprojectField(String yaml, String subprojectName,
String field, String newValue,
String afterField) {
SubprojectBlockBounds bounds = findSubprojectBlockBounds(yaml, subprojectName);
if (bounds == null) return yaml;
int blockStart = bounds.start();
int blockEnd = bounds.end();
String block = yaml.substring(blockStart, blockEnd);
// Strip ALL existing occurrences of the field in this block. Handles
// both the normal "one existing entry" case and the corrupted
// "multiple duplicate entries" case (#387) idempotently.
Pattern fieldLine = Pattern.compile(
"(?m)^ " + Pattern.quote(field) + ":[^\\n]*\\n?"
);
String stripped = fieldLine.matcher(block).replaceAll("");
// Insert one canonical entry after the reference field.
Pattern afterLine = Pattern.compile(
"(?m)^ " + Pattern.quote(afterField) + ":[^\\n]*$"
);
Matcher afterMatcher = afterLine.matcher(stripped);
String rebuilt;
if (afterMatcher.find()) {
rebuilt = stripped.substring(0, afterMatcher.end())
+ "\n " + field + ": " + newValue
+ stripped.substring(afterMatcher.end());
} else {
// Reference field not present in block — fall back to inserting
// the new field at the end of the block content (before the next
// sibling subproject's start).
String trimmed = stripped.replaceAll("\\s+$", "");
rebuilt = trimmed + "\n " + field + ": " + newValue + "\n";
}
return yaml.substring(0, blockStart) + rebuilt + yaml.substring(blockEnd);
}
/**
* Half-open character offsets of a subproject's body in the YAML
* text. {@code start} is the position immediately after the
* {@code " <name>:"} header line's newline, so
* {@code yaml.substring(start, end)} contains only the field
* lines under the subproject.
*
* @param start inclusive start offset (first byte of body)
* @param end exclusive end offset (first byte of next sibling
* subproject, next top-level key, or end of file)
*/
public record SubprojectBlockBounds(int start, int end) {}
/**
* Locate a subproject's body in the YAML text.
*
* @param yaml full YAML content
* @param subprojectName the subproject key to locate
* @return bounds of the subproject's body, or null if absent
*/
static SubprojectBlockBounds findSubprojectBlockBounds(String yaml,
String subprojectName) {
Pattern header = Pattern.compile(
"(?m)^ " + Pattern.quote(subprojectName) + ":\\s*$"
);
Matcher headerMatcher = header.matcher(yaml);
if (!headerMatcher.find()) return null;
int start = headerMatcher.end();
// Skip the newline that terminates the header line, if present.
if (start < yaml.length() && yaml.charAt(start) == '\n') {
start++;
}
// Block ends at the next sibling subproject ("^ <key>:") or any
// top-level (zero-indent) key, whichever comes first.
Pattern boundary = Pattern.compile(
"(?m)^(?: \\S[^:\\n]*:\\s*$|\\S)"
);
Matcher boundaryMatcher = boundary.matcher(yaml);
int end = yaml.length();
if (boundaryMatcher.find(start)) {
end = boundaryMatcher.start();
}
return new SubprojectBlockBounds(start, end);
}
/**
* Return whether the given field exists in the given subproject's
* block. Block-bounded scan so a same-named field in a sibling
* subproject does not produce a false positive.
*
* <p>This is the explicit replacement for the historical pattern
* of calling {@link #updateSubprojectField} and comparing the
* result to the original yaml — that approach (still used inside
* the pre-fix {@link #addOrUpdateSubprojectField}) couldn't
* distinguish "field absent" from "field present with same value"
* and produced the duplicate-key bug fixed in
* IKE-Network/ike-issues#387.
*
* @param yaml full YAML content
* @param subprojectName the subproject key
* @param field the field name
* @return true if the field appears at least once in the subproject's block
*/
public static boolean subprojectFieldExists(String yaml, String subprojectName,
String field) {
SubprojectBlockBounds bounds = findSubprojectBlockBounds(yaml, subprojectName);
if (bounds == null) return false;
String block = yaml.substring(bounds.start(), bounds.end());
Pattern fieldLine = Pattern.compile(
"(?m)^ " + Pattern.quote(field) + ":"
);
return fieldLine.matcher(block).find();
}
/**
* Collapse duplicate field entries in every subproject block.
*
* <p>For each subproject, if any field name appears more than once
* in the block, keep only the LAST occurrence (matches YAML
* last-wins semantics for duplicate keys) and remove the rest.
*
* <p>Safety-net cleanup for workspaces affected by the pre-fix
* duplicate-key bug (#387). Idempotent: running on a clean file
* is a no-op.
*
* @param yaml full YAML content
* @return yaml with duplicates collapsed
*/
public static String collapseDuplicateSubprojectFields(String yaml) {
// Find every subproject header to know the block bounds.
Pattern subprojectHeader = Pattern.compile(
"(?m)^ (\\S[^:\\n]*):\\s*$"
);
Matcher headerMatcher = subprojectHeader.matcher(yaml);
java.util.List<String> names = new java.util.ArrayList<>();
while (headerMatcher.find()) {
names.add(headerMatcher.group(1));
}
String result = yaml;
for (String name : names) {
result = collapseDuplicatesInBlock(result, name);
}
return result;
}
/**
* Helper for {@link #collapseDuplicateSubprojectFields}: collapse
* duplicate field keys in one subproject block, keeping the last
* occurrence of each field.
*/
private static String collapseDuplicatesInBlock(String yaml, String subprojectName) {
SubprojectBlockBounds bounds = findSubprojectBlockBounds(yaml, subprojectName);
if (bounds == null) return yaml;
int blockStart = bounds.start();
int blockEnd = bounds.end();
String block = yaml.substring(blockStart, blockEnd);
// Find every " <field>: <value>" line in encounter order,
// tracking the LAST occurrence per field name.
Pattern fieldLine = Pattern.compile(
"(?m)^ (\\S[^:\\n]*):[^\\n]*$"
);
Matcher fieldMatcher = fieldLine.matcher(block);
// Map field name → list of {start, end, lineWithNewline} for each occurrence.
java.util.Map<String, java.util.List<int[]>> occurrences =
new java.util.LinkedHashMap<>();
while (fieldMatcher.find()) {
String name = fieldMatcher.group(1);
int s = fieldMatcher.start();
int e = fieldMatcher.end();
// Include trailing newline if present so removal is clean.
if (e < block.length() && block.charAt(e) == '\n') {
e++;
}
occurrences.computeIfAbsent(name, k -> new java.util.ArrayList<>())
.add(new int[]{s, e});
}
// Determine which character ranges to remove: all but the last
// occurrence for each duplicated field.
java.util.List<int[]> toRemove = new java.util.ArrayList<>();
for (java.util.List<int[]> list : occurrences.values()) {
if (list.size() <= 1) continue;
for (int i = 0; i < list.size() - 1; i++) {
toRemove.add(list.get(i));
}
}
if (toRemove.isEmpty()) return yaml;
toRemove.sort((a, b) -> Integer.compare(b[0], a[0]));
StringBuilder sb = new StringBuilder(block);
for (int[] r : toRemove) {
sb.delete(r[0], r[1]);
}
return yaml.substring(0, blockStart) + sb + yaml.substring(blockEnd);
}
/**
* Update a field in the defaults section of the YAML text.
*
* @param yaml full YAML content
* @param field the field name to update
* @param newValue the new value
* @return updated YAML content
*/
static String updateDefaultField(String yaml, String field, String newValue) {
String escapedField = Pattern.quote(field);
Pattern pattern = Pattern.compile(
"(^ " + escapedField + ":\\s*)(\\S+.*?)$",
Pattern.MULTILINE
);
Matcher m = pattern.matcher(yaml);
if (m.find()) {
return m.replaceFirst("$1" + Matcher.quoteReplacement(newValue));
}
return yaml;
}
/**
* Update a named field within a subproject block in the YAML text.
*
* @param yaml full YAML content
* @param subprojectName the subproject key to find
* @param field the field name within the subproject block
* @param newValue the new value
* @return updated YAML content
*/
public static String updateSubprojectField(String yaml, String subprojectName,
String field, String newValue) {
String escapedName = Pattern.quote(subprojectName);
String escapedField = Pattern.quote(field);
Pattern blockPattern = Pattern.compile(
"(^ " + escapedName + ":\\s*$.*?^ " + escapedField + ":\\s*)(\\S+.*?)$",
Pattern.MULTILINE | Pattern.DOTALL
);
Matcher m = blockPattern.matcher(yaml);
if (m.find()) {
return m.replaceFirst("$1" + Matcher.quoteReplacement(newValue));
}
return yaml;
}
/**
* Update the branch field for a single subproject in the YAML text.
* If the subproject block does not yet declare a {@code branch:} field,
* it is inserted after the {@code repo:} line so the manifest and git
* state stay in sync (see issue #159).
*
* @param yaml full YAML content
* @param subprojectName the subproject key to find
* @param newBranch the new branch value
* @return updated YAML content (unchanged if the subproject is absent)
*/
public static String updateSubprojectBranch(String yaml, String subprojectName, String newBranch) {
return addOrUpdateSubprojectField(yaml, subprojectName, "branch", newBranch, "repo");
}
}