Repository: exercism/cli
Pull request #431 (2017-08-08 15:11:18)

[nextercism] Add user config tests for Windows
I've ported the darwin tests to Windows, which led to a couple of changes in the `user_config.go` file due to different assumptions on Windows, and I've moved the test in `user_config_test.go` file to the darwin and windows tests suites because of these different assumptions.

robphoenix: So I've refactored the test suite a bit, I may be way off, so do say if it's off the mark, and don't feel this needs to be anywhere near the top of your todo list, I imagine it's fairly substantial!

Initially I found a way to make the main user config test pass on Windows and *nix, then I copied the darwin tests to a linux test file and I didn't like having two test files that were exactly the same, so I thought we might be better off having the main `user_config_test.go` file that builds for linux and darwin, and then because it deals with filepath's so much, a separate Windows test file that can veer off and deal with Windows idiosyncrasies, without trying to make them play nice together. Does that make sense? Sorry, rambling.

I wonder if we need to set up appveyor, for these Windows tests.
kytrinyx: > Does that make sense?

Yeah that makes a lot of sense. Let me take a look through the whole thing, but your reasoning certainly sounds good to me.

> I wonder if we need to set up appveyor, for these Windows tests.

Yes! It's time. I'll get that set up. (Should be super straight forward, I already have an account).

For the build constraint... I suddenly wondered if we need a space between the constraint and the package declaration. Do you have your windows laptop handy to check if that would work? (Maybe AppVeyor will find out in a few minutes).
robphoenix: > I suddenly wondered if we need a space between the constraint and the package declaration.

:bulb: Yes! that's it, I'll open a PR now.

