Skip to content

Commit ab17503

Browse files
committed
feat(env): add type-safe environment variable utilities
1 parent 8d9e91e commit ab17503

File tree

4 files changed

+329
-59
lines changed

4 files changed

+329
-59
lines changed

internal/env/env.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Package env provides utilities for reading environment variables with defaults and type safety.
2+
package env
3+
4+
import (
5+
"os"
6+
"strconv"
7+
"time"
8+
)
9+
10+
// GetBool reads a boolean environment variable.
11+
// Returns defaultValue if the variable is not set or invalid.
12+
func GetBool(key string, defaultValue bool) bool {
13+
value := os.Getenv(key)
14+
if value == "" {
15+
return defaultValue
16+
}
17+
18+
switch value {
19+
case "1", "true", "TRUE", "True", "yes", "YES", "Yes":
20+
return true
21+
case "0", "false", "FALSE", "False", "no", "NO", "No":
22+
return false
23+
default:
24+
return defaultValue
25+
}
26+
}
27+
28+
// GetString reads a string environment variable.
29+
// Returns defaultValue if the variable is not set.
30+
func GetString(key string, defaultValue string) string {
31+
value := os.Getenv(key)
32+
if value == "" {
33+
return defaultValue
34+
}
35+
return value
36+
}
37+
38+
// GetInt reads an integer environment variable.
39+
// Returns defaultValue if the variable is not set or invalid.
40+
func GetInt(key string, defaultValue int) int {
41+
value := os.Getenv(key)
42+
if value == "" {
43+
return defaultValue
44+
}
45+
46+
intValue, err := strconv.Atoi(value)
47+
if err != nil {
48+
return defaultValue
49+
}
50+
51+
return intValue
52+
}
53+
54+
// GetDuration reads a duration environment variable.
55+
// Accepts values like "5s", "10m", "1h30m".
56+
// Returns defaultValue if the variable is not set or invalid.
57+
func GetDuration(key string, defaultValue time.Duration) time.Duration {
58+
value := os.Getenv(key)
59+
if value == "" {
60+
return defaultValue
61+
}
62+
63+
duration, err := time.ParseDuration(value)
64+
if err != nil {
65+
return defaultValue
66+
}
67+
68+
return duration
69+
}
70+
71+
// IsSet returns true if the environment variable is set (even if empty).
72+
func IsSet(key string) bool {
73+
_, exists := os.LookupEnv(key)
74+
return exists
75+
}
76+
77+
// MustGetString reads a string environment variable and panics if not set.
78+
// Use this for required configuration values.
79+
func MustGetString(key string) string {
80+
value := os.Getenv(key)
81+
if value == "" {
82+
panic("environment variable " + key + " is required but not set")
83+
}
84+
return value
85+
}

