CommandFlagsRegistryGenerator.java

package redis.clients.jedis.codegen;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import redis.clients.jedis.Endpoints;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Module;
import redis.clients.jedis.exceptions.JedisConnectionException;
import redis.clients.jedis.resps.CommandInfo;

import java.io.IOException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;

/**
 * Code generator for StaticCommandFlagsRegistry. This generator connects to a Redis server,
 * retrieves all command metadata using the COMMAND command, and automatically generates the
 * StaticCommandFlagsRegistry class that implements CommandFlagsRegistry interface.
 * <p>
 * Usage:
 *
 * <pre>
 * java -cp ... redis.clients.jedis.codegen.CommandFlagsRegistryGenerator [host] [port]
 * </pre>
 * <p>
 * Arguments:
 * <ul>
 * <li>host - Redis server hostname (default: localhost)</li>
 * <li>port - Redis server port (default: 6379)</li>
 * </ul>
 * <p>
 * Note: This is a code generation tool and should NOT be executed as part of regular tests.
 */
public class CommandFlagsRegistryGenerator {

  private static final String JAVA_FILE = "src/main/java/redis/clients/jedis/StaticCommandFlagsRegistryInitializer.java";
  private static final String BACKUP_JSON_FILE = "redis_commands_metadata.json";

  private final String redisHost;
  private final int redisPort;

  // Server metadata collected during generation
  private ServerMetadata serverMetadata;

  // Map JSON flag names to Java enum names
  private static final Map<String, String> FLAG_MAPPING = new LinkedHashMap<>();
  static {
    FLAG_MAPPING.put("readonly", "READONLY");
    FLAG_MAPPING.put("write", "WRITE");
    FLAG_MAPPING.put("denyoom", "DENYOOM");
    FLAG_MAPPING.put("admin", "ADMIN");
    FLAG_MAPPING.put("pubsub", "PUBSUB");
    FLAG_MAPPING.put("noscript", "NOSCRIPT");
    FLAG_MAPPING.put("random", "RANDOM");
    FLAG_MAPPING.put("sort_for_script", "SORT_FOR_SCRIPT");
    FLAG_MAPPING.put("loading", "LOADING");
    FLAG_MAPPING.put("stale", "STALE");
    FLAG_MAPPING.put("skip_monitor", "SKIP_MONITOR");
    FLAG_MAPPING.put("skip_slowlog", "SKIP_SLOWLOG");
    FLAG_MAPPING.put("asking", "ASKING");
    FLAG_MAPPING.put("fast", "FAST");
    FLAG_MAPPING.put("movablekeys", "MOVABLEKEYS");
    FLAG_MAPPING.put("module", "MODULE");
    FLAG_MAPPING.put("blocking", "BLOCKING");
    FLAG_MAPPING.put("no_auth", "NO_AUTH");
    FLAG_MAPPING.put("no_async_loading", "NO_ASYNC_LOADING");
    FLAG_MAPPING.put("no_multi", "NO_MULTI");
    FLAG_MAPPING.put("no_mandatory_keys", "NO_MANDATORY_KEYS");
    FLAG_MAPPING.put("allow_busy", "ALLOW_BUSY");
  }

  /**
   * Manual command flag overrides that take precedence over Redis server metadata. These overrides
   * allow defining custom flag combinations, request policies, and response policies for specific
   * commands when the server-provided metadata is incorrect or needs customization.
   * <p>
   * Key: Command name (uppercase, e.g., "KEYS" or "ACL CAT" for subcommands) Value: CommandMetadata
   * with the override values
   * <p>
   * To add a new override, add an entry to this map in the static initializer block.
   */
  private static final Map<String, CommandMetadata> MANUAL_OVERRIDES = new LinkedHashMap<>();
  static {
    // Override INFO: change request policy from ALL_SHARDS to DEFAULT
    // Reason: SPECIAL response policy not yet supported in the library and defaults to return
    // single result INFO should be executed on a single node, not broadcast to all shards
    MANUAL_OVERRIDES.put("INFO",
      new CommandMetadata(Arrays.asList("loading", "stale"), "default", "special"));

    // Override FUNCTION STATS: change request policy from ALL_SHARDS to DEFAULT
    // Reason: SPECIAL response policy not yet supported in the library and defaults to return
    // single result FUNCTION STATS should be executed on a single node, not broadcast to all shards
    MANUAL_OVERRIDES.put("FUNCTION STATS",
      new CommandMetadata(Arrays.asList("noscript", "allow_busy"), "default", "special"));
  }

  /**
   * Known parent commands that have subcommands. When the generator encounters a command name
   * containing a space (e.g., "FUNCTION LOAD"), the parent part (e.g., "FUNCTION") must be listed
   * here for proper subcommand registration.
   * <p>
   * If a new Redis command with subcommands is added, add the parent command name to this set. The
   * generator will fail with a helpful error message if an unknown parent command is encountered.
   */
  private static final Set<String> KNOWN_PARENT_COMMANDS = new HashSet<>(Arrays.asList("ACL",
    "CLIENT", "CLUSTER", "COMMAND", "CONFIG", "FUNCTION", "HOTKEYS", "LATENCY", "MEMORY", "MODULE",
    "OBJECT", "PUBSUB", "SCRIPT", "SLOWLOG", "XGROUP", "XINFO", "FT.CONFIG", "FT.CURSOR"));

  public CommandFlagsRegistryGenerator(String host, int port) {
    this.redisHost = host;
    this.redisPort = port;
  }

  public static void main(String[] args) {
    printLine();
    System.out.println("StaticCommandFlagsRegistry Generator");
    printLine();

    // Parse command line arguments
    String host = args.length > 0 ? args[0] : "localhost";
    int port = args.length > 1 ? Integer.parseInt(args[1]) : 6379;

    System.out.println("Redis server: " + host + ":" + port);
    System.out.println();

    try {
      CommandFlagsRegistryGenerator generator = new CommandFlagsRegistryGenerator(host, port);
      generator.generate();

      System.out.println();
      printLine();
      System.out.println("��� Code generation completed successfully!");
      printLine();
    } catch (Exception e) {
      System.err.println();
      printLine();
      System.err.println("��� Code generation failed!");
      printLine();
      e.printStackTrace();
      System.exit(1);
    }
  }

  private static void printLine() {
    for (int i = 0; i < 80; i++) {
      System.out.print("=");
    }
    System.out.println();
  }

  public void generate() throws IOException {
    Map<String, CommandMetadata> commandsMetadata;

    // Step 1: Retrieve commands from Redis
    System.out.println("\nStep 1: Connecting to Redis at " + redisHost + ":" + redisPort + "...");
    try {
      commandsMetadata = retrieveCommandsFromRedis();
      System.out.println("��� Retrieved " + commandsMetadata.size() + " commands from Redis");

      // Save to backup JSON file
      saveToJsonFile(commandsMetadata);
    } catch (JedisConnectionException e) {
      System.err.println("��� Failed to connect to Redis: " + e.getMessage());
      System.out.println("\nAttempting to use backup JSON file: " + BACKUP_JSON_FILE);
      commandsMetadata = readJsonFile();
      System.out.println("��� Loaded " + commandsMetadata.size() + " commands from backup file");
    }

    // Step 2: Process commands and group by metadata combinations
    System.out.println("\nStep 2: Processing commands and grouping by metadata...");
    Map<MetadataKey, List<String>> metadataCombinations = groupByMetadata(commandsMetadata);
    System.out.println("��� Found " + metadataCombinations.size() + " unique metadata combinations");

    // Step 3: Generate StaticCommandFlagsRegistry class
    System.out.println("\nStep 3: Generating StaticCommandFlagsRegistry class...");
    String classContent = generateRegistryClass(metadataCombinations);
    System.out.println("��� Generated " + classContent.split("\n").length + " lines of code");

    // Step 4: Write StaticCommandFlagsRegistry.java
    System.out.println("\nStep 4: Writing " + JAVA_FILE + "...");
    writeJavaFile(classContent);
    System.out.println("��� Successfully created StaticCommandFlagsRegistry.java");
  }

