ContentCanonicalizeCommand.java

package net.sasasin.sreader.cli;

import net.sasasin.sreader.domain.ContentCanonicalizationResult;
import net.sasasin.sreader.service.canonicalization.ContentCanonicalizationMaintenanceService;
import org.springframework.stereotype.Component;
import picocli.CommandLine.ArgGroup;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

@Command(
    name = "canonicalize",
    description = "Merge stored content records that resolve to the same canonical URL.",
    mixinStandardHelpOptions = true,
    usageHelpWidth = 100)
@Component
public class ContentCanonicalizeCommand implements java.util.concurrent.Callable<Integer> {

  private final ContentCanonicalizationMaintenanceService service;

  @ArgGroup(exclusive = true, multiplicity = "0..1", heading = "Execution mode:%n")
  private ExecutionModeOptions executionMode;

  @Option(
      names = "--host",
      paramLabel = "<HOSTNAME>",
      description = "Restrict processing to this host")
  private String host;

  @Option(
      names = "--batch-size",
      defaultValue = "100",
      paramLabel = "<N>",
      description = "Maximum candidate rows read at once (default: ${DEFAULT-VALUE})")
  private int batchSize;

  @Option(
      names = "--limit",
      paramLabel = "<N>",
      description = "Process at most this many changed groups")
  private Integer limit;

  public ContentCanonicalizeCommand(ContentCanonicalizationMaintenanceService service) {
    this.service = service;
  }

  @Override
  public Integer call() {
    CanonicalizationExecutionMode mode = executionMode();
    ContentCanonicalizationResult result =
        service.canonicalize(
            new ContentCanonicalizationMaintenanceService.Options(
                host, batchSize, limit, mode.apply()));
    print(result, mode.displayValue());
    return result.hasFailures() ? 1 : 0;
  }

  private CanonicalizationExecutionMode executionMode() {
    return executionMode == null ? CanonicalizationExecutionMode.DRY_RUN : executionMode.mode();
  }

  private void print(ContentCanonicalizationResult result, String mode) {
    var scan = result.scan();
    var groups = result.groups();
    var database = result.database();
    var files = result.files();
    System.out.printf("mode=%s%n", mode);
    System.out.printf("scanned_rows=%d%n", scan.scannedRows());
    System.out.printf("unchanged_rows=%d%n", scan.unchangedRows());
    System.out.printf("rename_groups=%d%n", groups.renameGroups());
    System.out.printf("merge_groups=%d%n", groups.mergeGroups());
    System.out.printf("duplicate_rows_to_delete=%d%n", database.deletedContentHeaders());
    System.out.printf("full_text_rows_to_delete=%d%n", database.deletedFullTexts());
    System.out.printf("export_history_rows_to_delete=%d%n", database.deletedExportHistories());
    System.out.printf("feed_conflict_groups=%d%n", groups.feedConflictGroups());
    System.out.printf("processed_groups=%d%n", groups.processedGroups());
    System.out.printf("deleted_files=%d%n", files.deletedFiles());
    System.out.printf("missing_files=%d%n", files.missingFiles());
    System.out.printf("failed_files=%d%n", files.failedFiles());
    System.out.printf("failed_groups=%d%n", groups.failedGroups());
  }

  /** CLI execution mode for content canonicalization (dry-run vs apply). */
  enum CanonicalizationExecutionMode {
    DRY_RUN(false, "dry-run"),
    APPLY(true, "apply");

    private final boolean apply;
    private final String displayValue;

    CanonicalizationExecutionMode(boolean apply, String displayValue) {
      this.apply = apply;
      this.displayValue = displayValue;
    }

    boolean apply() {
      return apply;
    }

    String displayValue() {
      return displayValue;
    }
  }

  /**
   * Optional exclusive group for {@code --dry-run} / {@code --apply}. When the group is omitted,
   * {@link CanonicalizationExecutionMode#DRY_RUN} is used.
   */
  static final class ExecutionModeOptions {

    @Option(
        names = "--dry-run",
        description = "Report changes without modifying the database or files (default mode)")
    boolean dryRun;

    @Option(names = "--apply", description = "Apply database changes and remove stale text files")
    boolean apply;

    CanonicalizationExecutionMode mode() {
      if (dryRun && apply) {
        throw new IllegalStateException("Picocli exclusive group invariant violated");
      }
      if (apply) {
        return CanonicalizationExecutionMode.APPLY;
      }
      return CanonicalizationExecutionMode.DRY_RUN;
    }
  }
}