internal/env/env_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package env
2+
3+
import (
4+
"os"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestGetBool(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
value string
16+
defaultValue bool
17+
expected bool
18+
}{
19+
{"true", "true", false, true},
20+
{"TRUE", "TRUE", false, true},
21+
{"1", "1", false, true},
22+
{"yes", "yes", false, true},
23+
{"false", "false", true, false},
24+
{"FALSE", "FALSE", true, false},
25+
{"0", "0", true, false},
26+
{"no", "no", true, false},
27+
{"empty", "", false, false},
28+
{"empty_default_true", "", true, true},
29+
{"invalid", "invalid", false, false},
30+
{"invalid_default_true", "invalid", true, true},
31+
}
32+
33+
for _, tt := range tests {
34+
t.Run(tt.name, func(t *testing.T) {
35+
key := "TEST_BOOL_" + tt.name
36+
if tt.value != "" {
37+
os.Setenv(key, tt.value)
38+
defer os.Unsetenv(key)
39+
}
40+
result := GetBool(key, tt.defaultValue)
41+
assert.Equal(t, tt.expected, result)
42+
})
43+
}
44+
}
45+
46+
func TestGetString(t *testing.T) {
47+
tests := []struct {
48+
name string
49+
value string
50+
defaultValue string
51+
expected string
52+
}{
53+
{"set", "value", "default", "value"},
54+
{"empty", "", "default", "default"},
55+
{"spaces", " spaces ", "default", " spaces "},
56+
}
57+
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
key := "TEST_STRING_" + tt.name
61+
if tt.value != "" {
62+
os.Setenv(key, tt.value)
63+
defer os.Unsetenv(key)
64+
}
65+
result := GetString(key, tt.defaultValue)
66+
assert.Equal(t, tt.expected, result)
67+
})
68+
}
69+
}
70+
71+
func TestGetInt(t *testing.T) {
72+
tests := []struct {
73+
name string
74+
value string
75+
defaultValue int
76+
expected int
77+
}{
78+
{"positive", "42", 0, 42},
79+
{"negative", "-10", 0, -10},
80+
{"zero", "0", 100, 0},
81+
{"empty", "", 99, 99},
82+
{"invalid", "not_a_number", 50, 50},
83+
}
84+
85+
for _, tt := range tests {
86+
t.Run(tt.name, func(t *testing.T) {
87+
key := "TEST_INT_" + tt.name
88+
if tt.value != "" {
89+
os.Setenv(key, tt.value)
90+
defer os.Unsetenv(key)
91+
}
92+
result := GetInt(key, tt.defaultValue)
93+
assert.Equal(t, tt.expected, result)
94+
})
95+
}
96+
}
97+
98+
func TestGetDuration(t *testing.T) {
99+
tests := []struct {
100+
name string
101+
value string
102+
defaultValue time.Duration
103+
expected time.Duration
104+
}{
105+
{"seconds", "5s", time.Minute, 5 * time.Second},
106+
{"minutes", "10m", time.Second, 10 * time.Minute},
107+
{"hours", "2h", time.Second, 2 * time.Hour},
108+
{"combined", "1h30m", time.Second, 90 * time.Minute},
109+
{"empty", "", time.Minute, time.Minute},
110+
{"invalid", "invalid", 5 * time.Second, 5 * time.Second},
111+
}
112+
113+
for _, tt := range tests {
114+
t.Run(tt.name, func(t *testing.T) {
115+
key := "TEST_DURATION_" + tt.name
116+
if tt.value != "" {
117+
os.Setenv(key, tt.value)
118+
defer os.Unsetenv(key)
119+
}
120+
result := GetDuration(key, tt.defaultValue)
121+
assert.Equal(t, tt.expected, result)
122+
})
123+
}
124+
}
125+
126+
func TestIsSet(t *testing.T) {
127+
key := "TEST_IS_SET"
128+
129+
// Not set
130+
assert.False(t, IsSet(key))
131+
132+
// Set to empty
133+
os.Setenv(key, "")
134+
defer os.Unsetenv(key)
135+
assert.True(t, IsSet(key))
136+
137+
// Set to value
138+
os.Setenv(key, "value")
139+
assert.True(t, IsSet(key))
140+
}
141+
142+
func TestMustGetString(t *testing.T) {
143+
t.Run("set", func(t *testing.T) {
144+
key := "TEST_MUST_GET_SET"
145+
os.Setenv(key, "value")
146+
defer os.Unsetenv(key)
147+
148+
result := MustGetString(key)
149+
assert.Equal(t, "value", result)
150+
})
151+
152+
t.Run("not_set_panics", func(t *testing.T) {
153+
key := "TEST_MUST_GET_NOT_SET"
154+
require.Panics(t, func() {
155+
MustGetString(key)
156+
})
157+
})
158+
}

internal/namespaces/rdb/v1/custom_benchmark_test.go

