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..003ec3a --- /dev/null +++ b/include/logit_cpp/logit/configuration.hpp @@ -0,0 +1,464 @@ +#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 + +#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 or nested value used by backend field maps. + class ConfigurationValue { + public: + enum class Type { + Null, + Boolean, + SignedInteger, + UnsignedInteger, + String, + Array, + Object + }; + + 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; + } + + 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 { + 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; + } + + 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; + + /// \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. + /// + /// 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; + + /// \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(); + 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", + "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(); + 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) + "]", + "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..b83b96e --- /dev/null +++ b/tests/configuration_contract_test.cpp @@ -0,0 +1,186 @@ +#include +#include +#include +#include + +#include + +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", + "test materializer requires directory"); + return false; + } + return true; + } + + bool materialize_backend(const logit::ConfigurationLoggerSpec& logger, + logit::ConfigurationMaterializedLogger& output, + 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; + 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_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"); + + 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_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; +}