Main.java

package pro.verron.officestamper;


import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.opencsv.CSVReader;
import com.opencsv.exceptions.CsvException;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.SAXException;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import pro.verron.officestamper.api.OfficeStamperException;
import pro.verron.officestamper.excel.ExcelContext;
import pro.verron.officestamper.excel.ExcelMergeStrategy;
import pro.verron.officestamper.experimental.ExperimentalStampers;
import pro.verron.officestamper.preset.ExceptionResolvers;
import pro.verron.officestamper.preset.OfficeStamperConfigurations;
import pro.verron.officestamper.preset.OfficeStampers;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchKey;
import java.util.*;
import java.util.stream.IntStream;

import static java.nio.file.Files.newOutputStream;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
import static java.nio.file.StandardWatchEventKinds.OVERFLOW;
import static java.time.OffsetDateTime.now;
import static java.util.stream.Collectors.toMap;

/// Main class for the CLI.
@Command(name = "officestamper",
         mixinStandardHelpOptions = true,
         description = "Office Stamper CLI tool",
         subcommands = {Preview.class, ReportView.class}) public class Main
        implements Runnable {

    private static final Logger logger = LoggerFactory.getLogger(Main.class);
    @Option(names = {"-t", "--template"},
            defaultValue = "diagnostic",
            description = "Template file path (.docx|.pptx), or a keyword "
                          + "(diagnostic) for a packaged sample template") private String templatePath;
    @Option(names = {"-d", "--data"},
            defaultValue = "diagnostic",
            description = "Data input: file (json|yaml|yml|properties|csv"
                          + "|xlsx|xml|html), a directory, or 'diagnostic'") private String dataPath;
    @Option(names = {"-o", "--output"},
            defaultValue = "output.docx",
            description = "Output file path") private String outputPath;
    @Option(names = {"--dry-run"},
            description = "Validate template + data and variables, but do not"
                          + " produce the output file") private boolean dryRun;
    @Option(names = {"--run-report"},
            defaultValue = "run-report.json",
            description = "Optional JSON report file path with run metadata "
                          + "and validation results") private String reportPath;
    @Option(names = {"--log-format"},
            defaultValue = "human",
            description = "Logging format: 'human' (default) or 'json' "
                          + "(structured logs to stdout)") private String logFormat;

    @Option(names = {"--excel-merge-strategy"},
            defaultValue = "MAP",
            description = "Excel merge strategy: MAP (default, each sheet is "
                          + "a key) or JOIN (inner join sheets)") private ExcelMergeStrategy excelMergeStrategy;
    @Option(names = {"--excel-join-key"},
            description = "Key to use for joining Excel sheets (used with "
                          + "JOIN strategy)") private String excelJoinKey;

    @Option(names = {"--bind-env"},
            description = "Expose environment variables in the SpEL context "
                          + "as 'env'") private boolean bindEnv;

    @Option(names = {"--watch"},
            description = "Watch template and data files for changes and "
                          + "re-run stamping automatically") private boolean watch;

    @Option(names = {"--report", "--traceability-report"},
            defaultValue = "trace-report.json",
            description = "Optional JSON traceability report file path with "
                          + "every placeholder resolution") private String traceabilityReportPath;

    /// Default constructor.
    public Main() {
    }

    static void main(String[] args) {
        var main = new Main();
        var cli = new CommandLine(main);
        int exitCode = cli.execute(args);
        System.exit(exitCode);
    }

    private static InputStream streamFile(Path path) {
        try {
            return Files.newInputStream(path);
        } catch (IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private static boolean isSupportedDataFile(Path p) {
        var extension = extension(p.getFileName());
        return extension.equals("csv") || //
               extension.equals("properties") || //
               extension.equals("html") || //
               extension.equals("xml") || //
               extension.equals("json") || //
               extension.equals("yaml") || //
               extension.equals("yml") || //
               extension.equals("xlsx");
    }

    private static String baseName(Path f) {
        var fileName = f.getFileName();
        var base = fileName.toString();
        var idx = base.lastIndexOf('.');
        if (idx > 0) base = base.substring(0, idx);
        return base;
    }

    private static String extension(Path f) {
        var fileName = f.getFileName();
        var base = fileName.toString();
        var idx = base.lastIndexOf('.');
        if (idx > 0) base = base.substring(idx + 1);
        return base;
    }

    private static Map<String, String> mapRowToHeaders(
            String[] row,
            String[] headers
    ) {
        return IntStream.range(0, headers.length)
                        .boxed()
                        .collect(toMap(i -> headers[i],
                                i -> row[i],
                                (_, b) -> b,
                                LinkedHashMap::new));
    }

    @Override
    public void run() {
        if (watch) runWatch();
        else runOnce();
    }

    private Map<String, Object> contextualiseDirectory(Path dir) {
        try (var stream = Files.list(dir)) {
            return stream.filter(Files::isRegularFile)
                         .filter(Main::isSupportedDataFile)
                         .collect(toMap(Main::baseName,
                                 this::contextualise,
                                 (_, b) -> b,
                                 TreeMap::new));
        } catch (IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private Object extractContextNew(String model) {
        if ("diagnostic".equals(model)) return Diagnostic.context();
        var path = Path.of(model);
        return Files.isDirectory(path)
                ? contextualiseDirectory(path)
                : contextualise(path);
    }

    private InputStream extractTemplateNew(String template) {
        if ("diagnostic".equals(template)) return Diagnostic.template();
        return streamFile(Path.of(template));
    }

    private OutputStream createOutputStream(Path path) {
        try {
            var parent = path.getParent();
            if (parent != null) Files.createDirectories(parent);
            return newOutputStream(path);
        } catch (IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private java.util.List<Item> buildItemsFromDataDirectory(Path dir) {
        try (var stream = Files.list(dir)) {
            var entries = stream.sorted()
                                .toList();
            var items = new ArrayList<Item>();
            for (var entry : entries) {
                if (Files.isRegularFile(entry) && isSupportedDataFile(entry)) {
                    items.add(new Item(baseName(entry), contextualise(entry)));
                }
                else if (Files.isDirectory(entry)) {
                    items.add(new Item(baseName(entry),
                            contextualiseDirectoryRecursive(entry)));
                }
            }
            return List.copyOf(items);
        } catch (IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private Map<String, Object> contextualiseDirectoryRecursive(Path dir) {
        try (var stream = Files.walk(dir)) {
            return stream.filter(Files::isRegularFile)
                         .filter(Main::isSupportedDataFile)
                         .collect(toMap(Main::baseName,
                                 this::contextualise,
                                 (_, b) -> b,
                                 TreeMap::new));
        } catch (IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private Path computeOutputPath(
            String output,
            String itemName,
            TemplateKind ext
    ) {
        var desiredExt = (ext == TemplateKind.WORD) ? ".docx" : ".pptx";
        var out = Path.of(output);
        // If output is an existing directory, place <itemName><ext> inside it
        if (Files.exists(out) && Files.isDirectory(out)) {
            return out.resolve(itemName + desiredExt);
        }
        var fn = out.getFileName() == null
                ? output
                : out.getFileName()
                     .toString();
        var dot = fn.lastIndexOf('.');
        if (dot > 0) {
            var base = fn.substring(0, dot);
            // Normalize to template extension
            var newName = base + "-" + itemName + desiredExt;
            var parent = out.getParent();
            return parent == null ? Path.of(newName) : parent.resolve(newName);
        }
        else {
            // Treat as directory path (may or may not exist)
            return out.resolve(itemName + desiredExt);
        }
    }

    private Object contextualise(Path path) {
        var extension = extension(path);
        return switch (extension) {
            case "csv" -> processCsv(path);
            case "properties" -> processProperties(path);
            case "html", "xml" -> processXmlOrHtml(path);
            case "json" -> processJson(path);
            case "yaml", "yml" -> processYaml(path);
            case "xlsx" -> processExcel(path);
            default -> throw new OfficeStamperException(
                    "Unsupported file type: " + path);
        };
    }

    private void runWatch() {
        var lf = getLogFormat();
        emit("INFO", "Watch mode enabled", null, lf);
        try {
            runOnce();
        } catch (Exception e) {
            var error = Map.of("error", String.valueOf(e.getMessage()));
            emit("ERROR", "Initial run failed", error, lf);
        }

        try (
                var watchService = FileSystems.getDefault()
                                              .newWatchService()
        ) {
            var templateFile = "diagnostic".equals(templatePath)
                    ? null
                    : Path.of(templatePath)
                          .toAbsolutePath();
            var dataFile = "diagnostic".equals(dataPath)
                    ? null
                    : Path.of(dataPath)
                          .toAbsolutePath();

            var pathsToWatch = new HashSet<Path>();
            if (templateFile != null) pathsToWatch.add(templateFile);
            if (dataFile != null) pathsToWatch.add(dataFile);

            var keys = new HashMap<WatchKey, Path>();
            for (var p : pathsToWatch) {
                var dir = Files.isDirectory(p) ? p : p.getParent();
                if (dir != null && Files.exists(dir)) {
                    var key = dir.register(watchService, ENTRY_MODIFY);
                    keys.put(key, dir);
                }
            }

            while (true) {
                var key = watchService.take();
                var dir = keys.get(key);
                for (var event : key.pollEvents()) {
                    if (event.kind() == OVERFLOW) continue;
                    var context = (Path) event.context();
                    var resolved = dir.resolve(context);

                    var relevant = false;
                    for (var p : pathsToWatch) {
                        if (resolved.equals(p) || (Files.isDirectory(p)
                                                   && resolved.startsWith(p))) {
                            relevant = true;
                            break;
                        }
                    }

                    if (relevant) {
                        var change = Map.of("file", resolved.toString());
                        emit("INFO",
                                "Change detected, re-stamping...",
                                change,
                                lf);
                        try {
                            runOnce();
                        } catch (Exception e) {
                            var errorMessage = String.valueOf(e.getMessage());
                            var error = Map.of("error", errorMessage);
                            emit("ERROR", "Re-stamping failed", error, lf);
                        }
                    }
                }
                if (!key.reset()) break;
            }
        } catch (Exception e) {
            var errorMessage = String.valueOf(e.getMessage());
            var error = Map.of("error", errorMessage);
            emit("ERROR", "Watch mode failed", error, lf);
        }
    }

    /// Return a list of objects with the csv properties
    private Object processCsv(Path path) {
        try (
                var inputStream = Files.newInputStream(path);
                var streamReader = new InputStreamReader(inputStream);
                var reader = new CSVReader(streamReader);
        ) {
            var headers = reader.readNext();
            return reader.readAll()
                         .stream()
                         .map(row -> mapRowToHeaders(row, headers))
                         .toList();
        } catch (IOException | CsvException e) {
            throw new OfficeStamperException(e);
        }
    }

    private Object processProperties(Path path) {
        var properties = new Properties();
        try (var inputStream = Files.newInputStream(path)) {
            properties.load(inputStream);
            return properties.entrySet()
                             .stream()
                             .collect(toMap(e -> String.valueOf(e.getKey()),
                                     e -> String.valueOf(e.getValue()),
                                     (a, b) -> b,
                                     LinkedHashMap::new));
        } catch (IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private Object processXmlOrHtml(Path path) {
        try {
            var factory = DocumentBuilderFactory.newInstance();
            factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
            factory.setExpandEntityReferences(false);

            var builder = factory.newDocumentBuilder();
            var document = builder.parse(Files.newInputStream(path));
            return processNode(document.getDocumentElement());
        } catch (ParserConfigurationException | SAXException | IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private Map<String, Object> processNode(Element element) {
        var result = new LinkedHashMap<String, Object>();
        var children = element.getChildNodes();

        for (int i = 0; i < children.getLength(); i++) {
            var node = children.item(i);
            if (node instanceof Element childElement) {
                var name = childElement.getTagName();
                if (!childElement.hasChildNodes()) {
                    result.put(name, childElement.getTextContent());
                }
                else {
                    var firstChild = childElement.getFirstChild();
                    if (firstChild.getNodeType() == Node.TEXT_NODE) {
                        result.put(name, childElement.getTextContent());
                    }
                    else {
                        result.put(name, processNode(childElement));
                    }
                }
            }
        }
        return result;
    }

    private Object processJson(Path path) {
        try {
            var mapper = SerializationUtils.newMapper();
            var typeRef = new TypeReference<LinkedHashMap<String, Object>>() {};
            return mapper.readValue(Files.newInputStream(path), typeRef);
        } catch (IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private Object processYaml(Path path) {
        try {
            // Lazy YAML support using Jackson if available on classpath;
            // else, provide a clear error.
            var yaml = "com.fasterxml.jackson.dataformat.yaml.YAMLFactory";
            var mapperClass = Class.forName(yaml);
            var declaredConstructor = mapperClass.getDeclaredConstructor();
            var jsonFactory = (JsonFactory) declaredConstructor.newInstance();
            var mapper = new ObjectMapper(jsonFactory);
            var typeRef = new TypeReference<LinkedHashMap<String, Object>>() {};
            return mapper.readValue(Files.newInputStream(path), typeRef);
        } catch (ClassNotFoundException e) {
            var msg = "YAML support requires 'jackson-dataformat-yaml' on the"
                      + " classpath";
            throw new OfficeStamperException(msg);
        } catch (Exception e) {
            throw new OfficeStamperException(e);
        }
    }

    private Object processExcel(Path path) {
        try (var is = Files.newInputStream(path)) {
            var ctx = ExcelContext.from(is);
            if (excelMergeStrategy == ExcelMergeStrategy.JOIN) {
                return ctx.joinAllSheets(excelJoinKey);
            }
            return ctx;
        } catch (IOException e) {
            throw new OfficeStamperException(e);
        }
    }

    private TemplateKind templateKind(String input) {
        if ("diagnostic".equals(input)) return TemplateKind.WORD;
        var lower = input.toLowerCase();
        if (lower.endsWith(".docx")) return TemplateKind.WORD;
        if (lower.endsWith(".pptx")) return TemplateKind.POWERPOINT;
        var msg = "Unsupported template type (expected .docx or .pptx): %s";
        throw new OfficeStamperException(msg.formatted(input));
    }

    private String getLogFormat() {
        return logFormat.trim()
                        .toLowerCase();
    }

    private void runOnce() {
        var traceabilityReport = new TraceabilityReport(now(),
                templatePath,
                dataPath);
        // Normalize log format
        var lf = getLogFormat();

        if (templatePath.isBlank()) {
            emit("ERROR", "Missing required --template path", null, lf);
            throw new CommandLine.ParameterException(new CommandLine(this),
                    "--template is required");
        }
        if (dataPath.isBlank() && !"diagnostic".equals(templatePath)) {
            emit("ERROR",
                    "Missing required --data when not using diagnostic "
                    + "template",
                    null,
                    lf);
            throw new CommandLine.ParameterException(new CommandLine(this),
                    "--data is required when template != diagnostic");
        }

        emit("INFO",
                "Start",
                Map.of("template",
                        templatePath,
                        "data",
                        dataPath,
                        "output",
                        outputPath,
                        "dryRun",
                        dryRun),
                lf);

        try {
            var ext = templateKind(templatePath);
            // Folder semantics: each top-level file is its own context and
            // yields one output;
            // each top-level subfolder merges its files (recursively) into a
            // bigger context and yields one output.
            if (!dataPath.isBlank() && Files.isDirectory(Path.of(dataPath))) {
                var items = buildItemsFromDataDirectory(Path.of(dataPath));
                var results = new ArrayList<RunResult>(items.size());
                int idx = 0;
                for (var item : items) {
                    idx++;
                    emit("INFO",
                            "Processing item",
                            Map.of("index",
                                    idx,
                                    "name",
                                    item.name,
                                    "total",
                                    items.size()),
                            lf);
                    try (
                            var templateStream =
                                    extractTemplateNew(templatePath)
                    ) {
                        var context = wrapContext(item.context);
                        var configuration =
                                OfficeStamperConfigurations.standard();
                        configuration.setTraceabilityReporter(traceabilityReport);
                        if (dryRun) {
                            configuration.setExceptionResolver(
                                    ExceptionResolvers.throwing());
                            switch (ext) {
                                case WORD -> {
                                    var stamper = OfficeStampers.docxStamper(
                                            configuration);
                                    stamper.stamp(templateStream,
                                            context,
                                            OutputStream.nullOutputStream());
                                }
                                case POWERPOINT -> {
                                    var stamper =
                                            ExperimentalStampers.pptxStamper();
                                    stamper.stamp(templateStream,
                                            context,
                                            OutputStream.nullOutputStream());
                                }
                            }
                            results.add(new RunResult(item.name,
                                    "ok",
                                    null,
                                    null));
                        }
                        else {
                            var out = computeOutputPath(outputPath,
                                    item.name,
                                    ext);
                            try (var os = createOutputStream(out)) {
                                switch (ext) {
                                    case WORD -> {
                                        var stamper =
                                                OfficeStampers.docxStamper(
                                                configuration);
                                        stamper.stamp(templateStream,
                                                context,
                                                os);
                                    }
                                    case POWERPOINT -> {
                                        var stamper =
                                                ExperimentalStampers.pptxStamper();
                                        stamper.stamp(templateStream,
                                                context,
                                                os);
                                    }
                                }
                            }
                            results.add(new RunResult(item.name,
                                    "ok",
                                    out.toString(),
                                    null));
                        }
                    } catch (Exception ex) {
                        emit("ERROR",
                                "Item failed",
                                Map.of("name",
                                        item.name,
                                        "error",
                                        ex.getMessage()),
                                lf);
                        results.add(new RunResult(item.name,
                                "error",
                                null,
                                ex.getMessage()));
                        // Continue with next item; overall exit code should
                        // be non-zero if any failed
                    }
                }
                var anyError = results.stream()
                                      .anyMatch(r -> "error".equals(r.status));
                if (dryRun) emit("INFO",
                        "Validation completed (dry-run)",
                        Map.of("items", results.size(), "errors", anyError),
                        lf);
                else emit("INFO",
                        "Stamping completed",
                        Map.of("items", results.size(), "errors", anyError),
                        lf);
                writeReport(results);
                if (anyError) throw new OfficeStamperException(
                        "One or more items " + "failed");
                return;
            }

            // Single context path
            final var context = wrapContext(extractContextNew(dataPath));
            try (var templateStream = extractTemplateNew(templatePath)) {
                var configuration = OfficeStamperConfigurations.standard();
                configuration.setTraceabilityReporter(traceabilityReport);
                if (dryRun) {
                    // Validate: fail on unresolved placeholders but do not
                    // write any file
                    configuration.setExceptionResolver(ExceptionResolvers.throwing());
                    switch (ext) {
                        case WORD -> {
                            var stamper = OfficeStampers.docxStamper(
                                    configuration);
                            stamper.stamp(templateStream,
                                    context,
                                    OutputStream.nullOutputStream());
                        }
                        case POWERPOINT -> {
                            var stamper = ExperimentalStampers.pptxStamper(); // no config variant exposed for PPTX yet
                            stamper.stamp(templateStream,
                                    context,
                                    OutputStream.nullOutputStream());
                        }
                    }
                    emit("INFO", "Validation successful (dry-run)", null, lf);
                    writeReport("ok", null);
                    writeTraceabilityReport(traceabilityReport,
                            Path.of(traceabilityReportPath));
                    return;
                }

                // Real stamping (single file)
                try (
                        var outputStream =
                                createOutputStream(Path.of(outputPath))
                ) {
                    switch (ext) {
                        case WORD -> {
                            var stamper = OfficeStampers.docxStamper(
                                    configuration);
                            stamper.stamp(templateStream,
                                    context,
                                    outputStream);
                        }
                        case POWERPOINT -> {
                            var stamper = ExperimentalStampers.pptxStamper(); // no config variant exposed for PPTX yet
                            stamper.stamp(templateStream,
                                    context,
                                    outputStream);
                        }
                    }
                }
            }

            emit("INFO",
                    "Stamping completed",
                    Map.of("output", outputPath),
                    lf);
            writeReport("ok", null);
            writeTraceabilityReport(traceabilityReport,
                    Path.of(traceabilityReportPath));
        } catch (Exception e) {
            emit("ERROR",
                    e.getMessage(),
                    Map.of("exception",
                            e.getClass()
                             .getSimpleName()),
                    lf);
            writeReport("error", e.getMessage());
            // Re-throw to ensure non-zero exit code from picocli
            throw (e instanceof RuntimeException re)
                    ? re
                    : new OfficeStamperException(e);
        }
    }

    // Minimal structured logging when --log-format=json
    private void emit(
            String level,
            String message,
            @Nullable Map<String, ?> fields,
            String lf
    ) {
        if (!"json".equals(lf)) {
            // Human logs via java.util.logging
            var lvl = switch (level) {
                case "ERROR" -> Level.ERROR;
                case "WARN" -> Level.WARN;
                default -> Level.INFO;
            };
            logger.atLevel(lvl)
                  .log(fields == null || fields.isEmpty()
                          ? message
                          : message + " | " + fields);
            return;
        }
        try {
            var map = new LinkedHashMap<String, Object>();
            map.put("ts", now().toString());
            map.put("level", level.toLowerCase());
            map.put("msg", message);
            if (fields != null && !fields.isEmpty()) map.put("fields", fields);
            var json = new ObjectMapper().writeValueAsString(map);
            System.out.println(json);
        } catch (Exception ignored) {
            System.out.println(
                    "{\"level\":\"error\",\"msg\":\"failed to emit json "
                    + "log\"}");
        }
    }

    private void writeReport(java.util.List<RunResult> results) {
        if (reportPath.isBlank()) return;
        var report = new LinkedHashMap<String, Object>();
        var anyError = results.stream()
                              .anyMatch(r -> "error".equals(r.status));
        report.put("status", anyError ? "error" : "ok");
        report.put("template", templatePath);
        report.put("data", dataPath);
        report.put("dryRun", dryRun);
        report.put("timestamp", now().toString());
        var items = new java.util.ArrayList<Map<String, Object>>();
        for (var r : results) {
            var it = new LinkedHashMap<String, Object>();
            it.put("name", r.name);
            it.put("status", r.status);
            if (r.output != null) it.put("output", r.output);
            if (r.error != null) it.put("error", r.error);
            items.add(it);
        }
        report.put("items", items);
        try {
            var mapper = SerializationUtils.newMapper();
            try (var os = createOutputStream(Path.of(reportPath))) {
                mapper.writeValue(os, report);
            }
        } catch (Exception e) {
            logger.atWarn()
                  .setCause(e)
                  .log("Failed to write report: {}", e.getMessage());
        }
    }

    private void writeReport(String status, @Nullable String errorMessage) {
        if (reportPath.isBlank()) return;
        var report = new LinkedHashMap<String, Object>();
        report.put("status", status);
        report.put("template", templatePath);
        report.put("data", dataPath);
        report.put("output", outputPath);
        report.put("dryRun", dryRun);
        report.put("timestamp", now().toString());
        if (errorMessage != null) report.put("error", errorMessage);
        try {
            var mapper = SerializationUtils.newMapper();
            try (var os = createOutputStream(Path.of(reportPath))) {
                mapper.writeValue(os, report);
            }
        } catch (Exception e) {
            // Best-effort: do not fail the run because report writing failed
            logger.atWarn()
                  .setCause(e)
                  .log("Failed to write report: {}", e.getMessage());
        }
    }

    private Object wrapContext(Object context) {
        if (!bindEnv) return context;
        var wrapper = new LinkedHashMap<String, Object>();
        wrapper.put("env", System.getenv());
        if (context instanceof Map<?, ?> map) {
            for (var entry : map.entrySet()) {
                wrapper.put(String.valueOf(entry.getKey()), entry.getValue());
            }
        }
        else {
            wrapper.put("data", context);
        }
        return wrapper;
    }

    private void writeTraceabilityReport(TraceabilityReport report, Path path) {
        try {
            var mapper = SerializationUtils.newMapper();
            try (var os = createOutputStream(path)) {
                var objectWriter = mapper.writerWithDefaultPrettyPrinter();
                objectWriter.writeValue(os, report);
            }
        } catch (IOException e) {
            logger.warn("Could not write traceability report", e);
        }
    }

    private enum TemplateKind {
        WORD,
        POWERPOINT
    }

    private record Item(String name, Object context) {}

    /**
     * @param status ok | error
     * @param output nullable
     * @param error  nullable
     */
    private record RunResult(
            String name,
            String status,
            @Nullable String output,
            @Nullable String error
    ) {}
}