diff --git a/docs/config-json.md b/docs/config-json.md new file mode 100644 index 0000000000..020c544128 --- /dev/null +++ b/docs/config-json.md @@ -0,0 +1,160 @@ +# Canonical JSON configuration export + +SNode.C can export its live CLI11/SNode.C configuration tree as a deterministic JSON document for tools such as `snodec-control`. +The export is generated from the in-memory CLI11 application tree and does not parse the legacy INI output. +The implementation is split between `src/utils/JsonWriter.*` for deterministic JSON emission and `src/utils/ConfigJsonFormatter.*` for configuration-tree traversal; the legacy INI/help formatter remains in `src/utils/Formatter.*`. +No external JSON library is used; SNode.C emits this document with its own small JSON writer. + +## Command-line behavior + +The legacy INI behavior remains the default for compatibility: + +```console +target-app -s +target-app --show-config +target-app --show-config=ini +``` + +The canonical JSON export is selected explicitly: + +```console +target-app --show-config=json +``` + +Invalid formats such as `--show-config=xml` are rejected by CLI validation. +A future release may choose to make bare `--show-config` default to JSON, but this PR intentionally preserves the INI default to avoid breaking existing scripts. + +## Top-level schema + +The top-level object uses deterministic field order: + +```json +{ + "format": { + "name": "snodec.config", + "version": 1, + "scope": "configurable-options-only" + }, + "application": {}, + "tree": {} +} +``` + +`format.name` is `snodec.config` and `format.version` is `1`. +The `scope` field is important: this is a configuration export, not a complete command-line schema, so only CLI11 options marked configurable are included. +Non-configurable operational options such as help and write-config are excluded in the same spirit as the legacy config export. + +## Application object + +The `application` object contains metadata from the root CLI11 application: + +- `name` +- `description` +- `version` + +## Tree nodes + +The `tree` object recursively represents the actual CLI11/SNode.C subcommand hierarchy. +The exporter does not hard-code an Application -> Instances -> Sections shape. +Instances and sections are common SNode.C group names, but arbitrary categories, nested sections, and future custom subcommands are preserved as generic child nodes. + +Node fields include: + +- `id`: `app` for the root node or the stable dotted path for children, including generated anonymous segments when needed. +- `kind`: best-effort node kind. +- `kindSource`: source of the kind classification, for example `root`, `cli11-group`, or `heuristic-cli11-group`. +- `name` +- `displayName` +- `group` +- `description` +- `configurable` and `configurableSource` +- `required` and `requiredSource` +- `disabled` and `disabledSource` +- `path` +- `optionGroups` +- `children` + +Kind inference is conservative. Only the root node is authoritative as `application`. +`Instances` and `Sections` group names produce `instance` and `section` with `kindSource` set to `cli11-group`. +Other grouped nodes are marked as `category` with a heuristic source, and ungrouped nodes fall back to `subcommand` or `anonymous`. +Anonymous or empty-name child subcommands are not collapsed into their parent. They receive deterministic generated path segments such as `` and `` within their sibling set, so IDs can look like `` or `named.`. The actual CLI11 `name` field remains empty, while `displayName` falls back to the group, description, or ``. + +## Option groups + +CLI11 option groups are emitted as structured JSON objects instead of INI comments. +Each group contains: + +- `name`: the original CLI11 group name. +- `kind`: `default`, `persistent`, `nonpersistent`, or `custom`. +- `kindSource`: whether the classification came from CLI11 default-group handling or from a group-name heuristic. +- `options`: configurable options in that group. + +Persistence is currently classified from the group name. Downstream tools must treat `persistentSource` / `kindSource` as part of the contract and should not assume heuristic values are authoritative. + +## Options + +Each option includes stable identity and best-effort CLI/config metadata: + +- `id`, `key`, `displayName`, `description` +- `configurable` and `configurableSource` +- `persistent` and `persistentSource` +- `required` and `requiredSource` +- `disabled` +- `commandLine` +- `configFile` +- `type` +- `constraints` +- `relations` +- `value` + +`id` is the unique identity of the option inside the exported JSON tree. It is derived from the current node ID plus the option name, so options below anonymous nodes include generated segments such as `.port`. +`key` remains the flattened SNode.C/CLI11 config key used for config-file and command-line mapping, and it is intentionally kept separate from `id` so config behavior does not change. +`commandLine` includes the visible long and short names where CLI11 exposes them, whether values are expected, the value separator, and repeatability. +`configFile` contains the flattened key used by the current config formatter, an optional section field, and writability derived from configurability. + +## Type and constraint metadata + +Type metadata is best effort: + +- `type.kind` +- `type.kindSource` +- `type.name` +- `type.cpp` +- `type.items` + +When CLI11 exposes direct information, such as zero expected items for a flag, the source reflects that. +Name-based classifications such as `integer` for `port` or `path` for `file` are marked with `kindSource: "heuristic-name"` so tools can distinguish hints from authoritative metadata. +Validators are emitted as opaque constraints when CLI11 exposes only a validator description. +The exporter does not invent numeric ranges or enum members when CLI11 does not provide them in decomposable form. + +## Value semantics + +The `value` object separates semantic values from INI/config-file literals: + +- `apiDefault`: semantic CLI11 default string, `` for required options without defaults, or an empty string if that is the effective API default. +- `configured`: semantic command-line or config-file value when active; otherwise `null`. This is not INI quoted. +- `effective`: semantic configured value if present, otherwise the semantic API default or required placeholder. +- `source`: `command-line-or-config`, `api-default`, `required-placeholder`, or `empty-default`. +- `isEffectiveDefault`: true when the semantic effective value equals the semantic API default, even if the value was explicitly configured. +- `isExplicitlyConfigured`: true when CLI11 reports an active configured value, regardless of whether it equals the API default. +- `isMissingRequired`: true when a required option has no usable effective value or the effective value is ``. +- `apiDefaultLiteral`: INI/config-file literal form of `apiDefault`. +- `configuredLiteral`: INI/config-file literal form of `configured`, or `null` when no configured value is active. +- `effectiveLiteral`: INI/config-file literal form of `effective`. + +For version 1, semantic multi-value options are represented as a deterministic space-joined string and the `type.items` metadata identifies list-like options. The `*Literal` fields preserve CLI11's config-file representation for tools that need to round-trip textual config snippets. +CLI11 does not currently expose enough origin metadata here to reliably distinguish command-line values from config-file values, so both are intentionally reported as `command-line-or-config`. + +## Output purity + +Successful `--show-config=json` output writes JSON to stdout only. +Diagnostics for JSON export failures are written to stderr and stdout is left empty instead of being mixed with colored plaintext. + +## Known limitations + +- This is a configurable-options export, not a complete CLI schema. +- Command-line and config-file value origins are merged as `command-line-or-config`. +- Validator metadata is opaque unless CLI11 exposes a decomposable description. +- Config-file section is currently `null` for flattened dotted keys. +- Semantic multi-value options are currently space-joined strings; consumers should inspect `type.items` and the literal fields when round-tripping. +- Some node, group, persistence, and type classifications are heuristic; every such field includes source metadata. diff --git a/docs/landing-pages/github-landing-openai-codex-pullreq/03-net-and-config.md b/docs/landing-pages/github-landing-openai-codex-pullreq/03-net-and-config.md index 56dbbb0c21..bb30ab04f8 100644 --- a/docs/landing-pages/github-landing-openai-codex-pullreq/03-net-and-config.md +++ b/docs/landing-pages/github-landing-openai-codex-pullreq/03-net-and-config.md @@ -86,3 +86,15 @@ This is particularly helpful for multi-instance binaries (common in gateways and ## Recommended landing-page emphasis When presenting SNode.C publicly, call out that configuration is not an afterthought: it is a central design feature that cleanly spans developer control (code), operations control (config file), and runtime override (CLI). + +## Canonical JSON configuration export + +SNode.C applications can emit a versioned canonical JSON configuration export with `--show-config=json` while preserving legacy INI behavior for `-s`, bare `--show-config`, and `--show-config=ini`. +The JSON document has format name `snodec.config`, version `1`, and an explicit `configurable-options-only` scope. +It is emitted directly from the live CLI11/SNode.C configuration tree without parsing INI and without adding a JSON library dependency. + +The tree model is generic: every CLI11/SNode.C app or subcommand is represented as a config node with structured `optionGroups` and recursive `children`. +Common networking nodes such as `Instances` and `Sections` are recognized from CLI11 groups, but they are not hard-coded hierarchy levels. +Options expose best-effort command-line/config-file mapping, required/configurable state, default/configured/effective values, validator metadata when available, relations, and explicit source fields for heuristic classifications. + +See `docs/config-json.md` for the schema, compatibility behavior, output-purity contract, and known limitations. diff --git a/src/utils/CMakeLists.txt b/src/utils/CMakeLists.txt index ccae3ddb12..316a31d8b1 100644 --- a/src/utils/CMakeLists.txt +++ b/src/utils/CMakeLists.txt @@ -41,8 +41,10 @@ cmake_minimum_required(VERSION 3.18) set(UTILS_CPP Config.cpp + ConfigJsonFormatter.cpp Daemon.cpp Formatter.cpp + JsonWriter.cpp PreserveErrno.cpp Random.cpp SubCommand.cpp @@ -59,8 +61,10 @@ set(UTILS_H AttributeInjector.h CLI11.hpp Config.h + ConfigJsonFormatter.h Daemon.h Formatter.h + JsonWriter.h PreserveErrno.h Random.h SubCommand.h diff --git a/src/utils/Config.cpp b/src/utils/Config.cpp index 406c37486a..422e41e3a6 100644 --- a/src/utils/Config.cpp +++ b/src/utils/Config.cpp @@ -41,7 +41,9 @@ #include "utils/Config.h" +#include "utils/ConfigJsonFormatter.h" #include "utils/Daemon.h" +#include "utils/Formatter.h" #ifndef DOXYGEN_SHOULD_SKIP_THIS @@ -288,14 +290,40 @@ namespace utils { return out.str(); } + + static std::string getShowConfigOptionFormat(CLI::App* configTriggeredApp) { + std::string format = "ini"; + + if (configTriggeredApp != nullptr) { + const CLI::Option* showConfigOption = configTriggeredApp->get_option_no_throw("--show-config"); + + if (showConfigOption != nullptr && showConfigOption->count() > 0) { + format = showConfigOption->as(); + } + } + + return format; + } + static std::string getConfig(CLI::App* configTriggeredApp) { std::stringstream out; + const bool jsonRequested = getShowConfigOptionFormat(configTriggeredApp) == "json"; + try { - out << configTriggeredApp->config_to_str(true, true); + if (jsonRequested) { + out << utils::ConfigJsonFormatter().toConfig(configTriggeredApp); + } else { + out << configTriggeredApp->config_to_str(true, true); + } } catch (const CLI::ParseError& e) { - out << std::string{"["} << Color::Code::FG_RED << "Error" << Color::Code::FG_DEFAULT - << "] Showing current config: " << configTriggeredApp->get_name() << " " << e.get_name() << " " << e.what(); + if (jsonRequested) { + std::cerr << "Showing current config: " << configTriggeredApp->get_name() << " " << e.get_name() << " " << e.what() + << std::endl; + } else { + out << std::string{"["} << Color::Code::FG_RED << "Error" << Color::Code::FG_DEFAULT + << "] Showing current config: " << configTriggeredApp->get_name() << " " << e.get_name() << " " << e.what(); + } } return out.str(); @@ -585,9 +613,11 @@ namespace utils { } catch (const CLI::ParseError& e) { if (helpTriggerApp != nullptr || showConfigTriggerApp != nullptr || commandlineTriggerApp != nullptr || versionOpt->count() > 0) { - std::cout << std::string{"["} << Color::Code::FG_RED << e.get_name() << Color::Code::FG_DEFAULT << "] " << e.what() - << std::endl - << std::endl; + if (showConfigTriggerApp == nullptr || getShowConfigOptionFormat(showConfigTriggerApp) == "ini") { + std::cout << std::string{"["} << Color::Code::FG_RED << e.get_name() << Color::Code::FG_DEFAULT << "] " << e.what() + << std::endl + << std::endl; + } if (versionOpt->count() > 0) { throw CLI::CallForVersion(); diff --git a/src/utils/ConfigJsonFormatter.cpp b/src/utils/ConfigJsonFormatter.cpp new file mode 100644 index 0000000000..b2b799faef --- /dev/null +++ b/src/utils/ConfigJsonFormatter.cpp @@ -0,0 +1,672 @@ +/* + * SNode.C - A Slim Toolkit for Network Communication + * Copyright (C) Volker Christian + * 2020, 2021, 2022, 2023, 2024, 2025, 2026 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +#include "utils/ConfigJsonFormatter.h" + +#include "utils/JsonWriter.h" + +#ifndef DOXYGEN_SHOULD_SKIP_THIS + +#include +#include +#include +#include +#include + +#endif // DOXYGEN_SHOULD_SKIP_THIS + +namespace utils { + namespace { + + struct Classification { + std::string kind; + std::string source; + }; + + struct OptionValueState { + std::string apiDefault; + std::string configured; + std::string effective; + std::string apiDefaultLiteral; + std::string configuredLiteral; + std::string effectiveLiteral; + std::string source; + bool isExplicitlyConfigured = false; + bool isEffectiveDefault = false; + bool isMissingRequired = false; + }; + + std::string lowerCopy(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + + return value; + } + + std::string displayNameForKey(const std::string& key) { + std::string displayName = key; + const std::string::size_type pos = key.find_last_of('.'); + + if (pos != std::string::npos) { + displayName = key.substr(pos + 1); + } + + return displayName; + } + + Classification classifyOptionGroup(const std::string& group) { + Classification classification{"custom", "heuristic-group-name"}; + const std::string lower = lowerCopy(group); + + if (group.empty() || group == "Options") { + classification.kind = "default"; + classification.source = "cli11-default-group"; + } else if (lower.find("nonpersistent") != std::string::npos) { + classification.kind = "nonpersistent"; + } else if (lower.find("persistent") != std::string::npos) { + classification.kind = "persistent"; + } + + return classification; + } + + Classification classifyNode(const CLI::App* app, bool root) { + Classification classification{"subcommand", "fallback"}; + + if (root) { + classification.kind = "application"; + classification.source = "root"; + } else if (app->get_name().empty()) { + classification.kind = "anonymous"; + classification.source = "cli11-name"; + } else if (app->get_group() == "Instances") { + classification.kind = "instance"; + classification.source = "cli11-group"; + } else if (app->get_group() == "Sections") { + classification.kind = "section"; + classification.source = "cli11-group"; + } else if (!app->get_group().empty()) { + classification.kind = "category"; + classification.source = "heuristic-cli11-group"; + } + + return classification; + } + + std::string nodeDisplayName(const CLI::App* app, bool root) { + std::string displayName; + + if (root) { + displayName = "Application"; + } else if (app->get_name().empty() && !app->get_group().empty()) { + displayName = app->get_group(); + } else if (!app->get_display_name(false).empty()) { + displayName = app->get_display_name(false); + } else if (!app->get_name().empty()) { + displayName = app->get_name(); + } else if (!app->get_description().empty()) { + displayName = app->get_description(); + } else { + displayName = ""; + } + + return displayName; + } + + Classification classifyType(const CLI::Option* opt, const std::string& key) { + Classification classification{"string", "fallback"}; + const std::string combined = lowerCopy(opt->get_type_name() + " " + key); + + if (opt->get_items_expected_max() == 0 || combined.find("bool") != std::string::npos) { + classification.kind = "boolean"; + classification.source = "cli11-items-or-type-name"; + } else if (combined.find('{') != std::string::npos) { + classification.kind = "enum"; + classification.source = "cli11-type-name"; + } else if (combined.find("port") != std::string::npos || combined.find("int") != std::string::npos || + combined.find("level") != std::string::npos) { + classification.kind = "integer"; + classification.source = "heuristic-name"; + } else if (combined.find("file") != std::string::npos || combined.find("path") != std::string::npos || + combined.find("config") != std::string::npos) { + classification.kind = "path"; + classification.source = "heuristic-name"; + } + + return classification; + } + + std::string optionSingleName(const CLI::Option* opt) { + std::string name = opt->get_single_name(); + + if (name.rfind("--", 0) == 0) { + name.erase(0, 2); + } else if (name.rfind('-', 0) == 0) { + name.erase(0, 1); + } + + return name; + } + + std::vector reducedResults(const CLI::Option* opt) { + std::vector results; + + try { + results = opt->reduced_results(); + if (results.empty() && !opt->results().empty()) { + results = opt->results(); + } + } catch (const CLI::ParseError& e) { + results.emplace_back(std::string{"<"} + e.get_name() + ": " + e.what() + ">"); + } + + return results; + } + + std::string joinSemanticValues(const std::vector& values) { + std::string joined; + + for (const std::string& value : values) { + if (!joined.empty()) { + joined += ' '; + } + joined += value; + } + + return joined; + } + + std::string joinLiteralValues(const std::vector& values) { + std::string literal; + + try { + literal = CLI::detail::ini_join(values, ' ', '[', ']', '"', '\''); + } catch (const CLI::ParseError& e) { + literal = std::string{"<"} + e.get_name() + ": " + e.what() + ">"; + } + + return literal; + } + + std::string apiDefaultValue(const CLI::Option* opt) { + std::string semanticDefault = opt->get_default_str(); + + if (semanticDefault.empty()) { + if (opt->get_required()) { + semanticDefault = ""; + } else if (opt->get_expected_min() == 0) { + semanticDefault = "false"; + } + } + + return semanticDefault; + } + + OptionValueState extractValueState(const CLI::Option* opt) { + OptionValueState state; + const std::vector configuredResults = reducedResults(opt); + + state.configured = joinSemanticValues(configuredResults); + state.configuredLiteral = joinLiteralValues(configuredResults); + state.isExplicitlyConfigured = opt->count() > 0 && !state.configured.empty(); + state.apiDefault = apiDefaultValue(opt); + state.apiDefaultLiteral = joinLiteralValues({state.apiDefault}); + state.effective = state.isExplicitlyConfigured ? state.configured : state.apiDefault; + state.effectiveLiteral = state.isExplicitlyConfigured ? state.configuredLiteral : state.apiDefaultLiteral; + state.isMissingRequired = opt->get_required() && (state.effective.empty() || state.effective == ""); + state.isEffectiveDefault = state.effective == state.apiDefault; + + if (state.isExplicitlyConfigured) { + state.source = "command-line-or-config"; + } else if (state.isMissingRequired) { + state.source = "required-placeholder"; + } else if (state.apiDefault.empty()) { + state.source = "empty-default"; + } else { + state.source = "api-default"; + } + + return state; + } + + std::string valueItemsKind(const CLI::Option* opt) { + std::string items = "single"; + + if (opt->get_items_expected_max() > 1) { + items = "list"; + } + + return items; + } + + void writeStringArray(JsonWriter& json, const std::vector& values) { + json.beginArray(); + for (const std::string& value : values) { + json.string(value); + } + json.endArray(); + } + + void writeCommandLine(JsonWriter& json, const CLI::Option* opt, const std::string& key) { + json.key("commandLine"); + json.beginObject(); + + json.key("long"); + if (!opt->get_lnames().empty()) { + json.string("--" + opt->get_lnames().front()); + } else { + json.string("--" + key); + } + + json.key("short"); + if (!opt->get_snames().empty()) { + json.string("-" + opt->get_snames().front()); + } else { + json.null(); + } + + json.key("takesValue"); + json.boolean(opt->get_items_expected_max() != 0); + + json.key("valueSeparator"); + json.string(" "); + + json.key("repeatable"); + json.boolean(opt->get_expected_max() > 1); + + json.endObject(); + } + + void writeConfigFile(JsonWriter& json, const CLI::Option* opt, const std::string& key) { + json.key("configFile"); + json.beginObject(); + + json.key("key"); + json.string(key); + + json.key("section"); + json.null(); + + json.key("writable"); + json.boolean(opt->get_configurable()); + + json.key("writableSource"); + json.string("cli11-configurable"); + + json.endObject(); + } + + void writeType(JsonWriter& json, const CLI::Option* opt, const std::string& key) { + const Classification type = classifyType(opt, key); + + json.key("type"); + json.beginObject(); + + json.key("kind"); + json.string(type.kind); + + json.key("kindSource"); + json.string(type.source); + + json.key("name"); + json.string(opt->get_type_name()); + + json.key("cpp"); + json.null(); + + json.key("items"); + json.string(valueItemsKind(opt)); + + json.endObject(); + } + + void writeConstraints(JsonWriter& json, const CLI::Option* opt) { + json.key("constraints"); + json.beginArray(); + + try { + const CLI::Validator* validator = const_cast(opt)->get_validator(); + if (validator != nullptr && !validator->get_description().empty()) { + json.beginObject(); + + json.key("kind"); + json.string("opaque"); + + json.key("source"); + json.string("cli11-validator-description"); + + json.key("description"); + json.string(validator->get_description()); + + json.endObject(); + } + } catch (const CLI::OptionNotFound&) { + // CLI11 reports a missing validator by exception; an absent validator means no constraints here. + } + + json.endArray(); + } + + void writeRelations(JsonWriter& json, const CLI::Option* opt) { + json.key("relations"); + json.beginObject(); + + json.key("needs"); + json.beginArray(); + for (const CLI::Option* need : opt->get_needs()) { + json.string(optionSingleName(need)); + } + json.endArray(); + + json.key("excludes"); + json.beginArray(); + for (const CLI::Option* exclude : opt->get_excludes()) { + json.string(optionSingleName(exclude)); + } + json.endArray(); + + json.endObject(); + } + + void writeValue(JsonWriter& json, const CLI::Option* opt) { + const OptionValueState state = extractValueState(opt); + + json.key("value"); + json.beginObject(); + + json.key("apiDefault"); + json.string(state.apiDefault); + + json.key("configured"); + if (state.isExplicitlyConfigured) { + json.string(state.configured); + } else { + json.null(); + } + + json.key("effective"); + json.string(state.effective); + + json.key("source"); + json.string(state.source); + + json.key("isEffectiveDefault"); + json.boolean(state.isEffectiveDefault); + + json.key("isExplicitlyConfigured"); + json.boolean(state.isExplicitlyConfigured); + + json.key("isMissingRequired"); + json.boolean(state.isMissingRequired); + + json.key("apiDefaultLiteral"); + json.string(state.apiDefaultLiteral); + + json.key("configuredLiteral"); + if (state.isExplicitlyConfigured) { + json.string(state.configuredLiteral); + } else { + json.null(); + } + + json.key("effectiveLiteral"); + json.string(state.effectiveLiteral); + + json.endObject(); + } + + void writeOption(JsonWriter& json, const CLI::Option* opt, const std::string& prefix, const std::string& nodeId) { + const std::string optionName = optionSingleName(opt); + const std::string key = prefix + optionName; + const std::string optionId = nodeId == "app" ? key : nodeId + "." + optionName; + const Classification group = classifyOptionGroup(opt->get_group()); + + json.beginObject(); + + json.key("id"); + json.string(optionId); + + json.key("key"); + json.string(key); + + json.key("displayName"); + json.string(displayNameForKey(key)); + + json.key("description"); + json.string(opt->get_description()); + + json.key("configurable"); + json.boolean(opt->get_configurable()); + + json.key("configurableSource"); + json.string("cli11"); + + json.key("persistent"); + json.boolean(group.kind != "nonpersistent"); + + json.key("persistentSource"); + json.string(group.source); + + json.key("required"); + json.boolean(opt->get_required()); + + json.key("requiredSource"); + json.string("cli11"); + + json.key("disabled"); + json.boolean(false); + + writeCommandLine(json, opt, key); + writeConfigFile(json, opt, key); + writeType(json, opt, key); + writeConstraints(json, opt); + writeRelations(json, opt); + writeValue(json, opt); + + json.endObject(); + } + + void writeOptionGroup(JsonWriter& json, + const std::string& group, + const std::vector& options, + const std::string& prefix, + const std::string& nodeId) { + const Classification classification = classifyOptionGroup(group); + + json.beginObject(); + + json.key("name"); + json.string(group); + + json.key("kind"); + json.string(classification.kind); + + json.key("kindSource"); + json.string(classification.source); + + json.key("options"); + json.beginArray(); + for (const CLI::Option* option : options) { + writeOption(json, option, prefix, nodeId); + } + json.endArray(); + + json.endObject(); + } + + void writeOptionGroups(JsonWriter& json, const CLI::App* app, const std::string& prefix, const std::string& nodeId) { + std::vector groups = app->get_groups(); + bool defaultUsed = false; + + groups.insert(groups.begin(), "Options"); + + json.key("optionGroups"); + json.beginArray(); + + for (const std::string& group : groups) { + if ((group == "Options" || group.empty()) && defaultUsed) { + continue; + } + if (group == "Options" || group.empty()) { + defaultUsed = true; + } + + std::vector options; + for (const CLI::Option* option : app->get_options({})) { + if (!option->get_configurable()) { + continue; + } + if (option->get_group() == group || (group == "Options" && option->get_group().empty())) { + options.push_back(option); + } + } + + if (!options.empty()) { + writeOptionGroup(json, group, options, prefix, nodeId); + } + } + + json.endArray(); + } + + void writeNode(JsonWriter& json, + const CLI::App* app, + const std::vector& path, + const std::string& prefix, + bool root) { + const Classification node = classifyNode(app, root); + const std::string nodeId = root ? "app" : CLI::detail::join(path, "."); + + json.beginObject(); + + json.key("id"); + json.string(nodeId); + + json.key("kind"); + json.string(node.kind); + + json.key("kindSource"); + json.string(node.source); + + json.key("name"); + json.string(app->get_name()); + + json.key("displayName"); + json.string(nodeDisplayName(app, root)); + + json.key("group"); + json.string(app->get_group()); + + json.key("description"); + json.string(app->get_description()); + + json.key("configurable"); + json.boolean(app->get_configurable()); + + json.key("configurableSource"); + json.string("cli11"); + + json.key("required"); + json.boolean(app->get_required()); + + json.key("requiredSource"); + json.string("cli11"); + + json.key("disabled"); + json.boolean(app->get_disabled()); + + json.key("disabledSource"); + json.string("cli11"); + + json.key("path"); + writeStringArray(json, path); + + writeOptionGroups(json, app, prefix, nodeId); + + json.key("children"); + json.beginArray(); + std::size_t anonymousIndex = 0; + for (const CLI::App* subcommand : app->get_subcommands({})) { + std::vector childPath = path; + std::string childPrefix = prefix; + std::string childSegment = subcommand->get_name(); + + if (childSegment.empty()) { + childSegment = ""; + ++anonymousIndex; + } else { + childPrefix += childSegment + "."; + } + + childPath.push_back(childSegment); + + writeNode(json, subcommand, childPath, childPrefix, false); + } + json.endArray(); + + json.endObject(); + } + + } // namespace + + std::string ConfigJsonFormatter::toConfig(const CLI::App* app) const { + std::stringstream out; + JsonWriter json(out); + + json.beginObject(); + + json.key("format"); + json.beginObject(); + + json.key("name"); + json.string("snodec.config"); + + json.key("version"); + json.number("1"); + + json.key("scope"); + json.string("configurable-options-only"); + + json.endObject(); + + json.key("application"); + json.beginObject(); + + json.key("name"); + json.string(app->get_name()); + + json.key("description"); + json.string(app->get_description()); + + json.key("version"); + json.string(app->version()); + + json.endObject(); + + json.key("tree"); + writeNode(json, app, {}, "", true); + + json.endObject(); + out << '\n'; + + return out.str(); + } + +} // namespace utils diff --git a/src/utils/ConfigJsonFormatter.h b/src/utils/ConfigJsonFormatter.h new file mode 100644 index 0000000000..1d02e6f1c1 --- /dev/null +++ b/src/utils/ConfigJsonFormatter.h @@ -0,0 +1,47 @@ +/* + * SNode.C - A Slim Toolkit for Network Communication + * Copyright (C) Volker Christian + * 2020, 2021, 2022, 2023, 2024, 2025, 2026 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +#ifndef UTILS_CONFIGJSONFORMATTER_H +#define UTILS_CONFIGJSONFORMATTER_H + +#ifndef DOXYGEN_SHOULD_SKIP_THIS + +#ifdef __GNUC__ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wfloat-equal" +#endif +#include "utils/CLI11.hpp" +#ifdef __GNUC__ +#pragma GCC diagnostic pop +#endif + +#include + +#endif // DOXYGEN_SHOULD_SKIP_THIS + +namespace utils { + + class ConfigJsonFormatter { + public: + std::string toConfig(const CLI::App* app) const; + }; + +} // namespace utils + +#endif // UTILS_CONFIGJSONFORMATTER_H diff --git a/src/utils/Formatter.cpp b/src/utils/Formatter.cpp index 5f672077ac..5505e8b388 100644 --- a/src/utils/Formatter.cpp +++ b/src/utils/Formatter.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #endif // DOXYGEN_SHOULD_SKIP_THIS diff --git a/src/utils/JsonWriter.cpp b/src/utils/JsonWriter.cpp new file mode 100644 index 0000000000..63629d4a6e --- /dev/null +++ b/src/utils/JsonWriter.cpp @@ -0,0 +1,147 @@ +/* + * SNode.C - A Slim Toolkit for Network Communication + * Copyright (C) Volker Christian + * 2020, 2021, 2022, 2023, 2024, 2025, 2026 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +#include "utils/JsonWriter.h" + +#ifndef DOXYGEN_SHOULD_SKIP_THIS + +#include + +#endif // DOXYGEN_SHOULD_SKIP_THIS + +namespace utils { + + JsonWriter::JsonWriter(std::ostream& out) + : out(out) { + } + + void JsonWriter::beginObject() { + beforeValue(); + + out << '{'; + stack.emplace_back(Container::Object, true); + } + + void JsonWriter::endObject() { + assert(!stack.empty()); + assert(stack.back().first == Container::Object); + + out << '}'; + stack.pop_back(); + } + + void JsonWriter::beginArray() { + beforeValue(); + + out << '['; + stack.emplace_back(Container::Array, true); + } + + void JsonWriter::endArray() { + assert(!stack.empty()); + assert(stack.back().first == Container::Array); + + out << ']'; + stack.pop_back(); + } + + void JsonWriter::key(std::string_view value) { + commaIfNeeded(); + writeEscaped(value); + out << ':'; + afterKey = true; + } + + void JsonWriter::string(std::string_view value) { + beforeValue(); + writeEscaped(value); + } + + void JsonWriter::number(std::string_view value) { + beforeValue(); + out << value; + } + + void JsonWriter::boolean(bool value) { + beforeValue(); + out << (value ? "true" : "false"); + } + + void JsonWriter::null() { + beforeValue(); + out << "null"; + } + + void JsonWriter::commaIfNeeded() { + if (!stack.empty()) { + if (!stack.back().second) { + out << ','; + } + stack.back().second = false; + } + } + + void JsonWriter::beforeValue() { + if (afterKey) { + afterKey = false; + } else { + commaIfNeeded(); + } + } + + void JsonWriter::writeEscaped(std::string_view value) { + static constexpr char hex[] = "0123456789abcdef"; + + out << '"'; + for (const unsigned char ch : value) { + switch (ch) { + case '"': + out << "\\\""; + break; + case '\\': + out << "\\\\"; + break; + case '\b': + out << "\\b"; + break; + case '\f': + out << "\\f"; + break; + case '\n': + out << "\\n"; + break; + case '\r': + out << "\\r"; + break; + case '\t': + out << "\\t"; + break; + default: + if (ch < 0x20) { + out << "\\u00" << hex[(ch >> 4) & 0x0f] << hex[ch & 0x0f]; + } else { + out << static_cast(ch); + } + break; + } + } + out << '"'; + } + +} // namespace utils diff --git a/src/utils/JsonWriter.h b/src/utils/JsonWriter.h new file mode 100644 index 0000000000..95d262621a --- /dev/null +++ b/src/utils/JsonWriter.h @@ -0,0 +1,67 @@ +/* + * SNode.C - A Slim Toolkit for Network Communication + * Copyright (C) Volker Christian + * 2020, 2021, 2022, 2023, 2024, 2025, 2026 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +#ifndef UTILS_JSONWRITER_H +#define UTILS_JSONWRITER_H + +#ifndef DOXYGEN_SHOULD_SKIP_THIS + +#include +#include +#include +#include + +#endif // DOXYGEN_SHOULD_SKIP_THIS + +namespace utils { + + // Minimal deterministic JSON writer for trusted internal exporters. + // Container nesting is checked with assertions in debug builds. + class JsonWriter { + public: + explicit JsonWriter(std::ostream& out); + + void beginObject(); + void endObject(); + + void beginArray(); + void endArray(); + + void key(std::string_view key); + + void string(std::string_view value); + void number(std::string_view value); + void boolean(bool value); + void null(); + + private: + enum class Container { Object, Array }; + + void commaIfNeeded(); + void beforeValue(); + void writeEscaped(std::string_view value); + + std::ostream& out; + std::vector> stack; + bool afterKey = false; + }; + +} // namespace utils + +#endif // UTILS_JSONWRITER_H diff --git a/src/utils/SubCommand.cpp b/src/utils/SubCommand.cpp index 04226cd08c..adb5071000 100644 --- a/src/utils/SubCommand.cpp +++ b/src/utils/SubCommand.cpp @@ -42,6 +42,7 @@ #include "SubCommand.h" #include "utils/Formatter.h" +#include "utils/ConfigJsonFormatter.h" #ifndef DOXYGEN_SHOULD_SKIP_THIS @@ -98,16 +99,20 @@ namespace utils { false); showConfigOpt = setConfigurable(subCommandApp - ->add_flag_function( - "-s,--show-config", - [subCommandApp = this->subCommandApp](std::size_t) { - if (showConfigTriggerApp == nullptr) { - showConfigTriggerApp = subCommandApp; - } - }, - "Show current configuration and exit") + ->add_flag( + "-s{ini},--show-config{ini}", + showConfigFlagValue, + "Show current configuration and exit\n" + "* ini (default): legacy INI-style config output\n" + "* json: canonical JSON config tree") + ->type_name("FORMAT") ->take_first() - ->disable_flag_override() + ->check(CLI::IsMember({"ini", "json"})) + ->each([subCommandApp = this->subCommandApp]([[maybe_unused]] std::string value) { + if (showConfigTriggerApp == nullptr) { + showConfigTriggerApp = subCommandApp; + } + }) ->configurable(false) ->trigger_on_parse(), false); @@ -184,6 +189,10 @@ namespace utils { return subCommandApp->config_to_str(true, true); } + std::string SubCommand::configToJsonStr() const { + return utils::ConfigJsonFormatter().toConfig(subCommandApp); + } + std::string SubCommand::help(const CLI::App* helpApp, const CLI::AppFormatMode& mode) const { return subCommandApp->help(helpApp, "", mode); } diff --git a/src/utils/SubCommand.h b/src/utils/SubCommand.h index 9aff155670..152ef95f17 100644 --- a/src/utils/SubCommand.h +++ b/src/utils/SubCommand.h @@ -125,6 +125,7 @@ namespace utils { public: std::string configToStr() const; + std::string configToJsonStr() const; std::string help(const CLI::App* helpApp, const CLI::AppFormatMode& mode) const; bool hasParent() const; @@ -238,6 +239,7 @@ namespace utils { static CLI::App* getShowConfigTriggerApp(); static CLI::App* getCommandlineTriggerApp(); + static std::map aliases; static CLI::App* helpTriggerApp; @@ -254,6 +256,7 @@ namespace utils { protected: CLI::Option* helpOpt = nullptr; CLI::Option* showConfigOpt = nullptr; + std::string showConfigFlagValue = "ini"; CLI::Option* commandlineOpt = nullptr; private: diff --git a/tests/unit/core/CMakeLists.txt b/tests/unit/core/CMakeLists.txt index 0fce81ae6d..acabb2eefa 100644 --- a/tests/unit/core/CMakeLists.txt +++ b/tests/unit/core/CMakeLists.txt @@ -39,7 +39,10 @@ snodec_add_test(SNodeCInitFreeSmokeTest SNodeCInitFreeSmokeTest.cpp) +snodec_add_test(ConfigJsonFormatterTest ConfigJsonFormatterTest.cpp) target_link_libraries(SNodeCInitFreeSmokeTest PRIVATE snodec-test-support snodec::core) +target_link_libraries(ConfigJsonFormatterTest PRIVATE snodec-test-support snodec::core) set_tests_properties(SNodeCInitFreeSmokeTest PROPERTIES LABELS "unit;core;smoke") +set_tests_properties(ConfigJsonFormatterTest PROPERTIES LABELS "unit;core;config" TIMEOUT 5) diff --git a/tests/unit/core/ConfigJsonFormatterTest.cpp b/tests/unit/core/ConfigJsonFormatterTest.cpp new file mode 100644 index 0000000000..8b1cc56734 --- /dev/null +++ b/tests/unit/core/ConfigJsonFormatterTest.cpp @@ -0,0 +1,255 @@ +#include "utils/ConfigJsonFormatter.h" +#include "utils/Formatter.h" +#include "utils/JsonWriter.h" + +#include "support/TestResult.h" + +#include +#include + +namespace { + + bool appearsBefore(const std::string& text, const std::string& left, const std::string& right) { + const std::string::size_type leftPos = text.find(left); + const std::string::size_type rightPos = text.find(right); + + return leftPos != std::string::npos && rightPos != std::string::npos && leftPos < rightPos; + } + + void testJsonWriter(tests::support::TestResult& result) { + std::ostringstream escaped; + utils::JsonWriter writer(escaped); + + writer.beginArray(); + writer.string("plain"); + writer.string("quote\""); + writer.string("back\\slash"); + writer.string("line\n tab\t cr\r"); + writer.string(std::string("ctrl") + static_cast(0x01) + static_cast(0x1f)); + writer.string("Grüße"); + writer.endArray(); + + result.expectTrue(escaped.str() == + std::string("[\"plain\",\"quote\\\"\",\"back\\\\slash\",\"line\\n tab\\t cr\\r\"," + "\"ctrl\\u0001\\u001f\",\"Grüße\"]"), + "JSON writer escapes strings deterministically"); + } + + void testExactSmallJson(tests::support::TestResult& result) { + CLI::App app("Tiny desc", "tiny"); + app.configurable(false); + + std::string name = "default"; + CLI::Option* option = app.add_option("--name", name, "Name option")->type_name("string")->default_val("default")->configurable(true); + option->group("Options"); + + const std::string expected = + "{\"format\":{\"name\":\"snodec.config\",\"version\":1,\"scope\":\"configurable-options-only\"}," + "\"application\":{\"name\":\"tiny\",\"description\":\"Tiny desc\",\"version\":\"\"}," + "\"tree\":{\"id\":\"app\",\"kind\":\"application\",\"kindSource\":\"root\",\"name\":\"tiny\"," + "\"displayName\":\"Application\",\"group\":\"SUBCOMMANDS\",\"description\":\"Tiny desc\",\"configurable\":false," + "\"configurableSource\":\"cli11\",\"required\":false,\"requiredSource\":\"cli11\",\"disabled\":false," + "\"disabledSource\":\"cli11\",\"path\":[],\"optionGroups\":[{\"name\":\"Options\",\"kind\":\"default\"," + "\"kindSource\":\"cli11-default-group\",\"options\":[{\"id\":\"name\",\"key\":\"name\",\"displayName\":\"name\"," + "\"description\":\"Name option\",\"configurable\":true,\"configurableSource\":\"cli11\",\"persistent\":true," + "\"persistentSource\":\"cli11-default-group\",\"required\":false,\"requiredSource\":\"cli11\",\"disabled\":false," + "\"commandLine\":{\"long\":\"--name\",\"short\":null,\"takesValue\":true,\"valueSeparator\":\" \",\"repeatable\":false}," + "\"configFile\":{\"key\":\"name\",\"section\":null,\"writable\":true,\"writableSource\":\"cli11-configurable\"}," + "\"type\":{\"kind\":\"string\",\"kindSource\":\"fallback\",\"name\":\"string\",\"cpp\":null,\"items\":\"single\"}," + "\"constraints\":[],\"relations\":{\"needs\":[],\"excludes\":[]}," + "\"value\":{\"apiDefault\":\"default\",\"configured\":null,\"effective\":\"default\",\"source\":\"api-default\"," + "\"isEffectiveDefault\":true,\"isExplicitlyConfigured\":false,\"isMissingRequired\":false," + "\"apiDefaultLiteral\":\"\\\"default\\\"\",\"configuredLiteral\":null,\"effectiveLiteral\":\"\\\"default\\\"\"}}]}]," + "\"children\":[]}}\n"; + + result.expectTrue(utils::ConfigJsonFormatter().toConfig(&app) == expected, "small JSON document matches exact expected output"); + } + + void testFormatter(tests::support::TestResult& result) { + CLI::App app("Root description", "test-app"); + app.configurable(false); + + int port = 0; + CLI::Option* portOpt = app.add_option("--port", port, "Port number")->type_name("port")->required()->configurable(true); + portOpt->group("Options (persistent)"); + + std::string mode = "default"; + CLI::Option* modeOpt = app.add_option("--mode", mode, "Mode option")->default_val("default")->configurable(true); + modeOpt->group("Options (persistent)"); + + std::string hidden = "hidden"; + app.add_option("--hidden", hidden, "Hidden option")->configurable(false); + + CLI::Option* needed = app.add_flag("--needed", "Needed flag")->configurable(true); + needed->needs(modeOpt); + needed->excludes(portOpt); + + CLI::App* category = app.add_subcommand("category", "Generic category"); + category->group("Categories")->configurable(false); + + CLI::App* nested = category->add_subcommand("nested", "Nested subcommand"); + nested->group("Custom")->configurable(false); + + bool flag = false; + nested->add_flag("--flag", flag, "Nested flag")->configurable(true)->group("Options (persistent)"); + + app.parse("--port 123 --mode configured"); + + CLI::Option* requiredOpt = app.add_option("--required-value", hidden, "Required value")->required()->configurable(true); + requiredOpt->group("Options (persistent)"); + + const std::string json = utils::ConfigJsonFormatter().toConfig(&app); + + result.expectTrue(appearsBefore(json, "\"format\":", "\"application\":"), "JSON top-level order starts with format"); + result.expectTrue(appearsBefore(json, "\"application\":", "\"tree\":"), "JSON top-level order keeps tree after application"); + result.expectTrue(json.find("\"format\":{\"name\":\"snodec.config\",\"version\":1,\"scope\":\"configurable-options-only\"}") != + std::string::npos, + "JSON includes canonical format and scope marker"); + result.expectTrue(json.find("\"application\":{\"name\":\"test-app\"") != std::string::npos, "JSON includes application name"); + result.expectTrue(json.find("\"tree\":{\"id\":\"app\",\"kind\":\"application\",\"kindSource\":\"root\"") != + std::string::npos, + "JSON includes root tree node with source metadata"); + result.expectTrue(json.find("\"children\":[{\"id\":\"category\",\"kind\":\"category\",\"kindSource\":\"heuristic-cli11-group\"") != + std::string::npos, + "JSON includes arbitrary child nodes with heuristic source metadata"); + result.expectTrue(json.find("\"id\":\"category.nested\"") != std::string::npos, "JSON includes nested child nodes"); + result.expectTrue(json.find("\"name\":\"Options (persistent)\",\"kind\":\"persistent\",\"kindSource\":\"heuristic-group-name\"") != + std::string::npos, + "JSON preserves option groups with source metadata"); + result.expectTrue(json.find("\"key\":\"hidden\"") == std::string::npos, "JSON excludes non-configurable options"); + result.expectTrue(json.find("\"apiDefault\":\"\"") != std::string::npos, "JSON includes required placeholder"); + result.expectTrue(json.find("\"isMissingRequired\":true") != std::string::npos, "JSON marks missing required values"); + result.expectTrue(json.find("\"configured\":\"configured\"") != std::string::npos, "JSON includes semantic configured value"); + result.expectTrue(json.find("\"effective\":\"configured\"") != std::string::npos, "JSON includes semantic effective value"); + result.expectTrue(json.find("\"configuredLiteral\":\"\\\"configured\\\"\"") != std::string::npos, + "JSON includes explicit configured literal value"); + result.expectTrue(json.find("\"effectiveLiteral\":\"\\\"configured\\\"\"") != std::string::npos, + "JSON includes explicit effective literal value"); + result.expectTrue(json.find("\"isExplicitlyConfigured\":true") != std::string::npos, "JSON marks explicit configuration"); + result.expectTrue(json.find("\"kind\":\"integer\",\"kindSource\":\"heuristic-name\"") != std::string::npos, + "JSON marks heuristic type information"); + result.expectTrue(json.find("\"needs\":[\"mode\"]") != std::string::npos, "JSON includes needs relations"); + result.expectTrue(json.find("\"excludes\":[\"port\"]") != std::string::npos, "JSON includes excludes relations"); + + const std::string ini = app.config_to_str(true, true); + result.expectTrue(ini.find("port") != std::string::npos, "INI formatter still emits options"); + } + + void testValueFlags(tests::support::TestResult& result) { + CLI::App defaultApp("Default", "default-app"); + std::string defaultValue = "same"; + defaultApp.add_option("--value", defaultValue)->default_val("same")->configurable(true); + const std::string defaultJson = utils::ConfigJsonFormatter().toConfig(&defaultApp); + result.expectTrue(defaultJson.find("\"isExplicitlyConfigured\":false") != std::string::npos, + "default-only value is not explicitly configured"); + result.expectTrue(defaultJson.find("\"isEffectiveDefault\":true") != std::string::npos, + "default-only value is effectively default"); + + CLI::App differentApp("Different", "different-app"); + std::string differentValue = "same"; + differentApp.add_option("--value", differentValue)->default_val("same")->configurable(true); + differentApp.parse("--value other"); + const std::string differentJson = utils::ConfigJsonFormatter().toConfig(&differentApp); + result.expectTrue(differentJson.find("\"configured\":\"other\"") != std::string::npos, + "explicit different value is semantic without INI quotes"); + result.expectTrue(differentJson.find("\"isExplicitlyConfigured\":true") != std::string::npos, + "explicit different value is explicitly configured"); + result.expectTrue(differentJson.find("\"isEffectiveDefault\":false") != std::string::npos, + "explicit different value is not effectively default"); + + CLI::App sameApp("Same", "same-app"); + std::string sameValue = "same"; + sameApp.add_option("--value", sameValue)->default_val("same")->configurable(true); + sameApp.parse("--value same"); + const std::string sameJson = utils::ConfigJsonFormatter().toConfig(&sameApp); + result.expectTrue(sameJson.find("\"isExplicitlyConfigured\":true") != std::string::npos, + "explicit default value is explicitly configured"); + result.expectTrue(sameJson.find("\"isEffectiveDefault\":true") != std::string::npos, + "explicit default value is still effectively default"); + } + + void testAnonymousNodeIds(tests::support::TestResult& result) { + CLI::App app("Anonymous root", "anon-app"); + app.configurable(false); + + CLI::App* firstAnonymous = app.add_subcommand("", "First anonymous"); + firstAnonymous->group("Anonymous Group"); + int firstPort = 0; + firstAnonymous->add_option("--first-port", firstPort, "First port")->configurable(true); + + CLI::App* secondAnonymous = app.add_subcommand("", "Second anonymous"); + secondAnonymous->group("Anonymous Group"); + int secondPort = 0; + secondAnonymous->add_option("--second-port", secondPort, "Second port")->configurable(true); + + CLI::App* named = app.add_subcommand("named", "Named child"); + CLI::App* nestedAnonymous = named->add_subcommand("", "Nested anonymous"); + nestedAnonymous->group("Nested Anonymous Group"); + + const std::string json = utils::ConfigJsonFormatter().toConfig(&app); + + result.expectTrue(json.find("\"id\":\"\"") != std::string::npos, "first anonymous child has generated ID"); + result.expectTrue(json.find("\"id\":\"\"") != std::string::npos, "second anonymous child has distinct generated ID"); + result.expectTrue(json.find("\"id\":\"named\"") != std::string::npos, "named sibling keeps named ID"); + result.expectTrue(json.find("\"id\":\"named.\"") != std::string::npos, + "nested anonymous child remains reachable"); + result.expectTrue(json.find("\"path\":[\"\"]") != std::string::npos, + "anonymous path contains generated segment"); + result.expectTrue(json.find("\"displayName\":\"Anonymous Group\"") != std::string::npos, + "anonymous display name uses group fallback"); + result.expectTrue(json.find("\"id\":\".first-port\",\"key\":\"first-port\"") != std::string::npos, + "first anonymous option has unique tree ID and flattened config key"); + result.expectTrue(json.find("\"id\":\".second-port\",\"key\":\"second-port\"") != std::string::npos, + "second anonymous option has distinct tree ID and flattened config key"); + } + + void testShowConfigOptionCompatibility(tests::support::TestResult& result) { + std::string format = "ini"; + CLI::App app("Compatibility", "compat"); + CLI::Option* showConfig = app.add_flag("-s{ini},--show-config{ini}", format, "Show current configuration and exit") + ->take_first() + ->check(CLI::IsMember({"ini", "json"})); + + app.parse("-s"); + result.expectTrue(showConfig->count() == 1 && format == "ini", "-s defaults to INI"); + + app.clear(); + format = "ini"; + app.parse("--show-config"); + result.expectTrue(showConfig->count() == 1 && format == "ini", "--show-config defaults to INI"); + + app.clear(); + format = "ini"; + app.parse("--show-config=ini"); + result.expectTrue(showConfig->count() == 1 && format == "ini", "--show-config=ini selects INI"); + + app.clear(); + format = "ini"; + app.parse("--show-config=json"); + result.expectTrue(showConfig->count() == 1 && format == "json", "--show-config=json selects JSON"); + + app.clear(); + format = "ini"; + bool rejected = false; + try { + app.parse("--show-config=xml"); + } catch (const CLI::ParseError&) { + rejected = true; + } + result.expectTrue(rejected, "--show-config rejects invalid formats"); + } + +} // namespace + +int main() { + tests::support::TestResult result; + + testJsonWriter(result); + testExactSmallJson(result); + testFormatter(result); + testValueFlags(result); + testAnonymousNodeIds(result); + testShowConfigOptionCompatibility(result); + + return result.processResult(); +}