Add an experimental tool with CLI to extract embedded R8 rules

This can be tested in the R8 repo with something like this:

```
java -cp build/libs/r8.jar com.android.tools.r8.ExtractR8Rules \
  third_party/kotlin/kotlin-compiler-2.1.0-Beta1/kotlinc/lib/kotlin-reflect.jar \
  third_party/kotlin/kotlin-compiler-2.1.0-Beta1/kotlinc/lib/kotlinx-coroutines-core-jvm.jar \
  --include-origin-comments \
  --rules-output extracted.rules
```

Then `extracted.rules` will contain the R8 rules for the version of the
compiler in `r8.jar`.

Bug: b/377144587

Change-Id: I8c2b5246d8a69bc27d0aa16610c124ef1054f6d7
diff --git a/src/main/java/com/android/tools/r8/ExtractR8Rules.java b/src/main/java/com/android/tools/r8/ExtractR8Rules.java
new file mode 100644
index 0000000..6d97afb
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/ExtractR8Rules.java
@@ -0,0 +1,97 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8;
+
+import com.android.tools.r8.keepanno.annotations.KeepForApi;
+import com.android.tools.r8.utils.AndroidApp;
+import com.android.tools.r8.utils.EmbeddedRulesExtractor;
+import com.android.tools.r8.utils.ExceptionDiagnostic;
+import com.android.tools.r8.utils.ExceptionUtils;
+import com.android.tools.r8.utils.InternalOptions;
+import com.android.tools.r8.utils.Reporter;
+import com.android.tools.r8.utils.SemanticVersion;
+import com.android.tools.r8.utils.SemanticVersionUtils;
+import com.android.tools.r8.utils.StringUtils;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.function.Supplier;
+
+@KeepForApi
+public class ExtractR8Rules {
+
+  private static void run(
+      AndroidApp app,
+      StringConsumer consumer,
+      boolean includeOriginComments,
+      SemanticVersion fakeCompilerVersion,
+      Reporter reporter) {
+    Supplier<SemanticVersion> semanticVersionSupplier =
+        SemanticVersionUtils.compilerVersionSemanticVersionSupplier(
+            fakeCompilerVersion,
+            "Using an artificial version newer than any known version for selecting"
+                + " Proguard configurations embedded under META-INF/. This means that"
+                + " all rules with a '-upto-' qualifier will be excluded and all"
+                + " rules with a -from- qualifier will be included.",
+            reporter);
+    for (ProgramResourceProvider provider : app.getProgramResourceProviders()) {
+      DataResourceProvider dataResourceProvider = provider.getDataResourceProvider();
+      if (dataResourceProvider == null) {
+        return;
+      }
+      try {
+        EmbeddedRulesExtractor embeddedProguardConfigurationVisitor =
+            new EmbeddedRulesExtractor(reporter, semanticVersionSupplier);
+        dataResourceProvider.accept(embeddedProguardConfigurationVisitor);
+        embeddedProguardConfigurationVisitor.visitRelevantRules(
+            rules -> {
+              try {
+                if (includeOriginComments) {
+                  consumer.accept("# Rules extracted from:", reporter);
+                  consumer.accept(StringUtils.LINE_SEPARATOR, reporter);
+                  consumer.accept("# ", reporter);
+                  consumer.accept(rules.getOrigin().toString(), reporter);
+                  consumer.accept(StringUtils.LINE_SEPARATOR, reporter);
+                }
+                consumer.accept(rules.get(), reporter);
+                consumer.accept(StringUtils.LINE_SEPARATOR, reporter);
+              } catch (IOException e) {
+                throw new UncheckedIOException(e);
+              }
+            });
+      } catch (ResourceException e) {
+        reporter.error(new ExceptionDiagnostic(e));
+      }
+    }
+    consumer.finished(reporter);
+  }
+
+  /** Experimental API to extract embedded rules from libraries. */
+  public static void run(ExtractR8RulesCommand command) throws CompilationFailedException {
+    AndroidApp app = command.getInputApp();
+    StringConsumer rulesConsumer = command.getRulesConsumer();
+    boolean includeOriginComments = command.getIncludeOriginComments();
+    SemanticVersion fakeCompilerVersion = command.getFakeCompilerVersion();
+    InternalOptions options = command.getInternalOptions();
+    ExceptionUtils.withCompilationHandler(
+        options.reporter,
+        () -> {
+          run(app, rulesConsumer, includeOriginComments, fakeCompilerVersion, options.reporter);
+        });
+  }
+
+  public static void main(String[] args) throws CompilationFailedException {
+    ExtractR8RulesCommand.Builder builder = ExtractR8RulesCommand.parse(args);
+    ExtractR8RulesCommand command = builder.build();
+    if (command.isPrintHelp()) {
+      System.out.println(ExtractR8RulesCommand.USAGE_MESSAGE);
+      return;
+    }
+    if (command.isPrintVersion()) {
+      System.out.println("ExtractR8Rules " + Version.LABEL);
+      return;
+    }
+    run(command);
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/ExtractR8RulesCommand.java b/src/main/java/com/android/tools/r8/ExtractR8RulesCommand.java
new file mode 100644
index 0000000..3a55917
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/ExtractR8RulesCommand.java
@@ -0,0 +1,184 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8;
+
+import com.android.tools.r8.graph.DexItemFactory;
+import com.android.tools.r8.keepanno.annotations.KeepForApi;
+import com.android.tools.r8.origin.CommandLineOrigin;
+import com.android.tools.r8.utils.AndroidApp;
+import com.android.tools.r8.utils.InternalOptions;
+import com.android.tools.r8.utils.Reporter;
+import com.android.tools.r8.utils.SemanticVersion;
+import com.android.tools.r8.utils.StringDiagnostic;
+import com.android.tools.r8.utils.StringUtils;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+@KeepForApi
+/** Experimental API to extract embedded rules from libraries. */
+public class ExtractR8RulesCommand extends BaseCommand {
+
+  private final StringConsumer rulesConsumer;
+  private final boolean includeOriginComments;
+  private final SemanticVersion fakeCompilerVersion;
+  private final DexItemFactory factory;
+  private final Reporter reporter;
+
+  @KeepForApi
+  public static class Builder extends BaseCommand.Builder<ExtractR8RulesCommand, Builder> {
+
+    private final DexItemFactory factory = new DexItemFactory();
+    private StringConsumer rulesConsumer = null;
+    private boolean includeOriginComments = false;
+    private SemanticVersion fakeCompilerVersion = null;
+
+    private Builder() {}
+
+    private Builder(DiagnosticsHandler diagnosticsHandler) {
+      super(diagnosticsHandler);
+    }
+
+    @Override
+    ExtractR8RulesCommand.Builder self() {
+      return this;
+    }
+
+    /** TBD */
+    public ExtractR8RulesCommand.Builder setRulesOutputPath(Path rulesOutputPath) {
+      rulesConsumer = new StringConsumer.FileConsumer(rulesOutputPath);
+      return self();
+    }
+
+    /** TBD */
+    public ExtractR8RulesCommand.Builder setRulesConsumer(StringConsumer rulesConsumer) {
+      this.rulesConsumer = rulesConsumer;
+      return self();
+    }
+
+    /** TBD */
+    public ExtractR8RulesCommand.Builder setIncludeOriginComments(boolean include) {
+      this.includeOriginComments = include;
+      return self();
+    }
+
+    /** TBD */
+    public Builder setFakeCompilerVersion(SemanticVersion version) {
+      fakeCompilerVersion = version;
+      return self();
+    }
+
+    @Override
+    protected ExtractR8RulesCommand makeCommand() {
+      // If printing versions ignore everything else.
+      if (isPrintHelp() || isPrintVersion()) {
+        return new ExtractR8RulesCommand(isPrintHelp(), isPrintVersion());
+      }
+
+      return new ExtractR8RulesCommand(
+          factory,
+          getAppBuilder().build(),
+          rulesConsumer,
+          includeOriginComments,
+          fakeCompilerVersion,
+          getReporter());
+    }
+  }
+
+  static final String USAGE_MESSAGE =
+      StringUtils.lines(
+          "Usage: TBD",
+          "  --rules-output <file>      # Output the extracted keep rules.",
+          "  --include-origin-comments  # Include comments with origin for extracted rules.",
+          "  --version                  # Print the version.",
+          "  --help                     # Print this message.");
+
+  public static ExtractR8RulesCommand.Builder builder() {
+    return new ExtractR8RulesCommand.Builder();
+  }
+
+  public static ExtractR8RulesCommand.Builder builder(DiagnosticsHandler diagnosticsHandler) {
+    return new ExtractR8RulesCommand.Builder(diagnosticsHandler);
+  }
+
+  public static ExtractR8RulesCommand.Builder parse(String[] args) {
+    ExtractR8RulesCommand.Builder builder = builder();
+    parse(args, builder);
+    return builder;
+  }
+
+  public StringConsumer getRulesConsumer() {
+    return rulesConsumer;
+  }
+
+  public boolean getIncludeOriginComments() {
+    return includeOriginComments;
+  }
+
+  public SemanticVersion getFakeCompilerVersion() {
+    return fakeCompilerVersion;
+  }
+
+  Reporter getReporter() {
+    return reporter;
+  }
+
+  private static void parse(String[] args, ExtractR8RulesCommand.Builder builder) {
+    for (int i = 0; i < args.length; i++) {
+      String arg = args[i].trim();
+      if (arg.length() == 0) {
+        continue;
+      } else if (arg.equals("--help")) {
+        builder.setPrintHelp(true);
+      } else if (arg.equals("--version")) {
+        builder.setPrintVersion(true);
+      } else if (arg.equals("--rules-output")) {
+        builder.setRulesOutputPath(Paths.get(args[++i]));
+      } else if (arg.equals("--include-origin-comments")) {
+        builder.setIncludeOriginComments(true);
+      } else {
+        if (arg.startsWith("--")) {
+          builder
+              .getReporter()
+              .fatalError(
+                  new StringDiagnostic("Unknown option: " + arg, CommandLineOrigin.INSTANCE));
+        }
+        builder.addProgramFiles(Paths.get(arg));
+      }
+    }
+  }
+
+  private ExtractR8RulesCommand(
+      DexItemFactory factory,
+      AndroidApp inputApp,
+      StringConsumer rulesConsumer,
+      boolean includeOriginComments,
+      SemanticVersion fakeCompilerVersion,
+      Reporter reporter) {
+    super(inputApp);
+    this.factory = factory;
+    this.rulesConsumer = rulesConsumer;
+    this.includeOriginComments = includeOriginComments;
+    this.fakeCompilerVersion = fakeCompilerVersion;
+    this.reporter = reporter;
+  }
+
+  private ExtractR8RulesCommand(boolean printHelp, boolean printVersion) {
+    super(printHelp, printVersion);
+    this.factory = new DexItemFactory();
+    this.rulesConsumer = null;
+    this.includeOriginComments = false;
+    this.fakeCompilerVersion = null;
+    this.reporter = new Reporter();
+  }
+
+  @Override
+  InternalOptions getInternalOptions() {
+    InternalOptions internal = new InternalOptions(factory, reporter);
+    internal.programConsumer = DexIndexedConsumer.emptyConsumer();
+    assert internal.retainCompileTimeAnnotations;
+    internal.retainCompileTimeAnnotations = false;
+    return internal;
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/SwissArmyKnife.java b/src/main/java/com/android/tools/r8/SwissArmyKnife.java
index b9dd50f..c5608b7 100644
--- a/src/main/java/com/android/tools/r8/SwissArmyKnife.java
+++ b/src/main/java/com/android/tools/r8/SwissArmyKnife.java
@@ -74,6 +74,9 @@
       case "verify":
         CfVerifierTool.main(shift(args));
         break;
+      case "extractr8rules":
+        ExtractR8Rules.main(shift(args));
+        break;
       default:
         runDefault(args);
         break;
diff --git a/src/main/java/com/android/tools/r8/utils/EmbeddedRulesExtractor.java b/src/main/java/com/android/tools/r8/utils/EmbeddedRulesExtractor.java
index 7f68aa3..d9b972a 100644
--- a/src/main/java/com/android/tools/r8/utils/EmbeddedRulesExtractor.java
+++ b/src/main/java/com/android/tools/r8/utils/EmbeddedRulesExtractor.java
@@ -134,7 +134,15 @@
     }
   }
 
+  private List<ProguardConfigurationSource> getRelevantRules() {
+    return !r8Sources.isEmpty() ? r8Sources : proguardSources;
+  }
+
   public void parseRelevantRules(ProguardConfigurationParser parser) {
-    parse(!r8Sources.isEmpty() ? r8Sources : proguardSources, parser);
+    parse(getRelevantRules(), parser);
+  }
+
+  public void visitRelevantRules(Consumer<ProguardConfigurationSource> consumer) {
+    getRelevantRules().forEach(consumer);
   }
 }
diff --git a/src/test/java/com/android/tools/r8/shaking/LibraryProvidedProguardRulesExtractR8RulesTest.java b/src/test/java/com/android/tools/r8/shaking/LibraryProvidedProguardRulesExtractR8RulesTest.java
new file mode 100644
index 0000000..36e68ca
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/shaking/LibraryProvidedProguardRulesExtractR8RulesTest.java
@@ -0,0 +1,230 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.shaking;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertEquals;
+
+import com.android.tools.r8.ExtractR8Rules;
+import com.android.tools.r8.ExtractR8RulesCommand;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.utils.BooleanUtils;
+import com.android.tools.r8.utils.FileUtils;
+import com.android.tools.r8.utils.Pair;
+import com.android.tools.r8.utils.SemanticVersion;
+import com.android.tools.r8.utils.StringUtils;
+import com.android.tools.r8.utils.ZipUtils.ZipBuilder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class LibraryProvidedProguardRulesExtractR8RulesTest
+    extends LibraryProvidedProguardRulesTestBase {
+
+  @Parameter(0)
+  public TestParameters parameters;
+
+  @Parameter(1)
+  public boolean includeOriginComments;
+
+  @Parameters(name = "{0}, includeOriginComments: {1}")
+  public static List<Object[]> data() {
+    return buildParameters(getTestParameters().withNoneRuntime().build(), BooleanUtils.values());
+  }
+
+  private static final String EXPECTED_A = StringUtils.lines("-keep class A1", "-keep class A2");
+
+  private static final String EXPECTED_B = StringUtils.lines("-keep class B1", "-keep class B2");
+
+  private static final String EXPECTED_C = StringUtils.lines("-keep class C1", "-keep class C2");
+
+  private static final String EXPECTED_D = StringUtils.lines("-keep class D1", "-keep class D2");
+
+  private static final String EXPECTED_E = StringUtils.lines("-keep class E1", "-keep class E2");
+
+  private static final String EXPECTED_X = StringUtils.lines("-keep class X1", "-keep class X2");
+
+  private List<Path> buildLibraries() throws Exception {
+    List<Path> result = new ArrayList<>();
+    List<Pair<String, String>> rules = new ArrayList<>();
+    rules.add(new Pair<>("META-INF/com.android.tools/r8/test1.pro", "-keep class A1"));
+    rules.add(new Pair<>("META-INF/com.android.tools/r8/test2.pro", "-keep class A2"));
+    rules.add(new Pair<>("META-INF/com.android.tools/r8-from-4.0.0/test1.pro", "-keep class B1"));
+    rules.add(new Pair<>("META-INF/com.android.tools/r8-from-4.0.0/test2.pro", "-keep class B2"));
+    rules.add(new Pair<>("META-INF/com.android.tools/r8-upto-8.1.0/test1.pro", "-keep class C1"));
+    rules.add(new Pair<>("META-INF/com.android.tools/r8-upto-8.1.0/test2.pro", "-keep class C2"));
+    rules.add(
+        new Pair<>(
+            "META-INF/com.android.tools/r8-from-5.0.0-upto-8.0.0/test1.pro", "-keep class D1"));
+    rules.add(
+        new Pair<>(
+            "META-INF/com.android.tools/r8-from-5.0.0-upto-8.0.0/test2.pro", "-keep class D2"));
+    rules.add(new Pair<>("META-INF/com.android.tools/r8-from-10.5.0/test1.pro", "-keep class E1"));
+    rules.add(new Pair<>("META-INF/com.android.tools/r8-from-10.5.0/test2.pro", "-keep class E2"));
+    rules.add(new Pair<>("META-INF/proguard/test1.pro", "-keep class X1"));
+    rules.add(new Pair<>("META-INF/proguard/test2.pro", "-keep class X2"));
+    Path folder = temp.newFolder().toPath();
+    for (int i = 0; i < rules.size(); i++) {
+      Path zipFile = folder.resolve("test" + i + ".jar");
+      ZipBuilder jarBuilder = ZipBuilder.builder(zipFile);
+      jarBuilder.addText(rules.get(i).getFirst(), rules.get(i).getSecond());
+      result.add(jarBuilder.build());
+    }
+    return result;
+  }
+
+  private String filterOriginCommentsFromExtractedRules(String extractedRules) {
+    List<String> newResult = new ArrayList<>();
+    List<String> lines = StringUtils.splitLines(extractedRules);
+    for (int i = 0; i < lines.size(); i++) {
+      String line = lines.get(i);
+      if (line.equals("# Rules extracted from:")) {
+        i++;
+        assertThat(lines.get(i), containsString("test"));
+        assertThat(lines.get(i), containsString(".jar"));
+      } else {
+        newResult.add(line);
+      }
+    }
+    return StringUtils.lines(newResult);
+  }
+
+  private void runTestRulesConsumer(SemanticVersion compilerVersion, String expected)
+      throws Exception {
+    List<Path> libraries = buildLibraries();
+    ExtractR8RulesCommand.Builder builder = ExtractR8RulesCommand.builder();
+    libraries.forEach(builder::addProgramFiles);
+    StringBuilder resultBuilder = new StringBuilder();
+    ExtractR8RulesCommand command =
+        builder
+            .setRulesConsumer((s, h) -> resultBuilder.append(s))
+            .setIncludeOriginComments(includeOriginComments)
+            .setFakeCompilerVersion(compilerVersion)
+            .build();
+    ExtractR8Rules.run(command);
+    String extractedRules = resultBuilder.toString();
+    if (includeOriginComments) {
+      extractedRules = filterOriginCommentsFromExtractedRules(extractedRules);
+    }
+    assertEquals(expected, extractedRules);
+  }
+
+  private void runTestRulesOutputPath(SemanticVersion compilerVersion, String expected)
+      throws Exception {
+    List<Path> libraries = buildLibraries();
+    ExtractR8RulesCommand.Builder builder = ExtractR8RulesCommand.builder();
+    libraries.forEach(builder::addProgramFiles);
+    Path rulesOutput = temp.newFile().toPath();
+    ExtractR8RulesCommand command =
+        builder
+            .setRulesOutputPath(rulesOutput)
+            .setIncludeOriginComments(includeOriginComments)
+            .setFakeCompilerVersion(compilerVersion)
+            .build();
+    ExtractR8Rules.run(command);
+    String extractedRules = FileUtils.readTextFile(rulesOutput, StandardCharsets.UTF_8);
+    if (includeOriginComments) {
+      extractedRules = filterOriginCommentsFromExtractedRules(extractedRules);
+    }
+    assertEquals(expected, extractedRules);
+  }
+
+  private void runTest(SemanticVersion compilerVersion, String expected) throws Exception {
+    runTestRulesConsumer(compilerVersion, expected);
+    runTestRulesOutputPath(compilerVersion, expected);
+  }
+
+  @Test
+  public void runTestVersion3() throws Exception {
+    runTest(
+        SemanticVersion.create(3, 0, 0),
+        StringUtils.lines(EXPECTED_A.trim(), EXPECTED_C.trim(), EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion4() throws Exception {
+    runTest(
+        SemanticVersion.create(4, 0, 0),
+        StringUtils.lines(
+            EXPECTED_A.trim(), EXPECTED_B.trim(), EXPECTED_C.trim(), EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion5() throws Exception {
+    runTest(
+        SemanticVersion.create(5, 0, 0),
+        StringUtils.lines(
+            EXPECTED_A.trim(),
+            EXPECTED_B.trim(),
+            EXPECTED_C.trim(),
+            EXPECTED_D.trim(),
+            EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion7_99_99() throws Exception {
+    runTest(
+        SemanticVersion.create(7, 99, 99),
+        StringUtils.lines(
+            EXPECTED_A.trim(),
+            EXPECTED_B.trim(),
+            EXPECTED_C.trim(),
+            EXPECTED_D.trim(),
+            EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion8() throws Exception {
+    runTest(
+        SemanticVersion.create(8, 0, 0),
+        StringUtils.lines(
+            EXPECTED_A.trim(), EXPECTED_B.trim(), EXPECTED_C.trim(), EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion8_0_99() throws Exception {
+    runTest(
+        SemanticVersion.create(8, 0, 99),
+        StringUtils.lines(
+            EXPECTED_A.trim(), EXPECTED_B.trim(), EXPECTED_C.trim(), EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion8_1() throws Exception {
+    runTest(
+        SemanticVersion.create(8, 1, 0),
+        StringUtils.lines(EXPECTED_A.trim(), EXPECTED_B.trim(), EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion8_2() throws Exception {
+    runTest(
+        SemanticVersion.create(8, 2, 0),
+        StringUtils.lines(EXPECTED_A.trim(), EXPECTED_B.trim(), EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion10() throws Exception {
+    runTest(
+        SemanticVersion.create(10, 0, 0),
+        StringUtils.lines(EXPECTED_A.trim(), EXPECTED_B.trim(), EXPECTED_X.trim()));
+  }
+
+  @Test
+  public void runTestVersion10_5() throws Exception {
+    runTest(
+        SemanticVersion.create(10, 5, 0),
+        StringUtils.lines(
+            EXPECTED_A.trim(), EXPECTED_B.trim(), EXPECTED_E.trim(), EXPECTED_X.trim()));
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/shaking/LibraryProvidedProguardRulesR8SpecificTest.java b/src/test/java/com/android/tools/r8/shaking/LibraryProvidedProguardRulesR8SpecificTest.java
index ecc00c4..6ff21ff 100644
--- a/src/test/java/com/android/tools/r8/shaking/LibraryProvidedProguardRulesR8SpecificTest.java
+++ b/src/test/java/com/android/tools/r8/shaking/LibraryProvidedProguardRulesR8SpecificTest.java
@@ -7,6 +7,8 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.fail;
 
+import com.android.tools.r8.ExtractR8Rules;
+import com.android.tools.r8.ExtractR8RulesCommand;
 import com.android.tools.r8.TestParameters;
 import com.android.tools.r8.Version;
 import com.android.tools.r8.utils.AndroidApiLevel;
@@ -137,6 +139,20 @@
                 assertEquals(
                     expected,
                     stripCommentsAndInJars(configuration, providerType == ProviderType.INJARS)));
+
+    // Test the standalone rules extractor (test independent of providerType).
+    if (providerType == ProviderType.API) {
+      StringBuilder resultBuilder = new StringBuilder();
+      ExtractR8RulesCommand.Builder builder = ExtractR8RulesCommand.builder();
+      ExtractR8RulesCommand command =
+          builder
+              .addProgramFiles(library)
+              .setRulesConsumer((s, h) -> resultBuilder.append(s))
+              .setFakeCompilerVersion(compilerVersion)
+              .build();
+      ExtractR8Rules.run(command);
+      assertEquals(expected, resultBuilder.toString());
+    }
   }
 
   private static String stripCommentsAndInJars(String configuration, boolean expectOneInJar) {