FeedTomlImportMapper.java

package net.sasasin.sreader.service.feed.toml;

import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.OptionalInt;
import java.util.Set;
import net.sasasin.sreader.domain.FeedStatus;
import net.sasasin.sreader.domain.FullTextMethod;
import net.sasasin.sreader.domain.UnsubscribeReason;
import net.sasasin.sreader.service.feed.FeedUrlNormalizer;

/**
 * Maps schema-validated raw feed tables into domain {@link FeedTomlService.ImportFeed} values. Does
 * not parse TOML text, inspect quotes/escapes, or touch repositories.
 */
final class FeedTomlImportMapper {

  record Result(List<FeedTomlService.ImportFeed> feeds, List<FeedTomlIssue> issues) {
    Result {
      feeds = List.copyOf(Objects.requireNonNull(feeds, "feeds"));
      issues = List.copyOf(Objects.requireNonNull(issues, "issues"));
    }
  }

  Result map(OptionalInt schemaVersion, List<FeedTomlEntry> entries) {
    Objects.requireNonNull(entries, "entries must not be null");
    List<FeedTomlIssue> issues = new ArrayList<>();
    if (schemaVersion.isPresent()) {
      int version = schemaVersion.getAsInt();
      if (version != 1 && version != 2) {
        issues.add(
            FeedTomlIssue.domain(
                FeedTomlPosition.unknown(),
                "schema_version",
                "unsupported schema_version: " + version,
                issues.size()));
      }
    }

    Set<String> seenUrls = new HashSet<>();
    List<FeedTomlService.ImportFeed> feeds = new ArrayList<>();
    for (FeedTomlEntry entry : entries) {
      mapEntry(entry, seenUrls, feeds, issues);
    }
    return new Result(feeds, issues);
  }

  private void mapEntry(
      FeedTomlEntry entry,
      Set<String> seenUrls,
      List<FeedTomlService.ImportFeed> feeds,
      List<FeedTomlIssue> issues) {
    int issuesBefore = issues.size();
    String url = mapUrl(entry, seenUrls, issues);

    FeedStatus status = FeedStatus.ACTIVE;
    boolean statusOk = true;
    if (entry.status().isPresent()) {
      String statusValue = entry.status().get();
      try {
        status = FeedStatus.fromValue(statusValue);
      } catch (IllegalArgumentException e) {
        issues.add(
            FeedTomlIssue.domain(
                entry.fieldPosition("status"),
                entry.path("status"),
                entry.path("status") + " must be active or unsubscribed: " + statusValue,
                issues.size()));
        statusOk = false;
      }
    }

    UnsubscribeReason reason = null;
    boolean reasonOk = true;
    if (status == FeedStatus.UNSUBSCRIBED && entry.unsubscribeReason().isEmpty()) {
      reason = UnsubscribeReason.OTHER;
    } else if (entry.unsubscribeReason().isPresent()) {
      String reasonValue = entry.unsubscribeReason().get();
      try {
        reason = UnsubscribeReason.fromValue(reasonValue);
      } catch (IllegalArgumentException e) {
        issues.add(
            FeedTomlIssue.domain(
                entry.fieldPosition("unsubscribe_reason"),
                entry.path("unsubscribe_reason"),
                entry.path("unsubscribe_reason") + " is invalid: " + reasonValue,
                issues.size()));
        reasonOk = false;
      }
    }

    OffsetDateTime unsubscribedAt = mapUnsubscribedAt(entry, issues);
    String note = entry.note().orElse(null);
    FullTextMethod method = mapFullTextMethod(entry, issues);

    if (status == FeedStatus.ACTIVE) {
      // Active feeds discard unsubscribe metadata at the import boundary (existing external
      // behavior). Invalid unsubscribe metadata is still validated above for active tables.
      reason = null;
      unsubscribedAt = null;
      note = null;
    }

    if (url != null && statusOk && reasonOk && issues.size() == issuesBefore) {
      feeds.add(
          new FeedTomlService.ImportFeed(
              entry.displayIndex(), url, status, reason, unsubscribedAt, note, method));
    }
  }

  private String mapUrl(FeedTomlEntry entry, Set<String> seenUrls, List<FeedTomlIssue> issues) {
    if (entry.url().isEmpty()) {
      // Missing/non-string URL already reported as schema issue.
      return null;
    }
    String raw = entry.url().get();
    try {
      String url = FeedUrlNormalizer.normalizeStrict(raw);
      if (!seenUrls.add(url)) {
        issues.add(
            FeedTomlIssue.domain(
                entry.urlPosition(),
                entry.path("url"),
                entry.path("url") + " duplicates another feed after normalization: " + url,
                issues.size()));
      }
      return url;
    } catch (IllegalArgumentException e) {
      issues.add(
          FeedTomlIssue.domain(
              entry.urlPosition(),
              entry.path("url"),
              entry.path("url") + ": " + e.getMessage(),
              issues.size()));
      return null;
    }
  }

  private OffsetDateTime mapUnsubscribedAt(FeedTomlEntry entry, List<FeedTomlIssue> issues) {
    if (entry.unsubscribedAt().isEmpty()) {
      return null;
    }
    String value = entry.unsubscribedAt().get();
    try {
      return OffsetDateTime.parse(value);
    } catch (RuntimeException e) {
      issues.add(
          FeedTomlIssue.domain(
              entry.fieldPosition("unsubscribed_at"),
              entry.path("unsubscribed_at"),
              entry.path("unsubscribed_at") + " must be an offset date-time: " + value,
              issues.size()));
      return null;
    }
  }

  private FullTextMethod mapFullTextMethod(FeedTomlEntry entry, List<FeedTomlIssue> issues) {
    if (entry.fullTextMethod().isEmpty()) {
      return FullTextMethod.defaultMethod();
    }
    String value = entry.fullTextMethod().get();
    try {
      return FullTextMethod.fromValue(value);
    } catch (IllegalArgumentException e) {
      issues.add(
          FeedTomlIssue.domain(
              entry.fieldPosition("full_text_method"),
              entry.path("full_text_method"),
              entry.path("full_text_method") + " is invalid: " + value,
              issues.size()));
      return FullTextMethod.defaultMethod();
    }
  }
}