Skip to content

Replace viper with an explicit environment lookup - #59

Merged
korya merged 5 commits into
masterfrom
korya-refactor-drop-viper
Aug 7, 2026
Merged

korya merged 5 commits into
masterfrom
korya-refactor-drop-viper

Conversation

@korya

@korya korya commented Aug 7, 2026 •

Copy link
Copy Markdown
Owner

Problem

Viper contributes 35 of the module's 38 non-stdlib packages to read six environment variables.

It is used for nothing but AutomaticEnv — no config files, no remote providers, no watching — and for that the module graph carries fsnotify, afero, mapstructure, pelletier/go-toml, locafero, sourcegraph/conc, gotenv, cast and yaml. It also produced a split-brain: six options were read through viper and thirteen straight off cobra, so HTTP_ASSERT_INSECURE worked while HTTP_ASSERT_REQUEST silently did nothing, with no rule a reader could infer.

Solution

Replace viper with one 25-line function over the flag set, and name the six environment-backed options in a single list.

Before After
Non-stdlib packages 38 3 (cobra, pflag, mousetrap)
Total packages 235 193
Binary 9.93 MB 9.01 MB
Coverage 99.3% 100.0%

Four commits, ordered so the risky part is provable

# Commit Behaviour change Test edits
1 Pin value-level environment semantics none +32 subtests
2 Replace viper with an explicit environment lookup none zero ← the proof
3 Reject unparseable environment values yes, deliberate edits the cases from #1 that pinned silence
4 Delete the unused logWarn and logError helpers none comment only

Commit 2 touches no test file. All 233 subtests pass unedited, including the 32 added in commit 1 specifically to cover environment parsing. That is the evidence the swap changed nothing — not an assertion in a commit message.

Commit 1 exists because the existing matrix asks "did this option take effect?" — a boolean — so it could not see how a value was parsed, only that it was. Empty variables, unparseable values, non-canonical booleans, precedence on five of six options, and log levels other than error had no coverage at all, and those are precisely the paths a viper replacement turns on.

Why the environment list stays at six

Preserving --maphost's existing behaviour requires splitting one variable into several values on whitespace (viper's semantics, verified against the library). Applied uniformly — as issue #54 originally proposed — that corrupts every option whose values contain spaces:

'X-Foo: bar baz'              -> strings.Fields -> ['X-Foo:', 'bar', 'baz']
'Cache-Control: max-age=3600' -> strings.Fields -> ['Cache-Control:', 'max-age=3600']

Header values routinely contain spaces; host mappings never do. Uniformity would trade one inconsistency for a silent value-corrupting footgun across ten flags. The inconsistency is instead fixed where it actually hurt — it is now a named list in the source and a table in the README, rather than an accident of which flags happened to get a BindPFlag line. #54 has been updated to record this rather than leaving the issue describing something we deliberately did not build.

The one deliberate behaviour change

A typo in HTTP_ASSERT_MAX_TIME used to disable the request timeout. The value was cast to the type's zero value, and a zero http.Client.Timeout means no timeout at all — so a monitoring check could hang indefinitely, in a tool whose entire purpose is enforcing a deadline. Likewise HTTP_ASSERT_VERBOSE=yes quietly turned verbosity off, because yes is not a Go boolean.

Before:

$ HTTP_ASSERT_MAX_TIME=abc http-assert --assert-ok https://api.example.com
[+] PASSED          # timeout silently removed
$ echo $?
0

After:

$ HTTP_ASSERT_MAX_TIME=abc http-assert --assert-ok https://api.example.com
Error: Invalid value for HTTP_ASSERT_MAX_TIME="abc": strconv.ParseInt: parsing "abc": invalid syntax
$ echo $?
71

Exit code 71 matches how --log-level and --maphost already report bad input. Empty variables are unaffected and still count as unset. This lands in its own commit, and the test diff is its record.

No visual change — this is a headless CLI.

Other Changes

  • go.mod is now enforced tidy. Nothing checked that it matched the imports, which is exactly how viper survived as a requirement long after it stopped earning its place. A tidy-check recipe runs go mod tidy -diff — it reports what it would change and exits non-zero without modifying either file — wired into pre-commit rather than added as a separate CI step, so local runs and CI check the same thing through the same command. Verified in both directions: an unused require makes just pre-commit exit 1.

  • Coverage reaches 100.0%. logWarn and logError had no callers and were kept alive with //nolint:unused, silencing a linter that was right. Their four statements were the only ones in the repo no test could reach. The LWarn level stays, since --log-level warn is documented flag surface.

  • README documents that an empty variable counts as unset, that unparseable values are rejected, and which boolean spellings are accepted.

  • Silences the TLS handshake warnings the test server logged whenever a case deliberately connected without --insecure — the expected result of those cases, not a failure.

Related:

🤖 Generated with Claude Code

korya and others added 4 commits August 7, 2026 17:13
The config matrix asks "did this option take effect?" -- a boolean -- so it cannot see how a value
was parsed, only that it was. Five behaviours therefore had no coverage at all: empty variables,
unparseable values, non-canonical booleans, precedence on five of the six options, and log levels
other than error.

