diff --git a/.golangci.yaml b/.golangci.yaml index 903596e..00f407e 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -2,21 +2,7 @@ version: "2" linters: enable: - gocritic - exclusions: - generated: lax - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - paths: - - third_party$ - - builtin$ - - examples$ -formatters: - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ + - dupl + settings: + dupl: + threshold: 100 diff --git a/cmd/install.go b/cmd/install.go index 84594f9..48f34de 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -63,7 +63,8 @@ If you run "mtvm install go latest" it will install the latest version of go`, Args: cobra.ExactArgs(2), Aliases: []string{"i", "in"}, Run: func(cmd *cobra.Command, args []string) { - err := createInstallDir(afero.NewOsFs()) + fs := afero.NewOsFs() + err := createInstallDir(fs) if err != nil { log.Fatal(err) } @@ -79,7 +80,7 @@ If you run "mtvm install go latest" it will install the latest version of go`, log.Fatal(err) } } - installed, err := shared.IsVersionInstalled(args[0], version) + installed, err := shared.IsVersionInstalled(args[0], version, fs) if err != nil { log.Fatal(err) } diff --git a/cmd/plugincmds/install.go b/cmd/plugincmds/install.go index 81b8e5c..eb1b9b1 100644 --- a/cmd/plugincmds/install.go +++ b/cmd/plugincmds/install.go @@ -83,6 +83,15 @@ func getPluginInfoCmd(metadata plugin.Metadata) tea.Cmd { url = v.Url } } + if url == "" { + return shared.NotFoundError{ + Thing: "download", + Source: shared.Source{ + File: "cmd/plugincmds/install.go", + Function: "getPluginInfoCmd(metadata plugin.Metadata) tea.Cmd", + }, + } + } return pluginDownloadInfo{ Url: url, Name: metadata.Name, diff --git a/cmd/plugincmds/install_test.go b/cmd/plugincmds/install_test.go index 51972d3..93e0909 100644 --- a/cmd/plugincmds/install_test.go +++ b/cmd/plugincmds/install_test.go @@ -2,11 +2,13 @@ package plugincmds import ( "context" - "errors" - "fmt" "runtime" "testing" + "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/assert" + "github.com/MTVersionManager/mtvm/components/downloader" "github.com/MTVersionManager/mtvm/plugin" "github.com/MTVersionManager/mtvm/shared" @@ -15,98 +17,118 @@ import ( ) func TestGetPluginInfo(t *testing.T) { - msg := getPluginInfoCmd(plugin.Metadata{ - Name: "loremIpsum", - Version: "0.0.0", - Downloads: []plugin.Download{ - { - OS: runtime.GOOS, - Arch: runtime.GOARCH, - Url: "https://example.com", - }, + type test struct { + metadata plugin.Metadata + testFunc func(t *testing.T, msg tea.Msg) + } + exampleUsableDownloads := []plugin.Download{ + { + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Url: "https://example.com", }, - })() - if downloadInfo, ok := msg.(pluginDownloadInfo); ok { - if downloadInfo.Name != "loremIpsum" { - t.Fatalf("want name to be 'loremIpsum', got name '%v'", downloadInfo.Name) - } - if downloadInfo.Url != "https://example.com" { - t.Fatalf("want url to be 'https://example.com', got url '%v'", downloadInfo.Url) - } - compareVersionTo := semver.New(0, 0, 0, "", "") - if !compareVersionTo.Equal(downloadInfo.Version) { - t.Fatalf("Want version 0.0.0 got %v", downloadInfo.Version.String()) - } - } else if err, ok := msg.(error); ok { - t.Fatalf("want no error, got %v", err) - } else { - t.Fatalf("want pluginDownloadInfo returned, got %T with content %v", msg, msg) } -} - -func TestGetPluginInfoInvalidVersion(t *testing.T) { - msg := getPluginInfoCmd(plugin.Metadata{ - Name: "loremIpsum", - Version: "loremIpsum", - Downloads: []plugin.Download{ - { - OS: runtime.GOOS, - Arch: runtime.GOARCH, - Url: "https://example.com", + tests := map[string]test{ + "existing download": { + metadata: plugin.Metadata{ + Name: "loremIpsum", + Version: "0.0.0", + Downloads: exampleUsableDownloads, + }, + testFunc: func(t *testing.T, msg tea.Msg) { + if downloadInfo, ok := msg.(pluginDownloadInfo); ok { + assert.Equalf(t, "loremIpsum", downloadInfo.Name, "want name to be 'loremIpsum', got name '%v'", downloadInfo.Name) + assert.Equalf(t, "https://example.com", downloadInfo.Url, "want url to be 'https://example.com', got url '%v'", downloadInfo.Url) + compareVersionTo := semver.New(0, 0, 0, "", "") + if !compareVersionTo.Equal(downloadInfo.Version) { + t.Errorf("Want version 0.0.0 got %v", downloadInfo.Version.String()) + } + } else if err, ok := msg.(error); ok { + assert.NoError(t, err) + } else { + t.Errorf("want pluginDownloadInfo returned, got %T with content %v", msg, msg) + } + }, + }, + "no download": { + metadata: plugin.Metadata{ + Name: "loremIpsum", + Version: "0.0.0", + Downloads: []plugin.Download{ + { + OS: func() string { + if runtime.GOOS == "imaginaryOS" { + return "fakeOS" + } + return "imaginaryOS" + }(), + Arch: func() string { + if runtime.GOARCH == "imaginaryArch" { + return "fakeArch" + } + return "imaginaryArch" + }(), + Url: "https://example.com", + }, + }, + }, + testFunc: func(t *testing.T, msg tea.Msg) { + if err, ok := msg.(error); ok { + shared.AssertIsNotFoundError(t, err, "download", shared.Source{ + File: "cmd/plugincmds/install.go", + Function: "getPluginInfoCmd(metadata plugin.Metadata) tea.Cmd", + }) + } else { + t.Errorf("want error, got %T with content %v", msg, msg) + } + }, + }, + "invalid version": { + metadata: plugin.Metadata{ + Name: "loremIpsum", + Version: "IAmAnInvalidVersion", + Downloads: exampleUsableDownloads, + }, + testFunc: func(t *testing.T, msg tea.Msg) { + if err, ok := msg.(error); ok { + assert.ErrorIs(t, err, semver.ErrInvalidSemVer) + } else { + t.Errorf("want error, got %T with content %v", msg, msg) + } }, }, - })() - if err, ok := msg.(error); !ok { - t.Fatalf("want error, got %T with contents %v", msg, msg) - } else if !errors.Is(err, semver.ErrInvalidSemVer) { - t.Fatalf("want error containing ErrInvalidSemVer, got %v", err) - } -} - -func TestInstallUpdateCancelQ(t *testing.T) { - err := CancelTest(tea.KeyMsg{ - Type: tea.KeyRunes, - Runes: []rune{'q'}, - }) - if err != nil { - t.Fatal(err) - } -} - -func TestPluginInstallUpdateCancelCtrlC(t *testing.T) { - err := CancelTest(tea.KeyMsg{ - Type: tea.KeyCtrlC, - }) - if err != nil { - t.Fatal(err) - } -} - -func CancelTest(keyPress tea.KeyMsg) error { - model := initialInstallModel("https://example.com") - _, cancel := context.WithCancel(context.Background()) - modelUpdated, _ := model.Update(downloader.DownloadStartedMsg{ - Cancel: cancel, - }) - _, cmd := modelUpdated.Update(keyPress) - if cmd == nil { - return errors.New("want not nil command, got nil") } - msg := cmd() - if _, ok := msg.(downloader.DownloadCanceledMsg); !ok { - return fmt.Errorf("expected returned command to return downloader.DownloadCanceledMsg, returned %v with type %T", msg, msg) + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + msg := getPluginInfoCmd(tt.metadata)() + tt.testFunc(t, msg) + }) } - return nil } -func TestPluginInstallUpdateEntriesSuccess(t *testing.T) { - model := initialInstallModel("https://example.com") - _, cmd := model.Update(shared.SuccessMsg("UpdateEntries")) - if cmd == nil { - t.Fatal("want not nil command, got nil") +func TestInstallUpdateCancel(t *testing.T) { + tests := map[string]tea.KeyMsg{ + "ctrl+c": { + Type: tea.KeyCtrlC, + }, + "q": { + Type: tea.KeyRunes, + Runes: []rune{'q'}, + }, } - msg := cmd() - if _, ok := msg.(tea.QuitMsg); !ok { - t.Fatalf("want command to return tea.QuitMsg, returned %T with content %v", msg, msg) + for name, keyPress := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + model := initialInstallModel("https://example.com") + _, cancel := context.WithCancel(context.Background()) + modelUpdated, _ := model.Update(downloader.DownloadStartedMsg{ + Cancel: cancel, + }) + _, cmd := modelUpdated.Update(keyPress) + require.NotNil(t, cmd, "want not nil command, got nil") + msg := cmd() + assert.IsType(t, downloader.DownloadCanceledMsg{}, msg) + }) } } diff --git a/cmd/remove.go b/cmd/remove.go index 1c0a731..bcdad52 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" + "github.com/spf13/afero" + "github.com/MTVersionManager/mtvm/shared" "github.com/MTVersionManager/mtvmplugin" "github.com/charmbracelet/bubbles/spinner" @@ -94,7 +96,8 @@ For example: log.Fatal(err) } } - installed, err := shared.IsVersionInstalled(args[0], version) + fs := afero.NewOsFs() + installed, err := shared.IsVersionInstalled(args[0], version, fs) if err != nil { log.Fatal(err) } diff --git a/cmd/use.go b/cmd/use.go index 07357d0..3e22819 100644 --- a/cmd/use.go +++ b/cmd/use.go @@ -117,7 +117,7 @@ So if you run go version it will print the version number 1.23.3`, log.Fatal(err) } } - versionInstalled, err := shared.IsVersionInstalled(args[0], version) + versionInstalled, err := shared.IsVersionInstalled(args[0], version, fs) if err != nil { log.Fatal(err) } diff --git a/components/downloader/downloader.go b/components/downloader/downloader.go index 60739b2..cb0fa4a 100644 --- a/components/downloader/downloader.go +++ b/components/downloader/downloader.go @@ -43,8 +43,7 @@ func (dw *downloadWriter) Start() { // This sends a signal to the update function that it is safe to close the response body dw.copyDone <- true if err != nil && !errors.Is(err, context.Canceled) { - fmt.Println("Error from copying") - log.Fatal(err) + log.Fatal(fmt.Errorf("from copying: %v", err)) } } @@ -132,13 +131,18 @@ func (m Model) startDownload() tea.Msg { return fmt.Errorf("%v %v", resp.StatusCode, http.StatusText(resp.StatusCode)) } contentLengthKnown := true - if resp.ContentLength <= 0 { - if resp.ContentLength == -1 { - contentLengthKnown = false - } else { - cancel() - return errors.New("error when getting content length") + switch resp.ContentLength { + case -1: + contentLengthKnown = false + case 0: + cancel() + return errors.New("content length is 0") + default: + if resp.ContentLength > 0 { + break } + cancel() + return errors.New("unexpected error when getting content length") } m.writer.totalSize = resp.ContentLength m.writer.resp = resp diff --git a/components/downloader/downloader_test.go b/components/downloader/downloader_test.go index 650d9b1..e1bdba3 100644 --- a/components/downloader/downloader_test.go +++ b/components/downloader/downloader_test.go @@ -1,6 +1,19 @@ package downloader -import "testing" +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/stretchr/testify/assert" +) + +var hundredByteLongLoremIpsum = []byte("Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesqu") func TestDownloadWriter_Write(t *testing.T) { dw := downloadWriter{ @@ -22,22 +35,146 @@ func TestDownloadWriter_Write(t *testing.T) { for i := 0; i < 2; i++ { select { case progress := <-dw.progressChannel: - if progress != 0.5 { - t.Fatalf("want 0.5 progress, got %v progress", progress) - } + assert.Equalf(t, 0.5, progress, "want 0.5 progress, got %v progress", progress) case returnedData := <-returnDataChannel: - if returnedData.error != nil { - t.Fatal(returnedData.error) - } - if returnedData.int != 50 { - t.Fatalf("want 50 bytes written, got %v bytes written", returnedData.int) - } + assert.NoError(t, returnedData.error) + assert.Equalf(t, 50, returnedData.int, "want 50 bytes written, got %v bytes written", returnedData.int) } } - if dw.downloadedSize != 50 { - t.Fatalf("want total witten size 50, got %v", dw.downloadedSize) + assert.Equalf(t, int64(50), dw.downloadedSize, "want total witten size 50, got %v", dw.downloadedSize) + assert.Equalf(t, 50, len(dw.downloadedData), "want 50 bytes of content, got %v bytes of content", len(dw.downloadedData)) +} + +func TestModel_StartDownload(t *testing.T) { + tests := map[string]struct { + headers map[string]string + options []Option + statusCode int + testFuncBeforeFinish func(t *testing.T, model Model, msg tea.Msg) + testFuncAfterFinish func(t *testing.T, model Model, msg tea.Msg) + shouldDownload bool + chunked bool + }{ + "content length present status 200": { + headers: map[string]string{ + "Content-Length": "100", + }, + statusCode: 200, + testFuncBeforeFinish: func(t *testing.T, model Model, msg tea.Msg) { + if err, ok := msg.(error); ok { + assert.NoError(t, err) + } + assert.IsType(t, DownloadStartedMsg{}, msg) + downloadStartedMsg := msg.(DownloadStartedMsg) + assert.True(t, downloadStartedMsg.contentLengthKnown, "want content length to be known, got not known") + assert.EqualValuesf(t, 100, model.writer.totalSize, "want 100 bytes total size, got %v bytes total size", model.writer.totalSize) + }, + testFuncAfterFinish: func(t *testing.T, model Model, msg tea.Msg) { + assert.Equal(t, hundredByteLongLoremIpsum, model.writer.downloadedData) + }, + shouldDownload: true, + chunked: false, + }, + "content length not present status 200": { + statusCode: 200, + shouldDownload: true, + chunked: true, + headers: map[string]string{ + "Transfer-Encoding": "chunked", + }, + testFuncBeforeFinish: func(t *testing.T, model Model, msg tea.Msg) { + if err, ok := msg.(error); ok { + assert.NoError(t, err) + } + assert.IsType(t, DownloadStartedMsg{}, msg) + downloadStartedMsg := msg.(DownloadStartedMsg) + assert.False(t, downloadStartedMsg.contentLengthKnown, "want content length to be not known, got known") + }, + testFuncAfterFinish: func(t *testing.T, model Model, msg tea.Msg) { + assert.Equal(t, hundredByteLongLoremIpsum, model.writer.downloadedData) + }, + }, + "status 400": { + statusCode: 400, + shouldDownload: false, + chunked: false, + testFuncBeforeFinish: func(t *testing.T, model Model, msg tea.Msg) { + if err, ok := msg.(error); ok { + assert.Error(t, err) + assert.EqualError(t, err, "400 Bad Request") + } else { + t.Errorf("want error, got %T with content %v", msg, msg) + } + }, + }, } - if len(dw.downloadedData) != 50 { - t.Fatalf("want 50 bytes of content, got %v bytes of content", len(dw.downloadedData)) + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + for header, value := range tt.headers { + writer.Header().Set(header, value) + } + writer.WriteHeader(tt.statusCode) + var written int + if tt.chunked { + writtenChunk1, err := writer.Write(hundredByteLongLoremIpsum[:50]) + assert.NoError(t, err) + assert.Equalf(t, 50, writtenChunk1, "want 50 bytes written from server for chunk 1, got %v bytes written", writtenChunk1) + written += writtenChunk1 + writtenChunk2, err := writer.Write(hundredByteLongLoremIpsum[50:]) + assert.NoError(t, err) + assert.Equalf(t, 50, writtenChunk2, "want 50 bytes written from server for chunk 2, got %v bytes written", writtenChunk2) + written += writtenChunk2 + } else { + var err error + written, err = writer.Write(hundredByteLongLoremIpsum) + assert.NoError(t, err) + } + assert.Equalf(t, 100, written, "want 100 bytes written from server, got %v bytes written", written) + })) + defer server.Close() + model := New(server.URL, tt.options...) + msg := model.startDownload() + require.NotNil(t, tt.testFuncBeforeFinish) + tt.testFuncBeforeFinish(t, model, msg) + if tt.shouldDownload { + go func() { + for range model.writer.progressChannel { + <-model.writer.progressChannel + } + }() + waitForResponseFinish(model.writer.copyDone)() + err := model.writer.resp.Body.Close() + assert.NoErrorf(t, err, "want no error closing response body, got %v", err) + assert.EqualValuesf(t, 100, model.writer.downloadedSize, "want 100 bytes downloaded, got %v bytes downloaded", model.writer.downloadedSize) + require.NotNil(t, tt.testFuncAfterFinish) + tt.testFuncAfterFinish(t, model, msg) + } + }) } + t.Run("invalid url", func(t *testing.T) { + model := New("loremIpsum") + msg := model.startDownload() + if err, ok := msg.(error); ok { + assert.Error(t, err) + var urlError *url.Error + assert.ErrorAs(t, err, &urlError) + } else { + t.Errorf("want error, got %T with content %v", msg, msg) + } + }) + t.Run("no content", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + written, err := writer.Write([]byte{}) + assert.NoError(t, err) + assert.Equalf(t, 0, written, "want 0 bytes written from server, got %v bytes written", written) + })) + defer server.Close() + model := New(server.URL) + msg := model.startDownload() + if err, ok := msg.(error); ok { + assert.Error(t, err) + assert.EqualError(t, err, "content length is 0") + } + }) } diff --git a/go.mod b/go.mod index 7d3e60f..5ef69c7 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/spf13/afero v1.14.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 + github.com/stretchr/testify v1.10.0 ) require ( @@ -22,6 +23,7 @@ require ( github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect @@ -39,11 +41,13 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 15e6132..0b3f53b 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= diff --git a/plugin/commands.go b/plugin/commands.go index 31bf2ce..8b3c1d6 100644 --- a/plugin/commands.go +++ b/plugin/commands.go @@ -1,8 +1,6 @@ package plugin import ( - "errors" - "github.com/MTVersionManager/mtvm/shared" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/afero" @@ -25,7 +23,7 @@ func InstalledVersionCmd(pluginName string, fs afero.Fs) tea.Cmd { return func() tea.Msg { version, err := InstalledVersion(pluginName, fs) if err != nil { - if errors.Is(err, ErrNotFound) { + if shared.IsNotFound(err) { return NotFoundMsg{ PluginName: pluginName, Source: "InstalledVersion", @@ -41,7 +39,7 @@ func RemoveEntryCmd(pluginName string, fs afero.Fs) tea.Cmd { return func() tea.Msg { err := RemoveEntry(pluginName, fs) if err != nil { - if errors.Is(err, ErrNotFound) { + if shared.IsNotFound(err) { return NotFoundMsg{ PluginName: pluginName, Source: "RemoveEntry", @@ -57,7 +55,7 @@ func RemoveCmd(pluginName string, fs afero.Fs) tea.Cmd { return func() tea.Msg { err := Remove(pluginName, fs) if err != nil { - if errors.Is(err, ErrNotFound) { + if shared.IsNotFound(err) { return NotFoundMsg{ PluginName: pluginName, Source: "Remove", diff --git a/plugin/errors.go b/plugin/errors.go deleted file mode 100644 index 7db2c3e..0000000 --- a/plugin/errors.go +++ /dev/null @@ -1,5 +0,0 @@ -package plugin - -import "errors" - -var ErrNotFound = errors.New("plugin not found") diff --git a/plugin/utils.go b/plugin/utils.go index af58248..8de4823 100644 --- a/plugin/utils.go +++ b/plugin/utils.go @@ -12,7 +12,7 @@ import ( "github.com/MTVersionManager/mtvm/config" ) -// UpdateEntries updates the data of an entry if it exists, and adds an entry if it doesn't +// UpdateEntries updates the data of an entry if it exists and adds an entry if it doesn't func UpdateEntries(entry Entry, fs afero.Fs) error { configDir, err := config.GetConfigDir() if err != nil { @@ -52,7 +52,7 @@ func UpdateEntries(entry Entry, fs afero.Fs) error { } // InstalledVersion returns the current version of a plugin that is installed. -// Returns an ErrNotFound if the version is not found. +// Returns a NotFoundError if the version is not found. func InstalledVersion(pluginName string, fs afero.Fs) (string, error) { configDir, err := config.GetConfigDir() if err != nil { @@ -61,7 +61,13 @@ func InstalledVersion(pluginName string, fs afero.Fs) (string, error) { data, err := afero.ReadFile(fs, filepath.Join(configDir, "plugins.json")) if err != nil { if os.IsNotExist(err) { - return "", ErrNotFound + return "", shared.NotFoundError{ + Thing: "plugins.json", + Source: shared.Source{ + File: "plugin/utils.go", + Function: "InstalledVersion(pluginName string, fs afero.Fs) (string, error)", + }, + } } return "", err } @@ -75,7 +81,13 @@ func InstalledVersion(pluginName string, fs afero.Fs) (string, error) { return v.Version, nil } } - return "", fmt.Errorf("%w: %s", ErrNotFound, pluginName) + return "", fmt.Errorf("%w: %s", shared.NotFoundError{ + Thing: "entry", + Source: shared.Source{ + File: "plugin/utils.go", + Function: "InstalledVersion(pluginName string, fs afero.Fs) (string, error)", + }, + }, pluginName) } // GetEntries returns a list of installed plugins and an error @@ -107,7 +119,13 @@ func RemoveEntry(pluginName string, fs afero.Fs) error { data, err := afero.ReadFile(fs, filepath.Join(configDir, "plugins.json")) if err != nil { if os.IsNotExist(err) { - return ErrNotFound + return shared.NotFoundError{ + Thing: "plugins.json", + Source: shared.Source{ + File: "plugin/utils.go", + Function: "RemoveEntry(pluginName string, fs afero.Fs) error", + }, + } } return err } @@ -116,8 +134,15 @@ func RemoveEntry(pluginName string, fs afero.Fs) error { if err != nil { return err } + notFound := shared.NotFoundError{ + Thing: "entry", + Source: shared.Source{ + File: "plugin/utils.go", + Function: "RemoveEntry(pluginName string, fs afero.Fs) error", + }, + } if len(entries) == 0 { - return ErrNotFound + return notFound } removed := make([]Entry, 0, len(entries)-1) for _, v := range entries { @@ -126,7 +151,7 @@ func RemoveEntry(pluginName string, fs afero.Fs) error { } } if len(removed) == len(entries) { - return ErrNotFound + return notFound } data, err = json.MarshalIndent(removed, "", " ") if err != nil { @@ -138,7 +163,13 @@ func RemoveEntry(pluginName string, fs afero.Fs) error { func Remove(pluginName string, fs afero.Fs) error { err := fs.Remove(filepath.Join(shared.Configuration.PluginDir, pluginName+shared.LibraryExtension)) if os.IsNotExist(err) { - return ErrNotFound + return shared.NotFoundError{ + Thing: "plugin", + Source: shared.Source{ + File: "plugin/utils.go", + Function: "Remove(pluginName string, fs afero.Fs) error", + }, + } } return err } diff --git a/plugin/utils_test.go b/plugin/utils_test.go index c63ba47..9183a9d 100644 --- a/plugin/utils_test.go +++ b/plugin/utils_test.go @@ -2,17 +2,19 @@ package plugin import ( "encoding/json" - "errors" "os" "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/MTVersionManager/mtvm/config" "github.com/MTVersionManager/mtvm/shared" "github.com/spf13/afero" ) -var oneEntryJson string = `[ +var oneEntryJson = `[ { "name": "loremIpsum", "version": "0.0.0", @@ -20,7 +22,7 @@ var oneEntryJson string = `[ } ]` -var twoEntryJson string = `[ +var twoEntryJson = `[ { "name": "loremIpsum", "version": "0.0.0", @@ -33,32 +35,74 @@ var twoEntryJson string = `[ } ]` -func TestInstalledVersionNoPluginFile(t *testing.T) { +func TestInstalledVersionNoPluginsJson(t *testing.T) { _, err := InstalledVersion("loremIpsum", afero.NewMemMapFs()) - checkIfErrNotFound(t, err) + shared.AssertIsNotFoundError(t, err, "plugins.json", shared.Source{ + File: "plugin/utils.go", + Function: "InstalledVersion(pluginName string, fs afero.Fs) (string, error)", + }) } -func TestInstalledVersionEmptyPluginFile(t *testing.T) { - fs := afero.NewMemMapFs() - createAndWritePluginsJson(t, []byte("[]"), fs) - _, err := InstalledVersion("loremIpsum", fs) - checkIfErrNotFound(t, err) +func TestInstalledVersionWithPluginsJson(t *testing.T) { + testFuncErrNotFound := func(t *testing.T, _ string, err error) { + shared.AssertIsNotFoundError(t, err, "entry", shared.Source{ + File: "plugin/utils.go", + Function: "InstalledVersion(pluginName string, fs afero.Fs) (string, error)", + }) + } + tests := map[string]struct { + pluginsJsonContent []byte + pluginName string + testFunc func(t *testing.T, version string, err error) + }{ + "empty plugins.json": { + pluginsJsonContent: []byte(`[]`), + pluginName: "loremIpsum", + testFunc: testFuncErrNotFound, + }, + "non-existent entry": { + pluginsJsonContent: []byte(oneEntryJson), + pluginName: "dolorSitAmet", + testFunc: testFuncErrNotFound, + }, + "invalid json": { + pluginsJsonContent: []byte(""), + pluginName: "loremIpsum", + testFunc: func(t *testing.T, version string, err error) { + checkIfJsonSyntaxError(t, err) + assert.Emptyf(t, version, "want version to be empty, got %v", version) + }, + }, + "existing entry": { + pluginsJsonContent: []byte(oneEntryJson), + pluginName: "loremIpsum", + testFunc: func(t *testing.T, version string, err error) { + assert.NoError(t, err) + assert.Equal(t, "0.0.0", version) + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + fs := afero.NewMemMapFs() + createAndWritePluginsJson(t, tt.pluginsJsonContent, fs) + version, err := InstalledVersion(tt.pluginName, fs) + tt.testFunc(t, version, err) + }) + } } -func TestAddFirstEntryNoPluginFile(t *testing.T) { +func TestAddFirstEntryNoPluginsJson(t *testing.T) { fs := afero.NewMemMapFs() err := UpdateEntries(Entry{ Name: "loremIpsum", Version: "0.0.0", MetadataUrl: "https://example.com", }, fs) - if err != nil { - t.Fatalf("want no error, got %v", err) - } + assert.NoError(t, err) data := readPluginsJson(t, fs) - if string(data) != oneEntryJson { - t.Fatalf("want plugins.json to contain\n%v\ngot plugins.json containing\n%v", oneEntryJson, string(data)) - } + assert.Equal(t, oneEntryJson, string(data)) } func TestUpdateEntryWithPluginsJson(t *testing.T) { @@ -78,9 +122,7 @@ func TestUpdateEntryWithPluginsJson(t *testing.T) { wantsError: false, testFunc: func(t *testing.T, fs afero.Fs, err error) { data := readPluginsJson(t, fs) - if string(data) != twoEntryJson { - t.Fatalf("want plugins.json to contain\n%v\ngot plugins.json containing\n%v", twoEntryJson, string(data)) - } + assert.Equal(t, twoEntryJson, string(data)) }, }, "update existing": { @@ -100,9 +142,7 @@ func TestUpdateEntryWithPluginsJson(t *testing.T) { "metadataUrl": "https://example.com" } ]` - if string(data) != expected { - t.Fatalf("want plugins.json to contain\n%v\ngot plugins.json containing\n%v", expected, string(data)) - } + assert.Equal(t, expected, string(data)) }, }, } @@ -112,11 +152,11 @@ func TestUpdateEntryWithPluginsJson(t *testing.T) { fs := afero.NewMemMapFs() createAndWritePluginsJson(t, tt.pluginsJsonContent, fs) err := UpdateEntries(tt.entry, fs) - if tt.wantsError && err == nil { - t.Fatal("want error, got nil") + if tt.wantsError { + assert.Error(t, err) } - if !tt.wantsError && err != nil { - t.Fatalf("want no error, got %v", err) + if !tt.wantsError { + assert.NoError(t, err) } tt.testFunc(t, fs, err) }) @@ -126,12 +166,8 @@ func TestUpdateEntryWithPluginsJson(t *testing.T) { func TestGetEntriesWithNoPluginsJson(t *testing.T) { fs := afero.NewMemMapFs() entries, err := GetEntries(fs) - if err != nil { - t.Fatalf("want no error, got %v", err) - } - if entries != nil { - t.Fatalf("want entries to be nil, got %v", entries) - } + assert.NoError(t, err) + assert.Nilf(t, entries, "want entries to be nil, got %v", err) } func TestGetEntriesWithPluginsJson(t *testing.T) { @@ -142,29 +178,17 @@ func TestGetEntriesWithPluginsJson(t *testing.T) { "empty": { pluginsJsonContent: []byte(`[]`), testFunc: func(t *testing.T, entries []Entry, err error) { - if err != nil { - t.Fatalf("want no error, got %v", err) - } - if len(entries) != 0 { - t.Fatalf("want entries to be empty, got %v", entries) - } + assert.NoError(t, err) + assert.Lenf(t, entries, 0, "want entries to be empty, got %v", entries) }, }, "two entries": { pluginsJsonContent: []byte(twoEntryJson), testFunc: func(t *testing.T, entries []Entry, err error) { - if err != nil { - t.Fatalf("want no error, got %v", err) - } - if len(entries) != 2 { - t.Fatalf("want 2 entries, got %v entries containing %v", len(entries), entries) - } - if entries[0].Name != "loremIpsum" { - t.Fatalf("wanted first entry name to be 'loremIpsum', got %v", entries[0].Name) - } - if entries[1].Name != "dolorSitAmet" { - t.Fatalf("wanted second entry name to be 'dolorSitAmet', got %v", entries[1].Name) - } + assert.NoError(t, err) + assert.Len(t, entries, 2, "want 2 entries") + assert.Equalf(t, "loremIpsum", entries[0].Name, "want first entry name to be 'loremIpsum', got %v", entries[0].Name) + assert.Equalf(t, "dolorSitAmet", entries[1].Name, "want second entry name to be 'dolorSitAmet', got %v", entries[1].Name) }, }, } @@ -182,12 +206,18 @@ func TestGetEntriesWithPluginsJson(t *testing.T) { func TestRemoveEntryWithoutPluginsJson(t *testing.T) { fs := afero.NewMemMapFs() err := RemoveEntry("loremIpsum", fs) - checkIfErrNotFound(t, err) + shared.AssertIsNotFoundError(t, err, "plugins.json", shared.Source{ + File: "plugin/utils.go", + Function: "RemoveEntry(pluginName string, fs afero.Fs) error", + }) } func TestRemoveEntryWithPluginsJson(t *testing.T) { testFuncErrNotFound := func(t *testing.T, _ afero.Fs, err error) { - checkIfErrNotFound(t, err) + shared.AssertIsNotFoundError(t, err, "entry", shared.Source{ + File: "plugin/utils.go", + Function: "RemoveEntry(pluginName string, fs afero.Fs) error", + }) } tests := map[string]struct { pluginToRemove string @@ -198,13 +228,9 @@ func TestRemoveEntryWithPluginsJson(t *testing.T) { pluginToRemove: "dolorSitAmet", pluginsJsonContent: []byte(twoEntryJson), testFunc: func(t *testing.T, fs afero.Fs, err error) { - if err != nil { - t.Fatalf("want no error, got %v", err) - } + assert.NoError(t, err) data := readPluginsJson(t, fs) - if string(data) != oneEntryJson { - t.Fatalf("want plugins.json to contain\n%v\ngot plugins.json containing\n%v", oneEntryJson, string(data)) - } + assert.Equal(t, oneEntryJson, string(data)) }, }, "non-existent entry": { @@ -221,12 +247,7 @@ func TestRemoveEntryWithPluginsJson(t *testing.T) { pluginToRemove: "loremIpsum", pluginsJsonContent: []byte(""), testFunc: func(t *testing.T, _ afero.Fs, err error) { - if err == nil { - t.Fatal("want error, got nil") - } - if _, ok := err.(*json.SyntaxError); !ok { - t.Fatalf("want JSON syntax error, got %v", err) - } + checkIfJsonSyntaxError(t, err) }, }, } @@ -245,75 +266,55 @@ func TestRemoveExisting(t *testing.T) { fs := afero.NewMemMapFs() var err error shared.Configuration, err = config.GetConfig() - if err != nil { - t.Fatalf("want no error when getting configuration, got %v", err) - } + require.NoError(t, err, "when getting configuration") err = fs.MkdirAll(shared.Configuration.PluginDir, 0o777) - if err != nil { - t.Fatalf("want no error when creating plugin directory, got %v", err) - } + require.NoError(t, err, "when creating plugin directory") pluginPath := filepath.Join(shared.Configuration.PluginDir, "loremIpsum"+shared.LibraryExtension) _, err = fs.Create(pluginPath) - if err != nil { - t.Fatalf("want no error when creating plugin file, got %v", err) - } + require.NoError(t, err, "when creating plugin file") err = Remove("loremIpsum", fs) - if err != nil { - t.Fatalf("want no error, got %v", err) - } + require.NoError(t, err) _, err = fs.Stat(pluginPath) - if err == nil { - t.Fatal("want error, got nil (stat)") - } + assert.Error(t, err, "when statting plugin file") if !os.IsNotExist(err) { - t.Fatalf("want file does not exist error, got %v (stat)", err) + t.Errorf("want file does not exist error, got %v (stat)", err) } } func TestRemoveNonExistent(t *testing.T) { fs := afero.NewMemMapFs() err := Remove("loremIpsum", fs) - checkIfErrNotFound(t, err) + shared.AssertIsNotFoundError(t, err, "plugin", shared.Source{ + File: "plugin/utils.go", + Function: "Remove(pluginName string, fs afero.Fs) error", + }) } func createAndWritePluginsJson(t *testing.T, content []byte, fs afero.Fs) { configDir, err := config.GetConfigDir() - if err != nil { - t.Fatalf("want no error when getting config directory, got %v", err) - } + require.NoError(t, err, "when getting config directory") err = fs.MkdirAll(configDir, 0o666) - if err != nil { - t.Fatalf("want no error when creating config directory, got %v", err) - } + require.NoError(t, err, "when creating config directory") file, err := fs.Create(filepath.Join(configDir, "plugins.json")) - if err != nil { - t.Fatalf("want no error when creating plugins.json, got %v", err) - } - defer file.Close() + require.NoError(t, err, "when creating plugins.json") + defer func(file afero.File) { + err := file.Close() + assert.NoError(t, err, "when closing plugins.json") + }(file) _, err = file.Write(content) - if err != nil { - t.Fatalf("want no error when writing to plugins.json, got %v", err) - } + require.NoError(t, err, "when writing to plugins.json") } func readPluginsJson(t *testing.T, fs afero.Fs) []byte { configDir, err := config.GetConfigDir() - if err != nil { - t.Fatalf("want no error when getting config directory, got %v", err) - return nil - } + require.NoError(t, err, "when getting config directory") data, err := afero.ReadFile(fs, filepath.Join(configDir, "plugins.json")) - if err != nil { - t.Fatalf("want no error when reading plugins.json, got %v", err) - } + require.NoError(t, err, "when reading plugins.json") return data } -func checkIfErrNotFound(t *testing.T, err error) { - if err == nil { - t.Fatal("want error, got nil") - } - if !errors.Is(err, ErrNotFound) { - t.Fatalf("want error to contain ErrNotFound, got error not containing ErrNotFound") - } +func checkIfJsonSyntaxError(t *testing.T, err error) { + require.Error(t, err) + var syntaxError *json.SyntaxError + assert.ErrorAs(t, err, &syntaxError) } diff --git a/shared/errors.go b/shared/errors.go new file mode 100644 index 0000000..4a6ce1e --- /dev/null +++ b/shared/errors.go @@ -0,0 +1,14 @@ +package shared + +import "fmt" + +type NotFoundError struct { + // The thing that couldn't be found + Thing string + // The source of the error (the function that returned it and what file that function is in) + Source Source +} + +func (e NotFoundError) Error() string { + return fmt.Sprintf("%v could not find %v", e.Source, e.Thing) +} diff --git a/shared/errors_test.go b/shared/errors_test.go new file mode 100644 index 0000000..5123b06 --- /dev/null +++ b/shared/errors_test.go @@ -0,0 +1,18 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNotFoundError_Error(t *testing.T) { + err := NotFoundError{ + Thing: "lorem", + Source: Source{ + File: "ipsum", + Function: "dolor", + }, + } + assert.Equal(t, "ipsum:dolor could not find lorem", err.Error()) +} diff --git a/shared/shared.go b/shared/shared.go deleted file mode 100644 index 2b81565..0000000 --- a/shared/shared.go +++ /dev/null @@ -1,41 +0,0 @@ -package shared - -import ( - "errors" - "os" - "path/filepath" - // "strings" - - // "github.com/MTVersionManager/goplugin" - "github.com/MTVersionManager/mtvmplugin" - "github.com/charmbracelet/lipgloss" - - "github.com/MTVersionManager/mtvm/config" -) - -var Configuration config.Config - -type SuccessMsg string - -var CheckMark = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).SetString("✓").String() - -func IsVersionInstalled(tool, version string) (bool, error) { - _, err := os.Stat(filepath.Join(Configuration.InstallDir, tool, version)) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - return true, nil -} - -func LoadPlugin(tool string) (mtvmplugin.Plugin, error) { - // var plugin mtvmplugin.Plugin - // if strings.ToLower(tool) == "go" { - // plugin = &goplugin.Plugin{} - // } else { - return nil, errors.New("plugin support is not yet implemented") - // } - // return plugin, nil -} diff --git a/shared/testUtils.go b/shared/testUtils.go new file mode 100644 index 0000000..8aa4ecc --- /dev/null +++ b/shared/testUtils.go @@ -0,0 +1,15 @@ +package shared + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func AssertIsNotFoundError(t mock.TestingT, err error, thing string, source Source) { + require.Error(t, err) + var notFoundError NotFoundError + require.ErrorAs(t, err, ¬FoundError) + assert.Equalf(t, thing, notFoundError.Thing, "want error to contain thing %v, got %v", thing, notFoundError.Thing) + assert.Equalf(t, source, notFoundError.Source, "want error to contain source %v, got %v", source, notFoundError.Source) +} diff --git a/shared/testUtils_test.go b/shared/testUtils_test.go new file mode 100644 index 0000000..c4f8da8 --- /dev/null +++ b/shared/testUtils_test.go @@ -0,0 +1,163 @@ +package shared + +import ( + "errors" + "fmt" + "runtime" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockTestingT struct { + mock.TestingT + logs []string + errors []string + failed bool +} + +func (m *mockTestingT) Logf(format string, args ...interface{}) { + m.logs = append(m.logs, fmt.Sprintf(format, args...)) +} + +func (m *mockTestingT) Errorf(format string, args ...interface{}) { + m.errors = append(m.errors, fmt.Sprintf(format, args...)) +} + +func (m *mockTestingT) FailNow() { + m.failed = true + runtime.Goexit() +} + +func newMockTestingT() *mockTestingT { + return &mockTestingT{ + logs: []string{}, + errors: []string{}, + failed: false, + } +} + +func TestAssertIsNotFoundError(t *testing.T) { + normalTestSource := Source{ + File: "ipsum", + Function: "dolor", + } + alteredTestSource := Source{ + File: "sit", + Function: "amet", + } + tests := map[string]struct { + err error + thing string + source Source + testFunc func(mockT *mockTestingT) + }{ + "matching": { + err: NotFoundError{ + Thing: "lorem", + Source: normalTestSource, + }, + thing: "lorem", + source: normalTestSource, + testFunc: func(mockT *mockTestingT) { + assertNotFailedNoLogs(t, *mockT) + assert.Len(t, mockT.errors, 0, "want no errors, got errors") + }, + }, + "thing mismatch": { + err: NotFoundError{ + Thing: "sit", + Source: normalTestSource, + }, + thing: "lorem", + source: normalTestSource, + testFunc: func(mockT *mockTestingT) { + assertNotFailedNoLogs(t, *mockT) + getExpectedErrorAndCompare(t, *mockT, func(mockForExpected *mockTestingT) { + assert.Equal(mockForExpected, "lorem", "sit", "want error to contain thing lorem, got sit") + }) + }, + }, + "source mismatch": { + err: NotFoundError{ + Thing: "lorem", + Source: alteredTestSource, + }, + thing: "lorem", + source: normalTestSource, + testFunc: func(mockT *mockTestingT) { + assertNotFailedNoLogs(t, *mockT) + getExpectedErrorAndCompare(t, *mockT, func(mockForExpected *mockTestingT) { + assert.Equalf(mockForExpected, normalTestSource, alteredTestSource, "want error to contain source %v, got %v", normalTestSource, alteredTestSource) + }) + }, + }, + "wrong error type": { + err: errors.New("loremIpsum"), + thing: "lorem", + source: normalTestSource, + testFunc: func(mockT *mockTestingT) { + assertFailedNoLogs(t, *mockT) + getExpectedErrorAndCompare(t, *mockT, func(mockForExpected *mockTestingT) { + assert.ErrorAs(mockForExpected, errors.New("loremIpsum"), &NotFoundError{}) + }) + }, + }, + "nil error": { + err: nil, + thing: "lorem", + source: normalTestSource, + testFunc: func(mockT *mockTestingT) { + assertFailedNoLogs(t, *mockT) + getExpectedErrorAndCompare(t, *mockT, func(mockForExpected *mockTestingT) { + assert.Error(mockForExpected, nil) + }) + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + mockT := newMockTestingT() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + AssertIsNotFoundError(mockT, tt.err, tt.thing, tt.source) + }() + wg.Wait() + // go AssertIsNotFoundError(mockT, tt.err, tt.thing, tt.source) + tt.testFunc(mockT) + }) + } +} + +func removeErrorTrace(error string) string { + if i := strings.Index(error, "Error:"); i != -1 { + return strings.TrimSpace(error[i:]) + } + return error +} + +func getExpectedErrorAndCompare(t *testing.T, mockT mockTestingT, function func(mockForExpected *mockTestingT)) { + require.Lenf(t, mockT.errors, 1, "want 1 error, got %v errors", len(mockT.errors)) + mockForExpected := newMockTestingT() + function(mockForExpected) + require.Lenf(t, mockForExpected.errors, 1, "want 1 error when getting expected, got %v errors", len(mockForExpected.errors)) + assert.Equal(t, removeErrorTrace(mockForExpected.errors[0]), removeErrorTrace(mockT.errors[0]), "unexpected error") +} + +func assertNotFailedNoLogs(t *testing.T, mockT mockTestingT) { + assert.False(t, mockT.failed, "want not failed, got failed") + assert.Len(t, mockT.logs, 0, "want no logs, got logs") +} + +func assertFailedNoLogs(t *testing.T, mockT mockTestingT) { + assert.True(t, mockT.failed, "want failed, got not failed") + assert.Len(t, mockT.logs, 0, "want no logs, got logs") +} diff --git a/shared/types.go b/shared/types.go new file mode 100644 index 0000000..851a534 --- /dev/null +++ b/shared/types.go @@ -0,0 +1,12 @@ +package shared + +type SuccessMsg string + +type Source struct { + File string + Function string +} + +func (s Source) String() string { + return s.File + ":" + s.Function +} diff --git a/shared/types_test.go b/shared/types_test.go new file mode 100644 index 0000000..bc82e10 --- /dev/null +++ b/shared/types_test.go @@ -0,0 +1,17 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSource_String(t *testing.T) { + source := Source{ + File: "lorem", + Function: "ipsum", + } + expected := "lorem:ipsum" + output := source.String() + assert.Equal(t, expected, output) +} diff --git a/shared/utils.go b/shared/utils.go new file mode 100644 index 0000000..076159a --- /dev/null +++ b/shared/utils.go @@ -0,0 +1,30 @@ +package shared + +import ( + "errors" + "os" + "path/filepath" + + "github.com/spf13/afero" + + "github.com/MTVersionManager/mtvmplugin" +) + +func IsVersionInstalled(tool, version string, fs afero.Fs) (bool, error) { + _, err := fs.Stat(filepath.Join(Configuration.InstallDir, tool, version)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func LoadPlugin(_ string) (mtvmplugin.Plugin, error) { + return nil, errors.New("plugin support is not yet implemented") +} + +func IsNotFound(err error) bool { + return errors.As(err, &NotFoundError{}) +} diff --git a/shared/utils_test.go b/shared/utils_test.go new file mode 100644 index 0000000..d7ddb93 --- /dev/null +++ b/shared/utils_test.go @@ -0,0 +1,127 @@ +package shared + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/assert" + + "github.com/spf13/afero" + "github.com/spf13/viper" +) + +type mockMemMapFs struct { + *afero.MemMapFs + openError error +} + +func (m *mockMemMapFs) Stat(name string) (os.FileInfo, error) { + if m.openError != nil { + return nil, m.openError + } + return m.MemMapFs.Stat(name) +} + +func newMockMemMapFs(openError error) afero.Fs { + return &mockMemMapFs{ + MemMapFs: &afero.MemMapFs{}, + openError: openError, + } +} + +func TestIsVersionInstalled(t *testing.T) { + viper.Reset() + viper.Set("installDir", "/test/install/") + err := viper.Unmarshal(&Configuration) + assert.NoError(t, err) + tests := map[string]struct { + tool, version string + fs afero.Fs + want bool + errorCheck func(t *testing.T, err error) + }{ + "installed": { + tool: "test", + version: "0.0.0", + want: true, + errorCheck: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + err := fs.MkdirAll("/test/install/test/0.0.0", 0o777) + assert.NoError(t, err) + return fs + }(), + }, + "not installed": { + tool: "test", + version: "0.0.0", + want: false, + errorCheck: func(t *testing.T, err error) { + assert.NoError(t, err) + }, + fs: afero.NewMemMapFs(), + }, + "permission error": { + tool: "test", + version: "0.0.0", + want: false, + errorCheck: func(t *testing.T, err error) { + assert.Error(t, err) + if !os.IsPermission(err) { + t.Errorf("want permission error, got %v", err) + } + }, + fs: func() afero.Fs { + fs := newMockMemMapFs(os.ErrPermission) + err := fs.MkdirAll("/test/install/test/0.0.0", 0o000) + assert.NoError(t, err) + return fs + }(), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.NotNil(t, tt.fs) + installed, err := IsVersionInstalled(tt.tool, tt.version, tt.fs) + tt.errorCheck(t, err) + assert.Equal(t, tt.want, installed) + }) + } +} + +func TestIsNotFound(t *testing.T) { + tests := map[string]struct { + err error + want bool + }{ + "not found": { + err: NotFoundError{ + Thing: "lorem", + Source: Source{ + File: "ipsum", + Function: "dolor", + }, + }, + want: true, + }, + "nil": { + err: nil, + want: false, + }, + "other error": { + err: &os.PathError{}, + want: false, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, IsNotFound(tt.err)) + }) + } +} diff --git a/shared/variables.go b/shared/variables.go new file mode 100644 index 0000000..53deed4 --- /dev/null +++ b/shared/variables.go @@ -0,0 +1,11 @@ +package shared + +import ( + "github.com/MTVersionManager/mtvm/config" + "github.com/charmbracelet/lipgloss" +) + +var ( + Configuration config.Config + CheckMark = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).SetString("✓").String() +)