diff --git a/env.go b/env.go new file mode 100644 index 0000000..29a243d --- /dev/null +++ b/env.go @@ -0,0 +1,131 @@ +package env + +import ( + "os" + "strings" +) + +// Clear removes all environment variables. +func Clear() { + os.Clearenv() +} + +// Get returns the value of the environment variable with the given name. If the +// variable is not set an empty string is returned. +// +// To differentiate between a variable that is not set and a variable that is set to +// an empty string, use the `Lookup` function. +// +// # parameters +// +// name string // the name of the environment variable +// +// # returns +// +// string // the value of the environment variable +func Get(name string) string { + return os.Getenv(name) +} + +// GetVars returns a map of environment variables. If no variable names are provided +// all environment variables are returned. If variable names are provided, only +// those variables are returned (if set). +// +// # parameters +// +// names ...string // (optional) names of environment variables to return; +// // if no names are provided the returned map contains all +// // environment variables. +// +// If a name is provided that is not set in the environment it is not included in +// the returned map. +// +// # returns +// +// Vars // a map of environment variables +// +// The returned map is a `map[string]string` where the key is the name of the +// environment variable and the value is the value of the environment variable. +// +// If no environment variables are set or all specified variables names are not set, +// the returned map is empty. +func GetVars(names ...string) Vars { + var result Vars + + switch len(names) { + case 0: // all environment variables + env := os.Environ() + result = make(Vars, len(env)) + for _, s := range env { + k, v, _ := strings.Cut(s, "=") + result[k] = v + } + default: // only the named variables (if set) + result = make(Vars, len(names)) + for _, k := range names { + if v, ok := os.LookupEnv(k); ok { + result[k] = v + } + } + } + + return result +} + +// Lookup returns the value of the environment variable with the given name and a +// boolean indicating whether the variable is set. If the variable is not set the +// returned value is an empty string and the boolean is `false`. +// +// If you do not need to differentiate between a variable that is not set and a +// variable that is set to an empty string, use the `Get` function. +// +// # parameters +// +// name string // the name of the environment variable +// +// # returns +// +// string // the value of the environment variable +// +// bool // true if the variable is set, false otherwise +func Lookup(name string) (string, bool) { + return os.LookupEnv(name) +} + +// Set sets the value of the environment variable with the given name. If the variable +// does not exist it is created. +// +// # parameters +// +// name string // the name of the environment variable +// +// value string // the value to set +// +// # returns +// +// error // any error that occurs while setting the environment variable +func Set(name, value string) error { + return os.Setenv(name, value) +} + +// Unset removes the environment variables with the given names. If a variable does +// not exist (already not set) it is ignored. +// +// # parameters +// +// names ...string // the names of the environment variables to remove +// +// # returns +// +// error // any error that occurs while unsetting the environment variables +// +// On Unix systems (including Linux and macOS) the error is always `nil`, but +// on Windows systems the error may be non-nil. +func Unset(name ...string) error { + for _, k := range name { + if err := osUnsetenv(k); err != nil { + return err + } + } + return nil +} diff --git a/env_test.go b/env_test.go new file mode 100644 index 0000000..dd690c8 --- /dev/null +++ b/env_test.go @@ -0,0 +1,184 @@ +package env + +import ( + "errors" + "os" + "testing" + + "github.com/blugnu/test" +) + +func TestClear(t *testing.T) { + // ARRANGE + defer State().Reset() + os.Setenv("VAR", "value") + + // ACT + Clear() + + // ASSERT + _, isSet := os.LookupEnv("VAR") + test.IsFalse(t, isSet) +} + +func TestGet(t *testing.T) { + // ARRANGE + defer State().Reset() + os.Clearenv() + os.Setenv("VAR", "value") + + // ACT + result := Get("VAR") + + // ASSERT + test.That(t, result, "variable present").Equals("value") + + // ACT + result = Get("NOTSET") + + // ASSERT + test.That(t, result, "variable not present").Equals("") +} + +func TestGetVars(t *testing.T) { + // ARRANGE + testcases := []struct { + scenario string + exec func(t *testing.T) + }{ + {scenario: "all variables", + exec: func(t *testing.T) { + // ARRANGE + defer State().Reset() + os.Clearenv() + os.Setenv("VAR1", "value1") + os.Setenv("VAR2", "value2") + + // ACT + result := GetVars() + + // ASSERT + test.Map(t, result).Equals(Vars{"VAR1": "value1", "VAR2": "value2"}) + }, + }, + {scenario: "specified variables (including ones not set)", + exec: func(t *testing.T) { + // ARRANGE + defer State().Reset() + os.Clearenv() + os.Setenv("VAR1", "value1") + os.Setenv("VAR2", "value2") + + // ACT + result := GetVars("VAR1", "VAR3") + + // ASSERT + test.Map(t, result).Equals(Vars{"VAR1": "value1"}) + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + tc.exec(t) + }) + } +} + +func TestLookup(t *testing.T) { + // ARRANGE + defer State().Reset() + os.Clearenv() + os.Setenv("VAR", "value") + + // ACT + result, ok := Lookup("VAR") + + // ASSERT + test.That(t, result, "variable present").Equals("value") + test.IsTrue(t, ok, "variable present") + + // ACT + result, ok = Lookup("NOTSET") + + // ASSERT + test.That(t, result, "variable not present").Equals("") + test.IsFalse(t, ok, "variable not present") +} + +func TestSet(t *testing.T) { + // ARRANGE + defer State().Reset() + os.Clearenv() + + // ACT + err := Set("VAR1", "value1") + + // ASSERT + test.That(t, err).IsNil() + test.That(t, os.Getenv("VAR1")).Equals("value1") +} + +func TestUnset(t *testing.T) { + // ARRANGE + testEnv := map[string]string{ + "VAR1": "value1", + "VAR2": "value2", + } + testcases := []struct { + scenario string + exec func(t *testing.T) + }{ + {scenario: "no names specified", + exec: func(t *testing.T) { + // ACT + err := Unset() + + // ASSERT + test.That(t, err).IsNil() + test.That(t, os.Getenv("VAR1")).Equals("value1") + test.That(t, os.Getenv("VAR2")).Equals("value2") + }, + }, + {scenario: "name specified", + exec: func(t *testing.T) { + // ACT + err := Unset("VAR1") + + // ASSERT + test.That(t, err).IsNil() + _, isSet := os.LookupEnv("VAR1") + test.IsFalse(t, isSet) + test.That(t, os.Getenv("VAR2")).Equals("value2") + }, + }, + { + scenario: "error when unsetting", + exec: func(t *testing.T) { + // ARRANGE + unseterr := errors.New("unset error") + defer test.Using(&osUnsetenv, func(string) error { return unseterr })() + + // ACT + err := Unset("VAR1", "VAR2") + + // ASSERT + test.Error(t, err).Is(unseterr) + test.That(t, os.Getenv("VAR1")).Equals("value1") + test.That(t, os.Getenv("VAR2")).Equals("value2") + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + // ARRANGE + defer State().Reset() + os.Clearenv() + for k, v := range testEnv { + os.Setenv(k, v) + } + + // ACT & ASSERT + tc.exec(t) + }) + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..67eee40 --- /dev/null +++ b/errors.go @@ -0,0 +1,157 @@ +package env + +import ( + "errors" + "fmt" +) + +var ( + ErrNotSet = errors.New("not set") + ErrSetVariableFailed = errors.New("set variable failed") +) + +// ParseError is an error that wraps an error occurring while +// parsing an environment variable. It includes the name of the +// variable being parsed at the time of the error. +type ParseError struct { + VariableName string + Err error +} + +// Error returns a string representation of the error in the form: +// +// env.ParseError: : +// +// If the VariableName field is empty: +// +// env.ParseError: +// +// If the Err field is nil: +// +// env.ParseError: +// +// If both fields are empty: +// +// env.ParseError +func (e ParseError) Error() string { + type when struct{ hasName, hasError bool } + fn := map[when]func() string{ + {false, false}: func() string { return "env.ParseError" }, + {false, true}: func() string { return "env.ParseError: " + e.Err.Error() }, + {true, false}: func() string { return fmt.Sprintf("env.ParseError: %v", e.VariableName) }, + {true, true}: func() string { return fmt.Sprintf("env.ParseError: %v: %v", e.VariableName, e.Err) }, + } + return fn[when{e.VariableName != "", e.Err != nil}]() +} + +// Is reports whether the target error is a match for the receiver. +// To be a match, the target must: +// +// - be a ParseError +// - the target VariableName must match the receiver's VariableName, +// or be empty +// - the target Err field must satisfy errors.Is with respect to the +// receiver Err, or be nil +func (e ParseError) Is(target error) bool { + if target, ok := target.(ParseError); ok { + return (target.VariableName == "" || e.VariableName == target.VariableName) && + (target.Err == nil || errors.Is(e.Err, target.Err)) + } + return false +} + +// Unwrap returns the error that caused the env.Parse. +func (e ParseError) Unwrap() error { + return e.Err +} + +// InvalidValueError is an error type that represents an invalid value. The Value +// field contains the invalid value, and the Err field contains the error that +// caused the value to be invalid. +type InvalidValueError struct { + Value string + Err error +} + +// Error returns a string representation of the error in the form: +// +// env.InvalidValueError: : +// +// If the Value field is empty: +// +// env.InvalidValueError: +// +// If the Err field is nil: +// +// env.InvalidValueError: +// +// If both fields are empty: +// +// env.InvalidValueError +func (e InvalidValueError) Error() string { + type when struct{ hasValue, hasError bool } + fn := map[when]func() string{ + {false, false}: func() string { return "env.InvalidValueError" }, + {false, true}: func() string { return "env.InvalidValueError: " + e.Err.Error() }, + {true, false}: func() string { return fmt.Sprintf("env.InvalidValueError: %v", e.Value) }, + {true, true}: func() string { return fmt.Sprintf("env.InvalidValueError: %v: %v", e.Value, e.Err) }, + } + return fn[when{e.Value != "", e.Err != nil}]() +} + +// Is reports whether the target error is a match for the receiver. +// To be a match, the target must: +// +// - be an InvalidValueError +// - the target Value field must match the receiver's Value, or be empty +// - the target Err field must satisfy errors.Is with respect to the receiver Err, +// or be nil +func (e InvalidValueError) Is(target error) bool { + if target, ok := target.(InvalidValueError); ok { + return (target.Value == "" || e.Value == target.Value) && + (target.Err == nil || errors.Is(e.Err, target.Err)) + } + return false +} + +// Unwrap returns the error that caused the invalid value error. +func (e InvalidValueError) Unwrap() error { + return e.Err +} + +// RangeError is an error type that represents a value that is out of range; Min and +// Max fields identify the range of valid values. +// +// If Min and Max are both the zero value of T, the error represents a general out-of-range +// error with no identified range. +type RangeError[T comparable] struct { + Min T + Max T +} + +// Error returns a string representation of the error in the form: +// +// out of range: <= (x) <= +// +// If Min and Max are both the zero value of T: +// +// out of range +func (e RangeError[T]) Error() string { + if e == (RangeError[T]{}) { + return "env.RangeError" + } + return fmt.Sprintf("env.RangeError: %v <= (x) <= %v", e.Min, e.Max) +} + +// Is reports whether the target error is a match for the receiver. +// To be a match, the target must: +// +// - be a RangeError +// - the target Min and Max fields must match the receiver's Min and Max fields, +// or be the zero value of T +func (e RangeError[T]) Is(target error) bool { + if target, ok := target.(RangeError[T]); ok { + return e == target || (target == RangeError[T]{}) + } + return false +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..5a54807 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,331 @@ +package env + +import ( + "errors" + "testing" + + "github.com/blugnu/test" +) + +func TestParseError_Error(t *testing.T) { + // ARRANGE + testcases := []struct { + scenario string + varname string + err error + assert func(t *testing.T, result string) + }{ + {scenario: "with VariableName and Err", + varname: "VAR", + err: errors.New("some error"), + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.ParseError: VAR: some error") + }, + }, + {scenario: "with VariableName and nil Err", + varname: "VAR", + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.ParseError: VAR") + }, + }, + {scenario: "with empty VariableName and Err", + err: errors.New("some error"), + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.ParseError: some error") + }, + }, + {scenario: "with empty VariableName and nil Err", + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.ParseError") + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + // ARRANGE + sut := ParseError{ + VariableName: tc.varname, + Err: tc.err, + } + + // ACT + result := sut.Error() + + // ASSERT + tc.assert(t, result) + }) + } +} + +func TestParseError_Is(t *testing.T) { + // ARRANGE + sut := ParseError{ + VariableName: "VAR", + Err: errors.New("some error"), + } + testcases := []struct { + scenario string + target error + assert func(t *testing.T, result bool) + }{ + {scenario: "target: non-ParseError", + target: errors.New("some error"), + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: zero-value ParseError", + target: ParseError{}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsTrue() + }, + }, + {scenario: "target: ParseError with different VariableName", + target: ParseError{VariableName: "OTHER", Err: errors.New("some error")}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: ParseError with different Err", + target: ParseError{VariableName: "VAR", Err: errors.New("other error")}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: ParseError with same VariableName and Err", + target: ParseError{VariableName: "VAR", Err: sut.Err}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsTrue() + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + // ACT + result := sut.Is(tc.target) + + // ASSERT + tc.assert(t, result) + }) + } +} + +func TestParseError_Unwrap(t *testing.T) { + // ARRANGE + sut := ParseError{ + VariableName: "VAR", + Err: errors.New("some error"), + } + + // ACT + result := sut.Unwrap() + + // ASSERT + test.Value(t, result).Equals(sut.Err) +} + +func TestInvalidValueError_Error(t *testing.T) { + // ARRANGE + testcases := []struct { + scenario string + value string + err error + assert func(t *testing.T, result string) + }{ + {scenario: "with Value and Err", + value: "VAL", + err: errors.New("some error"), + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.InvalidValueError: VAL: some error") + }, + }, + {scenario: "with Value and nil Err", + value: "VAL", + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.InvalidValueError: VAL") + }, + }, + {scenario: "with empty Value and Err", + err: errors.New("some error"), + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.InvalidValueError: some error") + }, + }, + {scenario: "with empty Value and nil Err", + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.InvalidValueError") + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + // ARRANGE + sut := InvalidValueError{ + Value: tc.value, + Err: tc.err, + } + + // ACT + result := sut.Error() + + // ASSERT + tc.assert(t, result) + }) + } +} + +func TestInvalidValueError_Is(t *testing.T) { + // ARRANGE + sut := InvalidValueError{ + Value: "VAL", + Err: errors.New("some error"), + } + testcases := []struct { + scenario string + target error + assert func(t *testing.T, result bool) + }{ + {scenario: "target: non-InvalidValueError", + target: errors.New("some error"), + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: zero-value InvalidValueError", + target: InvalidValueError{}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsTrue() + }, + }, + {scenario: "target: InvalidValueError with different Value", + target: InvalidValueError{Value: "OTHER", Err: errors.New("some error")}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: InvalidValueError with different Err", + target: InvalidValueError{Value: "VAL", Err: errors.New("other error")}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: InvalidValueError with same Value and Err", + target: InvalidValueError{Value: "VAL", Err: sut.Err}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsTrue() + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + // ACT + result := sut.Is(tc.target) + + // ASSERT + tc.assert(t, result) + }) + } +} + +func TestInvalidValueError_Unwrap(t *testing.T) { + // ARRANGE + sut := InvalidValueError{ + Value: "VAL", + Err: errors.New("some error"), + } + + // ACT + result := sut.Unwrap() + + // ASSERT + test.Value(t, result).Equals(sut.Err) +} + +func TestRangeError_Error(t *testing.T) { + // ARRANGE + testcases := []struct { + scenario string + min int + max int + assert func(t *testing.T, result string) + }{ + {scenario: "with Min and Max", + min: 1, + max: 10, + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.RangeError: 1 <= (x) <= 10") + }, + }, + {scenario: "with zero Min and Max", + assert: func(t *testing.T, result string) { + test.That(t, result).Equals("env.RangeError") + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + // ARRANGE + sut := RangeError[int]{ + Min: tc.min, + Max: tc.max, + } + + // ACT + result := sut.Error() + + // ASSERT + tc.assert(t, result) + }) + } +} + +func TestRangeError_Is(t *testing.T) { + // ARRANGE + sut := RangeError[int]{Min: 1, Max: 10} + + testcases := []struct { + scenario string + target error + assert func(t *testing.T, result bool) + }{ + {scenario: "target: non-RangeError", + target: errors.New("some error"), + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: zero-value RangeError", + target: RangeError[int]{}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsTrue() + }, + }, + {scenario: "target: RangeError with different Min", + target: RangeError[int]{Min: 2, Max: 10}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: RangeError with different Max", + target: RangeError[int]{Min: 1, Max: 20}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsFalse() + }, + }, + {scenario: "target: RangeError with same Min and Max", + target: RangeError[int]{Min: 1, Max: 10}, + assert: func(t *testing.T, result bool) { + test.Bool(t, result).IsTrue() + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + // ACT + result := sut.Is(tc.target) + + // ASSERT + tc.assert(t, result) + }) + } +} diff --git a/funcs.go b/funcs.go new file mode 100644 index 0000000..d5d0922 --- /dev/null +++ b/funcs.go @@ -0,0 +1,11 @@ +package env + +import "os" + +// function variables to facilitate testing +var ( + osLookupEnv = os.LookupEnv + osReadFile = os.ReadFile + osSetenv = os.Setenv + osUnsetenv = os.Unsetenv +) diff --git a/load.go b/load.go new file mode 100644 index 0000000..58e8a74 --- /dev/null +++ b/load.go @@ -0,0 +1,121 @@ +package env + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" +) + +// Load loads environment variables from one or more files. Files should be formatted as a list +// of key-value pairs, one per line, separated by an equals sign. Lines that are empty or start +// with a hash (#) are ignored. +// +// # example file format +// +// # this is a comment +// NAME1=value1 +// NAME2=value2 +// +// # this is another comment +// NAME3=value3 +// +// # parameters +// +// files: ...string // 0..n file path(s) +// +// # returns +// +// error // an error that wraps all errors that occurred while loading variables; +// // if no errors occurred the result is nil +// +// The joined errors will be in the order that the files were specified and will be wrapped +// with the file path that caused the error: +// +// "path/to/file: error" +// +// # .env file +// +// The function will always attempt to load variables from a ".env" file. +// +// If ".env" (or "./.env") is included in the files parameter it will be loaded +// in the order specified relative to other files; if the ".env" file does not exist +// an error will be included in the returned error. +// +// If ".env" is not explicitly specified it will be loaded before any other files, if it +// exists; if it does not exist it is ignored without error. +// +// If no files are specified the function will attempt to load variables from ".env" +// and will return an error if the file does not exist. +// +// # example: loading .env: +// +// if err := Load(); err != nil { +// log.Fatal(err) // possibly because .env does not exist +// } +// +// # example: loading .env and a specified file: +// +// if err := Load("test.env"); err != nil { +// log.Fatal(err) // will not be because .env did not exist; could be because test.env does not exist +// } +func Load(files ...string) error { + // determine if ".env" has been explicitly specified and if it is required + filenames := map[string]bool{} + for _, f := range files { + filenames[f] = true + } + dotenvRequired := len(filenames) == 0 || (filenames[".env"] || filenames["./.env"]) + + // if ".env" has not been explicitly specified we will load it before loading + // any other files + if !filenames["./.env"] && !filenames[".env"] { + files = append([]string{".env"}, files...) + } + + // we will be collecting any errors that occur while loading the files + errs := []error{} + + for _, filename := range files { + err := loadFile(filename) + if err == nil { + continue + } + if !dotenvRequired && (filename == ".env" || filename == "./.env") && errors.Is(err, fs.ErrNotExist) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", filename, err)) + } + + return errors.Join(errs...) +} + +// loadFile loads environment variables from a file. The file should be formatted as a list of +// key-value pairs, one per line, separated by an equals sign. Lines that are empty or start with +// a hash (#) are ignored. +// +// # parameters +// +// path: string // the path to the file to loadFile +// +// # returns +// +// error // any error that occurrs while loading or applying variables +func loadFile(path string) error { + bytes, err := osReadFile(path) + if err != nil { + return err + } + errs := []error{} + for _, line := range strings.Split(string(bytes), "\n") { + if line = strings.TrimSpace(line); line == "" || line[0] == '#' { + continue + } + parts := strings.SplitN(line, "=", 2) + vname := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + errs = append(errs, os.Setenv(vname, value)) + } + return errors.Join(errs...) +} diff --git a/load_test.go b/load_test.go new file mode 100644 index 0000000..cae46a1 --- /dev/null +++ b/load_test.go @@ -0,0 +1,244 @@ +package env + +import ( + "errors" + "io/fs" + "os" + "testing" + + "github.com/blugnu/test" +) + +func TestLoad(t *testing.T) { + // ARRANGE + testcases := []struct { + scenario string + args []any + exec func(t *testing.T) + }{ + {scenario: "no arguments/.env exists", + exec: func(t *testing.T) { + // ARRANGE + readsDotEnv := false + defer State().Reset() + defer test.Using(&osReadFile, func(path string) ([]byte, error) { + readsDotEnv = readsDotEnv || path == ".env" + return []byte("VAR1=loaded-value-1\nVAR2=loaded-value-2"), nil + })() + os.Clearenv() + os.Setenv("VAR1", "env-value") + + // ACT + err := Load() + + // ASSERT + test.That(t, err).IsNil() + test.IsTrue(t, readsDotEnv) + test.That(t, os.Getenv("VAR1")).Equals("loaded-value-1") + test.That(t, os.Getenv("VAR2")).Equals("loaded-value-2") + }, + }, + {scenario: "no arguments/.env does not exist", + exec: func(t *testing.T) { + // ARRANGE + defer State().Reset() + defer test.Using(&osReadFile, func(string) ([]byte, error) { + return nil, os.ErrNotExist + })() + os.Clearenv() + os.Setenv("VAR1", "env-value") + + // ACT + err := Load() + + // ASSERT + test.Error(t, err).Is(os.ErrNotExist) + test.That(t, os.Getenv("VAR1")).Equals("env-value") + }, + }, + {scenario: ".env argument/.env does not exist", + exec: func(t *testing.T) { + // ARRANGE + defer State().Reset() + defer test.Using(&osReadFile, func(string) ([]byte, error) { + return nil, os.ErrNotExist + })() + os.Clearenv() + os.Setenv("VAR1", "env-value") + + // ACT + err := Load(".env") + + // ASSERT + test.Error(t, err).Is(os.ErrNotExist) + test.That(t, os.Getenv("VAR1")).Equals("env-value") + }, + }, + {scenario: "file path argument/valid file/.env does not exist", + exec: func(t *testing.T) { + // ARRANGE + filesLoaded := []string{} + defer State().Reset() + defer test.Using(&osReadFile, func(path string) ([]byte, error) { + filesLoaded = append(filesLoaded, path) + switch path { + case ".env": + return nil, fs.ErrNotExist + case "test.env": + return []byte("VAR1=loaded-value-1\nVAR2=loaded-value-2"), nil + default: + panic("unexpected file path: " + path) + } + })() + os.Clearenv() + os.Setenv("VAR1", "env-value") + + // ACT + err := Load("test.env") + + // ASSERT + test.That(t, err).IsNil() + test.Slice(t, filesLoaded).Equals([]string{".env", "test.env"}) + test.That(t, os.Getenv("VAR1")).Equals("loaded-value-1") + test.That(t, os.Getenv("VAR2")).Equals("loaded-value-2") + }, + }, + {scenario: "file path argument/valid file/.env exists", + exec: func(t *testing.T) { + // ARRANGE + filesLoaded := []string{} + defer State().Reset() + defer test.Using(&osReadFile, func(path string) ([]byte, error) { + filesLoaded = append(filesLoaded, path) + switch path { + case ".env": + return []byte("VAR3=dotenv-value-3"), nil + case "test.env": + return []byte("VAR1=loaded-value-1\nVAR2=loaded-value-2"), nil + default: + panic("unexpected file path: " + path) + } + })() + os.Clearenv() + os.Setenv("VAR1", "env-value") + + // ACT + err := Load("test.env") + + // ASSERT + test.That(t, err).IsNil() + test.That(t, filesLoaded).Equals([]string{".env", "test.env"}) + test.That(t, os.Getenv("VAR1")).Equals("loaded-value-1") + test.That(t, os.Getenv("VAR2")).Equals("loaded-value-2") + test.That(t, os.Getenv("VAR3")).Equals("dotenv-value-3") + }, + }, + {scenario: "file path argument/valid file/.env error", + exec: func(t *testing.T) { + // ARRANGE + dotenverr := errors.New("error reading .env") + filesLoaded := []string{} + defer State().Reset() + defer test.Using(&osReadFile, func(path string) ([]byte, error) { + filesLoaded = append(filesLoaded, path) + switch path { + case ".env": + return nil, dotenverr + case "test.env": + return []byte("VAR1=loaded-value-1\nVAR2=loaded-value-2"), nil + default: + panic("unexpected file path: " + path) + } + })() + os.Clearenv() + os.Setenv("VAR1", "env-value") + + // ACT + err := Load("test.env") + + // ASSERT + test.Error(t, err).Is(dotenverr) + test.That(t, filesLoaded).Equals([]string{".env", "test.env"}) + test.That(t, os.Getenv("VAR1")).Equals("loaded-value-1") + test.That(t, os.Getenv("VAR2")).Equals("loaded-value-2") + }, + }, + {scenario: "explicit .env/before other files", + exec: func(t *testing.T) { + // ARRANGE + filesLoaded := []string{} + defer State().Reset() + defer test.Using(&osReadFile, func(path string) ([]byte, error) { + filesLoaded = append(filesLoaded, path) + switch path { + case ".env": + return []byte("VAR=dotenv-value"), nil + case "test.env": + return []byte("VAR=test-value"), nil + default: + panic("unexpected file path: " + path) + } + })() + os.Clearenv() + os.Setenv("VAR", "env-value") + + // ACT + err := Load(".env", "test.env") + + // ASSERT + test.That(t, err).IsNil() + test.That(t, filesLoaded).Equals([]string{".env", "test.env"}) + test.That(t, os.Getenv("VAR")).Equals("test-value") + }, + }, + {scenario: "explicit .env/after other files", + exec: func(t *testing.T) { + // ARRANGE + filesLoaded := []string{} + defer State().Reset() + defer test.Using(&osReadFile, func(path string) ([]byte, error) { + filesLoaded = append(filesLoaded, path) + switch path { + case ".env": + return []byte("VAR=dotenv-value"), nil + case "test.env": + return []byte("VAR=test-value"), nil + default: + panic("unexpected file path: " + path) + } + })() + os.Clearenv() + os.Setenv("VAR", "env-value") + + // ACT + err := Load("test.env", ".env") + + // ASSERT + test.That(t, err).IsNil() + test.That(t, filesLoaded).Equals([]string{"test.env", ".env"}) + test.That(t, os.Getenv("VAR")).Equals("dotenv-value") + }, + }, + } + for _, tc := range testcases { + t.Run(tc.scenario, func(t *testing.T) { + tc.exec(t) + }) + } +} + +func TestLoadFile_WithEmptyLinesAndComments(t *testing.T) { + // ARRANGE + defer State().Reset() + defer test.Using(&osReadFile, func(string) ([]byte, error) { + return []byte("VAR1=value-1\n\n# comment\nVAR2=value-2=with-equals"), nil + })() + + // ACT + err := loadFile("test.env") + + // ASSERT + test.That(t, err).IsNil() + test.That(t, os.Getenv("VAR1")).Equals("value-1") + test.That(t, os.Getenv("VAR2")).Equals("value-2=with-equals") +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..7c3e4ac --- /dev/null +++ b/parse.go @@ -0,0 +1,98 @@ +package env + +// ConversionFunc is a function that converts a string to a value of type T. +// It is the type of the conversion function used by the Parse and Override +// functions. +// +// # parameters +// +// string string // the string to convert +// +// # returns +// +// T // the converted value +// +// error // any error that occurs during conversion +type ConversionFunc[T any] func(string) (T, error) + +// Override replaces the current value of some variable with the value obtained +// by parsing a named environment variable. If the named environment variable is +// not set, the target variable is not modified. +// +// If an error occurs during parsing or conversion, the target variable is not +// modified and the error is returned. +// +// The function returns true if the target variable was modified from its +// original value, false otherwise. +// +// # parameters +// +// dest *T // a pointer to the variable to be changed +// +// name string // the name of the environment variable to parse +// +// cnv func(string) (T, error) // a function to parse the environment variable +// +// # returns +// +// bool // true if the target variable was modified, false otherwise +// +// error // any error resulting from parsing the named environment variable +// +// The bool result should not be relied on to determine whether an error occurred; +// The function will return false when: +// +// - an error occurs parsing or converting the named environment variable +// - the named environment variable is not set +// - the named environment variable is set with a value that is successfully +// parsed and converted to yield the same value as currently held in the +// destination variable. +// +// Only the error result can determine if an error occurred. +func Override[T comparable](dest *T, name string, cnv func(string) (T, error)) (bool, error) { + v, err := Parse(name, cnv) + switch { + case err == nil && *dest != v: + *dest = v + return true, nil + default: + return false, err + } +} + +// Parse parses the environment variable with the given name and returns a value of +// type T, obtained by passing the value of the environment variable to a provided +// conversion function. +// +// # parameters +// +// name string // the name of the environment variable to parse +// +// cnv ConversionFunc[T] // a function to parse the environment variable; +// // the function should return a value of type T and +// // an error if the value cannot be converted +// +// # returns +// +// T // the value of the environment variable; if an error occurs the +// // zero value of T is returned +// +// error // any error resulting from parsing the environment variable; +// // if the error is ErrNotSet a nil error is returned +// +// # conversion functions +// +// The `as` package provides a number of conversion functions that can be used with +// this function. For example, to parse an integer environment variable: +// +// value, err := env.Parse("MY_INT_VAR", as.Int) +func Parse[T any](name string, cnv ConversionFunc[T]) (T, error) { + if v, ok := osLookupEnv(name); ok { + r, err := cnv(v) + if err != nil { + return *new(T), ParseError{VariableName: name, Err: InvalidValueError{Value: v, Err: err}} + } + return r, nil + } + return *new(T), ParseError{VariableName: name, Err: ErrNotSet} +} diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000..237adfc --- /dev/null +++ b/parse_test.go @@ -0,0 +1,100 @@ +package env + +import ( + "errors" + "os" + "strconv" + "testing" + + "github.com/blugnu/test" +) + +func TestParse(t *testing.T) { + // ARRANGE + defer test.Using(&osLookupEnv, func(string) (string, bool) { + return "123", true + })() + + // ACT + value, err := Parse("VAR", strconv.Atoi) + + // ASSERT + test.That(t, value).Equals(123) + test.That(t, err).IsNil() +} + +func TestParse_WhenVariableNotSet(t *testing.T) { + // ARRANGE + defer test.Using(&osLookupEnv, func(string) (string, bool) { + return "", false + })() + + // ACT + value, err := Parse("NOT_SET", func(s string) (string, error) { return s, nil }) + + // ASSERT + test.That(t, value).Equals("") + test.Error(t, err).Is(ErrNotSet) +} + +func TestParse_WhenConversionFails(t *testing.T) { + // ARRANGE + converr := errors.New("conversion error") + defer test.Using(&osLookupEnv, func(string) (string, bool) { + return "not-a-number", true + })() + + // ACT + value, err := Parse("NOT_A_NUMBER", func(s string) (int, error) { return 0, converr }) + + // ASSERT + test.That(t, value).Equals(0) + test.Error(t, err).Is(InvalidValueError{Value: "not-a-number", Err: converr}) +} + +func TestOverride(t *testing.T) { + // ARRANGE + var value = 42 + defer State().Reset() + os.Clearenv() + os.Setenv("VAR", "123") + + // ACT + result, err := Override(&value, "VAR", strconv.Atoi) + + // ASSERT + test.That(t, err).IsNil() + test.IsTrue(t, result) + test.That(t, value).Equals(123) +} + +func TestOverride_WhenValueIsNotChanged(t *testing.T) { + // ARRANGE + var value = 123 + defer State().Reset() + os.Clearenv() + os.Setenv("VAR", "123") + + // ACT + result, err := Override(&value, "VAR", strconv.Atoi) + + // ASSERT + test.That(t, err).IsNil() + test.IsFalse(t, result) + test.That(t, value).Equals(123) +} + +func TestOverride_WhenVariableIsNotSet(t *testing.T) { + // ARRANGE + var value = 42 + defer State().Reset() + os.Clearenv() + + // ACT + result, err := Override(&value, "VAR", strconv.Atoi) + + // ASSERT + test.Error(t, err).Is(ErrNotSet) + test.IsFalse(t, result) + test.That(t, value).Equals(42) +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..b7cc4ad --- /dev/null +++ b/state.go @@ -0,0 +1,67 @@ +package env + +import ( + "os" + "strings" +) + +// state represents the state of all environment variables captured at +// a point in time. It is used to reset the environment to a known +// state, typically after a test has modified the environment. +type state []string + +// State captures the current state of the environment variables. It +// is typically used to capture the state before a test modifies the +// environment so that it can be reset to the original state after +// the test has run (by deferring a call to the Reset method on the +// returned state value). +// +// # returns +// +// state // the current state of the environment variables +// +// The state is captured using os.Environ(). +// +// # example +// +// func TestSomething(t *testing.T) { +// // ARRANGE +// defer env.State().Reset() +// env.Vars{ +// "VAR1", "value1", +// "VAR2", "value2", +// }.Set() +// +// // ACT +// // ... +// } +func State() state { + return os.Environ() +} + +// Reset resets the environment variables to the state captured when +// the State function was called. +// +// This function is typically used in a defer statement to ensure the +// environment is reset after a test has modified the environment. +// +// # example +// +// func TestSomething(t *testing.T) { +// // ARRANGE +// defer env.State().Reset() +// env.Vars{ +// "VAR1", "value1", +// "VAR2", "value2", +// }.Set() +// +// // ACT +// // ... +// } +func (s state) Reset() { + os.Clearenv() + for _, e := range s { + k, v, _ := strings.Cut(e, "=") + os.Setenv(k, v) + } +} diff --git a/state_test.go b/state_test.go new file mode 100644 index 0000000..6ad4642 --- /dev/null +++ b/state_test.go @@ -0,0 +1,44 @@ +package env + +import ( + "os" + "strings" + "testing" + + "github.com/blugnu/test" +) + +func TestState(t *testing.T) { + // ARRANGE + env := os.Environ() + + // ACT + result := State() + + // ASSERT + test.Slice(t, result).Equals(env) +} + +func TestState_Reset(t *testing.T) { + // ARRANGE + og := os.Environ() + defer func() { + os.Clearenv() + for _, s := range og { + k, v, _ := strings.Cut(s, "=") + os.Setenv(k, v) + } + }() + + os.Clearenv() + + // ACT + state{ + "VAR1=value1", + "VAR2=value2", + }.Reset() + + // ASSERT + test.That(t, os.Getenv("VAR1")).Equals("value1") + test.That(t, os.Getenv("VAR2")).Equals("value2") +} diff --git a/vars.go b/vars.go new file mode 100644 index 0000000..a29f089 --- /dev/null +++ b/vars.go @@ -0,0 +1,53 @@ +package env + +import ( + "slices" + "strings" +) + +// Vars is a map of environment variables. +type Vars map[string]string + +// Names returns the names of all variables in the map as a sorted slice. +func (v Vars) Names() []string { + names := make([]string, 0, len(v)) + for k := range v { + names = append(names, k) + } + slices.Sort(names) + return names +} + +// Set applies the variables to the environment. If an error occurs while setting +// a variable, the error is returned without any further variables being set. +// +// # returns +// +// error // any error that occurs while setting the variables +func (v Vars) Set() error { + for k, v := range v { + if err := osSetenv(k, v); err != nil { + return err + } + } + return nil +} + +// String returns a string representation of the variables. The result is a string of +// comma delimited NAME="VALUE" entries, sorted by NAME and enclosed in [-]'s. +// +// # result +// +// string // a string representation of the variables in the form: +// +// [NAME1="VALUE1",NAME2="VALUE2",NAME3="VALUE3"] +// +// If the map is empty the result is "[]". +func (v Vars) String() string { + n := v.Names() + ls := make([]string, 0, len(n)) + for _, k := range n { + ls = append(ls, k+`="`+v[k]+`"`) + } + return "[" + strings.Join(ls, ",") + "]" +} diff --git a/vars_test.go b/vars_test.go new file mode 100644 index 0000000..5e682ae --- /dev/null +++ b/vars_test.go @@ -0,0 +1,74 @@ +package env + +import ( + "errors" + "os" + "testing" + + "github.com/blugnu/test" +) + +func TestVars_Names(t *testing.T) { + // ACT + result := Vars{ + "VAR3": "value2", + "VAR1": "value1", + "VAR2": "value2", + }.Names() + + // ASSERT + test.That(t, result).Equals([]string{"VAR1", "VAR2", "VAR3"}) +} + +func TestVars_Set(t *testing.T) { + // ARRANGE + defer State().Reset() + os.Clearenv() + + // ACT + err := Vars{ + "VAR1": "value1", + "VAR2": "value2", + }.Set() + + // ASSERT + test.That(t, err).IsNil() + test.That(t, os.Getenv("VAR1")).Equals("value1") + test.That(t, os.Getenv("VAR2")).Equals("value2") +} + +func TestVars_Set_WhenSetenvFails(t *testing.T) { + // ARRANGE + defer State().Reset() + + seterr := errors.New("setenv error") + defer test.Using(&osSetenv, func(string, string) error { + return seterr + })() + + // ACT + err := Vars{"VAR1": "value1"}.Set() + + // ASSERT + test.Error(t, err).Is(seterr) +} + +func TestVars_String(t *testing.T) { + // ACT + result := Vars{ + "VAR3": "value3", + "VAR1": "value1", + "VAR2": "value2", + }.String() + + // ASSERT + test.That(t, result).Equals(`[VAR1="value1",VAR2="value2",VAR3="value3"]`) +} + +func TestVars_String_WhenEmpty(t *testing.T) { + // ACT + result := Vars{}.String() + + // ASSERT + test.That(t, result).Equals("[]") +}