diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a752764..907bc59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,12 @@ permissions: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: - go-version: ['1.26'] + os: [ubuntu-latest, macos-latest, windows-latest] + go-version: ['1.27'] steps: - uses: actions/checkout@v6 @@ -26,7 +28,7 @@ jobs: run: go vet ./... - name: Test - run: go test -race -coverprofile=coverage.out ./... + run: go test -race "-coverprofile=coverage.out" ./... - name: Build run: go build -o goenv ./cmd/goenv diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c8bc27..ef453d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.26' + go-version: '1.27' - uses: goreleaser/goreleaser-action@v7 with: diff --git a/README.md b/README.md index 519edcf..da68f40 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ A terminal UI (TUI) application for viewing and editing Go environment variables ## Installation +Building from source or using `go install` requires Go 1.27+. The mise configuration tracks Go 1.27 patches. Darwin binaries require macOS 13 or later ([Go 1.27 release notes](https://go.dev/doc/go1.27)). + ### Homebrew (macOS / Linux) ```bash @@ -109,6 +111,20 @@ mise run build bin/goenv ``` +### JSON compatibility + +Persistence (configuration, history, snapshots and presets), `go env -json` input and JSON exports use `encoding/json/v2` directly with its new defaults ([migration guide](https://go.dev/doc/jsonv2-migration)): + +- Duplicate object names and invalid UTF-8 are rejected. +- Field names match exactly, including capitalization. Unknown fields are ignored. +- Nil slices and maps serialize as `[]` and `{}` unless omitted by a field tag. Nil `favorites` is omitted; an empty non-nil list remains `[]`. +- Zero `watchInterval` and `maxHistory` values are omitted. +- Strings use minimal escaping; map key order is unspecified. + +Valid older files generated by goenv remain readable. Loading does not automatically rewrite files. Manually edited files relying on case-insensitive field names, duplicate names or invalid UTF-8 need correction. Serialization finishes before any file content is written. JSON export failures produce an error on stderr and exit code 1 in the CLI; the TUI displays the error in export options without opening a preview or copying invalid output. + +Building goenv with `GOEXPERIMENT=nojsonv2` is unsupported. Experiment suggestions follow the Go toolchain managed by the application: `jsonv2` and `nojsonv2` appear from Go 1.25, `simd` from Go 1.26, and `cacheprog` and `rangefunc` are excluded from Go 1.26 onward. Free-form experiment entry remains available; an unknown Go version shows all suggestions. + ## Usage ```bash diff --git a/go.mod b/go.mod index 081b1c9..ae66767 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/alexrios/goenv -go 1.26 +go 1.27.0 require ( charm.land/bubbles/v2 v2.0.0 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 50d3b02..433dfb3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -164,7 +164,15 @@ func runCLIExport(opts Options) bool { os.Exit(1) } - output := goenv.GenerateShellExport(items, shell, filter) + return printCLIExport(items, shell, filter) +} + +func printCLIExport(items []goenv.EnvVar, shell goenv.ShellType, filter goenv.ExportFilter) bool { + output, err := goenv.GenerateShellExport(items, shell, filter) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } fmt.Println(output) return true } diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go new file mode 100644 index 0000000..e86f042 --- /dev/null +++ b/internal/cli/export_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "bytes" + "os" + "os/exec" + "strings" + "testing" + + "github.com/alexrios/goenv/internal/goenv" + "github.com/alexrios/goenv/internal/testutil" +) + +func TestCLIExportSerializationError(t *testing.T) { + if os.Getenv("GOENV_TEST_INVALID_EXPORT") == "1" { + printCLIExport([]goenv.EnvVar{{Key: "GOPATH", Value: "\xff"}}, goenv.ShellJSON, goenv.ExportAll) + return + } + testutil.TempConfig(t) + cmd := exec.Command(os.Args[0], "-test.run=^TestCLIExportSerializationError$") + cmd.Env = append(os.Environ(), "GOENV_TEST_INVALID_EXPORT=1") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { + t.Fatalf("want exit 1, got %v", err) + } + if stdout.Len() != 0 || !strings.Contains(stderr.String(), "Error: failed to marshal JSON export") { + t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 43a4154..5698771 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -2,7 +2,7 @@ package commands import ( "bytes" - "encoding/json" + "encoding/json/v2" "fmt" "os/exec" "regexp" diff --git a/internal/commands/json_compat_test.go b/internal/commands/json_compat_test.go new file mode 100644 index 0000000..32881e7 --- /dev/null +++ b/internal/commands/json_compat_test.go @@ -0,0 +1,50 @@ +package commands + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestReloadEnvJSONValidation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake Go command uses a POSIX shell") + } + old := goPath + t.Cleanup(func() { goPath = old }) + for _, tc := range []struct { + name, all, changed string + wantErr bool + }{ + {"valid", `{"GOPATH":"/go","GOPROXY":"&"}`, `{"GOPATH":"/go"}`, false}, + {"duplicate", `{"GOPATH":"/a","GOPATH":"/b"}`, `{}`, true}, + {"invalid UTF-8", "{\"GOPATH\":\"\xff\"}", `{}`, true}, + {"duplicate changed", `{"GOPATH":"/go"}`, `{"GOPATH":"/a","GOPATH":"/b"}`, true}, + {"invalid changed UTF-8", `{"GOPATH":"/go"}`, "{\"GOPATH\":\"\xff\"}", true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for name, data := range map[string]string{"all": tc.all, "changed": tc.changed, "go": "#!/bin/sh\ncd -- \"$(dirname -- \"$0\")\"\nif [ \"$2\" = -changed ]; then cat changed; else cat all; fi\n"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0755); err != nil { + t.Fatal(err) + } + } + goPath = filepath.Join(dir, "go") + items, err := ReloadEnv() + if (err != nil) != tc.wantErr { + t.Fatalf("items=%v err=%v", items, err) + } + if !tc.wantErr { + if len(items) != 2 { + t.Fatal(items) + } + for _, v := range items { + if v.Key == "GOPATH" && (!v.Changed || v.Value != "/go") { + t.Fatal(v) + } + } + } + }) + } +} diff --git a/internal/goenv/descriptions.go b/internal/goenv/descriptions.go index dc187ca..01ad9b0 100644 --- a/internal/goenv/descriptions.go +++ b/internal/goenv/descriptions.go @@ -256,7 +256,7 @@ var EnvVarKnownValues = map[string][]string{ "GORISCV64": {"rva20u64", "rva22u64"}, "GOWASM": {"satconv", "signext"}, "GOTOOLCHAIN": {"auto", "local", "path"}, - "GOEXPERIMENT": {"arenas", "boringcrypto", "cacheprog", "newinliner", "rangefunc"}, + "GOEXPERIMENT": {"arenas", "boringcrypto", "cacheprog", "jsonv2", "nojsonv2", "newinliner", "rangefunc", "simd"}, } // GetEnvVarKnownValues returns the list of known values for a given environment variable. @@ -265,10 +265,11 @@ func GetEnvVarKnownValues(key string) []string { return EnvVarKnownValues[key] } -// versionedValue represents a known value that requires a minimum Go version. +// versionedValue represents a known value that has a Go version availability range. type versionedValue struct { Value string MinVersion GoVersion + MaxVersion GoVersion // Exclusive; zero means no upper limit. } // versionGatedValues maps env var keys to values that require specific Go versions. @@ -281,8 +282,11 @@ var versionGatedValues = map[string][]versionedValue{ {Value: "loong64", MinVersion: MinVersionLoong64}, }, "GOEXPERIMENT": { - {Value: "rangefunc", MinVersion: MinVersionRangefunc}, - {Value: "cacheprog", MinVersion: MinVersionCacheprog}, + {Value: "rangefunc", MinVersion: MinVersionRangefunc, MaxVersion: GoVersion{1, 26, 0}}, + {Value: "jsonv2", MinVersion: GoVersion{1, 25, 0}}, + {Value: "nojsonv2", MinVersion: GoVersion{1, 25, 0}}, + {Value: "simd", MinVersion: GoVersion{1, 26, 0}}, + {Value: "cacheprog", MinVersion: MinVersionCacheprog, MaxVersion: GoVersion{1, 26, 0}}, }, } @@ -294,7 +298,7 @@ var wholeVarMinVersions = map[string]GoVersion{ } // FilterKnownValuesForVersion returns known values for key, excluding those -// that require a newer Go version than ver. If ver is zero, returns all values. +// unavailable in ver. If ver is zero, returns all values. func FilterKnownValuesForVersion(key string, ver GoVersion) []string { values := GetEnvVarKnownValues(key) if values == nil { @@ -320,7 +324,7 @@ func FilterKnownValuesForVersion(key string, ver GoVersion) []string { for _, v := range values { include := true for _, gv := range gated { - if gv.Value == v && !ver.AtLeast(gv.MinVersion) { + if gv.Value == v && (!ver.AtLeast(gv.MinVersion) || (!gv.MaxVersion.IsZero() && ver.AtLeast(gv.MaxVersion))) { include = false break } @@ -386,7 +390,7 @@ var envVarExtendedDocs = map[string]string{ "GOPROXY": "Comma-separated list of proxy URLs. Use 'direct' to skip. Default: https://proxy.golang.org,direct", "GOPRIVATE": "Comma-separated glob patterns (e.g. github.com/myorg/*). Also sets GONOPROXY and GONOSUMDB.", "GODEBUG": "Comma-separated key=value pairs. Examples: gctrace=1, schedtrace=1000, http2debug=1", - "GOEXPERIMENT": "Comma-separated experiment names. Prefix with 'no' to disable (e.g. norangefunc).", + "GOEXPERIMENT": "Comma-separated experiment names. Prefix with 'no' to disable (e.g. nojsonv2).", "GOFLAGS": "Space-separated flags applied to every go command. Example: -mod=vendor -count=1", "GOTOOLCHAIN": "Controls toolchain selection. 'auto' downloads as needed. 'local' uses only installed version.", "CGO_ENABLED": "Set to 0 for static builds without C dependencies. Set to 1 to enable CGO linking.", diff --git a/internal/goenv/json_compat_test.go b/internal/goenv/json_compat_test.go new file mode 100644 index 0000000..4fd993e --- /dev/null +++ b/internal/goenv/json_compat_test.go @@ -0,0 +1,59 @@ +package goenv + +import ( + "encoding/json/v2" + "reflect" + "slices" + "strings" + "testing" +) + +func TestExperimentVersionBoundaries(t *testing.T) { + for _, tc := range []struct { + version string + old, json, simd bool + }{ + {"go1.24.9", true, false, false}, {"go1.25.0", true, true, false}, {"go1.25.9", true, true, false}, {"go1.26.0", false, true, true}, {"go1.27.0", false, true, true}, {"unknown", true, true, true}, + } { + t.Run(tc.version, func(t *testing.T) { + got := FilterKnownValuesForVersion("GOEXPERIMENT", ParseGoVersion(tc.version)) + for key, want := range map[string]bool{"cacheprog": tc.old, "rangefunc": tc.old, "jsonv2": tc.json, "nojsonv2": tc.json, "simd": tc.simd} { + if slices.Contains(got, key) != want { + t.Errorf("%s presence: %v, want %v", key, got, want) + } + } + if tc.version == "unknown" && !slices.Equal(got, GetEnvVarKnownValues("GOEXPERIMENT")) { + t.Fatal("unknown version changed") + } + }) + } +} + +func TestJSONExportCompatibility(t *testing.T) { + for _, vars := range [][]EnvVar{nil, {}, {{Key: "GOPROXY", Value: "&"}, {Key: "GOPATH", Value: "/go"}}} { + output, err := GenerateShellExport(vars, ShellJSON, ExportAll) + if err != nil { + t.Fatal(err) + } + want := map[string]string{} + for _, v := range vars { + want[v.Key] = v.Value + } + var got map[string]string + if err := json.Unmarshal([]byte(output), &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } + if strings.Contains(output, `\u0026`) || strings.Contains(output, `\u003c`) { + t.Fatalf("HTML escaping: %s", output) + } + } + for _, v := range []EnvVar{{Key: "GOPATH", Value: "\xff"}, {Key: "\xff", Value: "/go"}} { + output, err := GenerateShellExport([]EnvVar{v}, ShellJSON, ExportAll) + if err == nil || output != "" { + t.Fatalf("invalid UTF-8: output=%q err=%v", output, err) + } + } +} diff --git a/internal/goenv/shellexport.go b/internal/goenv/shellexport.go index 03c8087..de8bb0d 100644 --- a/internal/goenv/shellexport.go +++ b/internal/goenv/shellexport.go @@ -1,7 +1,8 @@ package goenv import ( - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "fmt" "strings" ) @@ -81,7 +82,7 @@ func (f ExportFilter) Next() ExportFilter { } // GenerateShellExport generates shell export commands for the given variables. -func GenerateShellExport(vars []EnvVar, shell ShellType, filter ExportFilter) string { +func GenerateShellExport(vars []EnvVar, shell ShellType, filter ExportFilter) (string, error) { // JSON format is special-cased if shell == ShellJSON { return generateJSONExport(vars, filter) @@ -115,11 +116,11 @@ func GenerateShellExport(vars []EnvVar, shell ShellType, filter ExportFilter) st lines = append(lines, formatExportLine(v.Key, v.Value, shell)) } - return strings.Join(lines, "\n") + return strings.Join(lines, "\n"), nil } // generateJSONExport produces a JSON object of variables. -func generateJSONExport(vars []EnvVar, filter ExportFilter) string { +func generateJSONExport(vars []EnvVar, filter ExportFilter) (string, error) { m := make(map[string]string) for _, v := range vars { if filter == ExportModified && !v.Changed { @@ -130,11 +131,11 @@ func generateJSONExport(vars []EnvVar, filter ExportFilter) string { } m[v.Key] = v.Value } - data, err := json.MarshalIndent(m, "", " ") + data, err := json.Marshal(m, jsontext.WithIndent(" ")) if err != nil { - return "{}" + return "", fmt.Errorf("failed to marshal JSON export: %w", err) } - return string(data) + return string(data), nil } // formatExportLine formats a single export line for the given shell. diff --git a/internal/goenv/shellexport_test.go b/internal/goenv/shellexport_test.go index 68b3f6d..9142556 100644 --- a/internal/goenv/shellexport_test.go +++ b/internal/goenv/shellexport_test.go @@ -87,7 +87,10 @@ func TestGenerateShellExport_Bash(t *testing.T) { {Key: "GOPROXY", Value: "https://proxy.golang.org,direct", Changed: false}, } - output := GenerateShellExport(vars, ShellBash, ExportAll) + output, err := GenerateShellExport(vars, ShellBash, ExportAll) + if err != nil { + t.Fatal(err) + } if !strings.Contains(output, "export GOPATH=/home/user/go") { t.Errorf("Expected GOPATH export in output, got:\n%s", output) @@ -102,7 +105,10 @@ func TestGenerateShellExport_Fish(t *testing.T) { {Key: "GOPATH", Value: "/home/user/go", Changed: true}, } - output := GenerateShellExport(vars, ShellFish, ExportAll) + output, err := GenerateShellExport(vars, ShellFish, ExportAll) + if err != nil { + t.Fatal(err) + } if !strings.Contains(output, "set -x GOPATH /home/user/go") { t.Errorf("Expected Fish set command in output, got:\n%s", output) @@ -114,7 +120,10 @@ func TestGenerateShellExport_PowerShell(t *testing.T) { {Key: "GOPATH", Value: "/home/user/go", Changed: true}, } - output := GenerateShellExport(vars, ShellPowerShell, ExportAll) + output, err := GenerateShellExport(vars, ShellPowerShell, ExportAll) + if err != nil { + t.Fatal(err) + } if !strings.Contains(output, "$env:GOPATH = /home/user/go") { t.Errorf("Expected PowerShell export in output, got:\n%s", output) @@ -130,7 +139,10 @@ func TestGenerateShellExport_JSON(t *testing.T) { {Key: "GOBIN", Value: "", Changed: true}, // Empty, should be skipped } - output := GenerateShellExport(vars, ShellJSON, ExportAll) + output, err := GenerateShellExport(vars, ShellJSON, ExportAll) + if err != nil { + t.Fatal(err) + } if !strings.Contains(output, `"GOPATH"`) { t.Errorf("Expected GOPATH key in JSON output, got:\n%s", output) @@ -149,7 +161,10 @@ func TestGenerateShellExport_ModifiedOnly(t *testing.T) { {Key: "GOPROXY", Value: "https://proxy.golang.org", Changed: false}, } - output := GenerateShellExport(vars, ShellBash, ExportModified) + output, err := GenerateShellExport(vars, ShellBash, ExportModified) + if err != nil { + t.Fatal(err) + } if !strings.Contains(output, "export GOPATH=") { t.Errorf("Expected GOPATH (modified) in output, got:\n%s", output) @@ -165,7 +180,10 @@ func TestGenerateShellExport_SkipsEmptyValues(t *testing.T) { {Key: "GOBIN", Value: "", Changed: true}, } - output := GenerateShellExport(vars, ShellBash, ExportAll) + output, err := GenerateShellExport(vars, ShellBash, ExportAll) + if err != nil { + t.Fatal(err) + } if !strings.Contains(output, "GOPATH") { t.Errorf("Expected GOPATH in output, got:\n%s", output) diff --git a/internal/goenv/validation_test.go b/internal/goenv/validation_test.go index 0a4389a..8244e89 100644 --- a/internal/goenv/validation_test.go +++ b/internal/goenv/validation_test.go @@ -88,18 +88,19 @@ func TestValidateEnvValue_GO111MODULE(t *testing.T) { } func TestValidateEnvValue_Path(t *testing.T) { + absPath := t.TempDir() tests := []struct { key string value string wantErr bool }{ - {"GOPATH", "/home/user/go", false}, + {"GOPATH", absPath, false}, {"GOPATH", "~/go", false}, {"GOPATH", "$HOME/go", false}, {"GOPATH", "relative/path", true}, - {"GOROOT", "/usr/local/go", false}, - {"GOBIN", "/home/user/go/bin", false}, - {"GOCACHE", "/tmp/gocache", false}, + {"GOROOT", absPath, false}, + {"GOBIN", absPath, false}, + {"GOCACHE", absPath, false}, {"GOPATH", "", false}, // empty is allowed } diff --git a/internal/persist/config.go b/internal/persist/config.go index bec153c..dd0d110 100644 --- a/internal/persist/config.go +++ b/internal/persist/config.go @@ -1,7 +1,8 @@ package persist import ( - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" "os" "path/filepath" @@ -77,8 +78,8 @@ type Config struct { SortMode string `json:"sortMode"` Theme string `json:"theme,omitempty"` Favorites []string `json:"favorites,omitzero"` - WatchInterval int `json:"watchInterval,omitempty"` - MaxHistory int `json:"maxHistory,omitempty"` + WatchInterval int `json:"watchInterval,omitzero"` + MaxHistory int `json:"maxHistory,omitzero"` } // ThemeDefault is the default theme name. @@ -139,7 +140,7 @@ func SaveConfig(cfg Config) error { return err } - data, err := json.MarshalIndent(cfg, "", " ") + data, err := json.Marshal(cfg, jsontext.WithIndent(" ")) if err != nil { return err } diff --git a/internal/persist/history.go b/internal/persist/history.go index b02b24d..871f939 100644 --- a/internal/persist/history.go +++ b/internal/persist/history.go @@ -1,7 +1,8 @@ package persist import ( - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" "os" "path/filepath" @@ -95,7 +96,7 @@ func SaveHistory(history SessionHistory, maxEntries int) error { } } - data, err := json.MarshalIndent(history, "", " ") + data, err := json.Marshal(history, jsontext.WithIndent(" ")) if err != nil { return err } diff --git a/internal/persist/history_test.go b/internal/persist/history_test.go index d686b91..0cd8dbb 100644 --- a/internal/persist/history_test.go +++ b/internal/persist/history_test.go @@ -4,12 +4,13 @@ import ( "os" "path/filepath" "testing" + + "github.com/alexrios/goenv/internal/testutil" ) func TestSaveAndLoadHistory(t *testing.T) { // Use a temp dir to avoid polluting the real config - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) + testutil.TempConfig(t) history := SessionHistory{ Records: []EditRecord{ @@ -40,8 +41,7 @@ func TestSaveAndLoadHistory(t *testing.T) { } func TestLoadHistory_MissingFile(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) + testutil.TempConfig(t) history, err := LoadHistory() if err != nil { @@ -53,8 +53,7 @@ func TestLoadHistory_MissingFile(t *testing.T) { } func TestLoadHistory_CorruptedFile(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) + tmpDir := testutil.TempConfig(t) dir := filepath.Join(tmpDir, ConfigDirName) if err := os.MkdirAll(dir, 0o755); err != nil { @@ -71,8 +70,7 @@ func TestLoadHistory_CorruptedFile(t *testing.T) { } func TestSaveHistory_TrimsToMaxEntries(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) + testutil.TempConfig(t) // Create history with 10 records var records []EditRecord @@ -108,8 +106,7 @@ func TestSaveHistory_TrimsToMaxEntries(t *testing.T) { } func TestSaveHistory_DefaultMaxOnZero(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) + testutil.TempConfig(t) history := SessionHistory{ Records: make([]EditRecord, 60), @@ -132,8 +129,7 @@ func TestSaveHistory_DefaultMaxOnZero(t *testing.T) { } func TestClearHistory(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) + testutil.TempConfig(t) history := SessionHistory{ Records: []EditRecord{{Key: "GOPATH", OldValue: "a", NewValue: "b"}}, @@ -158,8 +154,7 @@ func TestClearHistory(t *testing.T) { } func TestLoadHistory_ValidatesIndices(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) + testutil.TempConfig(t) // Save with invalid currentIdx (too high) history := SessionHistory{ diff --git a/internal/persist/json_compat_test.go b/internal/persist/json_compat_test.go new file mode 100644 index 0000000..e9902e1 --- /dev/null +++ b/internal/persist/json_compat_test.go @@ -0,0 +1,218 @@ +package persist + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/alexrios/goenv/internal/testutil" +) + +func TestJSONCompatibility(t *testing.T) { + for _, tc := range []struct { + name, legacy, wrongCase, duplicate, invalid, collection string + save func(string) error + load func(string) (any, error) + want any + }{ + {"config", `{"sortMode":"alpha","favorites":null,"future":true}`, `{"SortMode":"category"}`, `{"sortMode":"alpha","sortMode":"category"}`, "{\"theme\":\"\xff\"}", "favorites", + func(string) error { return SaveConfig(Config{SortMode: "alpha"}) }, + func(string) (any, error) { return LoadConfig() }, Config{SortMode: "alpha"}}, + {"history", `{"records":null,"currentIdx":0,"future":true}`, `{"Records":[{"key":"GOPATH"}]}`, `{"records":[],"records":[]}`, "{\"records\":[{\"key\":\"\xff\"}]}", "records", + func(string) error { return SaveHistory(SessionHistory{}, 50) }, + func(string) (any, error) { return LoadHistory() }, SessionHistory{}}, + {"snapshot", `{"name":"old","createdAt":"0001-01-01T00:00:00Z","variables":null,"future":true}`, `{"Name":"wrong"}`, `{"variables":{"GOPATH":"a","GOPATH":"b"}}`, "{\"variables\":{\"GOPATH\":\"\xff\"}}", "variables", + func(path string) error { return ExportSnapshot(Snapshot{Name: "old"}, path) }, + func(path string) (any, error) { return ImportSnapshot(path) }, Snapshot{Name: "old"}}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := testutil.TempConfig(t) + dir = filepath.Join(dir, ConfigDirName) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, tc.name+".json") + write := func(s string) { + t.Helper() + if err := os.WriteFile(path, []byte(s), 0644); err != nil { + t.Fatal(err) + } + } + write(tc.legacy) + got, err := tc.load(path) + if err != nil || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("legacy: got %#v, %v; want %#v", got, err, tc.want) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != tc.legacy { + t.Fatal("load rewrote legacy file") + } + write(tc.wrongCase) + got, err = tc.load(path) + if err != nil { + t.Fatal(err) + } + if !reflect.ValueOf(got).IsZero() { + t.Fatalf("wrong case populated fields: %#v", got) + } + for _, bad := range []string{tc.duplicate, tc.invalid} { + write(bad) + if _, err := tc.load(path); err == nil { + t.Fatalf("accepted malformed JSON: %q", bad) + } + } + if err := tc.save(path); err != nil { + t.Fatal(err) + } + data, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var fields map[string]jsontext.Value + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + wantCollection := "[]" + if tc.name == "snapshot" { + wantCollection = "{}" + } + if tc.name == "config" { + wantCollection = "" + } + if string(fields[tc.collection]) != wantCollection { + t.Fatalf("nil collection: %s", data) + } + got, err = tc.load(path) + if err != nil { + t.Fatal(err) + } + // v2 deliberately turns nil collections into non-nil empty collections. + switch v := got.(type) { + case SessionHistory: + if len(v.Records) != 0 { + t.Fatal(v) + } + case Snapshot: + if len(v.Variables) != 0 || v.Name != "old" { + t.Fatal(v) + } + case Config: + if v.SortMode != "alpha" { + t.Fatal(v) + } + } + }) + } +} + +func TestConfigJSONOmission(t *testing.T) { + testutil.TempConfig(t) + for _, favorites := range [][]string{nil, {}, {"GOPATH"}} { + cfg := Config{SortMode: "alpha", Favorites: favorites} + if err := SaveConfig(cfg); err != nil { + t.Fatal(err) + } + path, err := configPath() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var fields map[string]jsontext.Value + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + for _, key := range []string{"watchInterval", "maxHistory"} { + if _, ok := fields[key]; ok { + t.Fatalf("zero %s present: %s", key, data) + } + } + _, present := fields["favorites"] + if present != (favorites != nil) { + t.Fatalf("favorites omission: %s", data) + } + got, err := LoadConfig() + if err != nil || !reflect.DeepEqual(got, cfg) { + t.Fatalf("round trip: %#v, %v", got, err) + } + } +} + +func TestSerializationFailurePreservesFiles(t *testing.T) { + testutil.TempConfig(t) + cfgPath, err := configPath() + if err != nil { + t.Fatal(err) + } + histPath, err := historyPath() + if err != nil { + t.Fatal(err) + } + presetDir, err := PresetsDir() + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, path string + save func(string) error + }{ + {"config", cfgPath, func(string) error { return SaveConfig(Config{Theme: "\xff"}) }}, + {"history", histPath, func(string) error { return SaveHistory(SessionHistory{Records: []EditRecord{{Key: "\xff"}}}, 50) }}, + {"snapshot", filepath.Join(t.TempDir(), "snapshot.json"), func(path string) error { + return ExportSnapshot(Snapshot{Variables: map[string]string{"GOPATH": "\xff"}}, path) + }}, + {"preset", filepath.Join(presetDir, "preset.json"), func(path string) error { return ExportSnapshot(Snapshot{Name: "\xff"}, path) }}, + } { + t.Run(tc.name, func(t *testing.T) { + const original = `{"keep":"existing file"}` + if err := os.WriteFile(tc.path, []byte(original), 0644); err != nil { + t.Fatal(err) + } + if err := tc.save(tc.path); err == nil { + t.Fatal("expected serialization failure") + } + data, err := os.ReadFile(tc.path) + if err != nil { + t.Fatal(err) + } + if string(data) != original { + t.Fatalf("file changed: %q", data) + } + }) + } +} + +func TestLegacyPreset(t *testing.T) { + testutil.TempConfig(t) + dir, err := PresetsDir() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "old.json") + data := []byte(`{"name":"old","createdAt":"2026-01-01T00:00:00Z","variables":{"GOPATH":"/go","GOPROXY":"https://example.test?a=1\u0026b=2"}}`) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + presets, skipped, err := ListPresets() + if err != nil || skipped != 0 || len(presets) != 1 { + t.Fatalf("presets=%v skipped=%d err=%v", presets, skipped, err) + } + if presets[0].Variables["GOPROXY"] != "https://example.test?a=1&b=2" { + t.Fatal(presets) + } + if err := ExportSnapshot(presets[0].Snapshot, path); err != nil { + t.Fatal(err) + } + got, err := ImportSnapshot(path) + if err != nil || !reflect.DeepEqual(got, presets[0].Snapshot) { + t.Fatalf("round trip: %#v %v", got, err) + } +} diff --git a/internal/persist/snapshot.go b/internal/persist/snapshot.go index a0fcf7e..798ced2 100644 --- a/internal/persist/snapshot.go +++ b/internal/persist/snapshot.go @@ -1,7 +1,8 @@ package persist import ( - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" "fmt" "os" @@ -45,7 +46,7 @@ func ExportSnapshot(snapshot Snapshot, filePath string) error { } } - data, err := json.MarshalIndent(snapshot, "", " ") + data, err := json.Marshal(snapshot, jsontext.WithIndent(" ")) if err != nil { return fmt.Errorf("failed to marshal snapshot: %w", err) } diff --git a/internal/testutil/config.go b/internal/testutil/config.go new file mode 100644 index 0000000..d474d13 --- /dev/null +++ b/internal/testutil/config.go @@ -0,0 +1,49 @@ +// Package testutil provides configuration isolation for tests. +package testutil + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "testing" +) + +// ConfigEnv returns the variable used by os.UserConfigDir on this platform. +func ConfigEnv() string { + switch runtime.GOOS { + case "darwin", "ios": + return "HOME" + case "windows": + return "AppData" + case "plan9": + return "home" + default: + return "XDG_CONFIG_HOME" + } +} + +// ConfigDir checks that the resolved configuration directory stays inside root. +func ConfigDir(root string) (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, dir) + if err != nil || !filepath.IsLocal(rel) { + return "", fmt.Errorf("configuration directory %q is outside temporary root %q", dir, root) + } + return dir, nil +} + +// TempConfig isolates configuration until the test completes and returns its directory. +func TempConfig(t testing.TB) string { + t.Helper() + root := t.TempDir() + t.Setenv(ConfigEnv(), root) + dir, err := ConfigDir(root) + if err != nil { + t.Fatal(err) + } + return dir +} diff --git a/internal/testutil/config_test.go b/internal/testutil/config_test.go new file mode 100644 index 0000000..233241d --- /dev/null +++ b/internal/testutil/config_test.go @@ -0,0 +1,56 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTempConfigPreservesExistingFiles(t *testing.T) { + // Simulate an existing user configuration without accessing the real one. + root := t.TempDir() + t.Setenv(ConfigEnv(), root) + originalDir, err := ConfigDir(root) + if err != nil { + t.Fatal(err) + } + names := []string{"config.json", "history.json", filepath.Join("presets", "preset.json")} + for _, name := range names { + path := filepath.Join(originalDir, "goenv", name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + } + + t.Run("isolated fixture writes", func(t *testing.T) { + dir := TempConfig(t) + if dir == originalDir { + t.Fatal("temporary configuration reused the original directory") + } + for _, name := range names { + path := filepath.Join(dir, "goenv", name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("fixture"), 0o644); err != nil { + t.Fatal(err) + } + } + }) + + if dir, err := os.UserConfigDir(); err != nil || dir != originalDir { + t.Fatalf("configuration not restored: dir=%q err=%v", dir, err) + } + for _, name := range names { + data, err := os.ReadFile(filepath.Join(originalDir, "goenv", name)) + if err != nil || string(data) != "original" { + t.Errorf("original %s changed: data=%q err=%v", name, data, err) + } + } + if _, err := ConfigDir(t.TempDir()); err == nil { + t.Fatal("accepted a configuration directory outside the temporary root") + } +} diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go index 9bc494d..0a1335f 100644 --- a/internal/tui/handlers.go +++ b/internal/tui/handlers.go @@ -394,6 +394,7 @@ func (m mainModel) handleExportSuccess(msg exportSuccessMsg) (mainModel, tea.Cmd // handleExportError handles failed snapshot export. func (m mainModel) handleExportError(msg exportErrorMsg) (mainModel, tea.Cmd) { + m.clearTempMessages() m.isSaving = false m.spinnerActive = false m.saveError = fmt.Sprintf("Export failed: %v", msg.Err) diff --git a/internal/tui/json_timer_test.go b/internal/tui/json_timer_test.go new file mode 100644 index 0000000..69e8b8c --- /dev/null +++ b/internal/tui/json_timer_test.go @@ -0,0 +1,122 @@ +package tui + +import ( + "strings" + "testing" + "testing/synctest" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/alexrios/goenv/internal/goenv" + "github.com/alexrios/goenv/internal/persist" +) + +func TestShellExportJSONError(t *testing.T) { + m := NewShellExportScreenModel() + m.SetItems([]goenv.EnvVar{{Key: "GOPATH", Value: "\xff", Changed: true}}) + m.shell = goenv.ShellJSON + if cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}); cmd != nil { + t.Fatal("unexpected command on error") + } + if m.phase != shellExportPhaseOptions || len(m.previewLines) != 0 || m.exportError == "" { + t.Fatalf("invalid export state: %#v", m) + } + if !strings.Contains(m.View(), "failed to marshal JSON export") { + t.Fatal("error missing from options") + } + if cmd := m.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}); cmd != nil || m.copied { + t.Fatal("copied invalid export") + } + m.items[0].Value = "/go" + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.phase != shellExportPhasePreview || m.exportError != "" || !strings.Contains(m.View(), "/go") { + t.Fatal("valid retry did not open preview") + } +} + +func TestClearMessageTimer(t *testing.T) { + for _, delay := range []time.Duration{messageTimeoutInfo, messageTimeoutSuccess, messageTimeoutError} { + t.Run(delay.String(), func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + assertTimerDeadline(t, clearMessageAfter(delay), delay, clearMessageMsg{}) + }) + }) + } +} + +func TestWatchTimerDeadlines(t *testing.T) { + for _, mode := range []string{"enable", "repeat"} { + t.Run(mode, func(t *testing.T) { + m := NewMainModel(nil, nil, persist.Config{WatchInterval: 7}) + synctest.Test(t, func(t *testing.T) { + var cmd tea.Cmd + var tick tea.Cmd + if mode == "enable" { + _, cmd = m.handleToggleWatch() + tick = cmd().(tea.BatchMsg)[0] + } else { + m.watchMode = true + _, cmd = m.handleWatchTick() + // Do not execute the reload command: it performs real I/O. + tick = cmd().(tea.BatchMsg)[1] + } + assertTimerDeadline(t, tick, 7*time.Second, watchTickMsg{}) + }) + }) + } +} + +func assertTimerDeadline(t *testing.T, cmd tea.Cmd, delay time.Duration, want tea.Msg) { + t.Helper() + delivered := make(chan tea.Msg, 1) + go func() { delivered <- cmd() }() + synctest.Sleep(delay - time.Nanosecond) + select { + case msg := <-delivered: + t.Fatalf("early message: %T", msg) + default: + } + synctest.Sleep(time.Nanosecond) + select { + case msg := <-delivered: + switch want.(type) { + case clearMessageMsg: + if _, ok := msg.(clearMessageMsg); !ok { + t.Fatalf("got %T", msg) + } + case watchTickMsg: + if _, ok := msg.(watchTickMsg); !ok { + t.Fatalf("got %T", msg) + } + } + default: + t.Fatal("message not delivered at deadline") + } +} + +func TestExperimentSuggestionsAndFreeEntry(t *testing.T) { + for _, ver := range []string{"go1.25.0", "go1.26.0", "go1.27.0", "unknown"} { + t.Run(ver, func(t *testing.T) { + m := NewEditScreenModel(goenv.ParseGoVersion(ver)) + m.editingKey = "GOEXPERIMENT" + m.textInput.SetValue("") + m.updateSuggestions() + rendered := m.renderSuggestions() + if !strings.Contains(rendered, "jsonv2") { + t.Fatal("JSON experiment not rendered") + } + if (ver == "go1.26.0" || ver == "go1.27.0") && (strings.Contains(rendered, "rangefunc") || strings.Contains(rendered, "cacheprog")) { + t.Fatal("removed experiment rendered") + } + m.textInput.SetValue("customexperiment") + m.updateSuggestions() + cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("free-form experiment blocked") + } + if _, ok := cmd().(saveEnvRequestMsg); !ok { + t.Fatal("free-form experiment not submitted") + } + }) + } +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 3a286d6..c811bd0 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -11,13 +11,29 @@ import ( "github.com/alexrios/goenv/internal/goenv" "github.com/alexrios/goenv/internal/persist" + "github.com/alexrios/goenv/internal/testutil" ) // TestMain runs before all tests to ensure clean state. func TestMain(m *testing.M) { - // Clear any persisted history before running tests - _ = persist.ClearHistory() - os.Exit(m.Run()) + dir, err := os.MkdirTemp("", "goenv-tui-test-*") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := os.Setenv(testutil.ConfigEnv(), dir); err != nil { + _ = os.RemoveAll(dir) + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if _, err := testutil.ConfigDir(dir); err != nil { + _ = os.RemoveAll(dir) + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) } // --- envItem Tests --- @@ -232,10 +248,11 @@ func TestListScreen_RedoKey(t *testing.T) { // --- EditScreenModel Update Tests --- func TestEditScreen_EnterSaves(t *testing.T) { + newPath := t.TempDir() m := NewEditScreenModel(goenv.GoVersion{}) m.editingKey = "GOPATH" m.originalValue = "/old/path" - m.textInput.SetValue("/new/path") + m.textInput.SetValue(newPath) msg := tea.KeyPressMsg{Code: tea.KeyEnter} cmd := m.Update(msg) @@ -249,8 +266,8 @@ func TestEditScreen_EnterSaves(t *testing.T) { if !ok { t.Errorf("expected saveEnvRequestMsg, got %T", resultMsg) } - if saveReq.Env.Key != "GOPATH" || saveReq.Env.Value != "/new/path" { - t.Errorf("saveEnvRequestMsg.Env = {%q, %q}, want {GOPATH, /new/path}", saveReq.Env.Key, saveReq.Env.Value) + if saveReq.Env.Key != "GOPATH" || saveReq.Env.Value != newPath { + t.Errorf("saveEnvRequestMsg.Env = {%q, %q}, want {GOPATH, %q}", saveReq.Env.Key, saveReq.Env.Value, newPath) } if saveReq.OriginalValue != "/old/path" { t.Errorf("saveEnvRequestMsg.OriginalValue = %q, want /old/path", saveReq.OriginalValue) @@ -305,9 +322,10 @@ func TestEditScreen_CtrlR_ResetsToOriginal(t *testing.T) { // --- Undo/Redo History Tests --- func TestUndoRedo_RecordsEdit(t *testing.T) { - _ = persist.ClearHistory() // Ensure clean state items := []goenv.EnvVar{{Key: "GOPATH", Value: "/old"}} m := NewMainModel(items, nil, persist.DefaultConfig()) + m.editHistory = nil + m.historyIdx = 0 // Process envSetSuccessMsg with a new value (simulating successful save) msg := envSetSuccessMsg{ @@ -338,9 +356,10 @@ func TestUndoRedo_RecordsEdit(t *testing.T) { } func TestUndoRedo_NoRecordWhenValueUnchanged(t *testing.T) { - _ = persist.ClearHistory() // Ensure clean state items := []goenv.EnvVar{{Key: "GOPATH", Value: "/same"}} m := NewMainModel(items, nil, persist.DefaultConfig()) + m.editHistory = nil + m.historyIdx = 0 // Process envSetSuccessMsg with the same value msg := envSetSuccessMsg{ @@ -419,9 +438,10 @@ func TestUndoRedo_RedoReapplies(t *testing.T) { } func TestUndoRedo_NothingToUndo(t *testing.T) { - _ = persist.ClearHistory() // Ensure clean state items := []goenv.EnvVar{{Key: "GOPATH", Value: "/go"}} m := NewMainModel(items, nil, persist.DefaultConfig()) + m.editHistory = nil + m.historyIdx = 0 // historyIdx is 0, no history msg := undoMsg{} @@ -434,9 +454,10 @@ func TestUndoRedo_NothingToUndo(t *testing.T) { } func TestUndoRedo_NothingToRedo(t *testing.T) { - _ = persist.ClearHistory() // Ensure clean state items := []goenv.EnvVar{{Key: "GOPATH", Value: "/go"}} m := NewMainModel(items, nil, persist.DefaultConfig()) + m.editHistory = nil + m.historyIdx = 0 // historyIdx equals history length, nothing to redo msg := redoMsg{} @@ -449,9 +470,10 @@ func TestUndoRedo_NothingToRedo(t *testing.T) { } func TestUndoRedo_BlockedWhileSaving(t *testing.T) { - _ = persist.ClearHistory() items := []goenv.EnvVar{{Key: "GOPATH", Value: "/go"}} m := NewMainModel(items, nil, persist.DefaultConfig()) + m.editHistory = nil + m.historyIdx = 0 // Add a history entry so undo/redo would normally work m.editHistory = []persist.EditRecord{{Key: "GOPATH", OldValue: "/old", NewValue: "/go"}} m.historyIdx = 1 @@ -574,10 +596,11 @@ func TestMainModel_ShowHelp(t *testing.T) { // saveEnvRequestMsg (not envSetSuccessMsg), so the actual save is triggered. // This was a critical bug where edits appeared successful but weren't persisted. func TestRegression_EditScreenReturnsSaveRequest(t *testing.T) { + newPath := t.TempDir() m := NewEditScreenModel(goenv.GoVersion{}) m.editingKey = "GOPATH" m.originalValue = "/old" - m.textInput.SetValue("/new") + m.textInput.SetValue(newPath) msg := tea.KeyPressMsg{Code: tea.KeyEnter} cmd := m.Update(msg) @@ -603,9 +626,10 @@ func TestRegression_EditScreenReturnsSaveRequest(t *testing.T) { // TestRegression_HistoryRecordedOnlyAfterSaveSuccess verifies that history is // recorded only when save succeeds, not when the edit is initiated. func TestRegression_HistoryRecordedOnlyAfterSaveSuccess(t *testing.T) { - _ = persist.ClearHistory() // Ensure clean state items := []goenv.EnvVar{{Key: "GOPATH", Value: "/old"}} m := NewMainModel(items, nil, persist.DefaultConfig()) + m.editHistory = nil + m.historyIdx = 0 // saveEnvRequestMsg should NOT record history (save hasn't completed yet) saveReq := saveEnvRequestMsg{ @@ -682,13 +706,17 @@ func TestRegression_UpdateItemByKeyReturnsBoolean(t *testing.T) { // TestRegression_SaveEnvRequestIncludesOriginalValue verifies that saveEnvRequestMsg // includes the original value, which is needed for proper history recording. func TestRegression_SaveEnvRequestIncludesOriginalValue(t *testing.T) { + newPath := t.TempDir() m := NewEditScreenModel(goenv.GoVersion{}) m.editingKey = "GOPATH" m.originalValue = "/original/path" - m.textInput.SetValue("/new/path") + m.textInput.SetValue(newPath) msg := tea.KeyPressMsg{Code: tea.KeyEnter} cmd := m.Update(msg) + if cmd == nil { + t.Fatal("expected save command, got nil") + } resultMsg := cmd() saveReq, ok := resultMsg.(saveEnvRequestMsg) @@ -945,12 +973,13 @@ func TestRegression_KeyPressMsg_DelegatedToListScreen(t *testing.T) { // TestRegression_KeyPressMsg_DelegatedToEditScreen verifies that KeyPressMsg // is properly delegated to the EditScreen when on the edit screen. func TestRegression_KeyPressMsg_DelegatedToEditScreen(t *testing.T) { + newPath := t.TempDir() items := []goenv.EnvVar{{Key: "GOPATH", Value: "/go"}} m := NewMainModel(items, nil, persist.DefaultConfig()) m.currentScreen = AppScreenEdit m.editScreen.editingKey = "GOPATH" m.editScreen.originalValue = "/go" - m.editScreen.textInput.SetValue("/new/path") + m.editScreen.textInput.SetValue(newPath) // Press Enter which should trigger saveEnvRequestMsg msg := tea.KeyPressMsg{Code: tea.KeyEnter} @@ -1105,3 +1134,29 @@ func TestMainModel_ConfigApplied(t *testing.T) { t.Errorf("NewMainModel with alpha config: sortMode = %v, want SortAlpha", m.sortMode) } } + +func TestExportFailureClearsProgressMessage(t *testing.T) { + m := NewMainModel(nil, nil, persist.DefaultConfig()) + m.currentScreen = AppScreenExport + model, _ := m.Update(exportSnapshotMsg{Name: "snapshot"}) + m = model.(mainModel) + if m.successMessage != "Exporting..." { + t.Fatal("export did not enter progress state") + } + + model, cmd := m.Update(exportErrorMsg{Err: fmt.Errorf("permission denied")}) + m = model.(mainModel) + if m.isSaving || m.spinnerActive { + t.Error("export progress remains active after failure") + } + if m.successMessage != "" { + t.Errorf("stale progress message after failure: %q", m.successMessage) + } + if m.currentScreen != AppScreenExport || cmd == nil { + t.Error("failure should retain export screen and schedule error dismissal") + } + view := m.View().Content + if !strings.Contains(view, "Export failed: permission denied") || strings.Contains(view, "Exporting...") { + t.Errorf("failure view must show the error without stale progress: %s", view) + } +} diff --git a/internal/tui/shellexportscreen.go b/internal/tui/shellexportscreen.go index 5db7238..6a41c39 100644 --- a/internal/tui/shellexportscreen.go +++ b/internal/tui/shellexportscreen.go @@ -27,6 +27,7 @@ type ShellExportScreenModel struct { scrollOffset int copied bool copyError string + exportError string maxLines int } @@ -42,6 +43,7 @@ func NewShellExportScreenModel() ShellExportScreenModel { // SetItems sets the available items for export. func (m *ShellExportScreenModel) SetItems(items []goenv.EnvVar) { m.items = items + m.exportError = "" m.phase = shellExportPhaseOptions m.scrollOffset = 0 m.copied = false @@ -50,6 +52,7 @@ func (m *ShellExportScreenModel) SetItems(items []goenv.EnvVar) { // Init initializes the shell export screen. func (m *ShellExportScreenModel) Init() tea.Cmd { m.phase = shellExportPhaseOptions + m.exportError = "" m.copied = false return nil } @@ -81,7 +84,13 @@ func (m *ShellExportScreenModel) updateOptions(msg tea.KeyPressMsg) tea.Cmd { m.filter = m.filter.Next() case "enter", "p": // Generate and show preview - output := goenv.GenerateShellExport(m.items, m.shell, m.filter) + output, err := goenv.GenerateShellExport(m.items, m.shell, m.filter) + if err != nil { + m.exportError = err.Error() + m.previewLines = nil + return nil + } + m.exportError = "" m.previewLines = strings.Split(output, "\n") m.scrollOffset = 0 m.phase = shellExportPhasePreview @@ -148,6 +157,10 @@ func (m ShellExportScreenModel) viewOptions(b *strings.Builder) string { b.WriteString(suggestionHintStyle.Render("Step 1/2: Configure") + "\n\n") b.WriteString("Generate shell commands to set Go environment variables.\n\n") + if m.exportError != "" { + b.WriteString(statusErrorStyle.Render(m.exportError) + "\n\n") + } + // Count exportable variables count := goenv.CountExportableVars(m.items, m.filter) diff --git a/mise.toml b/mise.toml index da9a9ba..0f7b86d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -go = "1.26" +go = "1.27" [env] _.path = ["./bin"]