Repository: exercism/cli
Pull request #441 (2017-08-10 19:28:01)

[nextercism] Add CLI-specific config
We will eventually need to do much more in the CLI-config, but for now I'm just putting in place enough to be able to filter out files that we don't want to submit from the user's local directory (README, solution metadata, test files).

This is a prerequisite to the prepare and submit commands.


Patch:
diff --git a/cli/cli.go b/cli/cli.go
index 1b91fa4..293c5a3 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -79,7 +79,7 @@ func (c *CLI) IsUpToDate() (bool, error) {
 		return false, fmt.Errorf("unable to parse current version (%s): %s", c.Version, err)
 	}
 
-	return cv.GTE(rv), nil
+	return rv.EQ(cv), nil
 }
 
 // Upgrade allows the user to upgrade to the latest version of the CLI.
diff --git a/cmd/open.go b/cmd/open.go
index 5448928..3414400 100644
--- a/cmd/open.go
+++ b/cmd/open.go
@@ -33,7 +33,7 @@ the solution you want to see on the website.
 		paths, err := ws.Locate(args[0])
 		BailOnError(err)
 
-		var solutions []*workspace.Solution
+		var solutions []workspace.Solution
 
 		for _, path := range paths {
 			solution, err := workspace.NewSolution(path)
@@ -49,7 +49,7 @@ the solution you want to see on the website.
 		}
 
 		if len(solutions) > 1 {
-			var mine []*workspace.Solution
+			var mine []workspace.Solution
 			for _, s := range solutions {
 				if s.IsRequester {
 					mine = append(mine, s)
diff --git a/cmd/prepare.go b/cmd/prepare.go
index 8921584..68085b3 100644
--- a/cmd/prepare.go
+++ b/cmd/prepare.go
@@ -14,12 +14,13 @@ var prepareCmd = &cobra.Command{
 	Long: `Prepare downloads settings and dependencies for Exercism and the language tracks.
 
 When called without any arguments, this downloads all the copy for the CLI so we
-know what to say in all the various situations, as well as an up-to-date list
+know what to say in all the various situations. It also provides an up-to-date list
 of the API endpoints to use.
 
-When called with a track ID, it will download the files that the track maintainers
-have said are necessary for the track in general. Any files that are only
-necessary for a specific exercise will only be downloaded with the exercise.
+When called with a track ID, it will do specific setup for that track. This
+might include downloading the files that the track maintainers have said are
+necessary for the track in general. Any files that are only necessary for a specific
+exercise will only be downloaded with the exercise.
 
 To customize the CLI to suit your own preferences, use the configure command.
 	`,
@@ -28,6 +29,10 @@ To customize the CLI to suit your own preferences, use the configure command.
 	},
 }
 
+func initPrepareCmd() {
+	prepareCmd.Flags().StringP("track", "t", "", "the track you want to prepare")
+}
+
 func init() {
 	RootCmd.AddCommand(prepareCmd)
 }
diff --git a/cmd/version.go b/cmd/version.go
index 965561c..3269cec 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -3,7 +3,6 @@ package cmd
 import (
 	"fmt"
 
-	"github.com/exercism/cli/cli"
 	"github.com/spf13/cobra"
 )
 
@@ -11,9 +10,6 @@ import (
 // It follows semantic versioning.
 const Version = "3.0.0-alpha.1"
 
-// checkLatest flag for version command.
-var checkLatest bool
-
 // versionCmd outputs the version of the CLI.
 var versionCmd = &cobra.Command{
 	Use:     "version",
@@ -21,50 +17,14 @@ var versionCmd = &cobra.Command{
 	Short:   "Version outputs the version of CLI.",
 	Long: `Version outputs the version of the exercism binary that is in use.
 
-To check for the latest available version, call the command with the
+To see if there is a more recent version available, call the command with the
 --latest flag.
 	`,
-
 	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println(currentVersion())
-
-		if checkLatest {
-			c := cli.New(Version)
-			l, err := checkForUpdate(c)
-			BailOnError(err)
-
-			fmt.Println(l)
-		}
-
-		return
+		fmt.Println(Version)
 	},
 }
 
-// currentVersion returns a formatted version string for the Exercism CLI.
-func currentVersion() string {
-	return fmt.Sprintf("exercism version %s", Version)
-}
-
-// checkForUpdate verifies if the CLI is running the latest version.
-// If the client is out of date, the function returns upgrade instructions.
-func checkForUpdate(c *cli.CLI) (string, error) {
-
-	ok, err := c.IsUpToDate()
-	if err != nil {
-		return "", err
-	}
-
-	if ok {
-		return "Your CLI version is up to date.", nil
-	}
-
-	// Anything but ok is out of date.
-	msg := fmt.Sprintf("A new CLI version is available. Run `exercism upgrade` to update to %s", c.LatestRelease.Version())
-	return msg, nil
-
-}
-
 func init() {
 	RootCmd.AddCommand(versionCmd)
-	versionCmd.Flags().BoolVarP(&checkLatest, "latest", "l", false, "check latest available version")
 }
diff --git a/config/api_config.go b/config/api_config.go
index 7a6328c..4468c78 100644
--- a/config/api_config.go
+++ b/config/api_config.go
@@ -10,8 +10,9 @@ import (
 var (
 	defaultBaseURL   = "https://api.exercism.com/v1"
 	defaultEndpoints = map[string]string{
-		"download": "/solutions/%s",
-		"submit":   "/solutions/%s",
+		"download":      "/solutions/%s",
+		"submit":        "/solutions/%s",
+		"prepare-track": "/tracks/%s/settings",
 	}
 )
 
diff --git a/config/cli_config.go b/config/cli_config.go
new file mode 100644
index 0000000..79838fa
--- /dev/null
+++ b/config/cli_config.go
@@ -0,0 +1,62 @@
+package config
+
+import "github.com/spf13/viper"
+
+// CLIConfig contains settings specific to the behavior of the CLI.
+type CLIConfig struct {
+	*Config
+	Tracks Tracks
+}
+
+// NewCLIConfig loads the config file in the config directory.
+func NewCLIConfig() (*CLIConfig, error) {
+	cfg := NewEmptyCLIConfig()
+
+	if err := cfg.Load(viper.New()); err != nil {
+		return nil, err
+	}
+	cfg.SetDefaults()
+
+	return cfg, nil
+}
+
+// NewEmptyCLIConfig doesn't load the config from file or set default values.
+func NewEmptyCLIConfig() *CLIConfig {
+	return &CLIConfig{
+		Config: New(Dir(), "cli"),
+		Tracks: Tracks{},
+	}
+}
+
+// Write stores the config to disk.
+func (cfg *CLIConfig) Write() error {
+	cfg.SetDefaults()
+	if err := cfg.Validate(); err != nil {
+		return err
+	}
+	return Write(cfg)
+}
+
+// Validate ensures that the config is valid.
+// This is called before writing it.
+func (cfg *CLIConfig) Validate() error {
+	for _, track := range cfg.Tracks {
+		if err := track.CompileRegexes(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// SetDefaults ensures that we have all the necessary settings for the CLI.
+func (cfg *CLIConfig) SetDefaults() {
+	for _, track := range cfg.Tracks {
+		track.SetDefaults()
+	}
+}
+
+// Load reads a viper configuration into the config.
+func (cfg *CLIConfig) Load(v *viper.Viper) error {
+	cfg.readIn(v)
+	return v.Unmarshal(&cfg)
+}
diff --git a/config/track.go b/config/track.go
new file mode 100644
index 0000000..2f6749f
--- /dev/null
+++ b/config/track.go
@@ -0,0 +1,67 @@
+package config
+
+import (
+	"regexp"
+	"sort"
+)
+
+var defaultIgnorePatterns = []string{
+	".solution.json",
+	"README.md",
+}
+
+// Track holds the CLI-related settings for a track.
+type Track struct {
+	ID             string
+	IgnorePatterns []string
+	ignoreRegexes  []*regexp.Regexp
+}
+
+func NewTrack(id string) *Track {
+	t := &Track{
+		ID: id,
+	}
+	t.SetDefaults()
+	return t
+}
+
+func (t *Track) SetDefaults() {
+	m := map[string]bool{}
+	for _, pattern := range t.IgnorePatterns {
+		m[pattern] = true
+	}
+	for _, pattern := range defaultIgnorePatterns {
+		if !m[pattern] {
+			t.IgnorePatterns = append(t.IgnorePatterns, pattern)
+		}
+	}
+	sort.Strings(t.IgnorePatterns)
+}
+
+func (t *Track) AcceptFilename(f string) (bool, error) {
+	if err := t.CompileRegexes(); err != nil {
+		return false, err
+	}
+
+	for _, re := range t.ignoreRegexes {
+		if re.MatchString(f) {
+			return false, nil
+		}
+	}
+	return true, nil
+}
+
+func (t *Track) CompileRegexes() error {
+	if len(t.ignoreRegexes) == len(t.IgnorePatterns) {
+		return nil
+	}
+
+	for _, pattern := range t.IgnorePatterns {
+		re, err := regexp.Compile(pattern)
+		if err != nil {
+			return err
+		}
+		t.ignoreRegexes = append(t.ignoreRegexes, re)
+	}
+	return nil
+}
diff --git a/config/tracks.go b/config/tracks.go
new file mode 100644
index 0000000..8a964df
--- /dev/null
+++ b/config/tracks.go
@@ -0,0 +1,4 @@
+package config
+
+// Tracks is a collection of track-specific settings.
+type Tracks map[string]*Track
diff --git a/config/user_config.go b/config/user_config.go
index 4940dde..8022a8b 100644
--- a/config/user_config.go
+++ b/config/user_config.go
@@ -81,17 +81,12 @@ func (cfg *UserConfig) resolve(path string) string {
 	if path == "" {
 		return ""
 	}
-	if strings.HasPrefix(path, "~/") {
-		path = strings.Replace(path, "~/", "", 1)
-		return filepath.Join(cfg.Home, path)
+	if strings.HasPrefix(path, "~"+string(os.PathSeparator)) {
+		return strings.Replace(path, "~", cfg.Home, 1)
 	}
 	if filepath.IsAbs(path) {
 		return filepath.Clean(path)
 	}
-	// if using "/dir" on Windows
-	if strings.HasPrefix(path, "/") {
-		return filepath.Join(cfg.Home, filepath.Clean(path))
-	}
 	cwd, err := os.Getwd()
 	if err != nil {
 		return path
diff --git a/workspace/solution.go b/workspace/solution.go
index 519ccbf..1a2ca0a 100644
--- a/workspace/solution.go
+++ b/workspace/solution.go
@@ -27,28 +27,28 @@ type Solution struct {
 }
 
 // NewSolution reads solution metadata from a file in the given directory.
-func NewSolution(dir string) (*Solution, error) {
+func NewSolution(dir string) (Solution, error) {
 	path := filepath.Join(dir, solutionFilename)
 	b, err := ioutil.ReadFile(path)
 	if err != nil {
-		return &Solution{}, err
+		return Solution{}, err
 	}
 	var s Solution
 	if err := json.Unmarshal(b, &s); err != nil {
-		return &Solution{}, err
+		return Solution{}, err
 	}
 	s.Dir = dir
-	return &s, nil
+	return s, nil
 }
 
 // Suffix is the serial numeric value appended to an exercise directory.
 // This is appended to avoid name conflicts, and does not indicate a particular
 // iteration.
-func (s *Solution) Suffix() string {
+func (s Solution) Suffix() string {
 	return strings.Trim(strings.Replace(filepath.Base(s.Dir), s.Exercise, "", 1), "-.")
 }
 
-func (s *Solution) String() string {
+func (s Solution) String() string {
 	str := fmt.Sprintf("%s/%s", s.Track, s.Exercise)
 	if s.Suffix() != "" {
 		str = fmt.Sprintf("%s (%s)", str, s.Suffix())
@@ -60,7 +60,7 @@ func (s *Solution) String() string {
 }
 
 // Write stores solution metadata to a file.
-func (s *Solution) Write(dir string) error {
+func (s Solution) Write(dir string) error {
 	b, err := json.Marshal(s)
 	if err != nil {
 		return err
@@ -69,6 +69,5 @@ func (s *Solution) Write(dir string) error {
 	if err := ioutil.WriteFile(path, b, os.FileMode(0644)); err != nil {
 		return err
 	}
-	s.Dir = dir
 	return visibility.HideFile(path)
 }
diff --git a/workspace/solutions.go b/workspace/solutions.go
index 5b97709..4c5c4ba 100644
--- a/workspace/solutions.go
+++ b/workspace/solutions.go
@@ -11,10 +11,10 @@ import (
 )
 
 // Solutions is a collection of solutions to interactively choose from.
-type Solutions []*Solution
+type Solutions []Solution
 
 // Pick lets a user interactively select a solution from a list.
-func (sx Solutions) Pick(prompt string) (*Solution, error) {
+func (sx Solutions) Pick(prompt string) (Solution, error) {
 	// If there's just one, then we're done here.
 	if len(sx) == 1 {
 		return sx[0], nil
@@ -24,12 +24,12 @@ func (sx Solutions) Pick(prompt string) (*Solution, error) {
 
 	n, err := sx.ReadSelection(os.Stdin)
 	if err != nil {
-		return &Solution{}, err
+		return Solution{}, err
 	}
 
 	s, err := sx.Get(n)
 	if err != nil {
-		return &Solution{}, err
+		return Solution{}, err
 	}
 	return s, nil
 }
@@ -57,9 +57,9 @@ func (sx Solutions) ReadSelection(r io.Reader) (int, error) {
 
 // Get returns the solution corresponding to the number.
 // The list starts at 1, since that seems better in a user interface.
-func (sx Solutions) Get(n int) (*Solution, error) {
+func (sx Solutions) Get(n int) (Solution, error) {
 	if n <= 0 || n > len(sx) {
-		return &Solution{}, errors.New("can't do that")
+		return Solution{}, errors.New("can't do that")
 	}
 	return sx[n-1], nil
 }


Test patch:
diff --git a/cli/cli_test.go b/cli/cli_test.go
index a525974..0ea8aca 100644
--- a/cli/cli_test.go
+++ b/cli/cli_test.go
@@ -15,30 +15,8 @@ func TestIsUpToDate(t *testing.T) {
 		releaseTag string
 		ok         bool
 	}{
-		{
-			// It returns false for versions less than release.
-			cliVersion: "1.0.0",
-			releaseTag: "v1.0.1",
-			ok:         false,
-		},
-		{
-			// It returns false for pre-release versions of release.
-			cliVersion: "1.0.1-alpha.1",
-			releaseTag: "v1.0.1",
-			ok:         false,
-		},
-		{
-			// It returns true for versions equal to release.
-			cliVersion: "2.0.1",
-			releaseTag: "v2.0.1",
-			ok:         true,
-		},
-		{
-			// It returns true for versions greater than release.
-			cliVersion: "2.0.2",
-			releaseTag: "v2.0.1",
-			ok:         true,
-		},
+		{"1.0.0", "v1.0.1", false},
+		{"2.0.1", "v2.0.1", true},
 	}
 
 	for _, test := range tests {
diff --git a/cmd/version_test.go b/cmd/version_test.go
deleted file mode 100644
index d023428..0000000
--- a/cmd/version_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package cmd
-
-import (
-	"fmt"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-
-	"github.com/exercism/cli/cli"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestCurrentVersion(t *testing.T) {
-	expected := fmt.Sprintf("exercism version %s", Version)
-
-	actual := currentVersion()
-	assert.Equal(t, expected, actual)
-}
-
-func TestVersionUpdateCheck(t *testing.T) {
-	fakeEndpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprintln(w, `{"tag_name": "v2.0.0"}`)
-	})
-	ts := httptest.NewServer(fakeEndpoint)
-	defer ts.Close()
-	cli.LatestReleaseURL = ts.URL
-
-	tests := []struct {
-		version  string
-		expected string
-	}{
-		{
-			// It returns new version available for versions older than latest.
-			version:  "1.0.0",
-			expected: "A new CLI version is available. Run `exercism upgrade` to update to 2.0.0",
-		},
-		{
-			// It returns up to date for versions matching latest.
-			version:  "2.0.0",
-			expected: "Your CLI version is up to date.",
-		},
-		{
-			// It returns up to date for versions newer than latest.
-			version:  "2.0.1",
-			expected: "Your CLI version is up to date.",
-		},
-	}
-
-	for _, test := range tests {
-		c := &cli.CLI{
-			Version: test.version,
-		}
-
-		actual, err := checkForUpdate(c)
-
-		assert.NoError(t, err)
-		assert.NotEmpty(t, actual)
-		assert.Equal(t, test.expected, actual)
-	}
-}
diff --git a/config/cli_config_test.go b/config/cli_config_test.go
new file mode 100644
index 0000000..ffe5865
--- /dev/null
+++ b/config/cli_config_test.go
@@ -0,0 +1,89 @@
+package config
+
+import (
+	"io/ioutil"
+	"os"
+	"sort"
+	"testing"
+
+	"github.com/spf13/viper"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCLIConfig(t *testing.T) {
+	dir, err := ioutil.TempDir("", "cli-config")
+	assert.NoError(t, err)
+	defer os.RemoveAll(dir)
+
+	cfg := &CLIConfig{
+		Config: New(dir, "cli"),
+		Tracks: Tracks{
+			"bogus": &Track{ID: "bogus"},
+			"fake":  &Track{ID: "fake", IgnorePatterns: []string{"c", "b", "a"}},
+		},
+	}
+
+	// write it
+	err = cfg.Write()
+	assert.NoError(t, err)
+
+	// reload it
+	cfg = &CLIConfig{
+		Config: New(dir, "cli"),
+	}
+	err = cfg.Load(viper.New())
+	assert.NoError(t, err)
+	assert.Equal(t, "bogus", cfg.Tracks["bogus"].ID)
+	assert.Equal(t, "fake", cfg.Tracks["fake"].ID)
+
+	// The ignore patterns got sorted.
+	expected := append(defaultIgnorePatterns, "a", "b", "c")
+	sort.Strings(expected)
+	assert.Equal(t, expected, cfg.Tracks["fake"].IgnorePatterns)
+}
+
+func TestCLIConfigValidate(t *testing.T) {
+	cfg := &CLIConfig{
+		Tracks: Tracks{
+			"fake": &Track{
+				ID:             "fake",
+				IgnorePatterns: []string{"(?=re)"}, // not a valid regex
+			},
+		},
+	}
+
+	err := cfg.Validate()
+	assert.Error(t, err)
+}
+
+func TestCLIConfigSetDefaults(t *testing.T) {
+	// No tracks, no defaults.
+	cfg := &CLIConfig{}
+	cfg.SetDefaults()
+	assert.Equal(t, &CLIConfig{}, cfg)
+
+	// With a track, gets defaults.
+	cfg = &CLIConfig{
+		Tracks: map[string]*Track{
+			"bogus": &Track{
+				ID: "bogus",
+			},
+		},
+	}
+	cfg.SetDefaults()
+	assert.Equal(t, defaultIgnorePatterns, cfg.Tracks["bogus"].IgnorePatterns)
+
+	// With partial defaults and extras, gets everything.
+	cfg = &CLIConfig{
+		Tracks: map[string]*Track{
+			"bogus": &Track{
+				ID:             "bogus",
+				IgnorePatterns: []string{".solution.json", "_spec[.]ext$"},
+			},
+		},
+	}
+	cfg.SetDefaults()
+	expected := append(defaultIgnorePatterns, "_spec[.]ext$")
+	sort.Strings(expected)
+	assert.Equal(t, expected, cfg.Tracks["bogus"].IgnorePatterns)
+}
diff --git a/config/track_test.go b/config/track_test.go
new file mode 100644
index 0000000..8440e48
--- /dev/null
+++ b/config/track_test.go
@@ -0,0 +1,29 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTrackIgnoreString(t *testing.T) {
+	track := &Track{
+		IgnorePatterns: []string{
+			"con[.]txt",
+			"pro.f",
+		},
+	}
+
+	tests := map[string]bool{
+		"falcon.txt": false,
+		"beacon|txt": true,
+		"beacon.ext": true,
+		"proof":      false,
+	}
+
+	for name, acceptable := range tests {
+		ok, err := track.AcceptFilename(name)
+		assert.NoError(t, err, name)
+		assert.Equal(t, acceptable, ok, name)
+	}
+}
diff --git a/config/user_config_darwin_test.go b/config/user_config_darwin_test.go
new file mode 100644
index 0000000..5dcecd2
--- /dev/null
+++ b/config/user_config_darwin_test.go
@@ -0,0 +1,33 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNormalizeWorkspace(t *testing.T) {
+	cwd, err := os.Getwd()
+	assert.NoError(t, err)
+
+	cfg := &UserConfig{Home: "/home/alice"}
+	tests := []struct {
+		in, out string
+	}{
+		{"", ""}, // don't make wild guesses
+		{"/home/alice///foobar", "/home/alice/foobar"},
+		{"~/foobar", "/home/alice/foobar"},
+		{"/foobar/~/noexpand", "/foobar/~/noexpand"},
+		{"/no/modification", "/no/modification"},
+		{"relative", filepath.Join(cwd, "relative")},
+		{"relative///path", filepath.Join(cwd, "relative", "path")},
+	}
+
+	for _, test := range tests {
+		cfg.Workspace = test.in
+		cfg.Normalize()
+		assert.Equal(t, test.out, cfg.Workspace)
+	}
+}
diff --git a/config/user_config_test.go b/config/user_config_test.go
index f917cf4..8e0e3e8 100644
--- a/config/user_config_test.go
+++ b/config/user_config_test.go
@@ -1,62 +1,35 @@
-// +build !windows
-
-package config
-
-import (
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/spf13/viper"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestUserConfig(t *testing.T) {
-	dir, err := ioutil.TempDir("", "user-config")
-	assert.NoError(t, err)
-	defer os.RemoveAll(dir)
-
-	cfg := &UserConfig{
-		Config: New(dir, "user"),
-	}
-	cfg.Token = "a"
-	cfg.Workspace = "/a"
-
-	// write it
-	err = cfg.Write()
-	assert.NoError(t, err)
-
-	// reload it
-	cfg = &UserConfig{
-		Config: New(dir, "user"),
-	}
-	err = cfg.Load(viper.New())
-	assert.NoError(t, err)
-	assert.Equal(t, "a", cfg.Token)
-	assert.Equal(t, "/a", cfg.Workspace)
-}
-
-func TestNormalizeWorkspace(t *testing.T) {
-	cwd, err := os.Getwd()
-	assert.NoError(t, err)
-
-	cfg := &UserConfig{Home: "/home/alice"}
-	tests := []struct {
-		in, out string
-	}{
-		{"", ""}, // don't make wild guesses
-		{"/home/alice///foobar", "/home/alice/foobar"},
-		{"~/foobar", "/home/alice/foobar"},
-		{"/foobar/~/noexpand", "/foobar/~/noexpand"},
-		{"/no/modification", "/no/modification"},
-		{"relative", filepath.Join(cwd, "relative")},
-		{"relative///path", filepath.Join(cwd, "relative", "path")},
-	}
-
-	for _, test := range tests {
-		cfg.Workspace = test.in
-		cfg.Normalize()
-		assert.Equal(t, test.out, cfg.Workspace)
-	}
-}
+package config
+
+import (
+	"io/ioutil"
+	"os"
+	"testing"
+
+	"github.com/spf13/viper"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestUserConfig(t *testing.T) {
+	dir, err := ioutil.TempDir("", "user-config")
+	assert.NoError(t, err)
+	defer os.RemoveAll(dir)
+
+	cfg := &UserConfig{
+		Config: New(dir, "user"),
+	}
+	cfg.Token = "a"
+	cfg.Workspace = "/a"
+
+	// write it
+	err = cfg.Write()
+	assert.NoError(t, err)
+
+	// reload it
+	cfg = &UserConfig{
+		Config: New(dir, "user"),
+	}
+	err = cfg.Load(viper.New())
+	assert.NoError(t, err)
+	assert.Equal(t, "a", cfg.Token)
+	assert.Equal(t, "/a", cfg.Workspace)
+}
diff --git a/config/user_config_windows_test.go b/config/user_config_windows_test.go
index 62c8255..cc7caed 100644
--- a/config/user_config_windows_test.go
+++ b/config/user_config_windows_test.go
@@ -1,62 +1,9 @@
 package config
 
-import (
-	"io/ioutil"
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/spf13/viper"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestUserConfig(t *testing.T) {
-	dir, err := ioutil.TempDir("", "user-config")
-	assert.NoError(t, err)
-	defer os.RemoveAll(dir)
-
-	cfg := &UserConfig{
-		Config: New(dir, "user"),
-	}
-	cfg.Token = "a"
-	cfg.Workspace = "/a"
-
-	// write it
-	err = cfg.Write()
-	assert.NoError(t, err)
-
-	// reload it
-	cfg = &UserConfig{
-		Config: New(dir, "user"),
-	}
-	err = cfg.Load(viper.New())
-	assert.NoError(t, err)
-	assert.Equal(t, "a", cfg.Token)
-	assert.Equal(t, filepath.Join(cfg.Home, "a"), cfg.Workspace)
-}
+import "testing"
 
 func TestNormalizeWorkspace(t *testing.T) {
-	cwd, err := os.Getwd()
-	assert.NoError(t, err)
-
-	cfg := &UserConfig{Home: "C:\\Users\\alice"}
-	tests := []struct {
-		in, out string
-	}{
-		{"", ""}, // don't make wild guesses
-		{"C:\\Users\\alice\\\\\\foobar", "C:\\Users\\alice\\foobar"},
-		{"C:\\\\\\Users\\alice\\foobar", "C:\\Users\\alice\\foobar"},
-		{"/foobar", "C:\\Users\\alice\\foobar"},
-		{"~/foobar", "C:\\Users\\alice\\foobar"},
-		{"C:\\foobar\\~\\noexpand", "C:\\foobar\\~\\noexpand"},
-		{"C:\\no\\modification", "C:\\no\\modification"},
-		{"relative", filepath.Join(cwd, "relative")},
-		{"relative///path", filepath.Join(cwd, "relative", "path")},
-	}
-
-	for _, test := range tests {
-		cfg.Workspace = test.in
-		cfg.Normalize()
-		assert.Equal(t, test.out, cfg.Workspace)
-	}
+	// I don't have access to a windows machine,
+	// so I am guaranteed to get this wrong.
+	t.Fatal("Need to implement normalization tests for windows.")
 }
diff --git a/workspace/solution_test.go b/workspace/solution_test.go
index fb62109..f7ee793 100644
--- a/workspace/solution_test.go
+++ b/workspace/solution_test.go
@@ -14,7 +14,7 @@ func TestSolution(t *testing.T) {
 	assert.NoError(t, err)
 	defer os.RemoveAll(dir)
 
-	s1 := &Solution{
+	s1 := Solution{
 		Track:       "a-track",
 		Exercise:    "bogus-exercise",
 		ID:          "abc",
diff --git a/workspace/solutions_test.go b/workspace/solutions_test.go
index 27175d4..3c1cb04 100644
--- a/workspace/solutions_test.go
+++ b/workspace/solutions_test.go
@@ -7,7 +7,7 @@ import (
 )
 
 func TestSolutions(t *testing.T) {
-	solutions := []*Solution{
+	solutions := []Solution{
 		{Track: "a", Exercise: "foo"},
 		{Track: "b", Exercise: "foo"},
 		{Track: "c", Exercise: "foo"},