diff --git a/.omo/evidence/task-3-tofu-core.log b/.omo/evidence/task-3-tofu-core.log new file mode 100644 index 0000000..50602fd --- /dev/null +++ b/.omo/evidence/task-3-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-174602.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/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md new file mode 100644 index 0000000..2d4ff47 --- /dev/null +++ b/.omo/notepads/tofu-core/learnings.md @@ -0,0 +1,70 @@ +# Learnings — tofu-core + +Conventions, patterns, and successful approaches discovered during work on this plan. + +_Auto-scaffolded by /start-work. Append new entries below - never overwrite._ + +--- + +## Task 2: Config Loading (src/tofu/config.d) + +### toml package API (v1.0.0, Kripth/toml) +- Import: `import toml;` → provides `TOMLDocument`, `TOMLValue`, `parseTOML`, `TOMLException`, `TOMLParserException` +- `TOMLDocument` has `alias table this` — acts like `TOMLValue[string]` AA +- Access sections: `doc["core"]` returns `TOMLValue` with `type == TOML_TYPE.TABLE` +- Access string values: `doc["core"]["key"].str` (throws if type mismatch) +- Check existence: `"key" in doc` returns `TOMLValue*` (null if absent) +- `parseTOML()` is `@system` — must wrap in `@trusted` block when calling from `@safe` code +- Keys without `.str` / `.integer` access will throw `TOMLException` + +### D language gotchas +- **Nested function declarations**: D does NOT allow `void foo() { ... }` inside a function body. Use lambdas: `auto foo = () { ... }; foo();` or anonymous `() @trusted { ... }();` +- **`version` is a keyword**: Cannot use `string version;` as field name — use `string version_;` or initialize with `= ""` +- **`parseTOML` is `@system`**: The toml package's parse function isn't marked `@safe` — must wrap in `@trusted` lambda +- **`std.file.readText` is `@system`**: Same — wrap in `@trusted` +- **`std.process.environment.get()`**: Returns empty string when env var is unset; `"VAR" in environment` checks existence but `environment.get("VAR", "default")` is cleaner +- **`stderr.writefln` is `@safe`** in recent DMD (2.106+) — usable directly in `@safe` code +- **AA `in` operator**: Returns pointer (`V*`) or `null` — must check for null before dereferencing + +### Pattern: env-overrideable config loading +- Priority: env vars > TOML file > hardcoded defaults +- TOML read happens BEFORE env override — so TOML values serve as base, env vars overwrite +- Missing config file → no error, just skip to defaults +- Malformed TOML → `TOMLParserException` caught, warning to stderr, fall back to defaults +- Testability: `load()` accepts optional `envOverrides` map and explicit `configFile` path to avoid real env/filesystem + +### Config design decisions +- `TOFU_CONFIG` env var overrides default config file path (`~/.config/tofu/config.toml`) +- `~` in default paths expanded with `std.path.expandTilde` +- All path fields default to empty string when "find on PATH" — no hardcoded binary paths +- `defaultJobs` typed as `int` (not `size_t`/`ulong`) since it's a user-facing count + +### buildOptions warningsAsErrors deprecation +- DUB warns about `warningsAsErrors` in `buildOptions` — recommends `buildRequirements` instead +- Not blocking, informational only + +## Task 3 — `tofu.log` (colored, NO_COLOR-aware logging) + +- **dmd 2.112 gotcha**: the `std.stdio` globals `stdout`/`stderr` are `@system` to *access* — `makeGlobal` uses `__gshared File result`, which fails `@safe` inference. An `@safe` function cannot call `stdout.writeln(...)` directly. Fix: route every write through a tiny `@trusted` helper (`writeStdout(string)`/`writeStderr(string)`), keep the public log functions `@safe`. Compiler hint: *"using `__gshared` instead of `shared` makes it fail to infer `@safe`"*. +- **`std.process.environment` (dmd 2.112)**: it is an `abstract final class` of *static* methods — `environment.get(name)` is `@safe` and returns `null` when the var is unset (so "NO_COLOR set" == `get !is null`, even for an empty string). `environment["K"] = v` and `environment.remove(name)` are `@trusted`. NOT deprecated in 2.112. +- **Testable color decision**: make `colorEnabled()` re-read the environment on every call instead of caching in `static this()`. Enables NO_COLOR/TERM tests without restarting the process. Costs one `getenv` per log line — negligible. +- **Env save/restore in unittests**: a `private struct ColorEnv` (in `version(unittest)`) that snapshots NO_COLOR+TERM in its ctor and restores them in `~this()` (destructor) — `auto env = ColorEnv("1", "xterm")` scopes the restore. `null` value = remove the var. Double-destruction from a copied temporary is harmless (restore is idempotent). +- **Capturing stdout/stderr in unittests**: swap the global `stdout`/`stderr` File to a `File(name, "w+")`, write, `flush()`, swap back, then `rewind()` + `readln()`. Restore the global before the temp File's destructor runs. +- **zeta log format details**: `warn ` paints the *whole prefix* yellow (`paint("yellow", "warn ") ~ msg`), but `error` paints *only the word* then `" " ~ msg` (`paint("red","error") ~ " " ~ msg`). Assertion must expect `\x1b[31merror\x1b[0m failed deploy` — the reset code sits between "error" and the message. +- **`version` is a D keyword** — cannot be used as a struct field / identifier (trap for the parallel `types.d` work). +- **`std.stdio.writeln` flushes per call** — already satisfies "no buffering"; no explicit `flush()` needed in production paths. +- **Verifying a single module without the full build**: `dmd -main -unittest -i src/tofu/log.d` runs just that module's unittests when sibling modules (`config.d`, `types.d`) are mid-edit by parallel agents. `-i` pulls imports in automatically. + +## Task 4 — `tofu.types` (core data structures) + +- **`version` is a D keyword** — all struct fields named `version` must be renamed to `ver`. The D compiler treats `version` as a conditional-compilation directive and rejects it as a field name (see task 3 learnings above). +- **Struct field defaults in D**: `string` fields initialise to `null` by default. To enforce empty-string semantics (`""`), every string field must be explicitly `= ""`. +- **`final switch` for exhaustive enum handling**: using `final switch` on enum types forces the compiler to verify all cases are covered — catches missing branches at compile time. Used for `poolToString()`. +- **Manual character scanning preferred over `std.regex`**: the dep-spec parser uses simple char-by-char scanning with `isNameChar()` / `isWhite()` helpers instead of regex. This avoids the `std.regex` dependency, keeps the parser `@safe`, and matches the Lua reference semantics exactly. +- **Dep spec parsing order matters**: 2-character operators (`>=`, `<=`, `==`, `~=`) MUST be checked before 1-character operators (`>`, `<`, `=`) to avoid false matches. The Lua reference uses an ordered table `OPS = { ">=", "<=", "==", "~=", ">", "<", "=" }` — we replicate this with if/else chains. +- **`=` normalises to `==`**: the Lua code has `if op == "=" then op = "==" end`; we map both to `DepOp.eq`. +- **Trailing garbage detection**: after extracting version chars, any non-whitespace remaining in the spec string causes a `TypesException`. The Lua pattern uses `$` anchor for this. +- **`dub test` includes all `.d` files in source paths** for executable targets, not just imported modules. Pre-existing compile errors in sibling modules (config.d) block test runs. Fixed config.d by removing `private` from a nested function (access specifiers not allowed on local functions in D). +- **`BuildResult.failed` as `BuildFailure[]`**: the plan describes it as `struct {string name; string reason}[]` — in D this is a named struct `BuildFailure` with array field `BuildFailure[] failed`. The `failedNames()` helper extracts just the names for caller convenience. +- **`DepConstraint.parse` is a static factory method**: returns a new `DepConstraint` by value, never allocates on the heap. `@safe` throughout. +- **All structs are `@safe`**: no methods do I/O, no `@system` calls. The module stands alone — no imports from other tofu modules. diff --git a/src/tofu/log.d b/src/tofu/log.d new file mode 100644 index 0000000..d2c1161 --- /dev/null +++ b/src/tofu/log.d @@ -0,0 +1,242 @@ +/// Colored, always-verbose logging for tofu, mirroring zeta-toolchain's +/// `toolchain/lib/log.lua`. +/// +/// Every operation is printed so the user sees exactly what is happening. +/// Colors are disabled when `NO_COLOR` is set (to any value, including +/// empty) or when `TERM` is unset, empty, or `dumb`. All functions take a +/// format string plus optional arguments, e.g. `logStep("building %s", "foo")`, +/// and write immediately — nothing is buffered. +module tofu.log; + +import core.stdc.stdlib : exit; +import std.format : format; +import std.process : environment; +import std.stdio : File, stdout, stderr; + +/// ANSI escape sequences used by tofu. Values match zeta-toolchain exactly. +private enum ColorCodes +{ + reset = "\x1b[0m", + cyan = "\x1b[36m", + green = "\x1b[32m", + yellow = "\x1b[33m", + red = "\x1b[31m", + dim = "\x1b[2m", +} + +/// Whether the current terminal supports color output. +/// +/// Re-reads the environment on every call so it reacts to runtime changes +/// and is trivially unit-testable without restarting the process. Returns +/// `false` when `NO_COLOR` is set, or when `TERM` is missing, empty, or +/// `dumb`. +bool colorEnabled() @safe +{ + if (environment.get("NO_COLOR") !is null) + return false; + auto term = environment.get("TERM"); + return term !is null && term.length > 0 && term != "dumb"; +} + +/// Wrap `text` in `code` and a trailing reset, or return it unchanged when +/// colors are disabled. +private string paint(string code, string text) @safe +{ + return colorEnabled() ? code ~ text ~ ColorCodes.reset : text; +} + +/// Write `line` (already newline-terminated by `writeln`) to stdout. +/// `@trusted`: `stdout` itself is `@system` to access in dmd 2.112. +private void writeStdout(string line) @trusted +{ + stdout.writeln(line); +} + +/// Write `line` to stderr. See `writeStdout`. +private void writeStderr(string line) @trusted +{ + stderr.writeln(line); +} + +/// `==> message` in cyan on stdout. +void logStep(A...)(A args) @safe +{ + writeStdout(paint(ColorCodes.cyan, "==> " ~ format(args))); +} + +/// ` ok message` in green on stdout. +void logOk(A...)(A args) @safe +{ + writeStdout(paint(ColorCodes.green, " ok " ~ format(args))); +} + +/// `warn message` in yellow on stderr. +void logWarn(A...)(A args) @safe +{ + writeStderr(paint(ColorCodes.yellow, "warn " ~ format(args))); +} + +/// `error message` in red on stderr. +void logError(A...)(A args) @safe +{ + writeStderr(paint(ColorCodes.red, "error") ~ " " ~ format(args)); +} + +/// ` - message` plain on stdout. +void logInfo(A...)(A args) @safe +{ + writeStdout(" - " ~ format(args)); +} + +/// ` . message` dim on stdout. +void logDetail(A...)(A args) @safe +{ + writeStdout(paint(ColorCodes.dim, " . " ~ format(args))); +} + +/// `error message` in red on stderr, then exits with status 1. +void logFatal(A...)(A args) @trusted +{ + logError(args); + exit(1); +} + +/// Test helper: set `NO_COLOR`/`TERM` for a color test and restore the +/// previous values on scope exit, so tests are independent of the ambient +/// environment. A `null` value removes the variable. +private struct ColorEnv +{ + private + { + string oldNoColor; + string oldTerm; + bool hadNoColor; + bool hadTerm; + } + + this(string noColor, string term) @trusted + { + auto nc = environment.get("NO_COLOR"); + if (nc !is null) + { + hadNoColor = true; + oldNoColor = nc; + } + auto t = environment.get("TERM"); + if (t !is null) + { + hadTerm = true; + oldTerm = t; + } + if (noColor is null) + environment.remove("NO_COLOR"); + else + environment["NO_COLOR"] = noColor; + if (term is null) + environment.remove("TERM"); + else + environment["TERM"] = term; + } + + ~this() @trusted + { + if (hadNoColor) + environment["NO_COLOR"] = oldNoColor; + else + environment.remove("NO_COLOR"); + if (hadTerm) + environment["TERM"] = oldTerm; + else + environment.remove("TERM"); + } +} + +/// Captures stdout (or stderr) writes of a callback into a memory string by +/// temporarily swapping the global stream, mirroring the `(1)`/`(3)` tests. +private string capture(void delegate() dg) @trusted +{ + import std.file : exists, remove, tempDir; + + auto name = tempDir() ~ "/tofu-log-capture.tmp"; + scope (exit) if (exists(name)) remove(name); + + auto file = File(name, "w+"); + auto savedStream = stdout; + stdout = file; + scope (exit) stdout = savedStream; + scope (failure) stdout = savedStream; + + dg(); + stdout.flush(); + + file.rewind(); + return file.readln(); +} + +unittest +{ + // (1) logStep paints a cyan "==> " line to stdout. + import std.algorithm.searching : canFind; + + auto env = ColorEnv(null, "xterm-256color"); + auto line = capture({ logStep("building %s", "foo"); }); + assert(line.canFind("\x1b[36m"), "expected cyan escape in: `" ~ line ~ "`"); + assert(line.canFind("==> building foo"), + "expected prefixed message in: `" ~ line ~ "`"); +} + +unittest +{ + // (2) NO_COLOR, set to any value including empty, disables color. + auto env = ColorEnv(null, "xterm"); + assert(colorEnabled(), "color should be on with a real TERM and no NO_COLOR"); + + auto env1 = ColorEnv("1", "xterm"); + assert(!colorEnabled(), "NO_COLOR=1 must disable color"); + + auto env2 = ColorEnv("", "xterm"); + assert(!colorEnabled(), "NO_COLOR set-but-empty must disable color"); +} + +unittest +{ + // (3) logError writes a red "error " line to stderr. + import std.algorithm.searching : canFind; + import std.file : exists, remove, tempDir; + import std.stdio : stderr; + + auto env = ColorEnv(null, "xterm-256color"); + + auto name = tempDir() ~ "/tofu-log-stderr.tmp"; + scope (exit) if (exists(name)) remove(name); + + auto file = File(name, "w+"); + auto savedStream = stderr; + stderr = file; + scope (exit) stderr = savedStream; + + logError("failed %s", "deploy"); + stderr.flush(); + + file.rewind(); + auto line = file.readln(); + assert(line.canFind("\x1b[31m"), "expected red escape in: `" ~ line ~ "`"); + assert(line.canFind("\x1b[31merror\x1b[0m failed deploy"), + "expected error line in: `" ~ line ~ "`"); +} + +unittest +{ + // (4) TERM unset, empty, or "dumb" disables color. + auto env1 = ColorEnv(null, "dumb"); + assert(!colorEnabled(), "TERM=dumb must disable color"); + + auto env2 = ColorEnv(null, null); + assert(!colorEnabled(), "missing TERM must disable color"); + + auto env3 = ColorEnv(null, ""); + assert(!colorEnabled(), "empty TERM must disable color"); + + auto env4 = ColorEnv(null, "xterm"); + assert(colorEnabled(), "TERM=xterm must enable color"); +}