ProbeFeedCommand.java

package net.sasasin.sreader.cli;

import java.net.URI;
import java.util.Optional;
import java.util.concurrent.Callable;
import net.sasasin.sreader.domain.FeedEntrySelection;
import net.sasasin.sreader.domain.FullTextMethod;
import net.sasasin.sreader.domain.ProbeResult;
import net.sasasin.sreader.service.FullTextProbeService;
import org.springframework.stereotype.Component;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Spec;

@Command(
    name = "feed",
    description = {
      "Fetch a feed, select one entry, extract full text with chosen method, print text.",
      "Entry selection priority: --entry-index > --entry-title-regex > --entry-url-regex > --entry"
          + " latest > --entry first (default first).",
      "For --method feed: uses feed entry content/desc (no article fetch). --xpath invalid with"
          + " feed method.",
      "STDOUT: body text. --verbose: diagnostics to STDERR."
    },
    mixinStandardHelpOptions = true,
    usageHelpWidth = 100)
@Component
public class ProbeFeedCommand implements Callable<Integer> {

  private final FullTextProbeService fullTextProbeService;

  @Spec private CommandSpec spec;

  @Option(
      names = "--feed-url",
      paramLabel = "<RSS_OR_ATOM_URL>",
      description = "RSS or Atom feed URL to probe",
      required = true)
  private String feedUrl;

  @Option(
      names = "--method",
      paramLabel = "<METHOD>",
      description =
          "Full text method (feed uses entry body; others fetch article URL from chosen entry)",
      required = true,
      converter = FullTextMethodConverter.class)
  private FullTextMethod method;

  @Option(
      names = "--xpath",
      paramLabel = "<XPATH>",
      description = "XPath override (not for feed method)")
  private String xpath;

  @Option(
      names = "--entry",
      paramLabel = "<first|latest>",
      description = "Select first or latest entry (by date)")
  private String entry;

  @Option(
      names = "--entry-index",
      paramLabel = "<N>",
      description = "0-based index of entry in feed")
  private Integer entryIndex;

  @Option(
      names = "--entry-title-regex",
      paramLabel = "<REGEX>",
      description = "Select first entry whose title matches regex")
  private String entryTitleRegex;

  @Option(
      names = "--entry-url-regex",
      paramLabel = "<REGEX>",
      description = "Select first entry whose link matches regex")
  private String entryUrlRegex;

  @Option(names = "--verbose", description = "Print diagnostics to STDERR")
  private boolean verbose;

  @Option(
      names = {"--output", "-o"},
      paramLabel = "<PATH>",
      description = "Write text to file instead of STDOUT")
  private String output;

  @Option(names = "--max-chars", paramLabel = "<N>", description = "Truncate output to N chars")
  private Integer maxChars;

  public ProbeFeedCommand(FullTextProbeService fullTextProbeService) {
    this.fullTextProbeService = fullTextProbeService;
  }

  @Override
  public Integer call() {
    try {
      URI uri = UrlValidator.validateHttpUrl(feedUrl, "--feed-url", spec);

      if (xpath != null && !xpath.isBlank() && method == FullTextMethod.FEED) {
        throw new picocli.CommandLine.ParameterException(
            spec.commandLine(), "--xpath cannot be used with --method feed");
      }

      FeedEntrySelection selection = resolveSelection();

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

      ProbeResult result;
      try {
        result = fullTextProbeService.probeFeed(uri, method, selection, xp);
      } catch (FullTextProbeService.NoMatchingEntryException e) {
        spec.commandLine().getErr().println("No matching feed entry: " + e.getMessage());
        return 3;
      }

      if (result.text() == null || result.text().isBlank()) {
        if (verbose) {
          new ProbeOutputWriter(spec).writeResult(result, true, output, maxChars);
        }
        return 4;
      }

      return new ProbeOutputWriter(spec).writeResult(result, verbose, output, maxChars);

    } catch (picocli.CommandLine.ParameterException pe) {
      throw pe;
    } catch (FullTextProbeService.PlaywrightDisabledException e) {
      spec.commandLine().getErr().println(e.getMessage());
      return 5;
    } catch (Exception e) {
      spec.commandLine().getErr().println("Error: " + e.getMessage());
      return 1;
    }
  }

  private FeedEntrySelection resolveSelection() {
    // priority per spec: index > titleRegex > urlRegex > latest > first (default first)
    if (entryIndex != null) {
      return FeedEntrySelection.index(entryIndex);
    }
    if (entryTitleRegex != null && !entryTitleRegex.isBlank()) {
      return FeedEntrySelection.titleRegex(entryTitleRegex);
    }
    if (entryUrlRegex != null && !entryUrlRegex.isBlank()) {
      return FeedEntrySelection.urlRegex(entryUrlRegex);
    }
    if (entry != null) {
      String e = entry.trim().toLowerCase();
      if ("latest".equals(e)) {
        return FeedEntrySelection.latest();
      }
      if ("first".equals(e)) {
        return FeedEntrySelection.first();
      }
      throw new picocli.CommandLine.ParameterException(
          spec.commandLine(), "Invalid --entry value, must be first or latest: " + entry);
    }
    return FeedEntrySelection.first();
  }
}