feat(log): add colored NO_COLOR-aware logging
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-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
|
||||||
@@ -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.
|
||||||
+242
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user