  private Map<String, CommandMetadata> retrieveCommandsFromRedis() {
    Map<String, CommandMetadata> result = new LinkedHashMap<>();

    try (Jedis jedis = new Jedis(redisHost, redisPort)) {

      jedis.connect();
      jedis.auth(Endpoints.getRedisEndpoint("standalone0").getPassword());

      // Collect server metadata
      String infoServer = jedis.info("server");
      String version = extractInfoValue(infoServer, "redis_version");
      String mode = extractInfoValue(infoServer, "redis_mode");

      // Get loaded modules
      List<String> modules = new ArrayList<>();
      try {
        List<Module> moduleList = jedis.moduleList();
        for (Module module : moduleList) {
          modules.add(module.getName());
        }
      } catch (Exception e) {
        // Module list might not be available in all Redis versions
        System.out.println("  Note: Could not retrieve module list: " + e.getMessage());
      }

      serverMetadata = new ServerMetadata(version, mode, modules);

      // Get all commands using COMMAND
      Map<String, CommandInfo> commands = jedis.command();

      for (Map.Entry<String, CommandInfo> entry : commands.entrySet()) {
        CommandInfo cmdInfo = entry.getValue();
        String commandName = normalizeCommandName(cmdInfo.getName());

        // Check for subcommands
        Map<String, CommandInfo> subcommands = cmdInfo.getSubcommands();

        if (subcommands != null && !subcommands.isEmpty()) {
          // This command has subcommands - add parent command with its flags first
          if (!shouldExcludeCommand(commandName)) {
            CommandMetadata parentMetadata = extractCommandMetadata(cmdInfo);
            result.put(commandName, parentMetadata);
          }

          // Then process subcommands
          for (Map.Entry<String, CommandInfo> subEntry : subcommands.entrySet()) {
            CommandInfo subCmdInfo = subEntry.getValue();
            String subCommandName = normalizeCommandName(subCmdInfo.getName());

            // Filter out unwanted commands
            if (shouldExcludeCommand(subCommandName)) {
              continue;
            }

            CommandMetadata metadata = extractCommandMetadata(subCmdInfo);
            result.put(subCommandName, metadata);
          }
        } else {
          // Regular command without subcommands
          // Filter out unwanted commands
          if (!shouldExcludeCommand(commandName)) {
            CommandMetadata metadata = extractCommandMetadata(cmdInfo);
            result.put(commandName, metadata);
          }
        }
      }

    }
    // Ignore close errors

    return result;
  }

  /**
   * Extract command metadata (flags, request_policy, response_policy) from CommandInfo.
   */
  private CommandMetadata extractCommandMetadata(CommandInfo cmdInfo) {
    // Get flags
    List<String> flags = new ArrayList<>();
    if (cmdInfo.getFlags() != null) {
      for (String flag : cmdInfo.getFlags()) {
        flags.add(flag.toLowerCase());
      }
    }

    // Extract request_policy and response_policy from tips
    String requestPolicy = null;
    String responsePolicy = null;
    List<String> tips = cmdInfo.getTips();
    if (tips != null) {
      for (String tip : tips) {
        String tipLower = tip.toLowerCase();
        if (tipLower.startsWith("request_policy:")) {
          requestPolicy = tipLower.substring("request_policy:".length());
        } else if (tipLower.startsWith("response_policy:")) {
          responsePolicy = tipLower.substring("response_policy:".length());
        }
      }
    }

    return new CommandMetadata(flags, requestPolicy, responsePolicy);
  }

  /**
   * Normalize command name: replace pipe separators with spaces and convert to uppercase. Redis
   * returns command names like "acl|help" but Jedis uses "ACL HELP".
   */
  private String normalizeCommandName(String commandName) {
    return commandName.replace('|', ' ').toUpperCase();
  }

  /**
   * Check if a command should be excluded from the registry.
   * <p>
   * Exclusion rules:
   * <ul>
   * <li>All HELP subcommands (e.g., "ACL HELP", "CONFIG HELP", "XINFO HELP")</li>
   * <li>All FT.DEBUG subcommands (e.g., "FT.DEBUG DUMP_TERMS", "FT.DEBUG GIT_SHA")</li>
   * <li>All _FT.DEBUG subcommands (internal RediSearch debug commands)</li>
   * </ul>
   */
  private boolean shouldExcludeCommand(String commandName) {
    // Exclude all HELP subcommands
    if (commandName.endsWith(" HELP")) {
      return true;
    }

    // Exclude FT.DEBUG and _FT.DEBUG subcommands
    return commandName.startsWith("FT.DEBUG ") || commandName.startsWith("_FT.DEBUG ")
        || commandName.startsWith("_FT.CONFIG ");
  }

  private String extractInfoValue(String info, String key) {
    String[] lines = info.split("\n");
    for (String line : lines) {
      if (line.startsWith(key + ":")) {
        return line.substring(key.length() + 1).trim();
      }
    }
    return "unknown";
  }

