Skip to content

Split the module into a command and a library #43

Description

@fgm

Split the module so a Go program can obtain the same environment internally, with no wrapper process at all.

res, err := env.Apply() // that is the whole of it

Split out of #35 on 2026-08-23, so that #35 could ship the hand-off on its own.

The decision record is ADR-002, still Proposed while the questions below are open. It carries the reasoning; this issue carries the summary. Where they disagree, the ADR is right.

Why a library at all

Not symmetry with the command. The value is semantic parity: one file means one thing whether it is read by envrun cmd in CI or imported in a test binary. godotenv accepts what envrun rejects — interpolation, quoted multiline values, lax names — so a team using both would have its two halves reading different dialects of one file, and would find out only when they disagreed. A single shared core is what makes "the same file means the same thing" a promise rather than a slogan, and envrun's deliberate narrowness is what makes the promise keepable.

Layout

  • github.com/fgm/envrun — the CLI stays at the root
  • github.com/fgm/envrun/env — load, parse, merge, apply

Shape

Two entry points, because envrun has two callers wanting opposite things:

  • env.Load(paths ...string) (Result, error) — resolve and read, applying nothing. The command's entry point: it must not put the variables into its own process, because what it needs is the set to hand to the command. Also the entry point for any caller that cannot afford the mutation.
  • env.Apply(paths ...string) (Result, error)Load, then apply to this process, leaving an already-set name alone so the inherited value wins, exactly as the command's merge does. It belongs at the top of main or in TestMain: there is no concurrency-safe way to mutate a process environment.

Result carries Path (which file was used), Env (a map[string]string of what the file declared, never the merge#39 needs precisely that set), and Notes (non-fatal findings).

Rejections stay fatal but become inspectable two ways. Every problem wraps one of ErrNotAPair, ErrInvalidName or ErrNUL, and the file's ParseError wraps them all through Unwrap() []error — so errors.Is(err, env.ErrInvalidName) answers did it fail for this reason? without the caller knowing ParseError exists, while errors.AsType[*env.ParseError] reaches Path and []Problem{Line, Err, Name} for counting and locating. No value ever leaves the package inside a message: names and line numbers only, because a malformed line may hold a secret.

paths... is a search path — first match wins — with -f overriding it. Composition (.env then .env.local) stays a separate future feature rather than a second meaning for the same parameter.

Not in scope

  • No autoload package. The blank import was the shape this issue originally set out to deliver, and it is declined as currently imagined: the blank import and usefulness are mutually exclusive, since checking autoload.Err() makes the import non-blank and env.Apply() is then shorter; checking nothing contradicts envrun's own rule that a malformed file fails rather than proceeds; panicking does not rescue it, because that precedent (sql.Register, MustCompile) is for programmer error and a .env file is input; and its search rule is the one change here that no compiler would catch. envrun go test ./... and a three-line TestMain already serve its best habitat better. The ADR states five arguments; a future proposal should answer them rather than start fresh.
  • No logger parameter, in any form — io.Writer, *log.Logger or *slog.Logger. Result already hands back everything observed, so a logger would be a second channel for the same findings, and each form trades reach against fidelity in a way returning the data avoids entirely.
  • Not a general .env package. A feature is not adopted on the grounds that other .env libraries carry it. Variable interpolation is declined on that basis; see - Change the order in which the environment variables from the .env f… #6.

Demo

example/import, in-module and adding no direct dependencies, so go build ./... compile-tests it on every CI run. An ExampleApply in a _test.go complements it, since only Example functions appear on pkg.go.dev.

Open

  • When duplicate detection arrives, and with it Notes becoming a struct — a name, a primary line and a related line — rather than []string. The semantics are settled (last wins); only the timing is not.
  • Whether the file-over-inherited precedence reversal becomes a flag. See - Change the order in which the environment variables from the .env f… #6. Note the coupling: with inherited-wins, a file setting PATH has no effect at all.
  • How the CLI reports, now that the library will not: the two fail/note helpers as they stand, an injected io.Writer, or log/slog.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions