jgvariant-tool: Add 'ostree summary add-static-delta' command.

Change-Id: I3b318269c4c85b581d6639fe5ec6a14bf2604ad4
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/Main.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/Main.java
index fbf5b71..744d902 100644
--- a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/Main.java
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/Main.java
@@ -17,7 +17,9 @@
  *
  * <p>Also provides ways to manipulate OSTree repositories.
  *
- * <p>Usage example (dumping the contents of an OSTree summary file):
+ * <h2>Usage Examples</h2>
+ *
+ * <h3>Dumping the contents of an OSTree summary file</h3>
  *
  * {@snippet lang="sh" :
  * $ jgvariant ostree summary read ./jgvariant-ostree/src/test/resources/ostree/summary
@@ -56,6 +58,21 @@
  *     }
  * }
  * }
+ *
+ * <h3>Adding a static delta to an OSTree summary file</h3>
+ *
+ * <p>Static delta <code>3...</code> (in hex), between commits <code>1...</code> and <code>2...
+ * </code>:
+ *
+ * {@snippet lang="sh" :
+ * $ jgvariant ostree summary add-static-delta ./jgvariant-ostree/src/test/resources/ostree/summary 3333333333333333333333333333333333333333333333333333333333333333 2222222222222222222222222222222222222222222222222222222222222222 1111111111111111111111111111111111111111111111111111111111111111
+ * }
+ *
+ * <p>Static delta <code>3...</code> (in hex), between the empty commit and <code>2...</code>:
+ *
+ * {@snippet lang="sh" :
+ * $ jgvariant ostree summary add-static-delta ./jgvariant-ostree/src/test/resources/ostree/summary 4444444444444444444444444444444444444444444444444444444444444444 2222222222222222222222222222222222222222222222222222222222222222
+ * }
  */
 public final class Main {
   static {
diff --git a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/MainCommand.java b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/MainCommand.java
index fe8211e..2bea23c 100644
--- a/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/MainCommand.java
+++ b/jgvariant-tool/src/main/java/eu/mulk/jgvariant/tool/MainCommand.java
@@ -7,6 +7,10 @@
 import static java.util.logging.Level.*;
 
 import eu.mulk.jgvariant.core.Decoder;
+import eu.mulk.jgvariant.core.Signature;
+import eu.mulk.jgvariant.core.Variant;
+import eu.mulk.jgvariant.ostree.ByteString;
+import eu.mulk.jgvariant.ostree.Metadata;
 import eu.mulk.jgvariant.ostree.Summary;
 import eu.mulk.jgvariant.tool.jsonb.*;
 import jakarta.json.bind.Jsonb;
@@ -16,10 +20,15 @@
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
 import java.nio.file.FileSystem;
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+import java.text.ParseException;
+import java.util.*;
 import java.util.logging.Logger;
+import java.util.stream.IntStream;
 import org.jetbrains.annotations.VisibleForTesting;
 import picocli.AutoComplete;
 import picocli.CommandLine;
@@ -76,10 +85,56 @@
     static final class SummaryCommand extends BaseDecoderCommand<Summary> {
 
       @Command(mixinStandardHelpOptions = true)
-      void read(@Parameters(paramLabel = "<file>") File file) throws IOException {
+      void read(@Parameters(paramLabel = "<file>", description = "Summary file to read") File file)
+          throws IOException {
         read(file, Summary.decoder());
       }
 
+      @Command(name = "add-static-delta", mixinStandardHelpOptions = true)
+      void addStaticDelta(
+          @Parameters(paramLabel = "<file>", description = "Summary file to manipulate.")
+              File summaryFile,
+          @Parameters(paramLabel = "<delta>", description = "Checksum of the static delta (hex).")
+              String delta,
+          @Parameters(paramLabel = "<to>", description = "Commit checksum the delta ends at (hex).")
+              String toCommit,
+          @Parameters(
+                  paramLabel = "<from>",
+                  arity = "0..1",
+                  description = "Commit checksum the delta starts from (hex).")
+              String fromCommit)
+          throws IOException, ParseException {
+        var summaryDecoder = Summary.decoder();
+
+        var summary = decodeFile(summaryFile, summaryDecoder);
+
+        var staticDeltaMapSignature = Signature.parse("a{sv}");
+        var checksumSignature = Signature.parse("ay");
+
+        var metadata = summary.metadata();
+        var metadataFields = new LinkedHashMap<>(metadata.fields());
+        metadataFields.compute(
+            "ostree.static-deltas",
+            (k, v) -> {
+              Map<String, Variant> staticDeltas =
+                  v != null
+                      ? new LinkedHashMap<>((Map<String, Variant>) v.value())
+                      : new LinkedHashMap<>();
+              staticDeltas.put(
+                  fromCommit != null ? fromCommit + "-" + toCommit : toCommit,
+                  new Variant(checksumSignature, toByteList(ByteString.ofHex(delta).bytes())));
+              return new Variant(staticDeltaMapSignature, staticDeltas);
+            });
+        metadata = new Metadata(metadataFields);
+        summary = new Summary(summary.entries(), metadata);
+
+        encodeFile(summaryFile, summaryDecoder, summary);
+      }
+
+      private List<Byte> toByteList(byte[] bytes) {
+        return IntStream.range(0, bytes.length).mapToObj(i -> bytes[i]).toList();
+      }
+
       SummaryCommand() {}
     }
 
@@ -111,10 +166,24 @@
   abstract static class BaseDecoderCommand<T> extends BaseCommand {
 
     protected final void read(File file, Decoder<T> decoder) throws IOException {
+      var thing = decodeFile(file, decoder);
+      out().println(jsonb.toJson(thing));
+    }
+
+    protected final T decodeFile(File file, Decoder<T> decoder) throws IOException {
       LOG.fine(() -> "Reading file %s".formatted(file));
       var fileBytes = ByteBuffer.wrap(Files.readAllBytes(fs().getPath(file.getPath())));
-      var thing = decoder.decode(fileBytes);
-      out().println(jsonb.toJson(thing));
+      return decoder.decode(fileBytes);
+    }
+
+    @SuppressWarnings("ResultOfMethodCallIgnored")
+    protected final void encodeFile(File file, Decoder<T> decoder, T thing) throws IOException {
+      var thingBytes = decoder.encode(thing);
+
+      LOG.fine(() -> "Writing file %s".formatted(file));
+      try (var out = FileChannel.open(fs().getPath(file.getPath()), StandardOpenOption.WRITE)) {
+        out.write(thingBytes);
+      }
     }
   }
 
diff --git a/jgvariant-tool/src/main/java/module-info.java b/jgvariant-tool/src/main/java/module-info.java
index e62808e..6faa226 100644
--- a/jgvariant-tool/src/main/java/module-info.java
+++ b/jgvariant-tool/src/main/java/module-info.java
@@ -11,7 +11,9 @@
  *
  * <p>The {@link eu.mulk.jgvariant.tool.Main} class defines the entry point of the tool.
  *
- * <p>Usage example (dumping the contents of an OSTree summary file):
+ * <h2>Usage Examples</h2>
+ *
+ * <h3>Dumping the contents of an OSTree summary file</h3>
  *
  * {@snippet lang="sh" :
  * $ jgvariant ostree summary read ./jgvariant-ostree/src/test/resources/ostree/summary
@@ -50,6 +52,21 @@
  *     }
  * }
  * }
+ *
+ * <h3>Adding a static delta to an OSTree summary file</h3>
+ *
+ * <p>Static delta <code>3...</code> (in hex), between commits <code>1...</code> and <code>2...
+ * </code>:
+ *
+ * {@snippet lang="sh" :
+ * $ jgvariant ostree summary add-static-delta ./jgvariant-ostree/src/test/resources/ostree/summary 3333333333333333333333333333333333333333333333333333333333333333 2222222222222222222222222222222222222222222222222222222222222222 1111111111111111111111111111111111111111111111111111111111111111
+ * }
+ *
+ * <p>Static delta <code>3...</code> (in hex), between the empty commit and <code>2...</code>:
+ *
+ * {@snippet lang="sh" :
+ * $ jgvariant ostree summary add-static-delta ./jgvariant-ostree/src/test/resources/ostree/summary 4444444444444444444444444444444444444444444444444444444444444444 2222222222222222222222222222222222222222222222222222222222222222
+ * }
  */
 module eu.mulk.jgvariant.tool {
   requires transitive eu.mulk.jgvariant.ostree;