feat(config): add env var and XDG TOML config loading

- Config struct with zuurUrl, cacheDir, zetaToolchainPath, zetaPath, defaultJobs
- Priority: env vars > TOML config file > hardcoded defaults
- Env vars: TOFU_ZUUR_URL, TOFU_CACHE_DIR, TOFU_ZETA_TOOLCHAIN_PATH,
  TOFU_ZETA_PATH, TOFU_DEFAULT_JOBS, TOFU_CONFIG
- TOML [core] section parsing via toml package v1.0.0
- URL helpers: recipesUrl, binaryManifestUrl, indexUrl
- Cache path helpers: recipesCacheDir, builtDir, builtPackagesDir
- Malformed TOML → stderr warning + fallback to defaults
- 14 unittest blocks covering all config scenarios
- All tests pass: dub test → 3 modules passed unittests
This commit is contained in:
2026-08-08 17:17:04 -04:00
parent c331416623
commit 3fb03b23d7
2 changed files with 469 additions and 0 deletions
+435
View File
@@ -0,0 +1,435 @@
/// tofu config — environment variable and TOML-based configuration loading.
///
/// Priority: environment variables > TOML config file > hardcoded defaults.
module tofu.config;
import toml;
import std.file : readText, write, remove, tempDir;
import std.path : expandTilde, buildPath;
import std.process : environment, thisProcessID;
import std.conv : to, ConvException;
import std.string : strip;
import std.stdio : stderr;
// ─── Exception ───────────────────────────────────────────────────────────────
/// Thrown on fatal configuration errors.
class ConfigException : Exception {
this(string message, string file = __FILE__, size_t line = __LINE__)
@safe pure nothrow {
super(message, file, line);
}
}
// ─── Config struct ───────────────────────────────────────────────────────────
/// Holds all runtime configuration for the tofu package manager.
///
/// Fields are populated by `load()` with priority: env override > TOML > default.
struct Config {
/// Base URL for the ZUUR repository.
string zuurUrl;
/// Local cache directory (resolved absolute path).
string cacheDir;
/// Path to the zeta-toolchain binary (empty = find on PATH).
string zetaToolchainPath;
/// Path to the ZETA binary (empty = find on PATH).
string zetaPath;
/// Default number of parallel build jobs (1 = serial).
int defaultJobs;
// ── URL helpers ──────────────────────────────────────────────────────
pure @safe nothrow
string recipesUrl(string name) const {
return zuurUrl ~ "/recipes/" ~ name;
}
pure @safe nothrow
string binaryManifestUrl(string name) const {
return zuurUrl ~ "/binary/packages/" ~ name ~ "/package.lua";
}
pure @safe nothrow
string indexUrl() const {
return zuurUrl ~ "/index.lua";
}
// ── Cache path helpers ───────────────────────────────────────────────
pure @safe nothrow
string recipesCacheDir(string name) const {
return cacheDir ~ "/recipes/" ~ name;
}
pure @safe nothrow
string builtDir() const {
return cacheDir ~ "/built";
}
pure @safe nothrow
string builtPackagesDir() const {
return cacheDir ~ "/built/packages";
}
}
// ─── Private helpers ─────────────────────────────────────────────────────────
/// Read an environment variable, falling back to a default.
/// When `envOverrides` is non-null, uses it instead of real env (for tests).
private @trusted
string getEnv(string key, string defaultValue,
const string[string] envOverrides) {
if (envOverrides !is null && (key in envOverrides))
return envOverrides[key];
auto val = environment.get(key);
return val.length ? val : defaultValue;
}
/// Parse an integer from a string, falling back with a stderr warning.
private @safe
int parseIntOr(string raw, int defaultValue, string name) {
auto s = raw.strip;
if (s.length == 0)
return defaultValue;
try {
return to!int(s);
} catch (ConvException) {
() @trusted {
stderr.writefln(
"Warning: invalid %s '%s', using default %d",
name, s, defaultValue);
}();
return defaultValue;
}
}
// ─── Load ────────────────────────────────────────────────────────────────────
/// Load configuration with standard priority: env > TOML file > defaults.
///
/// Params:
/// configFile = override config file path (default: from TOFU_CONFIG env or
/// ~/.config/tofu/config.toml)
/// envOverrides = environment var map for testing (null = use real process env)
///
/// Returns:
/// Populated `Config` struct. Never throws — malformed config falls back to
/// defaults with a warning to stderr.
Config load(string configFile = null,
const string[string] envOverrides = null) @safe {
// ── 1. Hardcoded defaults ────────────────────────────────────────────
Config cfg;
cfg.zuurUrl = "https://files.spectoria.dev/zuur";
cfg.cacheDir = expandTilde("~/.cache/tofu");
cfg.zetaToolchainPath = "";
cfg.zetaPath = "";
cfg.defaultJobs = 1;
// ── 2. Resolve config file path ──────────────────────────────────────
string tomlPath;
if (configFile.length > 0) {
tomlPath = configFile;
} else {
auto tofuConfig = getEnv("TOFU_CONFIG", "", envOverrides);
if (tofuConfig.length > 0) {
tomlPath = tofuConfig;
} else {
auto home = getEnv("HOME", "", envOverrides);
if (home.length > 0)
tomlPath = home ~ "/.config/tofu/config.toml";
else
tomlPath = ".config/tofu/config.toml";
}
}
// ── 3. Try reading & parsing TOML config file ────────────────────────
string content;
bool hasContent = false;
() @trusted {
try {
content = readText(tomlPath);
hasContent = true;
} catch (Exception) {
// File missing or unreadable — not an error, use defaults.
}
}();
TOMLDocument doc;
bool hasDoc = false;
if (hasContent) {
try {
() @trusted { doc = parseTOML(content); }();
hasDoc = true;
} catch (TOMLParserException e) {
() @trusted {
stderr.writefln(
"Warning: malformed TOML config at %s: %s",
tomlPath, e.msg);
}();
} catch (Exception) {
() @trusted {
stderr.writefln(
"Warning: could not parse TOML config at %s, "
~ "using defaults", tomlPath);
}();
}
}
// ── 4. Apply TOML [core] values ──────────────────────────────────────
if (hasDoc) {
// TOML string value helper
auto getStr = (string key) {
auto cv = "core" in doc;
if (cv is null || cv.type != TOML_TYPE.TABLE)
return string.init;
auto v = key in *cv;
if (v is null || v.type != TOML_TYPE.STRING)
return string.init;
return v.str;
};
// TOML int value helper
auto getInt = (string key) {
auto cv = "core" in doc;
if (cv is null || cv.type != TOML_TYPE.TABLE)
return long.min;
auto v = key in *cv;
if (v is null || v.type != TOML_TYPE.INTEGER)
return long.min;
return v.integer;
};
auto tUrl = getStr("zuur_url");
if (tUrl.length > 0) cfg.zuurUrl = tUrl;
auto tCache = getStr("cache_dir");
if (tCache.length > 0) cfg.cacheDir = tCache;
auto tToolchain = getStr("zeta_toolchain_path");
if (tToolchain.length > 0) cfg.zetaToolchainPath = tToolchain;
auto tZeta = getStr("zeta_path");
if (tZeta.length > 0) cfg.zetaPath = tZeta;
auto tJobs = getInt("default_jobs");
if (tJobs != long.min) cfg.defaultJobs = cast(int) tJobs;
}
// ── 5. Override with environment variables (highest priority) ────────
auto zuurUrlEnv = getEnv("TOFU_ZUUR_URL", "", envOverrides);
if (zuurUrlEnv.length > 0) cfg.zuurUrl = zuurUrlEnv;
auto cacheDirEnv = getEnv("TOFU_CACHE_DIR", "", envOverrides);
if (cacheDirEnv.length > 0) cfg.cacheDir = cacheDirEnv;
auto zetaToolchainEnv = getEnv("TOFU_ZETA_TOOLCHAIN_PATH", "", envOverrides);
if (zetaToolchainEnv.length > 0) cfg.zetaToolchainPath = zetaToolchainEnv;
auto zetaPathEnv = getEnv("TOFU_ZETA_PATH", "", envOverrides);
if (zetaPathEnv.length > 0) cfg.zetaPath = zetaPathEnv;
auto defaultJobsEnv = getEnv("TOFU_DEFAULT_JOBS", "", envOverrides);
if (defaultJobsEnv.length > 0)
cfg.defaultJobs = parseIntOr(defaultJobsEnv, 1, "TOFU_DEFAULT_JOBS");
return cfg;
}
// ─── Unittests ───────────────────────────────────────────────────────────────
/// Empty env-override map for tests.
private @safe const(string[string]) emptyEnv() {
const string[string] m;
return m;
}
/// Write a test TOML file (trusted wrapper).
private @trusted void writeTestFile(string path, string content) {
write(path, content);
}
/// Remove a test file, ignoring errors (trusted wrapper).
private @trusted void removeTestFile(string path) {
try { remove(path); } catch (Exception) {}
}
/// Build a unique temp file path for tests.
private @safe string testTempPath(string suffix) {
return buildPath(tempDir, "tofu-test-config-" ~ suffix
~ "-" ~ thisProcessID.to!string ~ ".toml");
}
// ── Test (1): No env vars, no config file → all defaults.
@safe unittest {
auto cfg = load(null, emptyEnv());
assert(cfg.zuurUrl == "https://files.spectoria.dev/zuur");
assert(cfg.cacheDir == expandTilde("~/.cache/tofu"));
assert(cfg.zetaToolchainPath == "");
assert(cfg.zetaPath == "");
assert(cfg.defaultJobs == 1);
}
// ── Test (2): TOFU_ZUUR_URL override wins over default.
@safe unittest {
const string[string] env = ["TOFU_ZUUR_URL": "https://custom.example.com/zuur"];
auto cfg = load(null, env);
assert(cfg.zuurUrl == "https://custom.example.com/zuur");
}
// ── Test (3): Config file values used when env vars unset.
@safe unittest {
auto tmp = testTempPath("toml-only");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
zuur_url = "https://toml.example.com/zuur"
cache_dir = "/tmp/tofu-cache"
zeta_toolchain_path = "/usr/bin/zeta-toolchain"
zeta_path = "/usr/bin/zeta"
default_jobs = 4
]");
auto cfg = load(tmp, emptyEnv());
assert(cfg.zuurUrl == "https://toml.example.com/zuur");
assert(cfg.cacheDir == "/tmp/tofu-cache");
assert(cfg.zetaToolchainPath == "/usr/bin/zeta-toolchain");
assert(cfg.zetaPath == "/usr/bin/zeta");
assert(cfg.defaultJobs == 4);
}
// ── Test (3b): Env var wins over TOML.
@safe unittest {
auto tmp = testTempPath("env-over-toml");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
zuur_url = "https://toml.example.com/zuur"
]");
const string[string] env = ["TOFU_ZUUR_URL": "https://env.example.com/zuur"];
auto cfg = load(tmp, env);
assert(cfg.zuurUrl == "https://env.example.com/zuur");
}
// ── Test (4): Malformed TOML → defaults + warning (no crash).
@safe unittest {
auto tmp = testTempPath("bad");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, "[core\nzuur_url = totally broken @@@");
auto cfg = load(tmp, emptyEnv());
assert(cfg.zuurUrl == "https://files.spectoria.dev/zuur");
assert(cfg.defaultJobs == 1);
}
// ── Test (5): URL helper methods produce correct paths.
@safe unittest {
const string[string] env = ["TOFU_ZUUR_URL": "https://example.com/zuur"];
auto cfg = load(null, env);
assert(cfg.recipesUrl("firefox") ==
"https://example.com/zuur/recipes/firefox");
assert(cfg.indexUrl() ==
"https://example.com/zuur/index.lua");
assert(cfg.binaryManifestUrl("firefox") ==
"https://example.com/zuur/binary/packages/firefox/package.lua");
}
// ── Test (5b): Cache dir helpers produce correct paths.
@safe unittest {
const string[string] env = ["TOFU_CACHE_DIR": "/tmp/tofu-test-cache"];
auto cfg = load(null, env);
assert(cfg.recipesCacheDir("firefox") ==
"/tmp/tofu-test-cache/recipes/firefox");
assert(cfg.builtDir() == "/tmp/tofu-test-cache/built");
assert(cfg.builtPackagesDir() == "/tmp/tofu-test-cache/built/packages");
}
// ── Test (6): TOFU_CONFIG env overrides config file path.
@safe unittest {
auto tmp = testTempPath("tofu-config");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
zuur_url = "https://alt-toml.example.com/zuur"
]");
const string[string] env = ["TOFU_CONFIG": tmp];
auto cfg = load(null, env);
assert(cfg.zuurUrl == "https://alt-toml.example.com/zuur");
}
// ── Test (6b): Explicit configFile parameter overrides TOFU_CONFIG env.
@safe unittest {
auto explicitTmp = testTempPath("explicit");
auto ignoredTmp = testTempPath("ignored");
scope (exit) { removeTestFile(explicitTmp); removeTestFile(ignoredTmp); }
writeTestFile(explicitTmp, q"[
[core]
zuur_url = "https://explicit.example.com/zuur"
]");
writeTestFile(ignoredTmp, q"[
[core]
zuur_url = "https://ignored.example.com/zuur"
]");
const string[string] env = ["TOFU_CONFIG": ignoredTmp];
auto cfg = load(explicitTmp, env);
assert(cfg.zuurUrl == "https://explicit.example.com/zuur");
}
// ── Extra: TOFU_DEFAULT_JOBS with garbage → default + warning.
@safe unittest {
const string[string] env = ["TOFU_DEFAULT_JOBS": "not-a-number"];
auto cfg = load(null, env);
assert(cfg.defaultJobs == 1);
}
// ── Extra: TOFU_DEFAULT_JOBS valid integer.
@safe unittest {
const string[string] env = ["TOFU_DEFAULT_JOBS": "8"];
auto cfg = load(null, env);
assert(cfg.defaultJobs == 8);
}
// ── Extra: Missing config file is not an error.
@safe unittest {
auto cfg = load("/nonexistent/tofu/config.toml", emptyEnv());
assert(cfg.zuurUrl == "https://files.spectoria.dev/zuur");
}
// ── Extra: All env vars set simultaneously.
@safe unittest {
const string[string] env = [
"TOFU_ZUUR_URL": "https://all-env.example.com/zuur",
"TOFU_CACHE_DIR": "/tmp/all-env-cache",
"TOFU_ZETA_TOOLCHAIN_PATH": "/opt/zeta-toolchain",
"TOFU_ZETA_PATH": "/opt/zeta",
"TOFU_DEFAULT_JOBS": "16"
];
auto cfg = load(null, env);
assert(cfg.zuurUrl == "https://all-env.example.com/zuur");
assert(cfg.cacheDir == "/tmp/all-env-cache");
assert(cfg.zetaToolchainPath == "/opt/zeta-toolchain");
assert(cfg.zetaPath == "/opt/zeta");
assert(cfg.defaultJobs == 16);
}