Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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;

Expand All @@ -16,13 +17,8 @@ public class ContentCanonicalizeCommand implements java.util.concurrent.Callable

private final ContentCanonicalizationMaintenanceService service;

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

@Option(names = "--apply", description = "Apply database changes and remove stale text files")
private boolean apply;
@ArgGroup(exclusive = true, multiplicity = "0..1", heading = "Execution mode:%n")
private ExecutionModeOptions executionMode;

@Option(
names = "--host",
Expand All @@ -49,17 +45,19 @@ public ContentCanonicalizeCommand(ContentCanonicalizationMaintenanceService serv

@Override
public Integer call() {
if (dryRun && apply) {
throw new picocli.CommandLine.ParameterException(
new picocli.CommandLine(this), "--dry-run and --apply are mutually exclusive");
}
CanonicalizationExecutionMode mode = executionMode();
ContentCanonicalizationResult result =
service.canonicalize(
new ContentCanonicalizationMaintenanceService.Options(host, batchSize, limit, apply));
print(result, apply ? "apply" : "dry-run");
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();
Expand All @@ -80,4 +78,51 @@ private void print(ContentCanonicalizationResult result, String mode) {
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package net.sasasin.sreader.cli;

import net.sasasin.sreader.domain.FeedEntrySelection;
import picocli.CommandLine.ITypeConverter;
import picocli.CommandLine.TypeConversionException;

/** Converts {@code --entry-index <N>} to a {@link FeedEntrySelection}. */
final class EntryIndexSelectionConverter implements ITypeConverter<FeedEntrySelection> {

@Override
public FeedEntrySelection convert(String value) {
if (value == null || value.isBlank()) {
throw new TypeConversionException("--entry-index must be a non-negative integer: " + value);
}
String trimmed = value.trim();
final int index;
try {
index = Integer.parseInt(trimmed);
} catch (NumberFormatException e) {
throw new TypeConversionException("--entry-index must be a non-negative integer: " + value);
}
try {
return FeedEntrySelection.index(index);
} catch (IllegalArgumentException e) {
throw new TypeConversionException("--entry-index must be a non-negative integer: " + value);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package net.sasasin.sreader.cli;

import java.util.Locale;
import net.sasasin.sreader.domain.FeedEntrySelection;
import picocli.CommandLine.ITypeConverter;
import picocli.CommandLine.TypeConversionException;

/** Converts {@code --entry first|latest} to a {@link FeedEntrySelection}. */
final class EntryPositionSelectionConverter implements ITypeConverter<FeedEntrySelection> {

@Override
public FeedEntrySelection convert(String value) {
if (value == null || value.isBlank()) {
throw new TypeConversionException("--entry must be first or latest");
}
return switch (value.trim().toLowerCase(Locale.ROOT)) {
case "first" -> FeedEntrySelection.first();
case "latest" -> FeedEntrySelection.latest();
default -> throw new TypeConversionException("--entry must be first or latest: " + value);
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package net.sasasin.sreader.cli;

import net.sasasin.sreader.domain.FeedEntrySelection;
import picocli.CommandLine.ITypeConverter;
import picocli.CommandLine.TypeConversionException;

/** Converts {@code --entry-title-regex <REGEX>} to a {@link FeedEntrySelection}. */
final class EntryTitleRegexSelectionConverter implements ITypeConverter<FeedEntrySelection> {

@Override
public FeedEntrySelection convert(String value) {
if (value == null || value.isBlank()) {
throw new TypeConversionException("--entry-title-regex must not be blank");
}
try {
// Keep original value (no trim of pattern); domain validates syntax.
return FeedEntrySelection.titleRegex(value);
} catch (IllegalArgumentException e) {
throw new TypeConversionException(
"--entry-title-regex is not a valid regular expression: " + value);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package net.sasasin.sreader.cli;

import net.sasasin.sreader.domain.FeedEntrySelection;
import picocli.CommandLine.ITypeConverter;
import picocli.CommandLine.TypeConversionException;

/** Converts {@code --entry-url-regex <REGEX>} to a {@link FeedEntrySelection}. */
final class EntryUrlRegexSelectionConverter implements ITypeConverter<FeedEntrySelection> {

@Override
public FeedEntrySelection convert(String value) {
if (value == null || value.isBlank()) {
throw new TypeConversionException("--entry-url-regex must not be blank");
}
try {
// Keep original value (no trim of pattern); domain validates syntax.
return FeedEntrySelection.urlRegex(value);
} catch (IllegalArgumentException e) {
throw new TypeConversionException(
"--entry-url-regex is not a valid regular expression: " + value);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package net.sasasin.sreader.cli;

import java.net.URI;
import java.util.Objects;
import java.util.Optional;
import net.sasasin.sreader.domain.FullTextMethod;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.ParameterException;

/**
* Validated immutable CLI boundary for {@code probe article}. Invalid method/xpath combinations
* cannot be constructed.
*/
final class ProbeArticleCliRequest {

private final URI url;
private final FullTextMethod method;
private final Optional<String> xpath;
private final boolean verbose;
private final Optional<String> output;
private final Optional<Integer> maxChars;

private ProbeArticleCliRequest(
URI url,
FullTextMethod method,
Optional<String> xpath,
boolean verbose,
Optional<String> output,
Optional<Integer> maxChars) {
this.url = Objects.requireNonNull(url, "url");
this.method = Objects.requireNonNull(method, "method");
this.xpath = Objects.requireNonNull(xpath, "xpath");
this.verbose = verbose;
this.output = Objects.requireNonNull(output, "output");
this.maxChars = Objects.requireNonNull(maxChars, "maxChars");
}

static ProbeArticleCliRequest create(
CommandSpec spec,
String url,
FullTextMethod method,
String xpath,
boolean verbose,
String output,
Integer maxChars) {
Objects.requireNonNull(spec, "spec");
if (method == null) {
throw new ParameterException(spec.commandLine(), "--method is required");
}
if (!method.supportsArticleProbe()) {
throw new ParameterException(
spec.commandLine(), "--method feed is not valid for 'probe article'");
}
URI validatedUrl = UrlValidator.validateHttpUrl(url, "--url", spec);
Optional<String> normalizedXpath = normalizeXpath(xpath);
if (normalizedXpath.isPresent() && !method.supportsXpathOverride()) {
throw new ParameterException(
spec.commandLine(), "--xpath cannot be used with --method " + method.value());
}
return new ProbeArticleCliRequest(
validatedUrl,
method,
normalizedXpath,
verbose,
Optional.ofNullable(output),
Optional.ofNullable(maxChars));
}

URI url() {
return url;
}

FullTextMethod method() {
return method;
}

Optional<String> xpath() {
return xpath;
}

boolean verbose() {
return verbose;
}

Optional<String> output() {
return output;
}

Optional<Integer> maxChars() {
return maxChars;
}

private static Optional<String> normalizeXpath(String xpath) {
if (xpath == null || xpath.isBlank()) {
return Optional.empty();
}
return Optional.of(xpath);
}
}
29 changes: 13 additions & 16 deletions app/src/main/java/net/sasasin/sreader/cli/ProbeArticleCommand.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package net.sasasin.sreader.cli;

import java.net.URI;
import java.util.Optional;
import java.util.concurrent.Callable;
import net.sasasin.sreader.domain.FullTextMethod;
import net.sasasin.sreader.service.probe.FullTextProbeService;
Expand Down Expand Up @@ -76,18 +74,12 @@ public ProbeArticleCommand(FullTextProbeService fullTextProbeService) {
@Override
public Integer call() {
try {
URI uri = UrlValidator.validateHttpUrl(url, "--url", spec);
ProbeArticleCliRequest request =
ProbeArticleCliRequest.create(spec, url, method, xpath, verbose, output, maxChars);

if (!method.supportsArticleProbe()) {
throw new picocli.CommandLine.ParameterException(
spec.commandLine(), "--method feed is not valid for 'probe article'");
}

Optional<String> xp =
(xpath != null && !xpath.isBlank()) ? Optional.of(xpath) : Optional.empty();

ProbeOutcome outcome = fullTextProbeService.probeArticle(uri, method, xp);
return mapOutcome(outcome);
ProbeOutcome outcome =
fullTextProbeService.probeArticle(request.url(), request.method(), request.xpath());
return mapOutcome(outcome, request);
} catch (picocli.CommandLine.ParameterException pe) {
throw pe;
} catch (RuntimeException e) {
Expand All @@ -96,13 +88,18 @@ public Integer call() {
}
}

private int mapOutcome(ProbeOutcome outcome) {
private int mapOutcome(ProbeOutcome outcome, ProbeArticleCliRequest request) {
ProbeOutputWriter writer = new ProbeOutputWriter(spec);
return switch (outcome) {
case ProbeOutcome.Succeeded succeeded ->
writer.writeSucceeded(succeeded.document(), succeeded.text(), verbose, output, maxChars);
writer.writeSucceeded(
succeeded.document(),
succeeded.text(),
request.verbose(),
request.output().orElse(null),
request.maxChars().orElse(null));
case ProbeOutcome.NoContent noContent -> {
if (verbose) {
if (request.verbose()) {
writer.writeNoContentDiagnostics(noContent.document());
}
yield CliExitCodes.EMPTY_RESULT;
Expand Down
Loading
Loading