  private void saveToJsonFile(Map<String, CommandMetadata> commandsMetadata) throws IOException {
    Gson gson = new Gson();
    String json = gson.toJson(commandsMetadata);

    Path jsonPath = Paths.get(BACKUP_JSON_FILE);
    Files.write(jsonPath, json.getBytes(StandardCharsets.UTF_8));
    System.out.println("��� Saved backup to " + BACKUP_JSON_FILE);
  }

  private Map<String, CommandMetadata> readJsonFile() throws IOException {
    Path jsonPath = Paths.get(BACKUP_JSON_FILE);
    if (!Files.exists(jsonPath)) {
      throw new IOException("Backup file not found: " + BACKUP_JSON_FILE);
    }

    // JDK 8 compatible: read file as bytes and convert to string
    byte[] bytes = Files.readAllBytes(jsonPath);
    String jsonContent = new String(bytes, StandardCharsets.UTF_8);

    Gson gson = new Gson();

    // Parse JSON with proper type
    Type type = new TypeToken<Map<String, CommandMetadata>>() {
    }.getType();
    Map<String, CommandMetadata> parsed = gson.fromJson(jsonContent, type);

    return new LinkedHashMap<>(parsed);
  }

  private Map<MetadataKey, List<String>> groupByMetadata(
      Map<String, CommandMetadata> commandsMetadata) {
    Map<MetadataKey, List<String>> result = new LinkedHashMap<>();

    for (Map.Entry<String, CommandMetadata> entry : commandsMetadata.entrySet()) {
      String command = entry.getKey();
      String commandUpper = command.toUpperCase();

      // Check for manual override first - overrides take precedence over server metadata
      CommandMetadata metadata;
      if (MANUAL_OVERRIDES.containsKey(commandUpper)) {
        metadata = MANUAL_OVERRIDES.get(commandUpper);
        System.out.println("  Applying manual override for command: " + commandUpper);
      } else {
        metadata = entry.getValue();
      }

      // Convert JSON flags to Java enum names and sort
      List<String> javaFlags = metadata.flags.stream().map(f -> FLAG_MAPPING.get(f.toLowerCase()))
          .filter(Objects::nonNull).sorted().collect(Collectors.toList());

      // Convert request and response policies to Java enum names (uppercase)
      String requestPolicy = metadata.requestPolicy != null ? metadata.requestPolicy.toUpperCase()
          : null;
      String responsePolicy = metadata.responsePolicy != null
          ? metadata.responsePolicy.toUpperCase()
          : null;

      MetadataKey key = new MetadataKey(javaFlags, requestPolicy, responsePolicy);
      result.computeIfAbsent(key, k -> new ArrayList<>()).add(commandUpper);
    }

    return result;
  }