Those gaps matter now because removing viper (#54) turns on exactly those paths. Without these
tests a replacement could reject an empty variable, or parse "1" differently, and all 57 existing
subtests would still pass.

Adds 32 subtests, written against the current viper build so they record real behaviour rather
than intended behaviour. The uncomfortable one is HTTP_ASSERT_MAX_TIME=abc: viper casts it to 0,
and a zero http.Client.Timeout means no timeout at all, so a typo silently removes the deadline
from a tool whose job is enforcing one. That is pinned as-is here and rejected in a later commit,
so the change of behaviour shows up as a visible edit to this file rather than as a silent
difference.

Empty variables turned out to be nearly unobservable from outside: every option's default
coincides with its zero value, and 0 vs 20 seconds cannot be distinguished without a 20-second
request. The test still earns its place by guaranteeing an empty variable never becomes an error,
which is the regression that would otherwise slip through once unparseable values are rejected.

Also silences the TLS handshake warnings the test server logged whenever a case deliberately
connected without --insecure. That is the expected result of those cases, not a failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
Drops 35 of the module's 38 non-stdlib dependencies to configure six flags from the environment.
viper was used for nothing but AutomaticEnv -- no config files, no remote providers, no watching --
and pulled in fsnotify, afero, mapstructure, go-toml, locafero, conc, gotenv, cast and yaml to do
it. The replacement is one 25-line function over the flag set.

Previously six options were read through viper and the other thirteen straight off cobra, so
HTTP_ASSERT_INSECURE worked while HTTP_ASSERT_REQUEST silently did nothing, with no rule a reader
could infer. Now every option is read from cobra and a single named list, envFlags, says which
ones also consult the environment.

That list stays at six on purpose. Repeatable options take several values from one variable by
splitting on whitespace, which is right for host mappings and wrong for header values, since those
routinely contain spaces: HTTP_ASSERT_HEADER="X-Foo: bar baz" would become three headers. Issue #54
proposed making the environment uniform across all nineteen options; that part of it should not be
done, and the issue has been updated.

Behaviour is unchanged, including the parts that are arguably wrong. An empty variable still counts
as unset, whitespace still separates repeated values, the command line still wins, and an
unparseable value still becomes the type's zero value rather than an error. The last of those is
reproduced deliberately in six lines and removed in the next commit, so the fix arrives as its own
reviewable change.

The proof is that no test file is touched by this commit. All 233 subtests, including the 32 added
in the previous commit specifically to cover environment parsing, pass unedited.

    dependencies   38 -> 3      (cobra, pflag, mousetrap)
    packages      235 -> 193
    binary       9.93 -> 9.01 MB
    coverage     99.3% -> 99.4%

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
A typo in HTTP_ASSERT_MAX_TIME used to disable the request timeout instead of reporting an error.
The value was cast to the type's zero value, and a zero http.Client.Timeout means no timeout at
all, so `HTTP_ASSERT_MAX_TIME=abc` left a monitoring check able to hang indefinitely -- in a tool
whose entire purpose is enforcing a deadline.

Previously an unparseable value was applied silently: max-time became 0, booleans became false. So
`HTTP_ASSERT_VERBOSE=yes` quietly turned verbosity off, because "yes" is not a Go boolean. Now the
value is rejected with the parser's own reason and exit code 71, matching how --log-level and
--maphost already report bad input.

    $ HTTP_ASSERT_MAX_TIME=abc http-assert --assert-ok https://api.example.com
    Error: Invalid value for HTTP_ASSERT_MAX_TIME="abc": strconv.ParseInt: parsing "abc": invalid syntax
    $ echo $?
    71

This is a deliberate behaviour change, and the edits to e2e_config_test.go are its record: the
cases that previously asserted silent coercion now assert rejection. Empty variables are
unaffected and still count as unset, which the neighbouring test guards.

Adds explicit rejection cases for every typed option and for the shell-style boolean spellings,
and documents both rules in the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
Neither has ever had a caller. Both were kept alive with //nolint:unused, which silenced the
linter that was right rather than removing the code it was pointing at. Coverage now reaches
100.0%, since these four statements were the only ones in the repo no test could reach.

The LWarn level itself stays. `--log-level warn` is part of the documented flag surface, and
removing the level would change the CLI contract rather than delete dead code -- so the oddity
that warn and error behave identically survives, still pinned by
TestKnownIssueWarnLevelIsDead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
@korya
korya marked this pull request as ready for review August 7, 2026 21:29
Nothing checked that go.mod and go.sum matched the imports. A stale requirement could survive
indefinitely -- exactly the state this branch started from, with viper listed long after it was the
only thing pulling in nine transitive modules.

Adds a tidy-check recipe running `go mod tidy -diff`, which prints the changes it would make and
exits non-zero when the diff is not empty, without touching either file. Wired into pre-commit
rather than added as a separate CI step so that local runs and CI check the same thing through the
same command, which is how every other gate in this repo already works.

Verified in both directions: an unused require makes `just pre-commit` exit 1, and removing it
returns to 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EMMhgmTkbzAsmeNy97PrP
@korya
korya merged commit 508da4a into master Aug 7, 2026
1 check passed
@korya
korya deleted the korya-refactor-drop-viper branch August 7, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Delete viper: 35 of 38 packages to read six environment variables

1 participant