Repository: TomWright/dasel
Pull request #76 (2020-12-21 23:58:42)

dasel update command

TomWright: This is functioning as expected. I just need to add some tests.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fcb7eb1..dc7c415 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 
 - Benchmark info now contains graphs.
+- `update` command to self-update dasel.
 
 ### Changed
 
diff --git a/README.md b/README.md
index e16dcb8..5e3f22d 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,7 @@ Please [open a discussion](https://github.com/TomWright/dasel/discussions) if:
 - Available to [import and use in your own projects](#go-get).
 - [Run via Docker](#docker).
 - [Faster than jq/yq](#benchmarks).
+- [Self update](#self-update).
 
 ## Table of contents
 * [Dasel](#dasel)
@@ -60,6 +61,9 @@ Please [open a discussion](https://github.com/TomWright/dasel/discussions) if:
 * [Table of contents](#table-of-contents)
 * [Playground](#playground)
 * [Installation](#installation)
+  * [Command line](#command-line)
+  * [Self Update](#self-update)
+  * [Go get](#go-get)
 * [Notes](#notes)
   * [Preserved formatting and ordering](#preserved-formatting-and-ordering)
   * [Memory Usage](#memory-usage)
@@ -163,6 +167,18 @@ If you want to use a specific version of dasel simply change `latest` to the des
 - `dev` - The latest build from `master` branch.
 - `v*.*.*` - The build from the given release.
 
+### Self Update
+
+If you have dasel installed you can easily upgrade to the latest release using:
+```
+dasel update
+```
+
+If you have a development version of dasel you should run the following:
+```
+dasel update --dev
+```
+
 ### Go get
 As with any other go package, just use `go get`.
 ```bash
diff --git a/internal/command/root.go b/internal/command/root.go
index 6c61fa6..d94e577 100644
--- a/internal/command/root.go
+++ b/internal/command/root.go
@@ -3,6 +3,7 @@ package command
 import (
 	"github.com/spf13/cobra"
 	"github.com/tomwright/dasel/internal"
+	"github.com/tomwright/dasel/internal/selfupdate"
 	"os"
 )
 
@@ -16,6 +17,7 @@ func NewRootCMD() *cobra.Command {
 	cmd.AddCommand(
 		selectCommand(),
 		putCommand(),
+		updateCommand(selfupdate.NewUpdater(internal.Version)),
 	)
 	return cmd
 }
diff --git a/internal/command/update.go b/internal/command/update.go
new file mode 100644
index 0000000..e5724d6
--- /dev/null
+++ b/internal/command/update.go
@@ -0,0 +1,103 @@
+package command
+
+import (
+	"errors"
+	"fmt"
+	"github.com/spf13/cobra"
+	"github.com/tomwright/dasel/internal/selfupdate"
+	"runtime"
+)
+
+type updateOpts struct {
+	Updater           *selfupdate.Updater
+	UpdateDevelopment bool
+}
+
+var (
+	// ErrHaveLatestVersion is returned when you are already on the latest version.
+	ErrHaveLatestVersion = errors.New("you already have the latest version")
+	// ErrNewerVersion is returned when you are on a newer version that the latest release.
+	ErrNewerVersion = errors.New("current version is newer than the latest release")
+	// ErrIgnoredDev is returned when your local version is development and you have not used the --dev flag.
+	ErrIgnoredDev = errors.New("ignoring update for development version")
+)
+
+func runUpdateCommand(opts updateOpts, cmd *cobra.Command) error {
+	currentVersion := opts.Updater.CurrentVersion()
+
+	out := cmd.OutOrStdout()
+
+	_, _ = fmt.Fprintf(out, "Updating...\nCurrent version: %s\n", currentVersion)
+
+	if currentVersion.IsDevelopment() && !opts.UpdateDevelopment {
+		return ErrIgnoredDev
+	}
+
+	release, err := opts.Updater.FindLatestRelease()
+	if err != nil {
+		return fmt.Errorf("could not find latest release: %w", err)
+	}
+
+	releaseVersion := release.Version()
+	_, _ = fmt.Fprintf(out, "Release version: %s\n", releaseVersion)
+
+	if !currentVersion.IsDevelopment() {
+		switch currentVersion.Compare(releaseVersion) {
+		case 1:
+			return ErrNewerVersion
+		case 0:
+			return ErrHaveLatestVersion
+		case -1:
+			// Latest version is newer.
+		}
+	}
+
+	asset := release.FindAssetForSystem(runtime.GOOS, runtime.GOARCH)
+	if asset == nil {
+		return fmt.Errorf("could not find asset for %s %s", runtime.GOOS, runtime.GOARCH)
+	}
+
+	downloadPath, err := opts.Updater.DownloadAsset(asset)
+	if err != nil {
+		return fmt.Errorf("could not download asset: %w", err)
+	}
+
+	// Make sure the downloaded file is deleted if an error occurs.
+	defer opts.Updater.CleanUp(downloadPath)
+
+	latestVersion, err := opts.Updater.GetVersion(downloadPath)
+	if err != nil {
+		return fmt.Errorf("could not get version information: %w", err)
+	}
+
+	_, _ = fmt.Fprintf(out, "New version: %s\n", latestVersion)
+
+	if err := opts.Updater.Replace(downloadPath); err != nil {
+		return err
+	}
+
+	_, _ = fmt.Fprintln(out, "Successfully updated")
+
+	return nil
+}
+
+func updateCommand(updater *selfupdate.Updater) *cobra.Command {
+	var updateDevFlag bool
+	cmd := &cobra.Command{
+		Use:   "update",
+		Short: "Update dasel to the latest stable release.",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			dev, _ := cmd.Flags().GetBool("dev")
+			opts := updateOpts{
+				Updater:           updater,
+				UpdateDevelopment: dev,
+			}
+			return runUpdateCommand(opts, cmd)
+		},
+	}
+
+	cmd.Flags().BoolVar(&updateDevFlag, "dev", false, "Allow updates in development version of dasel.")
+
+	return cmd
+}
diff --git a/internal/selfupdate/impl.go b/internal/selfupdate/impl.go
new file mode 100644
index 0000000..ca57a2f
--- /dev/null
+++ b/internal/selfupdate/impl.go
@@ -0,0 +1,71 @@
+package selfupdate
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"os/exec"
+)
+
+func executeCmd(name string, arg ...string) ([]byte, error) {
+	return exec.Command(name, arg...).Output()
+}
+
+func fetchGitHubRelease(httpClient *http.Client, user string, repo string, tag string) (*Release, error) {
+	if tag != "latest" {
+		tag = fmt.Sprintf("tags/%s", tag)
+	}
+	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/%s", user, repo, tag)
+
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not create request to get latest release: %w", err)
+	}
+
+	resp, err := httpClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("could not perform request to get latest release: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		return nil, nil
+	}
+
+	respBytes, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("could not read response bytes: %w", err)
+	}
+
+	release := &Release{}
+	if err := json.Unmarshal(respBytes, release); err != nil {
+		return nil, fmt.Errorf("could not parse response: %w", err)
+	}
+
+	return release, nil
+}
+
+func downloadFile(url string, dest string) error {
+	resp, err := http.Get(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	out, err := os.Create(dest)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+
+	// Write the body to file
+	_, err = io.Copy(out, resp.Body)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/selfupdate/release.go b/internal/selfupdate/release.go
new file mode 100644
index 0000000..d379733
--- /dev/null
+++ b/internal/selfupdate/release.go
@@ -0,0 +1,155 @@
+package selfupdate
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+const (
+	gitHubUsername = "TomWright"
+	gitHubRepo     = "dasel"
+)
+
+// NewUpdater returns an Updater.
+func NewUpdater(currentVersion string) *Updater {
+	return &Updater{
+		httpClient: &http.Client{
+			Timeout: time.Second * 10,
+		},
+		currentVersion: currentVersion,
+
+		FetchReleaseFn: fetchGitHubRelease,
+		DownloadFileFn: downloadFile,
+		ChmodFn:        os.Chmod,
+		ExecuteCmdFn:   executeCmd,
+		ExecutableFn:   os.Executable,
+		RenameFn:       os.Rename,
+		RemoveFn:       os.Remove,
+	}
+}
+
+// Release is a github release.
+type Release struct {
+	URL     string          `json:"url"`
+	Assets  []*ReleaseAsset `json:"assets"`
+	Name    string          `json:"name"`
+	TagName string          `json:"tag_name"`
+}
+
+// FindAssetForSystem searches returns the asset for the given OS and arch.
+func (r *Release) FindAssetForSystem(os string, arch string) *ReleaseAsset {
+	ext := ""
+	if os == "windows" {
+		ext = ".exe"
+	}
+	matches := []string{fmt.Sprintf("dasel_%s_%s%s", os, arch, ext)}
+	if os == "darwin" {
+		matches = append(matches, fmt.Sprintf("dasel_%s_%s%s", "macos", arch, ext))
+	}
+
+	for _, a := range r.Assets {
+		for _, m := range matches {
+			if a.Name == m {
+				return a
+			}
+		}
+	}
+
+	return nil
+}
+
+// Version returns the version of the release.
+func (r *Release) Version() *Version {
+	return versionFromString(r.TagName)
+}
+
+// ReleaseAsset is an asset of a Release.
+type ReleaseAsset struct {
+	URL                string `json:"url"`
+	Name               string `json:"name"`
+	BrowserDownloadURL string `json:"browser_download_url"`
+}
+
+// Updater provides functionality that allows you to update a binary from github.
+type Updater struct {
+	httpClient     *http.Client
+	currentVersion string
+
+	// FetchReleaseFn is used to fetch github release information.
+	FetchReleaseFn func(httpClient *http.Client, user string, repo string, tag string) (*Release, error)
+	// DownloadFileFn is used to download a file.
+	DownloadFileFn func(url string, dest string) error
+	// ChmodFn is used to change the permissions of a file.
+	ChmodFn func(name string, mode os.FileMode) error
+	// ExecuteCmdFn executes the given command and returns the output.
+	ExecuteCmdFn func(name string, arg ...string) ([]byte, error)
+	// ExecutableFn is used to return the path of the current executable.
+	ExecutableFn func() (string, error)
+	// RenameFn is used to rename a file.
+	RenameFn func(src string, dst string) error
+	// RemoveFn is used to remove a file.
+	RemoveFn func(path string) error
+}
+
+// FindLatestRelease returns the latest release on the given repo.
+func (u *Updater) FindLatestRelease() (*Release, error) {
+	return u.FetchReleaseFn(u.httpClient, gitHubUsername, gitHubRepo, "latest")
+}
+
+// DownloadAsset downloads and returns a path to the given asset.
+func (u *Updater) DownloadAsset(asset *ReleaseAsset) (string, error) {
+	path, err := filepath.Abs(fmt.Sprintf("./%s", asset.Name))
+	if err != nil {
+		return "", err
+	}
+
+	if err := u.DownloadFileFn(asset.BrowserDownloadURL, path); err != nil {
+		return "", fmt.Errorf("could not download file: %w", err)
+	}
+
+	if err := u.ChmodFn(path, os.ModePerm); err != nil {
+		return "", fmt.Errorf("could not make executable: %w", err)
+	}
+
+	return path, nil
+}
+
+// CurrentVersion returns the current version.
+func (u *Updater) CurrentVersion() *Version {
+	return versionFromString(u.currentVersion)
+}
+
+// GetVersion returns the version of the given executable.
+func (u *Updater) GetVersion(path string) (*Version, error) {
+	versionOutput, err := u.ExecuteCmdFn(path, "--version")
+	if err != nil {
+		return nil, fmt.Errorf("could not get new version: %w", err)
+	}
+
+	latest := versionFromString(string(versionOutput))
+
+	return latest, nil
+}
+
+// Replace replaces the current executable with the given executable.
+func (u *Updater) Replace(path string) error {
+	currentPath, err := u.ExecutableFn()
+	if err != nil {
+		return fmt.Errorf("cannot get current executable path: %w", err)
+	}
+
+	if err := u.RenameFn(path, currentPath); err != nil {
+		return fmt.Errorf("could not replace old executable: %w", err)
+	}
+
+	return nil
+}
+
+// CleanUp cleans up the given path.
+// This should be deferred once a download has completed.
+func (u *Updater) CleanUp(path string) {
+	_ = u.RemoveFn(path)
+}
diff --git a/internal/selfupdate/version.go b/internal/selfupdate/version.go
new file mode 100644
index 0000000..45a39db
--- /dev/null
+++ b/internal/selfupdate/version.go
@@ -0,0 +1,76 @@
+package selfupdate
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// Version is a semver version.
+type Version struct {
+	Raw   string
+	Major int
+	Minor int
+	Patch int
+}
+
+// String returns a string representation of the Version.
+func (v *Version) String() string {
+	if v.Major == 0 && v.Minor == 0 && v.Patch == 0 {
+		return v.Raw
+	}
+	return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
+}
+
+// IsDevelopment returns true if it's a development version.
+func (v *Version) IsDevelopment() bool {
+	return v.Raw == "development"
+}
+
+// Compare compares this version to the other version.
+// Returns 1 if newer, -1 if older, 0 if same.
+func (v *Version) Compare(other *Version) int {
+	if v.Major > other.Major {
+		return 1
+	}
+	if v.Major < other.Major {
+		return -1
+	}
+	if v.Minor > other.Minor {
+		return 1
+	}
+	if v.Minor < other.Minor {
+		return -1
+	}
+	if v.Patch > other.Patch {
+		return 1
+	}
+	if v.Patch < other.Patch {
+		return -1
+	}
+
+	return 0
+}
+
+var versionRegexp = regexp.MustCompile(`v([0-9]+)\.([0-9]+)\.([0-9]+)`)
+
+func versionFromString(version string) *Version {
+	res := &Version{
+		Raw: strings.TrimSpace(version),
+	}
+	match := versionRegexp.FindStringSubmatch(res.Raw)
+	mustAtoi := func(in string) int {
+		out, err := strconv.Atoi(in)
+		if err != nil {
+			panic(err)
+		}
+		return out
+	}
+	if len(match) == 4 {
+		res.Major = mustAtoi(match[1])
+		res.Minor = mustAtoi(match[2])
+		res.Patch = mustAtoi(match[3])
+	}
+	return res
+}
diff --git a/internal/storage/csv.go b/internal/storage/csv.go
index 654a63c..1c9c63c 100644
--- a/internal/storage/csv.go
+++ b/internal/storage/csv.go
@@ -83,9 +83,8 @@ func interfaceToCSVDocument(val interface{}) (*CSVDocument, error) {
 			Value:   []map[string]interface{}{mapVal},
 			Headers: headers,
 		}, nil
-	} else {
-		return nil, fmt.Errorf("CSVParser.toBytes cannot handle type %T", val)
 	}
+	return nil, fmt.Errorf("CSVParser.toBytes cannot handle type %T", val)
 }
 
 // ToBytes returns a slice of bytes that represents the given value.
diff --git a/internal/storage/parser.go b/internal/storage/parser.go
index 1d92dc5..61ae8b2 100644
--- a/internal/storage/parser.go
+++ b/internal/storage/parser.go
@@ -156,10 +156,12 @@ func (d originalRequired) OriginalRequired() bool {
 	return true
 }
 
+// SingleDocument is a parser result that contains a single document.
 type SingleDocument interface {
 	Document() interface{}
 }
 
+// MultiDocument is a parser result that contains multiple documents.
 type MultiDocument interface {
 	Documents() []interface{}
 }
diff --git a/internal/storage/plain.go b/internal/storage/plain.go
index e77cc58..335f7ee 100644
--- a/internal/storage/plain.go
+++ b/internal/storage/plain.go
@@ -13,6 +13,7 @@ func init() {
 type PlainParser struct {
 }
 
+// ErrPlainParserNotImplemented is returned when you try to use the PlainParser.FromBytes func.
 var ErrPlainParserNotImplemented = fmt.Errorf("PlainParser.FromBytes not implemented")
 
 // FromBytes returns some data that is represented by the given bytes.


Test patch:
diff --git a/internal/command/put_document_internal_test.go b/internal/command/put_document_internal_test.go
index 26152a5..6168365 100644
--- a/internal/command/put_document_internal_test.go
+++ b/internal/command/put_document_internal_test.go
@@ -53,4 +53,20 @@ func TestPut_Document(t *testing.T) {
 			return
 		}
 	})
+	t.Run("InvalidDocument", func(t *testing.T) {
+		err := runPutDocumentCommand(putDocumentOpts{
+			Parser:         "json",
+			Selector:       ".[*]",
+			Reader:         strings.NewReader(`[{"name": "Tom"}]`),
+			DocumentString: `{"name": "Frank}`,
+			DocumentParser: "json",
+		}, nil)
+
+		exp := "could not parse document: could not unmarshal data: unexpected EOF"
+
+		if err == nil || err.Error() != exp {
+			t.Errorf("expected error %v, got %v", exp, err)
+			return
+		}
+	})
 }
diff --git a/internal/command/put_internal_test.go b/internal/command/put_internal_test.go
index dcb579c..4c11733 100644
--- a/internal/command/put_internal_test.go
+++ b/internal/command/put_internal_test.go
@@ -201,12 +201,46 @@ func TestPut(t *testing.T) {
 			t.Errorf("unexpected error: %v", err)
 		}
 	})
-	t.Run("ObjectMissingParserFlag", func(t *testing.T) {
+	t.Run("ObjectMissingReadParserFlag", func(t *testing.T) {
 		err := runPutObjectCommand(putObjectOpts{}, nil)
 		if err == nil || err.Error() != "read parser flag required when reading from stdin" {
 			t.Errorf("unexpected error: %v", err)
 		}
 	})
+	t.Run("ObjectMissingWriteParserFlag", func(t *testing.T) {
+		err := runPutObjectCommand(putObjectOpts{
+			ReadParser:  "yaml",
+			WriteParser: "bad",
+			Reader:      bytes.NewReader([]byte(`name: Tom`)),
+			Writer:      new(bytes.Buffer),
+			Selector:    ".name",
+			InputTypes:  []string{"string"},
+			InputValues: []string{"name=Tom"},
+		}, nil)
+		if err == nil || err.Error() != "could not get write parser: unknown parser: bad" {
+			t.Errorf("unexpected error: %v", err)
+		}
+	})
+	t.Run("DocumentMissingReadParserFlag", func(t *testing.T) {
+		err := runPutDocumentCommand(putDocumentOpts{}, nil)
+		if err == nil || err.Error() != "read parser flag required when reading from stdin" {
+			t.Errorf("unexpected error: %v", err)
+		}
+	})
+	t.Run("DocumentMissingWriteParserFlag", func(t *testing.T) {
+		err := runPutDocumentCommand(putDocumentOpts{
+			ReadParser:     "yaml",
+			WriteParser:    "bad",
+			Reader:         bytes.NewReader([]byte(`name: Tom`)),
+			Writer:         new(bytes.Buffer),
+			Selector:       ".name",
+			DocumentString: "first: Tom",
+			DocumentParser: "yaml",
+		}, nil)
+		if err == nil || err.Error() != "could not get write parser: unknown parser: bad" {
+			t.Errorf("unexpected error: %v", err)
+		}
+	})
 	t.Run("ObjectInvalidTypes", func(t *testing.T) {
 		err := runPutObjectCommand(putObjectOpts{
 			File:        "../../tests/assets/example.json",
diff --git a/internal/command/root_update_test.go b/internal/command/root_update_test.go
new file mode 100644
index 0000000..1556157
--- /dev/null
+++ b/internal/command/root_update_test.go
@@ -0,0 +1,581 @@
+package command
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"github.com/spf13/cobra"
+	"github.com/tomwright/dasel/internal/selfupdate"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"path/filepath"
+	"reflect"
+	"runtime"
+	"testing"
+)
+
+func mustAbs(path string) string {
+	res, err := filepath.Abs(path)
+	if err != nil {
+		panic(err)
+	}
+	return res
+}
+
+func expectedExecutableName() string {
+	var ext string
+	if runtime.GOOS == "windows" {
+		ext = ".exe"
+	}
+	return fmt.Sprintf("dasel_%s_%s%s", runtime.GOOS, runtime.GOARCH, ext)
+}
+
+func TestRootCMD_Update(t *testing.T) {
+	expectedErr := errors.New("some expected error")
+
+	t.Run("Successful", updateTestOutputEqual("v1.0.0",
+		func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+			if exp, got := "TomWright", user; exp != got {
+				return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+			}
+			if exp, got := "dasel", repo; exp != got {
+				return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+			}
+			if exp, got := "latest", tag; exp != got {
+				return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+			}
+			return &selfupdate.Release{
+				Assets: []*selfupdate.ReleaseAsset{
+					{
+						Name:               expectedExecutableName(),
+						BrowserDownloadURL: "asd",
+					},
+				},
+				TagName: "v1.1.0",
+			}, nil
+		},
+		func(url string, dest string) error {
+			if exp, got := "asd", url; exp != got {
+				return fmt.Errorf("exp url %s, got %s", exp, got)
+			}
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), dest; exp != got {
+				return fmt.Errorf("exp dest %s, got %s", exp, got)
+			}
+			return nil
+		},
+		func(name string, mode os.FileMode) error {
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+				return fmt.Errorf("exp name %s, got %s", exp, got)
+			}
+			if exp, got := os.ModePerm, mode; exp != got {
+				return fmt.Errorf("exp mode %s, got %s", exp, got)
+			}
+			return nil
+		},
+		func(name string, arg ...string) ([]byte, error) {
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+				return nil, fmt.Errorf("exp name %s, got %s", exp, got)
+			}
+			if exp, got := []string{"--version"}, arg; !reflect.DeepEqual(exp, got) {
+				return nil, fmt.Errorf("exp args %v, got %s", exp, got)
+			}
+			return []byte(`dasel version v1.1.0`), nil
+		},
+		func() (string, error) {
+			return "/current", nil
+		},
+		func(src string, dst string) error {
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), src; exp != got {
+				return fmt.Errorf("exp src %s, got %s", exp, got)
+			}
+			if exp, got := "/current", dst; exp != got {
+				return fmt.Errorf("exp dst %s, got %s", exp, got)
+			}
+			return nil
+		},
+		func(path string) error {
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), path; exp != got {
+				return fmt.Errorf("exp path %s, got %s", exp, got)
+			}
+			return nil
+		},
+		`Updating...
+Current version: v1.0.0
+Release version: v1.1.0
+New version: v1.1.0
+Successfully updated
+`, nil))
+
+	t.Run("SuccessfulDevelopment", updateTestOutputEqual("development",
+		func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+			if exp, got := "TomWright", user; exp != got {
+				return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+			}
+			if exp, got := "dasel", repo; exp != got {
+				return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+			}
+			if exp, got := "latest", tag; exp != got {
+				return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+			}
+			return &selfupdate.Release{
+				Assets: []*selfupdate.ReleaseAsset{
+					{
+						Name:               expectedExecutableName(),
+						BrowserDownloadURL: "asd",
+					},
+				},
+				TagName: "v1.1.0",
+			}, nil
+		},
+		func(url string, dest string) error {
+			if exp, got := "asd", url; exp != got {
+				return fmt.Errorf("exp url %s, got %s", exp, got)
+			}
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), dest; exp != got {
+				return fmt.Errorf("exp dest %s, got %s", exp, got)
+			}
+			return nil
+		},
+		func(name string, mode os.FileMode) error {
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+				return fmt.Errorf("exp name %s, got %s", exp, got)
+			}
+			if exp, got := os.ModePerm, mode; exp != got {
+				return fmt.Errorf("exp mode %s, got %s", exp, got)
+			}
+			return nil
+		},
+		func(name string, arg ...string) ([]byte, error) {
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+				return nil, fmt.Errorf("exp name %s, got %s", exp, got)
+			}
+			if exp, got := []string{"--version"}, arg; !reflect.DeepEqual(exp, got) {
+				return nil, fmt.Errorf("exp args %v, got %s", exp, got)
+			}
+			return []byte(`dasel version v1.1.0`), nil
+		},
+		func() (string, error) {
+			return "/current", nil
+		},
+		func(src string, dst string) error {
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), src; exp != got {
+				return fmt.Errorf("exp src %s, got %s", exp, got)
+			}
+			if exp, got := "/current", dst; exp != got {
+				return fmt.Errorf("exp dst %s, got %s", exp, got)
+			}
+			return nil
+		},
+		func(path string) error {
+			if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), path; exp != got {
+				return fmt.Errorf("exp path %s, got %s", exp, got)
+			}
+			return nil
+		},
+		`Updating...
+Current version: development
+Release version: v1.1.0
+New version: v1.1.0
+Successfully updated
+`, nil, "--dev"))
+
+	t.Run("SkipDevelopment", updateTestOutputEqual("development",
+		nil, nil, nil, nil, nil, nil, nil,
+		``, ErrIgnoredDev))
+
+	t.Run("AlreadyOnLatestVersion", updateTestOutputEqual("v1.1.0",
+		func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+			if exp, got := "TomWright", user; exp != got {
+				return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+			}
+			if exp, got := "dasel", repo; exp != got {
+				return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+			}
+			if exp, got := "latest", tag; exp != got {
+				return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+			}
+			return &selfupdate.Release{
+				Assets: []*selfupdate.ReleaseAsset{
+					{
+						Name:               expectedExecutableName(),
+						BrowserDownloadURL: "asd",
+					},
+				},
+				TagName: "v1.1.0",
+			}, nil
+		},
+		nil, nil, nil, nil, nil, nil,
+		``, ErrHaveLatestVersion))
+
+	t.Run("AlreadyOnNewerVersion", updateTestOutputEqual("v1.1.0",
+		func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+			if exp, got := "TomWright", user; exp != got {
+				return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+			}
+			if exp, got := "dasel", repo; exp != got {
+				return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+			}
+			if exp, got := "latest", tag; exp != got {
+				return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+			}
+			return &selfupdate.Release{
+				Assets: []*selfupdate.ReleaseAsset{
+					{
+						Name:               expectedExecutableName(),
+						BrowserDownloadURL: "asd",
+					},
+				},
+				TagName: "v1.0.0",
+			}, nil
+		},
+		nil, nil, nil, nil, nil, nil,
+		``, ErrNewerVersion))
+
+	t.Run("ErrorGettingLatestRelease", updateTestOutputEqual("v1.0.0",
+		func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+			return nil, expectedErr
+		},
+		nil, nil, nil, nil, nil, nil,
+		``, expectedErr))
+
+	t.Run("MissingAssetForSystem", updateTestOutputEqual("v1.0.0",
+		func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+			if exp, got := "TomWright", user; exp != got {
+				return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+			}
+			if exp, got := "dasel", repo; exp != got {
+				return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+			}
+			if exp, got := "latest", tag; exp != got {
+				return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+			}
+			return &selfupdate.Release{
+				Assets:  []*selfupdate.ReleaseAsset{},
+				TagName: "v1.1.0",
+			}, nil
+		},
+		nil, nil, nil, nil, nil, nil,
+		``, fmt.Errorf("could not find asset for %s %s", runtime.GOOS, runtime.GOARCH)))
+
+	t.Run("DownloadError", updateTestOutputEqual("v1.0.0",
+		func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+			if exp, got := "TomWright", user; exp != got {
+				return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+			}
+			if exp, got := "dasel", repo; exp != got {
+				return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+			}
+			if exp, got := "latest", tag; exp != got {
+				return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+			}
+			return &selfupdate.Release{
+				Assets: []*selfupdate.ReleaseAsset{
+					{
+						Name:               expectedExecutableName(),
+						BrowserDownloadURL: "asd",
+					},
+				},
+				TagName: "v1.1.0",
+			}, nil
+		},
+		func(url string, dest string) error {
+			return expectedErr
+		}, nil, nil, nil, nil, nil,
+		``, expectedErr))
+
+	t.Run("FailGettingNewVersion", func(t *testing.T) {
+		removed := false
+		testFunc := updateTestOutputEqual("v1.0.0",
+			func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+				if exp, got := "TomWright", user; exp != got {
+					return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+				}
+				if exp, got := "dasel", repo; exp != got {
+					return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+				}
+				if exp, got := "latest", tag; exp != got {
+					return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+				}
+				return &selfupdate.Release{
+					Assets: []*selfupdate.ReleaseAsset{
+						{
+							Name:               expectedExecutableName(),
+							BrowserDownloadURL: "asd",
+						},
+					},
+					TagName: "v1.1.0",
+				}, nil
+			},
+			func(url string, dest string) error {
+				if exp, got := "asd", url; exp != got {
+					return fmt.Errorf("exp url %s, got %s", exp, got)
+				}
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), dest; exp != got {
+					return fmt.Errorf("exp dest %s, got %s", exp, got)
+				}
+				return nil
+			},
+			func(name string, mode os.FileMode) error {
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+					return fmt.Errorf("exp name %s, got %s", exp, got)
+				}
+				if exp, got := os.ModePerm, mode; exp != got {
+					return fmt.Errorf("exp mode %s, got %s", exp, got)
+				}
+				return nil
+			},
+			func(name string, arg ...string) ([]byte, error) {
+				return nil, expectedErr
+			},
+			nil, nil, func(path string) error {
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), path; exp != got {
+					return fmt.Errorf("exp path %s, got %s", exp, got)
+				}
+				removed = true
+				return nil
+			},
+			``, expectedErr)
+		testFunc(t)
+		if !removed {
+			t.Errorf("downloaded file was not removed")
+		}
+	})
+
+	t.Run("FailGettingCurrentExecutablePath", func(t *testing.T) {
+		removed := false
+		testFunc := updateTestOutputEqual("v1.0.0",
+			func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+				if exp, got := "TomWright", user; exp != got {
+					return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+				}
+				if exp, got := "dasel", repo; exp != got {
+					return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+				}
+				if exp, got := "latest", tag; exp != got {
+					return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+				}
+				return &selfupdate.Release{
+					Assets: []*selfupdate.ReleaseAsset{
+						{
+							Name:               expectedExecutableName(),
+							BrowserDownloadURL: "asd",
+						},
+					},
+					TagName: "v1.1.0",
+				}, nil
+			},
+			func(url string, dest string) error {
+				if exp, got := "asd", url; exp != got {
+					return fmt.Errorf("exp url %s, got %s", exp, got)
+				}
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), dest; exp != got {
+					return fmt.Errorf("exp dest %s, got %s", exp, got)
+				}
+				return nil
+			},
+			func(name string, mode os.FileMode) error {
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+					return fmt.Errorf("exp name %s, got %s", exp, got)
+				}
+				if exp, got := os.ModePerm, mode; exp != got {
+					return fmt.Errorf("exp mode %s, got %s", exp, got)
+				}
+				return nil
+			},
+			func(name string, arg ...string) ([]byte, error) {
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+					return nil, fmt.Errorf("exp name %s, got %s", exp, got)
+				}
+				if exp, got := []string{"--version"}, arg; !reflect.DeepEqual(exp, got) {
+					return nil, fmt.Errorf("exp args %v, got %s", exp, got)
+				}
+				return []byte(`dasel version v1.1.0`), nil
+			},
+			func() (string, error) {
+				return "", expectedErr
+			},
+			nil,
+			func(path string) error {
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), path; exp != got {
+					return fmt.Errorf("exp path %s, got %s", exp, got)
+				}
+				removed = true
+				return nil
+			},
+			``, expectedErr)
+		testFunc(t)
+		if !removed {
+			t.Errorf("downloaded file was not removed")
+		}
+	})
+
+	t.Run("FailReplacingCurrentExecutable", func(t *testing.T) {
+		removed := false
+		testFunc := updateTestOutputEqual("v1.0.0",
+			func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error) {
+				if exp, got := "TomWright", user; exp != got {
+					return nil, fmt.Errorf("exp user %s, got %s", exp, got)
+				}
+				if exp, got := "dasel", repo; exp != got {
+					return nil, fmt.Errorf("exp repo %s, got %s", exp, got)
+				}
+				if exp, got := "latest", tag; exp != got {
+					return nil, fmt.Errorf("exp tag %s, got %s", exp, got)
+				}
+				return &selfupdate.Release{
+					Assets: []*selfupdate.ReleaseAsset{
+						{
+							Name:               expectedExecutableName(),
+							BrowserDownloadURL: "asd",
+						},
+					},
+					TagName: "v1.1.0",
+				}, nil
+			},
+			func(url string, dest string) error {
+				if exp, got := "asd", url; exp != got {
+					return fmt.Errorf("exp url %s, got %s", exp, got)
+				}
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), dest; exp != got {
+					return fmt.Errorf("exp dest %s, got %s", exp, got)
+				}
+				return nil
+			},
+			func(name string, mode os.FileMode) error {
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+					return fmt.Errorf("exp name %s, got %s", exp, got)
+				}
+				if exp, got := os.ModePerm, mode; exp != got {
+					return fmt.Errorf("exp mode %s, got %s", exp, got)
+				}
+				return nil
+			},
+			func(name string, arg ...string) ([]byte, error) {
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), name; exp != got {
+					return nil, fmt.Errorf("exp name %s, got %s", exp, got)
+				}
+				if exp, got := []string{"--version"}, arg; !reflect.DeepEqual(exp, got) {
+					return nil, fmt.Errorf("exp args %v, got %s", exp, got)
+				}
+				return []byte(`dasel version v1.1.0`), nil
+			},
+			func() (string, error) {
+				return "/current", nil
+			},
+			func(src string, dst string) error {
+				return expectedErr
+			},
+			func(path string) error {
+				if exp, got := mustAbs(fmt.Sprintf("./%s", expectedExecutableName())), path; exp != got {
+					return fmt.Errorf("exp path %s, got %s", exp, got)
+				}
+				removed = true
+				return nil
+			},
+			``, expectedErr)
+		testFunc(t)
+		if !removed {
+			t.Errorf("downloaded file was not removed")
+		}
+	})
+
+}
+
+func updateTestOutputEqual(currentVersion string,
+	fetchReleaseFn func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error),
+	downloadFileFn func(url string, dest string) error,
+	chmodFn func(name string, mode os.FileMode) error,
+	executeCmdFn func(name string, arg ...string) ([]byte, error),
+	executableFn func() (string, error),
+	renameFn func(src string, dst string) error,
+	removeFn func(path string) error,
+	exp string, expErr error, additionalArgs ...string) func(t *testing.T) {
+
+	return updateTestCheck(currentVersion, fetchReleaseFn, downloadFileFn, chmodFn, executeCmdFn, executableFn,
+		renameFn, removeFn, func(out string) error {
+			if exp != out {
+				return fmt.Errorf("expected output %s, got %s", exp, out)
+			}
+			return nil
+		}, expErr, additionalArgs...)
+}
+
+func updateTestCheck(currentVersion string,
+	fetchReleaseFn func(httpClient *http.Client, user string, repo string, tag string) (*selfupdate.Release, error),
+	downloadFileFn func(url string, dest string) error,
+	chmodFn func(name string, mode os.FileMode) error,
+	executeCmdFn func(name string, arg ...string) ([]byte, error),
+	executableFn func() (string, error),
+	renameFn func(src string, dst string) error,
+	removeFn func(path string) error,
+	checkFn func(out string) error, expErr error, additionalArgs ...string) func(t *testing.T) {
+
+	updater := selfupdate.NewUpdater(currentVersion)
+
+	updater.FetchReleaseFn = fetchReleaseFn
+	updater.DownloadFileFn = downloadFileFn
+	updater.ChmodFn = chmodFn
+	updater.ExecuteCmdFn = executeCmdFn
+	updater.ExecutableFn = executableFn
+	updater.RenameFn = renameFn
+	updater.RemoveFn = removeFn
+
+	newRootCmd := func() *cobra.Command {
+		root := NewRootCMD()
+
+		for _, c := range root.Commands() {
+			if c.Use == "update" {
+				root.RemoveCommand(c)
+			}
+		}
+
+		root.AddCommand(updateCommand(updater))
+
+		return root
+	}
+
+	return func(t *testing.T) {
+		cmd := newRootCmd()
+		outputBuffer := bytes.NewBuffer([]byte{})
+
+		args := []string{
+			"update",
+		}
+		if additionalArgs != nil {
+			args = append(args, additionalArgs...)
+		}
+
+		cmd.SetOut(outputBuffer)
+		cmd.SetArgs(args)
+
+		err := cmd.Execute()
+
+		if expErr == nil && err != nil {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+		if expErr != nil && err == nil {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+		if expErr != nil && err != nil && !(errors.Is(err, expErr) || err.Error() == expErr.Error()) {
+			t.Errorf("expected err %v, got %v", expErr, err)
+			return
+		}
+
+		if expErr != nil || err != nil {
+			return
+		}
+
+		output, err := ioutil.ReadAll(outputBuffer)
+		if err != nil {
+			t.Errorf("unexpected error reading output buffer: %s", err)
+			return
+		}
+
+		if err := checkFn(string(output)); err != nil {
+			t.Errorf("unexpected output: %s", err)
+		}
+	}
+}