Lines changed: 16 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import (
1212
"time"
1313

1414
"github.com/scaleway/scaleway-cli/v2/core"
15+
"github.com/scaleway/scaleway-cli/v2/internal/env"
1516
"github.com/scaleway/scaleway-cli/v2/internal/namespaces/rdb/v1"
17+
"github.com/scaleway/scaleway-cli/v2/internal/testhelpers"
1618
rdbSDK "github.com/scaleway/scaleway-sdk-go/api/rdb/v1"
1719
"github.com/scaleway/scaleway-sdk-go/scw"
1820
)
@@ -21,13 +23,21 @@ import (
2123
//
2224
// Baseline stored in testdata/benchmark.baseline (like golden files).
2325
//
26+
// Install benchstat (required for comparison):
27+
//
28+
// go install golang.org/x/perf/cmd/benchstat@latest
29+
//
2430
// To compare performance:
2531
//
2632
// benchstat testdata/benchmark.baseline <(CLI_RUN_BENCHMARKS=true go test -bench=. -benchtime=100x .)
2733
//
2834
// To update baseline:
2935
//
3036
// CLI_RUN_BENCHMARKS=true go test -bench=. -benchtime=100x . > testdata/benchmark.baseline
37+
//
38+
// Or use the automated tool (installs benchstat automatically):
39+
//
40+
// go run ./cmd/scw-benchstat --install-benchstat --bench=. --count=10
3141

3242
const (
3343
defaultCmdTimeout = 30 * time.Second
@@ -49,60 +59,7 @@ func TestMain(m *testing.M) {
4959

5060
func setupBenchmark(b *testing.B) (*scw.Client, core.TestMetadata, func(args []string) any) {
5161
b.Helper()
52-
53-
clientOpts := []scw.ClientOption{
54-
scw.WithDefaultRegion(scw.RegionFrPar),
55-
scw.WithDefaultZone(scw.ZoneFrPar1),
56-
scw.WithUserAgent("cli-benchmark-test"),
57-
scw.WithEnv(),
58-
}
59-
60-
config, err := scw.LoadConfig()
61-
if err == nil {
62-
activeProfile, err := config.GetActiveProfile()
63-
if err == nil {
64-
envProfile := scw.LoadEnvProfile()
65-
profile := scw.MergeProfiles(activeProfile, envProfile)
66-
clientOpts = append(clientOpts, scw.WithProfile(profile))
67-
}
68-
}
69-
70-
client, err := scw.NewClient(clientOpts...)
71-
if err != nil {
72-
b.Fatalf(
73-
"Failed to create Scaleway client: %v\nMake sure you have configured your credentials with 'scw config'",
74-
err,
75-
)
76-
}
77-
78-
meta := core.TestMetadata{
79-
"t": b,
80-
}
81-
82-
executeCmd := func(args []string) any {
83-
stdoutBuffer := &bytes.Buffer{}
84-
stderrBuffer := &bytes.Buffer{}
85-
_, result, err := core.Bootstrap(&core.BootstrapConfig{
86-
Args: args,
87-
Commands: rdb.GetCommands().Copy(),
88-
BuildInfo: nil,
89-
Stdout: stdoutBuffer,
90-
Stderr: stderrBuffer,
91-
Client: client,
92-
DisableTelemetry: true,
93-
DisableAliases: true,
94-
OverrideEnv: map[string]string{},
95-
Ctx: context.Background(),
96-
})
97-
if err != nil {
98-
b.Errorf("error executing cmd (%s): %v\nstdout: %s\nstderr: %s",
99-
args, err, stdoutBuffer.String(), stderrBuffer.String())
100-
}
101-
102-
return result
103-
}
104-
105-
return client, meta, executeCmd
62+
return testhelpers.SetupBenchmark(b, rdb.GetCommands())
10663
}
10764

10865
func cleanupWithRetry(b *testing.B, name string, resourceID string, cleanupFn func() error) {
@@ -138,7 +95,7 @@ type benchmarkStats struct {
13895

13996
func newBenchmarkStats() *benchmarkStats {
14097
return &benchmarkStats{
141-
enabled: os.Getenv("CLI_BENCH_TRACE") == "true",
98+
enabled: env.GetBool("CLI_BENCH_TRACE", false),
14299
timings: make([]time.Duration, 0, 1000),
143100
}
144101
}
@@ -291,7 +248,7 @@ func cleanupSharedInstance() {
291248
}
292249

293250
func BenchmarkInstanceGet(b *testing.B) {
294-
if os.Getenv("CLI_RUN_BENCHMARKS") != "true" {
251+
if !env.GetBool("CLI_RUN_BENCHMARKS", false) {
295252
b.Skip("Skipping benchmark. Set CLI_RUN_BENCHMARKS=true to run.")
296253
}
297254

@@ -327,7 +284,7 @@ func BenchmarkInstanceGet(b *testing.B) {
327284
}
328285

329286
func BenchmarkBackupGet(b *testing.B) {
330-
if os.Getenv("CLI_RUN_BENCHMARKS") != "true" {
287+
if !env.GetBool("CLI_RUN_BENCHMARKS", false) {
331288
b.Skip("Skipping benchmark. Set CLI_RUN_BENCHMARKS=true to run.")
332289
}
333290

@@ -396,7 +353,7 @@ func BenchmarkBackupGet(b *testing.B) {
396353
}
397354

398355
func BenchmarkBackupList(b *testing.B) {
399-
if os.Getenv("CLI_RUN_BENCHMARKS") != "true" {
356+
if !env.GetBool("CLI_RUN_BENCHMARKS", false) {
400357
b.Skip("Skipping benchmark. Set CLI_RUN_BENCHMARKS=true to run.")
401358
}
402359

@@ -478,7 +435,7 @@ func BenchmarkBackupList(b *testing.B) {
478435
}
479436

480437
func BenchmarkDatabaseList(b *testing.B) {
481-
if os.Getenv("CLI_RUN_BENCHMARKS") != "true" {
438+
if !env.GetBool("CLI_RUN_BENCHMARKS", false) {
482439
b.Skip("Skipping benchmark. Set CLI_RUN_BENCHMARKS=true to run.")
483440
}
484441

0 commit comments

Comments
 (0)