package com.hubilon.config; import com.hubilon.json.Json; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; /** * Config file discovery + assembly, mirroring run.ps1's Get-ConfigFiles / * Resolve-DefaultConfig / Get-ConfigDocument. Groups/include files are treated * as external, user-authored files (never auto-generated) -- only config.json * itself gets a sample written out when nothing is present. */ public final class ConfigService { private static final long MAX_BODY_FILE_BYTES = 4L * 1024 * 1024; private final Path workDir; public ConfigService(Path workDir) { this.workDir = workDir; } public void ensureSampleConfigExists() throws IOException { List available = listConfigFiles(); if (available.isEmpty()) { Files.writeString(workDir.resolve("config.json"), SampleConfig.SAMPLE_JSON, StandardCharsets.UTF_8); } } public List listConfigFiles() throws IOException { if (!Files.isDirectory(workDir)) { return List.of(); } try (Stream stream = Files.list(workDir)) { return stream .filter(p -> !Files.isDirectory(p) && p.getFileName().toString().toLowerCase().endsWith(".json")) .map(p -> p.getFileName().toString()) .sorted() .collect(Collectors.toList()); } } public String resolveDefaultConfig(String preferred, List available) { if (preferred != null && available.contains(preferred)) { return preferred; } if (available.contains("config.json")) { return "config.json"; } if (!available.isEmpty()) { return available.get(0); } return null; } @SuppressWarnings("unchecked") public Map assemble(String fileName) throws IOException { Map doc = readJsonFile(workDir.resolve(fileName), fileName); List> groups = new ArrayList<>(); Object rootApis = doc.get("apis"); if (rootApis instanceof List apisList && !apisList.isEmpty()) { groups.add(orderedGroup("default", "default", fileName, (List) apisList)); } Object rootGroups = doc.get("groups"); if (rootGroups instanceof List groupList) { int i = 0; for (Object g : groupList) { groups.add(normalizeGroup(g, fileName, "group" + i)); i++; } } Object include = doc.get("include"); if (include instanceof List includeList) { for (Object relObj : includeList) { String relPath = String.valueOf(relObj); Path full = resolveIncludePath(relPath); Map sub = readJsonFile(full, relPath); if (sub.containsKey("include")) { throw new IOException("Nested include is not supported (" + relPath + ")"); } String fallback = stripExtension(relPath); Object subGroups = sub.get("groups"); if (subGroups instanceof List subGroupList && !subGroupList.isEmpty()) { int i = 0; for (Object g : subGroupList) { groups.add(normalizeGroup(g, relPath, fallback + i)); i++; } } else { groups.add(normalizeGroup(sub, relPath, fallback)); } } } inlineExpectedBodyFiles(groups); List compareHeadersOut = new ArrayList<>(); Object compareHeaders = doc.get("compareHeaders"); if (compareHeaders instanceof List chList) { for (Object c : chList) { if (c != null && !String.valueOf(c).isBlank()) { compareHeadersOut.add(String.valueOf(c)); } } } Map result = new LinkedHashMap<>(); result.put("timeoutMs", intOrDefault(doc.get("timeoutMs"), 30000)); result.put("requestIntervalMs", intOrDefault(doc.get("requestIntervalMs"), 0)); result.put("variables", doc.get("variables")); result.put("variableGroups", doc.get("variableGroups")); result.put("targets", doc.get("targets")); result.put("compareHeaders", compareHeadersOut); result.put("compareMode", doc.get("compareMode") != null ? String.valueOf(doc.get("compareMode")) : "dual"); result.put("compareTargets", doc.get("compareTargets")); result.put("singleTarget", doc.get("singleTarget") != null ? String.valueOf(doc.get("singleTarget")) : null); result.put("debug", Boolean.TRUE.equals(doc.get("debug"))); result.put("groups", groups); return result; } @SuppressWarnings("unchecked") private void inlineExpectedBodyFiles(List> groups) { for (Map group : groups) { Object apisObj = group.get("apis"); if (!(apisObj instanceof List apis)) { continue; } for (Object apiObj : apis) { if (!(apiObj instanceof Map)) { continue; } Map api = (Map) apiObj; Object expectedObj = api.get("expected"); if (!(expectedObj instanceof Map)) { continue; } Map expected = (Map) expectedObj; Object bodyFileObj = expected.get("bodyFile"); if (bodyFileObj == null || String.valueOf(bodyFileObj).isBlank()) { continue; } String bodyFile = String.valueOf(bodyFileObj); try { Path exFull = resolveIncludePath(bodyFile); if (!Files.exists(exFull)) { throw new IOException("expected.bodyFile not found: " + bodyFile); } long size = Files.size(exFull); if (size > MAX_BODY_FILE_BYTES) { throw new IOException("expected.bodyFile too large (" + size + " bytes, max 4MB)"); } byte[] bytes = Files.readAllBytes(exFull); if (bytes.length >= 2 && ((bytes[0] == (byte) 0xFF && bytes[1] == (byte) 0xFE) || (bytes[0] == (byte) 0xFE && bytes[1] == (byte) 0xFF))) { throw new IOException("expected.bodyFile must be UTF-8 (UTF-16 BOM detected)"); } String text = stripUtf8Bom(new String(bytes, StandardCharsets.UTF_8)); expected.put("bodyText", text); } catch (IOException e) { expected.put("bodyFileError", e.getMessage()); } } } } @SuppressWarnings("unchecked") private Map normalizeGroup(Object rawGroup, String source, String fallbackName) { if (!(rawGroup instanceof Map)) { throw new IllegalArgumentException("Empty group in " + source); } Map group = (Map) rawGroup; String name = group.get("name") != null ? String.valueOf(group.get("name")) : fallbackName; String label = group.get("label") != null ? String.valueOf(group.get("label")) : name; Object apis = group.get("apis"); if (apis == null) { throw new IllegalArgumentException("Group '" + name + "' in " + source + " has no 'apis' array"); } return orderedGroup(name, label, source, (List) apis); } private Map orderedGroup(String name, String label, String source, List apis) { Map group = new LinkedHashMap<>(); group.put("name", name); group.put("label", label); group.put("source", source); group.put("apis", apis); return group; } private Path resolveIncludePath(String relative) throws IOException { if (relative == null || relative.isBlank()) { throw new IOException("include entry is empty"); } Path candidate = Path.of(relative); if (candidate.isAbsolute()) { throw new IOException("include path must be relative: " + relative); } Path rootFull = workDir.toAbsolutePath().normalize(); Path full = rootFull.resolve(relative).normalize(); if (!full.startsWith(rootFull)) { throw new IOException("include path escapes the tool directory: " + relative); } return full; } private Map readJsonFile(Path path, String displayName) throws IOException { if (!Files.exists(path)) { throw new IOException("Config file not found: " + displayName); } byte[] bytes = Files.readAllBytes(path); String text = stripUtf8Bom(new String(bytes, StandardCharsets.UTF_8)); try { return Json.parseObject(text); } catch (Exception e) { throw new IOException(displayName + " is not valid JSON: " + e.getMessage()); } } private static String stripUtf8Bom(String text) { if (!text.isEmpty() && text.charAt(0) == '') { return text.substring(1); } return text; } private static String stripExtension(String path) { String fileName = Path.of(path).getFileName().toString(); int dot = fileName.lastIndexOf('.'); return dot > 0 ? fileName.substring(0, dot) : fileName; } private static int intOrDefault(Object value, int fallback) { if (value instanceof Number number) { return number.intValue(); } return fallback; } }