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
+34
View File
@@ -0,0 +1,34 @@
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Generating test runner configuration 'tofu-test-application' for 'application' (executable).
Warning Excluding main source file src/main.d from test.
Starting Performing "unittest" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Up-to-date tofu ~main: target for configuration [tofu-test-application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
Warning: malformed TOML config at /tmp/tofu-test-config-bad-174212.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
3 modules passed unittests
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+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);
}