From a7c5378e189eac8780cb6049c108af25c51e3ea4 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sun, 11 Oct 2026 15:42:39 +0300 Subject: [PATCH 1/2] feat(config): add semantic loading model Add a C++11 parser-neutral configuration document with typed scalar fields, stable diagnostics, structural validation, and a two-phase side-effect-free materialization contract. Keep JSON parsing and live logger construction outside this layer. --- CHANGELOG.md | 4 + .../0009-configuration-loading-contract.md | 1 + include/logit_cpp/logit.hpp | 1 + include/logit_cpp/logit/configuration.hpp | 425 ++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/configuration_contract_test.cpp | 107 +++++ 6 files changed, 539 insertions(+) create mode 100644 include/logit_cpp/logit/configuration.hpp create mode 100644 tests/configuration_contract_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index b05385e..63e4d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to this project will be documented in this file. corresponding compression feature is enabled. - Added a non-public binary log record format and reader research prototype; no production binary backend is enabled yet. +- Added the parser-neutral configuration model, diagnostics, structural + validation, and side-effect-free materialization contract for Configuration + Loading v1. JSON/properties parsing and logger instantiation remain separate + follow-up layers. ### Fixed diff --git a/docs/adr/0009-configuration-loading-contract.md b/docs/adr/0009-configuration-loading-contract.md index ec333a2..5333a86 100644 --- a/docs/adr/0009-configuration-loading-contract.md +++ b/docs/adr/0009-configuration-loading-contract.md @@ -166,5 +166,6 @@ should follow a stable static-load contract rather than constrain it in advance. ## References - [Roadmap: Configuration loading](../future-plans.md) +- [Parser-neutral model header](../../include/logit_cpp/logit/configuration.hpp) - [ADR 0002: Shared and dedicated asynchronous executors](0002-async-executor-model.md) - [ADR 0008: Binary log record format research prototype](0008-binary-log-record-format-research.md) diff --git a/include/logit_cpp/logit.hpp b/include/logit_cpp/logit.hpp index a87391f..c4ff98e 100644 --- a/include/logit_cpp/logit.hpp +++ b/include/logit_cpp/logit.hpp @@ -11,6 +11,7 @@ #include "logit/config.hpp" #include "logit/enums.hpp" +#include "logit/configuration.hpp" #include "logit/utils.hpp" #include "logit/formatter.hpp" #include "logit/loggers.hpp" diff --git a/include/logit_cpp/logit/configuration.hpp b/include/logit_cpp/logit/configuration.hpp new file mode 100644 index 0000000..5d16772 --- /dev/null +++ b/include/logit_cpp/logit/configuration.hpp @@ -0,0 +1,425 @@ +#pragma once +#ifndef LOGIT_CPP_HEADER_LOGIT_CPP_LOGIT_CONFIGURATION_HPP_INCLUDED +#define LOGIT_CPP_HEADER_LOGIT_CPP_LOGIT_CONFIGURATION_HPP_INCLUDED + +/// \file configuration.hpp +/// \brief Parser-neutral configuration model and validation contract. + +#include +#include +#include +#include +#include + +#include "enums.hpp" + +namespace logit { + + /// \enum ConfigurationErrorCode + /// \brief Stable categories emitted by configuration validation. + enum class ConfigurationErrorCode { + UnsupportedSchemaVersion, + EmptyLoggerId, + DuplicateLoggerId, + EmptyBackendType, + UnknownBackendType, + InvalidValue, + UnsupportedCombination, + UnavailableBackend, + MissingProgrammaticBinding, + UnknownField, + BackendValidationFailed, + MaterializationFailed + }; + + inline const char* configuration_error_code_name(ConfigurationErrorCode code) { + switch (code) { + case ConfigurationErrorCode::UnsupportedSchemaVersion: return "unsupported_schema_version"; + case ConfigurationErrorCode::EmptyLoggerId: return "empty_logger_id"; + case ConfigurationErrorCode::DuplicateLoggerId: return "duplicate_logger_id"; + case ConfigurationErrorCode::EmptyBackendType: return "empty_backend_type"; + case ConfigurationErrorCode::UnknownBackendType: return "unknown_backend_type"; + case ConfigurationErrorCode::InvalidValue: return "invalid_value"; + case ConfigurationErrorCode::UnsupportedCombination: return "unsupported_combination"; + case ConfigurationErrorCode::UnavailableBackend: return "unavailable_backend"; + case ConfigurationErrorCode::MissingProgrammaticBinding: return "missing_programmatic_binding"; + case ConfigurationErrorCode::UnknownField: return "unknown_field"; + case ConfigurationErrorCode::BackendValidationFailed: return "backend_validation_failed"; + case ConfigurationErrorCode::MaterializationFailed: return "materialization_failed"; + } + return "unknown_configuration_error"; + } + + /// \struct ConfigurationDiagnostic + /// \brief One validation or materialization diagnostic with a document path. + struct ConfigurationDiagnostic { + ConfigurationErrorCode code = ConfigurationErrorCode::InvalidValue; + std::string path; + std::string message; + }; + + /// \class ConfigurationDiagnostics + /// \brief Ordered diagnostics produced while validating a configuration. + class ConfigurationDiagnostics { + public: + void add(ConfigurationErrorCode code, + const std::string& path, + const std::string& message) { + ConfigurationDiagnostic diagnostic; + diagnostic.code = code; + diagnostic.path = path; + diagnostic.message = message; + m_items.push_back(diagnostic); + } + + bool empty() const { return m_items.empty(); } + std::size_t size() const { return m_items.size(); } + const std::vector& items() const { return m_items; } + + bool has_code(ConfigurationErrorCode code) const { + for (std::vector::const_iterator it = m_items.begin(); + it != m_items.end(); ++it) { + if (it->code == code) { + return true; + } + } + return false; + } + + private: + std::vector m_items; + }; + + /// \class ConfigurationValue + /// \brief Parser-neutral scalar value used by backend field maps. + class ConfigurationValue { + public: + enum class Type { + Null, + Boolean, + SignedInteger, + UnsignedInteger, + String + }; + + ConfigurationValue() : m_type(Type::Null), m_bool(false), m_signed(0), m_unsigned(0) {} + + static ConfigurationValue make_boolean(bool value) { + ConfigurationValue result; + result.m_type = Type::Boolean; + result.m_bool = value; + return result; + } + + static ConfigurationValue make_signed(std::int64_t value) { + ConfigurationValue result; + result.m_type = Type::SignedInteger; + result.m_signed = value; + return result; + } + + static ConfigurationValue make_unsigned(std::uint64_t value) { + ConfigurationValue result; + result.m_type = Type::UnsignedInteger; + result.m_unsigned = value; + return result; + } + + static ConfigurationValue make_string(const std::string& value) { + ConfigurationValue result; + result.m_type = Type::String; + result.m_string = value; + return result; + } + + Type type() const { return m_type; } + + bool get_boolean(bool& value) const { + if (m_type != Type::Boolean) return false; + value = m_bool; + return true; + } + + bool get_signed(std::int64_t& value) const { + if (m_type != Type::SignedInteger) return false; + value = m_signed; + return true; + } + + bool get_unsigned(std::uint64_t& value) const { + if (m_type != Type::UnsignedInteger) return false; + value = m_unsigned; + return true; + } + + bool get_string(std::string& value) const { + if (m_type != Type::String) return false; + value = m_string; + return true; + } + + private: + Type m_type; + bool m_bool; + std::int64_t m_signed; + std::uint64_t m_unsigned; + std::string m_string; + }; + + typedef std::map ConfigurationFieldMap; + + /// \struct ConfigurationFormatterSpec + /// \brief Data-only formatter settings in the semantic model. + struct ConfigurationFormatterSpec { + std::string type = "simple"; + bool has_pattern = false; + std::string pattern; + bool has_json_format = false; + bool json_format = false; + }; + + /// \struct ConfigurationStrategySpec + /// \brief Logger-level settings independent of backend-native fields. + struct ConfigurationStrategySpec { + bool has_level = false; + LogLevel level = LogLevel::LOG_LVL_TRACE; + bool has_formatter = false; + ConfigurationFormatterSpec formatter; + }; + + /// \struct ConfigurationLoggerSpec + /// \brief One parser-neutral logger declaration. + struct ConfigurationLoggerSpec { + std::string id; + std::string type; + ConfigurationStrategySpec strategy; + ConfigurationFieldMap backend; + }; + + /// \struct ConfigurationDocument + /// \brief Versioned parser-neutral configuration document. + struct ConfigurationDocument { + std::uint32_t schema_version = 0; + std::vector loggers; + }; + + /// \struct ConfigurationMaterializedLogger + /// \brief Side-effect-free materialized logger plan entry. + struct ConfigurationMaterializedLogger { + std::string id; + std::string type; + ConfigurationStrategySpec strategy; + ConfigurationFieldMap backend; + }; + + /// \struct ConfigurationPlan + /// \brief Complete side-effect-free materialization result. + struct ConfigurationPlan { + std::uint32_t schema_version = 0; + std::vector loggers; + }; + + /// \class ConfigurationMaterializer + /// \brief Backend adapter contract; it must not instantiate live loggers. + class ConfigurationMaterializer { + public: + virtual ~ConfigurationMaterializer() {} + + /// \brief Validate backend fields and required bindings without side effects. + virtual bool validate_backend(const ConfigurationLoggerSpec& logger, + ConfigurationDiagnostics& diagnostics) const = 0; + + /// \brief Materialize a DTO after all document validation has succeeded. + virtual bool materialize_backend(const ConfigurationLoggerSpec& logger, + ConfigurationMaterializedLogger& output, + ConfigurationDiagnostics& diagnostics) const = 0; + }; + + /// \brief Return whether a type names a built-in backend known to the contract. + inline bool is_known_configuration_backend(const std::string& type) { + return type == "console" || + type == "file" || + type == "unique_file" || + type == "memory" || + type == "crash" || + type == "syslog" || + type == "event_log" || + type == "windows_debug" || + type == "mdbx" || + type == "otlp_http" || + type == "otlp_payload" || + type == "prometheus_http" || + type == "prometheus_payload"; + } + + inline bool configuration_log_level_is_valid(LogLevel level) { + switch (level) { + case LogLevel::LOG_LVL_TRACE: + case LogLevel::LOG_LVL_DEBUG: + case LogLevel::LOG_LVL_INFO: + case LogLevel::LOG_LVL_WARN: + case LogLevel::LOG_LVL_ERROR: + case LogLevel::LOG_LVL_FATAL: + return true; + } + return false; + } + + inline bool configuration_get_bool(const ConfigurationFieldMap& fields, + const char* name, + bool& value, + bool& present) { + ConfigurationFieldMap::const_iterator it = fields.find(name); + present = it != fields.end(); + return !present || it->second.get_boolean(value); + } + + inline bool configuration_has_field(const ConfigurationFieldMap& fields, const char* name) { + return fields.find(name) != fields.end(); + } + + /// \brief Validate document structure and strategy-level combinations. + inline ConfigurationDiagnostics validate_configuration(const ConfigurationDocument& document) { + ConfigurationDiagnostics diagnostics; + if (document.schema_version != 1) { + diagnostics.add(ConfigurationErrorCode::UnsupportedSchemaVersion, + "schema_version", + "Only configuration schema version 1 is supported"); + } + + std::map seen_ids; + for (std::size_t index = 0; index < document.loggers.size(); ++index) { + const ConfigurationLoggerSpec& logger = document.loggers[index]; + const std::string path = "loggers[" + std::to_string(index) + "]"; + if (logger.id.empty()) { + diagnostics.add(ConfigurationErrorCode::EmptyLoggerId, + path + ".id", + "Logger id must not be empty"); + } else if (seen_ids.find(logger.id) != seen_ids.end()) { + diagnostics.add(ConfigurationErrorCode::DuplicateLoggerId, + path + ".id", + "Logger id must be unique within the document"); + } else { + seen_ids[logger.id] = true; + } + + if (logger.type.empty()) { + diagnostics.add(ConfigurationErrorCode::EmptyBackendType, + path + ".type", + "Logger backend type must not be empty"); + } else if (!is_known_configuration_backend(logger.type)) { + diagnostics.add(ConfigurationErrorCode::UnknownBackendType, + path + ".type", + "Logger backend type is not known to the v1 contract"); + } + + if (logger.strategy.has_level && + !configuration_log_level_is_valid(logger.strategy.level)) { + diagnostics.add(ConfigurationErrorCode::InvalidValue, + path + ".strategy.level", + "Logger level is not valid"); + } + if (logger.strategy.has_formatter && logger.strategy.formatter.type != "simple") { + diagnostics.add(ConfigurationErrorCode::InvalidValue, + path + ".strategy.formatter.type", + "Only the simple formatter is defined by the v1 model"); + } + + bool dedicated = false; + bool dedicated_present = false; + if (!configuration_get_bool(logger.backend, + "use_dedicated_executor", + dedicated, + dedicated_present)) { + diagnostics.add(ConfigurationErrorCode::InvalidValue, + path + ".backend.use_dedicated_executor", + "use_dedicated_executor must be boolean"); + } + + bool async = true; + bool async_present = false; + if (!configuration_get_bool(logger.backend, "async", async, async_present)) { + diagnostics.add(ConfigurationErrorCode::InvalidValue, + path + ".backend.async", + "async must be boolean"); + } + + const bool has_executor_field = + configuration_has_field(logger.backend, "queue_capacity") || + configuration_has_field(logger.backend, "queue_policy"); + if (has_executor_field && (!dedicated_present || !dedicated || + (async_present && !async))) { + diagnostics.add(ConfigurationErrorCode::UnsupportedCombination, + path + ".backend", + "Executor queue fields require an effective dedicated executor"); + } + + ConfigurationFieldMap::const_iterator queue_policy = + logger.backend.find("queue_policy"); + if (queue_policy != logger.backend.end()) { + std::string value; + if (!queue_policy->second.get_string(value) || + (value != "block" && value != "drop_newest" && value != "drop_oldest")) { + diagnostics.add(ConfigurationErrorCode::InvalidValue, + path + ".backend.queue_policy", + "queue_policy must be block, drop_newest, or drop_oldest"); + } + } + + ConfigurationFieldMap::const_iterator queue_capacity = + logger.backend.find("queue_capacity"); + if (queue_capacity != logger.backend.end() && + queue_capacity->second.type() != ConfigurationValue::Type::UnsignedInteger) { + diagnostics.add(ConfigurationErrorCode::InvalidValue, + path + ".backend.queue_capacity", + "queue_capacity must be an unsigned integer"); + } + } + return diagnostics; + } + + /// \brief Validate then materialize a complete side-effect-free plan. + inline bool materialize_configuration(const ConfigurationDocument& document, + const ConfigurationMaterializer& materializer, + ConfigurationPlan& plan, + ConfigurationDiagnostics& diagnostics) { + diagnostics = validate_configuration(document); + if (!diagnostics.empty()) { + return false; + } + + for (std::size_t index = 0; index < document.loggers.size(); ++index) { + const std::size_t diagnostic_count = diagnostics.size(); + if (!materializer.validate_backend(document.loggers[index], diagnostics)) { + if (diagnostics.size() == diagnostic_count) { + diagnostics.add(ConfigurationErrorCode::BackendValidationFailed, + "loggers[" + std::to_string(index) + "].backend", + "Backend materializer rejected the logger configuration"); + } + return false; + } + } + + ConfigurationPlan candidate; + candidate.schema_version = document.schema_version; + candidate.loggers.reserve(document.loggers.size()); + for (std::size_t index = 0; index < document.loggers.size(); ++index) { + ConfigurationMaterializedLogger output; + const std::size_t diagnostic_count = diagnostics.size(); + if (!materializer.materialize_backend(document.loggers[index], output, diagnostics)) { + if (diagnostics.size() == diagnostic_count) { + diagnostics.add(ConfigurationErrorCode::MaterializationFailed, + "loggers[" + std::to_string(index) + "]", + "Backend materializer could not produce a logger plan"); + } + return false; + } + candidate.loggers.push_back(output); + } + plan = candidate; + return true; + } + +} // namespace logit + +#endif // LOGIT_CPP_HEADER_LOGIT_CPP_LOGIT_CONFIGURATION_HPP_INCLUDED diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7b195a5..809a5d0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,6 +16,7 @@ else() backend_shutdown_terminal_test.cpp compiled_level_runtime_caveat_test.cpp compiled_level_test.cpp + configuration_contract_test.cpp console_logger_dedicated_config_test.cpp console_logger_stream_test.cpp crash_logger_test.cpp diff --git a/tests/configuration_contract_test.cpp b/tests/configuration_contract_test.cpp new file mode 100644 index 0000000..91a61a0 --- /dev/null +++ b/tests/configuration_contract_test.cpp @@ -0,0 +1,107 @@ +#include +#include + +#include + +namespace { + +struct RecordingMaterializer : logit::ConfigurationMaterializer { + mutable int validate_calls = 0; + mutable int materialize_calls = 0; + + bool validate_backend(const logit::ConfigurationLoggerSpec& logger, + logit::ConfigurationDiagnostics& diagnostics) const override { + ++validate_calls; + if (logger.type == "file" && logger.backend.find("directory") == logger.backend.end()) { + diagnostics.add(logit::ConfigurationErrorCode::UnknownField, + "loggers[0].backend", + "test materializer requires directory"); + return false; + } + return true; + } + + bool materialize_backend(const logit::ConfigurationLoggerSpec& logger, + logit::ConfigurationMaterializedLogger& output, + logit::ConfigurationDiagnostics&) const override { + ++materialize_calls; + output.id = logger.id; + output.type = logger.type; + output.strategy = logger.strategy; + output.backend = logger.backend; + return true; + } +}; + +logit::ConfigurationDocument valid_document() { + logit::ConfigurationDocument document; + document.schema_version = 1; + logit::ConfigurationLoggerSpec logger; + logger.id = "file-main"; + logger.type = "file"; + logger.strategy.has_level = true; + logger.strategy.level = logit::LogLevel::LOG_LVL_INFO; + logger.backend["directory"] = logit::ConfigurationValue::make_string("logs"); + logger.backend["async"] = logit::ConfigurationValue::make_boolean(true); + logger.backend["use_dedicated_executor"] = + logit::ConfigurationValue::make_boolean(true); + logger.backend["queue_capacity"] = + logit::ConfigurationValue::make_unsigned(8192); + logger.backend["queue_policy"] = + logit::ConfigurationValue::make_string("block"); + document.loggers.push_back(logger); + return document; +} + +void test_validation_and_materialization() { + const logit::ConfigurationDocument document = valid_document(); + const logit::ConfigurationDiagnostics diagnostics = logit::validate_configuration(document); + assert(diagnostics.empty()); + + RecordingMaterializer materializer; + logit::ConfigurationPlan plan; + logit::ConfigurationDiagnostics materialization_diagnostics; + assert(logit::materialize_configuration(document, + materializer, + plan, + materialization_diagnostics)); + assert(materialization_diagnostics.empty()); + assert(materializer.validate_calls == 1); + assert(materializer.materialize_calls == 1); + assert(plan.loggers.size() == 1); + assert(plan.loggers[0].id == "file-main"); +} + +void test_structural_diagnostics() { + logit::ConfigurationDocument document = valid_document(); + document.schema_version = 2; + document.loggers[0].backend.erase("use_dedicated_executor"); + + logit::ConfigurationLoggerSpec duplicate = document.loggers[0]; + document.loggers.push_back(duplicate); + const logit::ConfigurationDiagnostics diagnostics = logit::validate_configuration(document); + assert(diagnostics.has_code(logit::ConfigurationErrorCode::UnsupportedSchemaVersion)); + assert(diagnostics.has_code(logit::ConfigurationErrorCode::DuplicateLoggerId)); + assert(diagnostics.has_code(logit::ConfigurationErrorCode::UnsupportedCombination)); +} + +void test_materializer_is_not_called_after_validation_failure() { + logit::ConfigurationDocument document = valid_document(); + document.loggers[0].backend.erase("use_dedicated_executor"); + + RecordingMaterializer materializer; + logit::ConfigurationPlan plan; + logit::ConfigurationDiagnostics diagnostics; + assert(!logit::materialize_configuration(document, materializer, plan, diagnostics)); + assert(materializer.validate_calls == 0); + assert(materializer.materialize_calls == 0); +} + +} // namespace + +int main() { + test_validation_and_materialization(); + test_structural_diagnostics(); + test_materializer_is_not_called_after_validation_failure(); + return 0; +} From acb76c71f9f1ac6e2d895136059b50ddcd002654 Mon Sep 17 00:00:00 2001 From: Aster Seker Date: Sun, 11 Oct 2026 21:47:20 +0300 Subject: [PATCH 2/2] fix(config): complete semantic value contract Add recursive parser-neutral array and object values so nested backend configuration remains representable. Treat diagnostics emitted by materializer callbacks as failures and preserve the caller's plan until every logger materializes successfully. Extend the contract tests for nested values, fail-closed diagnostics, and atomic plan updates. --- include/logit_cpp/logit/configuration.hpp | 47 +++++++++++-- tests/configuration_contract_test.cpp | 81 ++++++++++++++++++++++- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/include/logit_cpp/logit/configuration.hpp b/include/logit_cpp/logit/configuration.hpp index 5d16772..003ec3a 100644 --- a/include/logit_cpp/logit/configuration.hpp +++ b/include/logit_cpp/logit/configuration.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -91,7 +92,7 @@ namespace logit { }; /// \class ConfigurationValue - /// \brief Parser-neutral scalar value used by backend field maps. + /// \brief Parser-neutral scalar or nested value used by backend field maps. class ConfigurationValue { public: enum class Type { @@ -99,7 +100,9 @@ namespace logit { Boolean, SignedInteger, UnsignedInteger, - String + String, + Array, + Object }; ConfigurationValue() : m_type(Type::Null), m_bool(false), m_signed(0), m_unsigned(0) {} @@ -132,6 +135,21 @@ namespace logit { return result; } + static ConfigurationValue make_array(const std::vector& value) { + ConfigurationValue result; + result.m_type = Type::Array; + result.m_array.reset(new std::vector(value)); + return result; + } + + static ConfigurationValue make_object( + const std::map& value) { + ConfigurationValue result; + result.m_type = Type::Object; + result.m_object.reset(new std::map(value)); + return result; + } + Type type() const { return m_type; } bool get_boolean(bool& value) const { @@ -158,12 +176,26 @@ namespace logit { return true; } + bool get_array(std::vector& value) const { + if (m_type != Type::Array || !m_array) return false; + value = *m_array; + return true; + } + + bool get_object(std::map& value) const { + if (m_type != Type::Object || !m_object) return false; + value = *m_object; + return true; + } + private: Type m_type; bool m_bool; std::int64_t m_signed; std::uint64_t m_unsigned; std::string m_string; + std::shared_ptr> m_array; + std::shared_ptr> m_object; }; typedef std::map ConfigurationFieldMap; @@ -226,6 +258,10 @@ namespace logit { virtual ~ConfigurationMaterializer() {} /// \brief Validate backend fields and required bindings without side effects. + /// + /// Returning true with a diagnostic is still a failed phase. Adapters + /// must add only error diagnostics; warnings are not part of this v1 + /// contract. virtual bool validate_backend(const ConfigurationLoggerSpec& logger, ConfigurationDiagnostics& diagnostics) const = 0; @@ -390,7 +426,8 @@ namespace logit { for (std::size_t index = 0; index < document.loggers.size(); ++index) { const std::size_t diagnostic_count = diagnostics.size(); - if (!materializer.validate_backend(document.loggers[index], diagnostics)) { + const bool valid = materializer.validate_backend(document.loggers[index], diagnostics); + if (!valid || diagnostics.size() != diagnostic_count) { if (diagnostics.size() == diagnostic_count) { diagnostics.add(ConfigurationErrorCode::BackendValidationFailed, "loggers[" + std::to_string(index) + "].backend", @@ -406,7 +443,9 @@ namespace logit { for (std::size_t index = 0; index < document.loggers.size(); ++index) { ConfigurationMaterializedLogger output; const std::size_t diagnostic_count = diagnostics.size(); - if (!materializer.materialize_backend(document.loggers[index], output, diagnostics)) { + const bool materialized = materializer.materialize_backend( + document.loggers[index], output, diagnostics); + if (!materialized || diagnostics.size() != diagnostic_count) { if (diagnostics.size() == diagnostic_count) { diagnostics.add(ConfigurationErrorCode::MaterializationFailed, "loggers[" + std::to_string(index) + "]", diff --git a/tests/configuration_contract_test.cpp b/tests/configuration_contract_test.cpp index 91a61a0..b83b96e 100644 --- a/tests/configuration_contract_test.cpp +++ b/tests/configuration_contract_test.cpp @@ -1,5 +1,7 @@ #include +#include #include +#include #include @@ -8,10 +10,18 @@ namespace { struct RecordingMaterializer : logit::ConfigurationMaterializer { mutable int validate_calls = 0; mutable int materialize_calls = 0; + bool add_validation_diagnostic = false; + bool add_materialization_diagnostic = false; + int fail_materialization_at = -1; bool validate_backend(const logit::ConfigurationLoggerSpec& logger, logit::ConfigurationDiagnostics& diagnostics) const override { ++validate_calls; + if (add_validation_diagnostic) { + diagnostics.add(logit::ConfigurationErrorCode::UnavailableBackend, + "loggers[0].type", + "test validation diagnostic"); + } if (logger.type == "file" && logger.backend.find("directory") == logger.backend.end()) { diagnostics.add(logit::ConfigurationErrorCode::UnknownField, "loggers[0].backend", @@ -23,8 +33,16 @@ struct RecordingMaterializer : logit::ConfigurationMaterializer { bool materialize_backend(const logit::ConfigurationLoggerSpec& logger, logit::ConfigurationMaterializedLogger& output, - logit::ConfigurationDiagnostics&) const override { + logit::ConfigurationDiagnostics& diagnostics) const override { ++materialize_calls; + if (add_materialization_diagnostic) { + diagnostics.add(logit::ConfigurationErrorCode::MissingProgrammaticBinding, + "loggers[0].binding", + "test materialization diagnostic"); + } + if (fail_materialization_at == materialize_calls) { + return false; + } output.id = logger.id; output.type = logger.type; output.strategy = logger.strategy; @@ -85,6 +103,64 @@ void test_structural_diagnostics() { assert(diagnostics.has_code(logit::ConfigurationErrorCode::UnsupportedCombination)); } +void test_nested_values_are_representable() { + std::vector routes; + routes.push_back(logit::ConfigurationValue::make_object( + std::map{{ + "min_level", logit::ConfigurationValue::make_string("info") + }})); + const logit::ConfigurationValue value = logit::ConfigurationValue::make_array(routes); + std::vector observed; + assert(value.get_array(observed)); + assert(observed.size() == 1); + std::map observed_route; + assert(observed[0].get_object(observed_route)); + assert(observed_route["min_level"].type() == + logit::ConfigurationValue::Type::String); +} + +void test_diagnostics_fail_closed() { + const logit::ConfigurationDocument document = valid_document(); + + RecordingMaterializer validation_diagnostic; + validation_diagnostic.add_validation_diagnostic = true; + logit::ConfigurationPlan plan; + logit::ConfigurationDiagnostics diagnostics; + assert(!logit::materialize_configuration(document, + validation_diagnostic, + plan, + diagnostics)); + assert(validation_diagnostic.materialize_calls == 0); + assert(!diagnostics.empty()); + + RecordingMaterializer materialization_diagnostic; + materialization_diagnostic.add_materialization_diagnostic = true; + assert(!logit::materialize_configuration(document, + materialization_diagnostic, + plan, + diagnostics)); + assert(!diagnostics.empty()); +} + +void test_plan_is_preserved_on_late_failure() { + logit::ConfigurationDocument document = valid_document(); + document.loggers.push_back(document.loggers[0]); + document.loggers[1].id = "file-secondary"; + + RecordingMaterializer materializer; + materializer.fail_materialization_at = 2; + logit::ConfigurationPlan plan; + plan.schema_version = 77; + logit::ConfigurationMaterializedLogger sentinel; + sentinel.id = "existing"; + plan.loggers.push_back(sentinel); + logit::ConfigurationDiagnostics diagnostics; + assert(!logit::materialize_configuration(document, materializer, plan, diagnostics)); + assert(plan.schema_version == 77); + assert(plan.loggers.size() == 1); + assert(plan.loggers[0].id == "existing"); +} + void test_materializer_is_not_called_after_validation_failure() { logit::ConfigurationDocument document = valid_document(); document.loggers[0].backend.erase("use_dedicated_executor"); @@ -102,6 +178,9 @@ void test_materializer_is_not_called_after_validation_failure() { int main() { test_validation_and_materialization(); test_structural_diagnostics(); + test_nested_values_are_representable(); + test_diagnostics_fail_closed(); + test_plan_is_preserved_on_late_failure(); test_materializer_is_not_called_after_validation_failure(); return 0; }