diff --git a/.omo/evidence/task-2-tofu-core.log b/.omo/evidence/task-2-tofu-core.log new file mode 100644 index 0000000..62f8816 --- /dev/null +++ b/.omo/evidence/task-2-tofu-core.log @@ -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 diff --git a/src/tofu/config.d b/src/tofu/config.d new file mode 100644 index 0000000..274be55 --- /dev/null +++ b/src/tofu/config.d @@ -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); +}