Patch:
diff --git a/config/user_config.go b/config/user_config.go
index 8022a8b..4940dde 100644
--- a/config/user_config.go
+++ b/config/user_config.go
@@ -81,12 +81,17 @@ func (cfg *UserConfig) resolve(path string) string {
 	if path == "" {
 		return ""
 	}
-	if strings.HasPrefix(path, "~"+string(os.PathSeparator)) {
-		return strings.Replace(path, "~", cfg.Home, 1)
+	if strings.HasPrefix(path, "~/") {
+		path = strings.Replace(path, "~/", "", 1)
+		return filepath.Join(cfg.Home, path)
 	}
 	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/fixtures/is-solution-path/broken/.solution.json b/fixtures/is-solution-path/broken/.solution.json
new file mode 100644
index 0000000..004e1e2
--- /dev/null
+++ b/fixtures/is-solution-path/broken/.solution.json
@@ -0,0 +1,1 @@
+{,}
diff --git a/fixtures/is-solution-path/nope/.keep b/fixtures/is-solution-path/nope/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/fixtures/is-solution-path/yepp/.solution.json b/fixtures/is-solution-path/yepp/.solution.json
new file mode 100644
index 0000000..c2fbc80
--- /dev/null
+++ b/fixtures/is-solution-path/yepp/.solution.json
@@ -0,0 +1,3 @@
+{
+  "id": "abc"
+}
diff --git a/fixtures/solution-path/creatures/gazelle-2/.solution.json b/fixtures/solution-path/creatures/gazelle-2/.solution.json
new file mode 100644
index 0000000..c6d7de2
--- /dev/null
+++ b/fixtures/solution-path/creatures/gazelle-2/.solution.json
@@ -0,0 +1,1 @@
+{"id": "bbb"}
diff --git a/fixtures/solution-path/creatures/gazelle-3/.solution.json b/fixtures/solution-path/creatures/gazelle-3/.solution.json
new file mode 100644
index 0000000..ae9e57c
--- /dev/null
+++ b/fixtures/solution-path/creatures/gazelle-3/.solution.json
@@ -0,0 +1,1 @@
+{"id": "ccc"}
diff --git a/fixtures/solution-path/creatures/gazelle/.solution.json b/fixtures/solution-path/creatures/gazelle/.solution.json
new file mode 100644
index 0000000..640669c
--- /dev/null
+++ b/fixtures/solution-path/creatures/gazelle/.solution.json
@@ -0,0 +1,1 @@
+{"id": "aaa"}
diff --git a/visibility/hide_file.go b/visibility/hide_file.go
index e419efe..7d16878 100644
--- a/visibility/hide_file.go
+++ b/visibility/hide_file.go
@@ -1,4 +1,5 @@
 // +build !windows
+
 package visibility
 
 // HideFile is a no-op for non-Windows systems.
diff --git a/workspace/workspace.go b/workspace/workspace.go
index 8f957fa..ac6f072 100644
--- a/workspace/workspace.go
+++ b/workspace/workspace.go
@@ -1,6 +1,7 @@
 package workspace
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -102,3 +103,62 @@ func (ws Workspace) Locate(exercise string) ([]string, error) {
 	}
 	return paths, nil
 }
+
+// SolutionPath returns the full path where the exercise will be stored.
+// By default this the directory name matches that of the exercise, but if
+// a different solution already exists, then a numeric suffix will be added
+// to the name.
+func (ws Workspace) SolutionPath(exercise, solutionID string) (string, error) {
+	paths, err := ws.Locate(exercise)
+	if !IsNotExist(err) && err != nil {
+		return "", err
+	}
+
+	return ws.ResolveSolutionPath(paths, exercise, solutionID, IsSolutionPath)
+}
+
+// IsSolutionPath checks whether the given path contains the solution with the given ID.
+func IsSolutionPath(solutionID, path string) (bool, error) {
+	s, err := NewSolution(path)
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	return s.ID == solutionID, nil
+}
+
+// ResolveSolutionPath determines the path for the given exercise solution.
+// It will locate an existing path, or indicate the name of a new path, if this is a new solution.
+func (ws Workspace) ResolveSolutionPath(paths []string, exercise, solutionID string, existsFn func(string, string) (bool, error)) (string, error) {
+	// Do we already have a directory for this solution?
+	for _, path := range paths {
+		ok, err := existsFn(solutionID, path)
+		if err != nil {
+			return "", err
+		}
+		if ok {
+			return path, nil
+		}
+	}
+	// If we didn't find the solution in one of the paths that
+	// were passed in, we're going to construct some new ones
+	// using a numeric suffix. Create a lookup table so we can
+	// reject constructed paths if they match existing ones.
+	m := map[string]bool{}
+	for _, path := range paths {
+		m[path] = true
+	}
+	suffix := 1
+	root := filepath.Join(ws.Dir, exercise)
+	path := root
+	for {
+		exists := m[path]
+		if !exists {
+			return path, nil
+		}
+		suffix++
+		path = fmt.Sprintf("%s-%d", root, suffix)
+	}
+}


Test patch:
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
new file mode 100644
index 0000000..da1e92b
--- /dev/null
+++ b/cmd/cmd_test.go
@@ -0,0 +1,86 @@
+package cmd
+
+import (
+	"io/ioutil"
+	"os"
+	"testing"
+
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/assert"
+)
+
+const cfgHomeKey = "EXERCISM_CONFIG_HOME"
+
+// CommandTest makes it easier to write tests for Cobra commands.
+//
+// To initialize, give it the three fields Cmd, InitFn, and Args.
+// Then call Setup, and defer the Teardown.
+// The args are the faked out os.Args. The first two arguments
+// in the Args will be ignored. These represent the command (e.g. exercism)
+// and the subcommand (e.g. download).
+//
+// Finally, when you have done whatever other setup you need in your
+// test, call the command by calling Execute on the App.
+//
+// Example:
+// cmdTest := &CommandTest{
+// 	Cmd:    myCmd,
+// 	InitFn: initMyCmd,
+// 	Args:   []string{"fakeapp", "mycommand", "arg1", "--flag", "value"},
+// }
+// cmdTest.Setup(t)
+// defer cmdTest.Teardown(t)
+// ...
+// cmdTest.App.Execute()
+type CommandTest struct {
+	App            *cobra.Command
+	Cmd            *cobra.Command
+	InitFn         func()
+	TmpDir         string
+	Args           []string
+	OriginalValues struct {
+		ConfigHome string
+		Args       []string
+	}
+}
+
+// Setup does all the prep and initialization for testing a command.
+// It creates a fake Cobra app to provide a clean harness for the test,
+// and adds the command under test to it as a subcommand.
+// It also resets and reconfigures the command under test to
+// make sure we're not getting any accidental pollution from the existing
+// environment or other tests. Lastly, because we need to override some of
+// the global environment settings, the setup method also stores the existing
+// values so that Teardown can set them back the way they were when the test
+// has completed.
+// The method takes a *testing.T as an argument, that way the method can
+// fail the test if the creation of the temporary directory fails.
+func (test *CommandTest) Setup(t *testing.T) {
+	dir, err := ioutil.TempDir("", "command-test")
+	assert.NoError(t, err)
+
+	test.TmpDir = dir
+	test.OriginalValues.ConfigHome = os.Getenv(cfgHomeKey)
+	test.OriginalValues.Args = os.Args
+
+	os.Setenv(cfgHomeKey, test.TmpDir)
+
+	os.Args = test.Args
+
+	test.Cmd.ResetFlags()
+	test.InitFn()
+
+	test.App = &cobra.Command{}
+	test.App.AddCommand(test.Cmd)
+}
+
+// Teardown puts the environment back the way it was before the test.
+// The method takes a *testing.T so that it can blow up if it fails to
+// clean up after itself.
+func (test *CommandTest) Teardown(t *testing.T) {
+	os.Setenv(cfgHomeKey, test.OriginalValues.ConfigHome)
+	os.Args = test.OriginalValues.Args
+	if err := os.RemoveAll(test.TmpDir); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/cmd/configure_test.go b/cmd/configure_test.go
index 4b4ab24..0ec338c 100644
--- a/cmd/configure_test.go
+++ b/cmd/configure_test.go
@@ -1,34 +1,15 @@
 package cmd
 
 import (
-	"fmt"
-	"io/ioutil"
-	"os"
 	"testing"
 
 	"github.com/exercism/cli/config"
-	"github.com/spf13/cobra"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestConfigure(t *testing.T) {
-	// Make sure we put back the config env var.
-	cfgHomeKey := "EXERCISM_CONFIG_HOME"
-	cfgHome := os.Getenv(cfgHomeKey)
-	defer os.Setenv(cfgHomeKey, cfgHome)
-
-	// Make sure we put back the real command-line arguments.
-	osArgs := os.Args
-	defer func() {
-		os.Args = osArgs
-	}()
-
-	// Set up a bogus root command.
-	fakeCmd := &cobra.Command{}
-	// Add the real configureCmd to it.
-	fakeCmd.AddCommand(configureCmd)
-
 	tests := []struct {
+		desc           string
 		args           []string
 		existingUsrCfg *config.UserConfig
 		expectedUsrCfg *config.UserConfig
@@ -36,7 +17,7 @@ func TestConfigure(t *testing.T) {
 		expectedAPICfg *config.APIConfig
 	}{
 		{
-			// It writes the flags when there is no config file.
+			desc:           "It writes the flags when there is no config file.",
 			args:           []string{"fakeapp", "configure", "--token", "a", "--workspace", "/a", "--api", "http://example.com"},
 			existingUsrCfg: nil,
 			expectedUsrCfg: &config.UserConfig{Token: "a", Workspace: "/a"},
@@ -44,7 +25,7 @@ func TestConfigure(t *testing.T) {
 			expectedAPICfg: &config.APIConfig{BaseURL: "http://example.com"},
 		},
 		{
-			// It overwrites the flags in the config file.
+			desc:           "It overwrites the flags in the config file.",
 			args:           []string{"fakeapp", "configure", "--token", "b", "--workspace", "/b", "--api", "http://example.com/v2"},
 			existingUsrCfg: &config.UserConfig{Token: "token-b", Workspace: "/workspace-b"},
 			expectedUsrCfg: &config.UserConfig{Token: "b", Workspace: "/b"},
@@ -52,55 +33,50 @@ func TestConfigure(t *testing.T) {
 			expectedAPICfg: &config.APIConfig{BaseURL: "http://example.com/v2"},
 		},
 		{
-			args: []string{"fakeapp", "configure", "--token", "c"},
-			// It overwrites the flags that are passed without losing the ones that are not.
+			desc:           "It overwrites the flags that are passed, without losing the ones that are not.",
+			args:           []string{"fakeapp", "configure", "--token", "c"},
 			existingUsrCfg: &config.UserConfig{Token: "token-c", Workspace: "/workspace-c"},
 			expectedUsrCfg: &config.UserConfig{Token: "c", Workspace: "/workspace-c"},
-			// It gets the default API base URL.
+		},
+		{
+			desc:           "It gets the default API base URL.",
+			args:           []string{"fakeapp", "configure"},
 			existingAPICfg: &config.APIConfig{},
 			expectedAPICfg: &config.APIConfig{BaseURL: "https://api.exercism.com/v1"},
 		},
 	}
 
-	for i, test := range tests {
-		// Create a fake config dir.
-		dir, err := ioutil.TempDir("", fmt.Sprintf("user-config-%d", i))
-		assert.NoError(t, err)
-		defer os.RemoveAll(dir)
-
-		// Override the environment to use the fake config dir.
-		os.Setenv(cfgHomeKey, dir)
+	for _, test := range tests {
+		cmdTest := &CommandTest{
+			Cmd:    configureCmd,
+			InitFn: initConfigureCmd,
+			Args:   test.args,
+		}
+		cmdTest.Setup(t)
+		defer cmdTest.Teardown(t)
 
 		if test.existingUsrCfg != nil {
 			// Write a fake config.
 			cfg := config.NewEmptyUserConfig()
 			cfg.Token = test.existingUsrCfg.Token
 			cfg.Workspace = test.existingUsrCfg.Workspace
-			err = cfg.Write()
-			assert.NoError(t, err)
+			err := cfg.Write()
+			assert.NoError(t, err, test.desc)
 		}
 
-		// Fake out the command-line arguments with the correct subcommand.
-		os.Args = test.args
+		cmdTest.App.Execute()
 
-		// Re-initialize the command so it picks up the fake environment.
-		configureCmd.ResetFlags()
-		// Rerun the config initialization so that the flags get bound properly.
-		initConfigureCmd()
-
-		// Finally. Execute the configure command.
-		fakeCmd.Execute()
-
-		// Now let's get a new config and see that it got written properly.
-		usrCfg, err := config.NewUserConfig()
-		assert.NoError(t, err)
-
-		assert.Equal(t, test.expectedUsrCfg.Token, usrCfg.Token)
-		assert.Equal(t, test.expectedUsrCfg.Workspace, usrCfg.Workspace)
-
-		apiCfg, err := config.NewAPIConfig()
-		assert.NoError(t, err)
+		if test.expectedUsrCfg != nil {
+			usrCfg, err := config.NewUserConfig()
+			assert.NoError(t, err, test.desc)
+			assert.Equal(t, test.expectedUsrCfg.Token, usrCfg.Token, test.desc)
+			assert.Equal(t, test.expectedUsrCfg.Workspace, usrCfg.Workspace, test.desc)
+		}
 
-		assert.Equal(t, test.expectedAPICfg.BaseURL, apiCfg.BaseURL)
+		if test.expectedAPICfg != nil {
+			apiCfg, err := config.NewAPIConfig()
+			assert.NoError(t, err, test.desc)
+			assert.Equal(t, test.expectedAPICfg.BaseURL, apiCfg.BaseURL, test.desc)
+		}
 	}
 }
diff --git a/config/user_config_darwin_test.go b/config/user_config_darwin_test.go
deleted file mode 100644
index 5dcecd2..0000000
--- a/config/user_config_darwin_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-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 8e0e3e8..f917cf4 100644
--- a/config/user_config_test.go
+++ b/config/user_config_test.go
@@ -1,35 +1,62 @@
-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)
-}
+// +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)
+	}
+}
diff --git a/config/user_config_windows_test.go b/config/user_config_windows_test.go
index cc7caed..62c8255 100644
--- a/config/user_config_windows_test.go
+++ b/config/user_config_windows_test.go
@@ -1,9 +1,62 @@
 package config
 
-import "testing"
+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)
+}
 
 func TestNormalizeWorkspace(t *testing.T) {
-	// 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.")
+	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)
+	}
 }
diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go
index edd2dab..6c3b861 100644
--- a/workspace/workspace_test.go
+++ b/workspace/workspace_test.go
@@ -153,3 +153,129 @@ func TestLocate(t *testing.T) {
 		assert.Equal(t, test.out, dirs, test.desc)
 	}
 }
+
+func TestSolutionPath(t *testing.T) {
+	root := filepath.Join("..", "fixtures", "solution-path", "creatures")
+	ws := New(root)
+
+	// An existing exercise.
+	path, err := ws.SolutionPath("gazelle", "ccc")
+	assert.NoError(t, err)
+	assert.Equal(t, filepath.Join(root, "gazelle-3"), path)
+
+	path, err = ws.SolutionPath("gazelle", "abc")
+	assert.NoError(t, err)
+	assert.Equal(t, filepath.Join(root, "gazelle-4"), path)
+
+	// A new exercise.
+	path, err = ws.SolutionPath("lizard", "abc")
+	assert.NoError(t, err)
+	assert.Equal(t, filepath.Join(root, "lizard"), path)
+}
+
+func TestIsSolutionPath(t *testing.T) {
+	root := filepath.Join("..", "fixtures", "is-solution-path")
+
+	ok, err := IsSolutionPath("abc", filepath.Join(root, "yepp"))
+	assert.NoError(t, err)
+	assert.True(t, ok)
+
+	// The ID has to actually match.
+	ok, err = IsSolutionPath("xxx", filepath.Join(root, "yepp"))
+	assert.NoError(t, err)
+	assert.False(t, ok)
+
+	ok, err = IsSolutionPath("abc", filepath.Join(root, "nope"))
+	assert.NoError(t, err)
+	assert.False(t, ok)
+
+	_, err = IsSolutionPath("abc", filepath.Join(root, "broken"))
+	assert.Error(t, err)
+}
+
+func TestResolveSolutionPath(t *testing.T) {
+	ws := New("tmp")
+
+	existsFn := func(solutionID, path string) (bool, error) {
+		pathToSolutionID := map[string]string{
+			filepath.Join(ws.Dir, "pig"):      "xxx",
+			filepath.Join(ws.Dir, "gecko"):    "aaa",
+			filepath.Join(ws.Dir, "gecko-2"):  "xxx",
+			filepath.Join(ws.Dir, "gecko-3"):  "ccc",
+			filepath.Join(ws.Dir, "bat"):      "aaa",
+			filepath.Join(ws.Dir, "dog"):      "aaa",
+			filepath.Join(ws.Dir, "dog-2"):    "bbb",
+			filepath.Join(ws.Dir, "dog-3"):    "ccc",
+			filepath.Join(ws.Dir, "rabbit"):   "aaa",
+			filepath.Join(ws.Dir, "rabbit-2"): "bbb",
+			filepath.Join(ws.Dir, "rabbit-4"): "ccc",
+		}
+		return pathToSolutionID[path] == solutionID, nil
+	}
+
+	tests := []struct {
+		desc     string
+		paths    []string
+		exercise string
+		expected string
+	}{
+		{
+			desc:     "If we don't have that exercise yet, it gets the default name.",
+			exercise: "duck",
+			paths:    []string{},
+			expected: filepath.Join(ws.Dir, "duck"),
+		},
+		{
+			desc:     "If we already have a directory for the solution in question, return it.",
+			exercise: "pig",
+			paths: []string{
+				filepath.Join(ws.Dir, "pig"),
+			},
+			expected: filepath.Join(ws.Dir, "pig"),
+		},
+		{
+			desc:     "If we already have multiple solutions, and this is one of them, find it.",
+			exercise: "gecko",
+			paths: []string{
+				filepath.Join(ws.Dir, "gecko"),
+				filepath.Join(ws.Dir, "gecko-2"),
+				filepath.Join(ws.Dir, "gecko-3"),
+			},
+			expected: filepath.Join(ws.Dir, "gecko-2"),
+		},
+		{
+			desc:     "If we already have a solution, but this is a new one, add a suffix.",
+			exercise: "bat",
+			paths: []string{
+				filepath.Join(ws.Dir, "bat"),
+			},
+			expected: filepath.Join(ws.Dir, "bat-2"),
+		},
+		{
+			desc:     "If we already have multiple solutions, but this is a new one, add a new suffix.",
+			exercise: "dog",
+			paths: []string{
+				filepath.Join(ws.Dir, "dog"),
+				filepath.Join(ws.Dir, "dog-2"),
+				filepath.Join(ws.Dir, "dog-3"),
+			},
+			expected: filepath.Join(ws.Dir, "dog-4"),
+		},
+		{
+			desc:     "Use the first available suffix.",
+			exercise: "rabbit",
+			paths: []string{
+				filepath.Join(ws.Dir, "rabbit"),
+				filepath.Join(ws.Dir, "rabbit-2"),
+				filepath.Join(ws.Dir, "rabbit-4"),
+			},
+			expected: filepath.Join(ws.Dir, "rabbit-3"),
+		},
+	}
+
+	for _, test := range tests {
+		path, err := ws.ResolveSolutionPath(test.paths, test.exercise, "xxx", existsFn)
+		assert.NoError(t, err, test.desc)
+		assert.Equal(t, test.expected, path, test.desc)
+	}
+}