FeedTomlWriter.java

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

import java.time.Clock;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import net.sasasin.sreader.domain.FeedStatus;
import net.sasasin.sreader.domain.FeedUrl;

/**
 * Deterministic sreader feed-file formatter. Deliberately not TomlJ table serialization: field
 * order, {@code [[feeds]]} layout, blank lines, LF, and optional-field omission must stay stable
 * for Git-friendly exports.
 */
final class FeedTomlWriter {

  String write(List<FeedUrl> feeds, Clock clock) {
    Objects.requireNonNull(feeds, "feeds must not be null");
    Objects.requireNonNull(clock, "clock must not be null");
    StringBuilder toml = new StringBuilder("schema_version = 2\n");
    toml.append("generated_at = \"").append(OffsetDateTime.now(clock)).append("\"\n");
    for (FeedUrl feed : feeds) {
      Objects.requireNonNull(feed, "feed must not be null");
      toml.append("\n[[feeds]]\nurl = \"").append(escapeBasicString(feed.url())).append("\"\n");
      toml.append("status = \"").append(feed.status().value()).append("\"\n");
      toml.append("full_text_method = \"").append(feed.fullTextMethod().value()).append("\"\n");
      if (feed.status() == FeedStatus.UNSUBSCRIBED) {
        appendString(
            toml,
            "unsubscribe_reason",
            feed.unsubscribeReason() == null ? null : feed.unsubscribeReason().value());
        if (feed.unsubscribedAt() != null) {
          toml.append("unsubscribed_at = \"").append(feed.unsubscribedAt()).append("\"\n");
        }
        appendString(toml, "note", feed.note());
      }
    }
    return toml.toString();
  }

  private void appendString(StringBuilder toml, String key, String value) {
    if (value != null) {
      toml.append(key).append(" = \"").append(escapeBasicString(value)).append("\"\n");
    }
  }

  /**
   * Escapes a value for a TOML basic string. TomlJ {@code tomlEscape} rewrites non-ASCII to Unicode
   * escapes and is therefore not used: canonical export keeps UTF-8 literals for Japanese/emoji.
   */
  static String escapeBasicString(String value) {
    StringBuilder escaped = new StringBuilder(value.length());
    for (int index = 0; index < value.length(); index++) {
      char character = value.charAt(index);
      switch (character) {
        case '\\' -> escaped.append("\\\\");
        case '"' -> escaped.append("\\\"");
        case '\b' -> escaped.append("\\b");
        case '\t' -> escaped.append("\\t");
        case '\n' -> escaped.append("\\n");
        case '\f' -> escaped.append("\\f");
        case '\r' -> escaped.append("\\r");
        default -> {
          if (character <= '\u001f') {
            escaped.append(String.format(Locale.ROOT, "\\u%04X", (int) character));
          } else {
            escaped.append(character);
          }
        }
      }
    }
    return escaped.toString();
  }
}