  private String generateRegistryClass(Map<MetadataKey, List<String>> metadataCombinations) {
    StringBuilder sb = new StringBuilder();

    // Package and imports
    sb.append("package redis.clients.jedis;\n\n");
    sb.append("import java.util.EnumSet;\n");
    sb.append("import static redis.clients.jedis.StaticCommandFlagsRegistry.EMPTY_FLAGS;\n");
    sb.append("import static redis.clients.jedis.CommandFlagsRegistry.CommandFlag;\n");
    sb.append("import static redis.clients.jedis.CommandFlagsRegistry.RequestPolicy;\n");
    sb.append("import static redis.clients.jedis.CommandFlagsRegistry.ResponsePolicy;\n");

    // Class javadoc
    sb.append("/**\n");
    sb.append(
      " * Static implementation of CommandFlagsRegistry. This class is auto-generated by\n");
    sb.append(" * CommandFlagsRegistryGenerator. DO NOT EDIT MANUALLY.\n");

    // Add server metadata if available
    if (serverMetadata != null) {
      sb.append(" * <p>Generated from Redis Server:\n");
      sb.append(" * <ul>\n");
      sb.append(" * <li>Version: ").append(serverMetadata.version).append("</li>\n");
      sb.append(" * <li>Mode: ").append(serverMetadata.mode).append("</li>\n");
      if (!serverMetadata.modules.isEmpty()) {
        sb.append(" * <li>Loaded Modules: ").append(String.join(", ", serverMetadata.modules))
            .append("</li>\n");
      } else {
        sb.append(" * <li>Loaded Modules: none</li>\n");
      }
      sb.append(" * <li>Generated at: ").append(serverMetadata.generatedAt).append("</li>\n");
      sb.append(" * </ul>\n");
    }

    sb.append(" */\n");
    sb.append("final class StaticCommandFlagsRegistryInitializer {\n\n");

    // Static initializer block
    sb.append("  static void initialize(StaticCommandFlagsRegistry.Builder builder) {\n");

    // Organize commands into parent commands and simple commands
    Map<String, Map<String, MetadataKey>> parentCommands = new LinkedHashMap<>();
    Map<String, MetadataKey> simpleCommands = new LinkedHashMap<>();

    // Categorize commands
    for (Map.Entry<MetadataKey, List<String>> entry : metadataCombinations.entrySet()) {
      MetadataKey metadataKey = entry.getKey();
      for (String command : entry.getValue()) {
        int spaceIndex = command.indexOf(' ');
        if (spaceIndex > 0) {
          // This is a compound command (e.g., "FUNCTION LOAD")
          String parent = command.substring(0, spaceIndex);
          String subcommand = command.substring(spaceIndex + 1);

          if (KNOWN_PARENT_COMMANDS.contains(parent)) {
            parentCommands.computeIfAbsent(parent, k -> new LinkedHashMap<>()).put(subcommand,
              metadataKey);
          } else {
            // Unknown parent command with subcommands - fail with helpful error message
            throw new IllegalStateException(String.format(
              "Unknown command with subcommands encountered: '%s' (parent: '%s', subcommand: '%s'). "
                  + "Command names cannot contain spaces unless the parent command is registered. "
                  + "To fix this, add '%s' to the 'KNOWN_PARENT_COMMANDS' set in CommandFlagsRegistryGenerator.java.",
              command, parent, subcommand, parent));
          }
        } else {
          // Simple command without subcommands
          simpleCommands.put(command, metadataKey);
        }
      }
    }

    // Generate parent command registries
    for (String parent : KNOWN_PARENT_COMMANDS) {
      // Use parent command's actual metadata if available, otherwise use EMPTY_FLAGS
      MetadataKey parentMetadata = simpleCommands.remove(parent);
      if (parentMetadata != null) {
        sb.append(generateRegisterCall(parent, null, parentMetadata));
      } else {
        sb.append(String.format("    builder.register(\"%s\", EMPTY_FLAGS);\n", parent));
      }

      Map<String, MetadataKey> subcommands = parentCommands.get(parent);
      if (subcommands != null && !subcommands.isEmpty()) {
        sb.append(String.format("    // %s subcommands\n", parent));
        // Add subcommands
        List<String> sortedSubcommands = new ArrayList<>(subcommands.keySet());
        Collections.sort(sortedSubcommands);

        for (String subcommand : sortedSubcommands) {
          MetadataKey metadataKey = subcommands.get(subcommand);
          sb.append(generateRegisterCall(parent, subcommand, metadataKey));
        }
      }
    }

    // Generate simple commands grouped by metadata
    Map<MetadataKey, List<String>> simpleCommandsByMetadata = new LinkedHashMap<>();
    for (Map.Entry<String, MetadataKey> entry : simpleCommands.entrySet()) {
      simpleCommandsByMetadata.computeIfAbsent(entry.getValue(), k -> new ArrayList<>())
          .add(entry.getKey());
    }

    // Sort by flag count, then alphabetically
    List<Map.Entry<MetadataKey, List<String>>> sortedEntries = simpleCommandsByMetadata.entrySet()
        .stream()
        .sorted(
          Comparator.comparing((Map.Entry<MetadataKey, List<String>> e) -> e.getKey().flags.size())
              .thenComparing(e -> e.getKey().toString()))
        .collect(Collectors.toList());

    for (Map.Entry<MetadataKey, List<String>> entry : sortedEntries) {
      MetadataKey metadataKey = entry.getKey();
      List<String> commands = entry.getValue();
      Collections.sort(commands);

      // Add comment describing the metadata
      sb.append(String.format("    // %d command(s) with: %s\n", commands.size(),
        metadataKey.toDescription()));

      // Add registry entries
      for (String command : commands) {
        sb.append(generateRegisterCall(command, null, metadataKey));
      }
      sb.append("\n");
    }

    // Close initializer block
    sb.append("  }\n\n");

    // Close class
    sb.append("}\n");

    return sb.toString();
  }

