feat(loading): robust error reporting and mutation

cfg now reports which fields are missing when loading. Further, the mutate method has been added for easier mutation of the underlying configuration struct
This commit is contained in:
2026-04-07 09:28:27 -04:00
parent 46dce35eaf
commit d4bbd9cb3a
9 changed files with 382 additions and 10 deletions

View File

@@ -0,0 +1,62 @@
#pragma once
#include <cstdlib>
#include <string>
#ifdef _WIN32
#include <windows.h>
#include <io.h>
#define ISATTY _isatty
#define FILENO _fileno
#else
#include <unistd.h>
#define ISATTY isatty
#define FILENO fileno
#endif
namespace fourdst::config::utils {
bool supports_ansi_colors() {
if (std::getenv("NO_COLOR")) return false;
if (std::getenv("FORCE_COLOR")) return true;
if (!ISATTY(FILENO(stdout))) return false;
#ifdef _WIN32
HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
if (hOut == INVALID_HANDLE_VALUE) return false;
DWORD dwMode = 0;
if (!GetConsoleMode(hOut, &dwMode)) return false;
return (dwMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0;
#else
const char* term = std::getenv("TERM");
if (!term) return false;
const std::string term_str(term);
return term_str != "dumb";
#endif
}
const static bool TERM_COLOR_SUPPORT = supports_ansi_colors();
class ANSIColor {
public:
explicit ANSIColor(const std::string& value) : m_value(value) {}
std::string_view get() const {
if (TERM_COLOR_SUPPORT) {
return m_value;
}
return "";
}
private:
std::string m_value{""};
ANSIColor() = default;
};
static ANSIColor RED{"\033[31m"};
static ANSIColor GREEN{"\033[32m"};
static ANSIColor BLUE{"\033[34m"};
static ANSIColor CYAN{"\033[36m"};
static ANSIColor RESET{"\033[0m"};
}

View File

@@ -14,8 +14,10 @@
#include <vector>
#include <string_view>
#include <type_traits>
#include <mutex>
#include "fourdst/config/exceptions/exceptions.h"
#include "fourdst/config/validate.h"
#include "rfl.hpp"
#include "rfl/toml.hpp"
@@ -65,7 +67,9 @@ namespace fourdst::config {
/**
* @brief Configuration has been successfully populated from a file.
*/
LOADED_FROM_FILE
LOADED_FROM_FILE,
MODIFIED
};
@@ -127,7 +131,7 @@ namespace fourdst::config {
* @brief Get a mutable pointer to the configuration content.
* @return Pointer to the mutable configuration content.
*/
T* write() const { return &m_content; }
[[deprecated("write has been depreceated for mutate() and will be removed in versions >= v3.0.0")]] T* write() const { return &m_content; }
/**
* @brief Dereference operator to access the underlying configuration struct.
@@ -246,7 +250,7 @@ namespace fourdst::config {
* }
* @endcode
*/
void load(const std::string_view path) {
void load(const std::string_view path, const bool verbose = false) {
if (m_state == ConfigState::LOADED_FROM_FILE) {
throw exceptions::ConfigLoadError(
"Config has already been loaded from file. Reloading is not supported.");
@@ -261,8 +265,32 @@ namespace fourdst::config {
const rfl::Result<wrapper> result = rfl::toml::load<wrapper>(std::string(path));
if (!result) {
std::vector<std::string> missing_fields;
try {
toml::table root_tbl = toml::parse_file(std::string(path));
if (!root_tbl.empty()) {
const auto loaded_root_name = std::string(root_tbl.begin()->first);
const toml::table* t_tbl = root_tbl[loaded_root_name].as_table();
if (t_tbl) {
validate::ConfigValidator<T>::check(t_tbl, loaded_root_name, missing_fields);
}
}
} catch (const toml::parse_error&) {
throw exceptions::ConfigParseError("Unable to parse TOML file for an unknown reason. This normally means the toml file is empty or completely malformed. Please check the file content and ensure it is valid TOML. If the file is empty, consider adding at least an empty table (e.g., [main]) to it.");
}
if (!missing_fields.empty() && verbose) {
std::cerr << validate::report_all_missing_fields(missing_fields) << std::endl;
}
throw exceptions::ConfigParseError(
std::format("Failed to load config from file: {}", path));
std::format("Failed to load config from file: {}. Reason: {}",
path,
result.error().what())
);
}
@@ -328,13 +356,35 @@ namespace fourdst::config {
return "DEFAULT";
case ConfigState::LOADED_FROM_FILE:
return "LOADED_FROM_FILE";
case ConfigState::MODIFIED:
return "MODIFIED";
default:
return "UNKNOWN";
}
}
template <typename MutatorFunc>
void mutate(MutatorFunc&& mutator) {
m_content_mutex.lock();
m_content_orig = m_content;
mutator(m_content);
m_state = ConfigState::MODIFIED;
m_content_mutex.unlock();
}
void reset() {
m_content_mutex.lock();
if (m_state == ConfigState::MODIFIED) {
m_content = m_content_orig;
m_state = ConfigState::LOADED_FROM_FILE;
}
m_content_mutex.unlock();
}
private:
T m_content;
T m_content_orig;
std::mutex m_content_mutex;
std::string m_root_name = "main";
ConfigState m_state = ConfigState::DEFAULT;
RootNameLoadPolicy m_root_name_load_policy = RootNameLoadPolicy::KEEP_CURRENT;

View File

@@ -0,0 +1,157 @@
#pragma once
#include "fourdst/config/ansi.h"
#include <rfl.hpp>
#include <toml++/toml.h>
#include <string>
#include <string_view>
#include <vector>
#include <iostream>
#include <format>
#include <type_traits>
#include <optional>
#include <map>
#include <unordered_map>
#include <sstream>
namespace fourdst::config::validate {
template <typename T> struct is_optional_impl : std::false_type {};
template <typename T> struct is_optional_impl<std::optional<T>> : std::true_type {};
template <typename Type> constexpr bool is_optional_v = is_optional_impl<std::remove_cvref_t<Type>>::value;
template <typename T> struct is_vector_impl : std::false_type {};
template <typename T, typename A> struct is_vector_impl<std::vector<T, A>> : std::true_type {};
template <typename Type> constexpr bool is_vector_v = is_vector_impl<std::remove_cvref_t<Type>>::value;
template <typename T> struct is_map_impl : std::false_type {};
template <typename K, typename V, typename C, typename A> struct is_map_impl<std::map<K, V, C, A>> : std::true_type {};
template <typename K, typename V, typename H, typename E, typename A> struct is_map_impl<std::unordered_map<K, V, H, E, A>> : std::true_type {};
template <typename Type> constexpr bool is_map_v = is_map_impl<std::remove_cvref_t<Type>>::value;
template <typename Type>
constexpr bool is_string_like_v = std::is_same_v<std::remove_cvref_t<Type>, std::string> ||
std::is_same_v<std::remove_cvref_t<Type>, std::string_view>;
template <typename Type>
constexpr bool is_reflectable_struct_v = std::is_class_v<std::remove_cvref_t<Type>> &&
!is_string_like_v<Type> &&
!is_vector_v<Type> &&
!is_optional_v<Type> &&
!is_map_v<Type>;
template <typename StructType>
struct ConfigValidator {
using NT = rfl::named_tuple_t<StructType>;
static void check(const toml::table* tbl, const std::string& current_path, std::vector<std::string>& missing) {
if (!tbl) return;
check_tuple<NT>(tbl, current_path, missing);
}
private:
template <typename Tuple>
struct TupleChecker;
template <typename... Fields>
struct TupleChecker<rfl::NamedTuple<Fields...>> {
static void check(const toml::table* tbl, const std::string& path, std::vector<std::string>& missing) {
(check_field<Fields>(tbl, path, missing), ...);
}
};
template <typename TupleType>
static void check_tuple(const toml::table* tbl, const std::string& path, std::vector<std::string>& missing) {
TupleChecker<TupleType>::check(tbl, path, missing);
}
template <typename Field>
static void check_field(const toml::table* tbl, const std::string& path, std::vector<std::string>& missing) {
std::string_view name_sv = Field::name();
std::string name(name_sv);
using RawType = typename Field::Type;
using Type = std::remove_cvref_t<RawType>;
std::string full_path = path.empty() ? name : path + "." + name;
const toml::node* node = tbl->get(name_sv);
if (!node) {
if constexpr (!is_optional_v<Type>) {
missing.push_back(full_path);
}
} else {
if constexpr (is_reflectable_struct_v<Type>) {
if (node->is_table()) {
ConfigValidator<Type>::check(node->as_table(), full_path, missing);
}
} else if constexpr (is_vector_v<Type>) {
using ElementType = std::remove_cvref_t<typename Type::value_type>;
if constexpr (is_reflectable_struct_v<ElementType>) {
if (node->is_array()) {
const auto& arr = *node->as_array();
for (size_t i = 0; i < arr.size(); ++i) {
if (arr.get(i)->is_table()) {
ConfigValidator<ElementType>::check(
arr.get(i)->as_table(),
std::format("{}[{}]", full_path, i),
missing
);
}
}
}
}
} else if constexpr (is_optional_v<Type>) {
using InnerType = std::remove_cvref_t<typename Type::value_type>;
if constexpr (is_reflectable_struct_v<InnerType>) {
if (node->is_table()) {
ConfigValidator<InnerType>::check(node->as_table(), full_path, missing);
}
}
}
}
}
};
struct MissingFieldTree {
std::map<std::string, MissingFieldTree> children;
bool is_missing = false;
};
inline void print_missing_field_tree(const MissingFieldTree& tree, std::string indent, bool is_last, const std::string& name, std::string& output) {
if (!name.empty()) {
std::string display_name = tree.is_missing ? std::format("{}{}{}", utils::RED.get(), name, utils::RESET.get()) : name;
output += std::format("{}{} {}\n", indent, is_last ? "└──" : "├──", display_name);
indent += is_last ? " " : "";
}
size_t count = 0;
for (auto const& [child_name, child_tree] : tree.children) {
print_missing_field_tree(child_tree, indent, count == tree.children.size() - 1, child_name, output);
++count;
}
}
inline std::string report_all_missing_fields(const std::vector<std::string>& missing) {
if (missing.empty()) return "";
MissingFieldTree root;
for (const auto& path : missing) {
std::stringstream ss(path);
std::string part;
MissingFieldTree* current = &root;
while (std::getline(ss, part, '.')) {
current = &current->children[part];
}
current->is_missing = true;
}
std::string output = "\nConfiguration Missing Field Path(s):\n";
size_t count = 0;
for (auto const& [name, child_tree] : root.children) {
print_missing_field_tree(child_tree, "", count == root.children.size() - 1, name, output);
++count;
}
return output;
}
}