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:
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user