  /**
   * Generate a builder.register() call for a command with its metadata.
   */
  private String generateRegisterCall(String command, String subcommand, MetadataKey metadataKey) {
    String enumSetExpr = createEnumSetExpression(metadataKey.flags);
    String requestPolicyExpr = metadataKey.requestPolicy != null
        ? "RequestPolicy." + metadataKey.requestPolicy
        : "null";
    String responsePolicyExpr = metadataKey.responsePolicy != null
        ? "ResponsePolicy." + metadataKey.responsePolicy
        : "null";

    // Check if we need to use the extended register method (with policies)
    boolean hasPolicies = metadataKey.requestPolicy != null || metadataKey.responsePolicy != null;

    if (subcommand != null) {
      // Subcommand registration
      if (hasPolicies) {
        return String.format("    builder.register(\"%s\", \"%s\", %s, %s, %s);\n", command,
          subcommand, enumSetExpr, requestPolicyExpr, responsePolicyExpr);
      } else {
        return String.format("    builder.register(\"%s\", \"%s\", %s);\n", command, subcommand,
          enumSetExpr);
      }
    } else {
      // Simple command registration
      if (hasPolicies) {
        return String.format("    builder.register(\"%s\", %s, %s, %s);\n", command, enumSetExpr,
          requestPolicyExpr, responsePolicyExpr);
      } else {
        return String.format("    builder.register(\"%s\", %s);\n", command, enumSetExpr);
      }
    }
  }

  private String createEnumSetExpression(List<String> flags) {
    if (flags.isEmpty()) {
      return "EMPTY_FLAGS";
    } else if (flags.size() == 1) {
      return "EnumSet.of(CommandFlag." + flags.get(0) + ")";
    } else {
      String flagsList = flags.stream().map(f -> "CommandFlag." + f)
          .collect(Collectors.joining(", "));
      return "EnumSet.of(" + flagsList + ")";
    }
  }

  private void writeJavaFile(String classContent) throws IOException {
    Path javaPath = Paths.get(JAVA_FILE);

    // JDK 8 compatible: write string as bytes
    Files.write(javaPath, classContent.getBytes(StandardCharsets.UTF_8));
  }

  /**
   * Holds command metadata extracted from Redis (flags, request_policy, response_policy). Used for
   * JSON serialization/deserialization.
   */
  private static class CommandMetadata {
    final List<String> flags;
    final String requestPolicy;
    final String responsePolicy;

    CommandMetadata(List<String> flags, String requestPolicy, String responsePolicy) {
      this.flags = flags != null ? flags : new ArrayList<>();
      this.requestPolicy = requestPolicy;
      this.responsePolicy = responsePolicy;
    }
  }

  /**
   * Represents a unique combination of flags, request policy, and response policy for grouping
   * commands.
   */
  private static class MetadataKey {
    final List<String> flags;
    final String requestPolicy;
    final String responsePolicy;
    final int hashCode;

    MetadataKey(List<String> flags, String requestPolicy, String responsePolicy) {
      this.flags = new ArrayList<>(flags);
      this.requestPolicy = requestPolicy;
      this.responsePolicy = responsePolicy;
      this.hashCode = Objects.hash(this.flags, requestPolicy, responsePolicy);
    }

    @Override
    public boolean equals(Object o) {
      if (this == o) return true;
      if (!(o instanceof MetadataKey)) return false;
      MetadataKey that = (MetadataKey) o;
      return flags.equals(that.flags) && Objects.equals(requestPolicy, that.requestPolicy)
          && Objects.equals(responsePolicy, that.responsePolicy);
    }

    @Override
    public int hashCode() {
      return hashCode;
    }

    @Override
    public String toString() {
      return String.format("flags=%s, request=%s, response=%s", flags, requestPolicy,
        responsePolicy);
    }

    /**
     * Generate a human-readable description for comments.
     */
    String toDescription() {
      StringBuilder sb = new StringBuilder();
      if (flags.isEmpty()) {
        sb.append("no flags");
      } else {
        sb.append(flags.stream().map(String::toLowerCase).collect(Collectors.joining(", ")));
      }
      if (requestPolicy != null) {
        sb.append("; request_policy=").append(requestPolicy.toLowerCase());
      }
      if (responsePolicy != null) {
        sb.append("; response_policy=").append(responsePolicy.toLowerCase());
      }
      return sb.toString();
    }
  }

  /**
   * Holds metadata about the Redis server used for generation
   */
  private static class ServerMetadata {
    final String version;
    final String mode;
    final List<String> modules;
    final String generatedAt;

    ServerMetadata(String version, String mode, List<String> modules) {
      this.version = version;
      this.mode = mode;
      this.modules = modules;
      this.generatedAt = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss z")
          .format(new java.util.Date());
    }
  }
}