Document the library's implementation

Every function in context.cpp, modules.cpp, and version.cpp carries a
Doxygen block, and the bodies explain the decisions the code alone does
not show: why dlerror is cleared before dlsym rather than testing the
returned address, what RTLD_NOW and RTLD_LOCAL buy, why missing contract
symbols are collected into one report, why a candidate that fails
validation is logged and skipped instead of failing the listing, and why
version parsing tests the first character itself.

Functions declared in a header carry a brief and implementation prose
rather than a second parameter list; Doxygen was reporting duplicate
documentation sections for each of them.

READMEs in include/internal and src/cli state what those directories
hold.
This commit is contained in:
2026-08-16 00:01:22 -04:00
parent 12a4f68025
commit 303d96ef73
5 changed files with 326 additions and 15 deletions

View File

@@ -33,20 +33,49 @@
namespace fs = std::filesystem;
namespace {
/** Configuration directory used when no override is supplied. */
const char* DEFAULT_CONFIG_DIR = "/etc/dpm/conf.d/";
/** Module directory used when neither an override nor configuration names one. */
const char* DEFAULT_MODULE_PATH = "/usr/lib/dpm/modules/";
/** Log file used when configuration enables logging without naming a path. */
const char* DEFAULT_LOG_FILE = "/var/log/dpm/dpm.log";
/**
* @brief Strips leading and trailing whitespace
*
* Applied to every configuration line, section name, key, and value,
* so that surrounding spaces and the line's terminator never reach
* the configuration store.
*
* @param s The string to trim
* @return The trimmed string; empty when the input is entirely whitespace
*/
std::string trim(const std::string& s) {
const char* ws = " \t\r\n\f\v";
size_t start = s.find_first_not_of(ws);
// Every character is whitespace, so nothing survives trimming.
if (start == std::string::npos) {
return "";
}
size_t end = s.find_last_not_of(ws);
return s.substr(start, end - start + 1);
}
/**
* @brief Appends a trailing slash when one is absent
*
* Directory paths are stored with a trailing slash so that callers
* concatenating a filename onto them produce a valid path without
* checking the separator themselves.
*
* @param s The path to normalize
* @return The path, ending in a slash unless it is empty
*/
std::string with_trailing_slash(std::string s) {
if (!s.empty() && s.back() != '/') {
s += '/';
@@ -54,20 +83,44 @@ namespace {
return s;
}
/**
* @brief Interprets a configured boolean value
*
* Accepts the spellings a configuration file is likely to carry, in
* any case. A value that matches none of them leaves the setting at
* the caller's fallback rather than guessing.
*
* @param v The configured value
* @param fallback Returned when the value matches no known spelling
* @return The interpreted boolean, or the fallback
*/
bool parse_bool(const std::string& v, bool fallback) {
// Compare case-insensitively by folding a copy to lowercase.
std::string lower;
for (char c : v) {
lower += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
if (lower == "true" || lower == "yes" || lower == "1" || lower == "on") {
return true;
}
if (lower == "false" || lower == "no" || lower == "0" || lower == "off") {
return false;
}
return fallback;
}
/**
* @brief Maps a configured log level name to its numeric level
*
* The comparison is exact and case-sensitive, matching the spelling
* the shipped core.conf uses.
*
* @param v The level name read from configuration
* @param fallback Returned when the name is unrecognized
* @return A DPM_LOG_* value, or the fallback
*/
int level_from_string(const std::string& v, int fallback) {
if (v == "FATAL") { return DPM_LOG_FATAL; }
if (v == "ERROR") { return DPM_LOG_ERROR; }
@@ -77,6 +130,14 @@ namespace {
return fallback;
}
/**
* @brief Names a numeric log level for display
*
* Used to label each message on the console and in the log file.
*
* @param level A DPM_LOG_* value
* @return The level's name, or "UNKNOWN" for a value outside the set
*/
const char* level_name(int level) {
switch (level) {
case DPM_LOG_FATAL: return "FATAL";
@@ -88,6 +149,22 @@ namespace {
}
}
/**
* @brief Parses one configuration file into the context's store
*
* Reads a sectioned key/value file. Keys appearing before any
* section header belong to the section named "main". Blank lines,
* lines opening with '#' or ';', and lines carrying no '=' are
* skipped, so a malformed line costs that line alone.
*
* A file that cannot be opened contributes nothing and is not an
* error: configuration is optional, and defaults cover its absence.
*
* @param ctx The context whose configuration store receives the values
* @param file Path of the file to read
* @param module_name The namespace the values are filed under, which
* is the file's name without its extension
*/
void parse_config_file(dpm_ctx* ctx, const fs::path& file,
const std::string& module_name) {
std::ifstream in(file);
@@ -96,21 +173,26 @@ namespace {
}
std::string line;
// Keys read before the first section header belong to "main".
std::string section = "main";
while (std::getline(in, line)) {
line = trim(line);
// Blank line or comment.
if (line.empty() || line[0] == '#' || line[0] == ';') {
continue;
}
// Section header: everything after it files under this name.
if (line.front() == '[' && line.back() == ']') {
std::string name = trim(line.substr(1, line.length() - 2));
section = name.empty() ? "main" : name;
continue;
}
// Anything without a separator is not a key/value pair.
size_t eq = line.find('=');
if (eq == std::string::npos) {
continue;
@@ -118,6 +200,8 @@ namespace {
std::string key = trim(line.substr(0, eq));
std::string value = trim(line.substr(eq + 1));
// A separator with nothing before it names no key.
if (key.empty()) {
continue;
}
@@ -128,12 +212,28 @@ namespace {
} // namespace
namespace dpm_core {
/**
* @brief Records a failure reason on the context
*
* Overwrites whatever reason was recorded previously, so the context
* carries the most recent failure and nothing older.
*/
void set_error(dpm_ctx* ctx, const std::string& msg) {
if (ctx) {
ctx->last_error = msg;
}
}
/**
* @brief Loads every configuration file in the context's config directory
*
* Each .conf file becomes one configuration namespace named after the
* file, so core.conf fills the "core" namespace and mymodule.conf
* fills "mymodule". Files with any other extension are ignored.
*
* A missing or unreadable directory leaves the store empty, which
* leaves every setting at its default.
*/
void load_config_dir(dpm_ctx* ctx) {
std::error_code ec;
if (!fs::is_directory(ctx->config_dir, ec)) {
@@ -141,6 +241,7 @@ namespace dpm_core {
}
for (const auto& entry : fs::directory_iterator(ctx->config_dir, ec)) {
// Stop on a directory read error rather than iterating further.
if (ec) {
break;
}
@@ -150,14 +251,31 @@ namespace dpm_core {
if (entry.path().extension() != ".conf") {
continue;
}
// The namespace is the filename without its extension.
parse_config_file(ctx, entry.path(), entry.path().stem().string());
}
}
} // namespace dpm_core
extern "C" {
/**
* @brief Creates a context
*
* Reads configuration, resolves the module path and target root, and
* initializes logging. Loads no modules.
*
* Each setting is resolved from the most specific source available.
* The module path takes an override first, then the configured
* value, then the built-in default; logging reads configuration and
* then lets an override replace the level.
*
* Explicit overrides are validated before anything is allocated, so
* a caller that names an unusable path gets NULL rather than a
* context that fails later.
*/
dpm_ctx* dpm_open(const dpm_open_overrides* overrides) {
/* Validate explicit overrides before allocating anything. */
// Validate explicit overrides before allocating anything.
if (overrides) {
if (overrides->config_dir && *overrides->config_dir) {
std::error_code ec;
@@ -175,15 +293,16 @@ extern "C" {
return nullptr;
}
/* Config directory: override > default. */
// Config directory: override, then default.
ctx->config_dir = DEFAULT_CONFIG_DIR;
if (overrides && overrides->config_dir && *overrides->config_dir) {
ctx->config_dir = with_trailing_slash(overrides->config_dir);
}
// Everything below reads configuration, so it loads first.
dpm_core::load_config_dir(ctx);
/* Logging: config, then override. */
// Logging: defaults, then configuration, then the override.
ctx->log_level = DPM_LOG_INFO;
ctx->write_to_log = false;
ctx->log_file = DEFAULT_LOG_FILE;
@@ -201,7 +320,7 @@ extern "C" {
ctx->log_level = overrides->log_level;
}
/* Module path: override > config > default. */
// Module path: default, then configuration, then the override.
ctx->module_path = DEFAULT_MODULE_PATH;
if (const char* v = dpm_config_get(ctx, "core", "modules", "path")) {
ctx->module_path = with_trailing_slash(v);
@@ -210,7 +329,7 @@ extern "C" {
ctx->module_path = with_trailing_slash(overrides->module_path);
}
/* Target root: override > default. */
// Target root: override, then default.
ctx->root = "/";
if (overrides && overrides->root && *overrides->root) {
ctx->root = overrides->root;
@@ -219,20 +338,37 @@ extern "C" {
return ctx;
}
/**
* @brief Releases a context
*
* Unloads every module the context loaded, then frees the context
* itself. Every handle and string the context issued is invalid
* afterwards.
*/
void dpm_close(dpm_ctx* ctx) {
if (!ctx) {
return;
}
/* dpm_module destructors do not dlclose; do it here so the
registry teardown order is explicit. */
// A dpm_module destructor does not close its handle, so the
// registry is unloaded here and the teardown order stays visible.
for (auto& [name, mod] : ctx->modules) {
if (mod->handle) {
dpm_internal_unload(mod->handle);
}
}
delete ctx;
}
/**
* @brief Reads a configured value
*
* Values are addressed by the three levels the configuration store
* is keyed on: the namespace a file supplied, a section within it,
* and a key within that section. A level that is absent yields NULL,
* which the caller reads as unset.
*/
const char* dpm_config_get(dpm_ctx* ctx, const char* module,
const char* section, const char* key) {
if (!ctx || !module || !section || !key) {
@@ -257,13 +393,24 @@ extern "C" {
return key_it->second.c_str();
}
/**
* @brief Writes a message to the context's log targets
*
* Messages at or below the configured level are written; the rest
* are dropped. Errors and warnings go to stderr so they survive a
* caller redirecting stdout, and the remaining levels go to stdout
* as ordinary output.
*/
void dpm_log(dpm_ctx* ctx, int level, const char* message) {
if (!ctx || !message) {
return;
}
// A level outside the known set is treated as ordinary reporting.
if (level < DPM_LOG_FATAL || level > DPM_LOG_DEBUG) {
level = DPM_LOG_INFO;
}
if (level > ctx->log_level) {
return;
}
@@ -284,13 +431,19 @@ extern "C" {
out << stamp << " [" << level_name(level) << "] "
<< message << "\n";
} else {
/* Log-file trouble never fails the operation; disable and
continue on console alone. */
// Log-file trouble never fails the operation; disable the
// file target and carry on writing to the console.
ctx->write_to_log = false;
}
}
}
/**
* @brief Reports the module directory this context resolved
*
* The path was fixed when the context opened, so it reflects the
* override, configured value, or default that won at that point.
*/
const char* dpm_module_path(dpm_ctx* ctx) {
if (!ctx) {
return nullptr;
@@ -298,6 +451,13 @@ extern "C" {
return ctx->module_path.c_str();
}
/**
* @brief Reports the most recent failure recorded on the context
*
* The reason is replaced by the next failing call, so it describes
* the most recent failure and nothing earlier. An empty reason reads
* as no failure recorded.
*/
const char* dpm_last_error(dpm_ctx* ctx) {
if (!ctx || ctx->last_error.empty()) {
return nullptr;