Repository: exercism/cli
Pull request #696 (2018-08-05 16:29:02)

Get rid of the workspace.Locate behavior and comms package
This is big in the sense that it deletes a lot of code, but the core of the PR is three small simplifications:

- the open command - https://github.com/exercism/cli/commit/20ebf1956904f21a1aa9a255f898a08aa6ac5f7b
- the submit command - https://github.com/exercism/cli/commit/693b42910ed2913a1cbef98e8d5595c419480075
- the download command - https://github.com/exercism/cli/commit/dd6b948a28b3e98eff94cc676ade19ccc30fe2ca

Once all of those were simplified, we no longer used `SolutionPath`, `IsSolutionPath`, `ResolveSolutionPath`, or `Locate`, and could delete all of the functions and their associated tests. And the entire `comms` package!

This removes the behavior of adding a numeric suffix when downloading an exercise if the exercise already exists, which is the first step in fixing https://github.com/exercism/cli/issues/661
The second step is to make the download command more robust so it doesn't clobber existing solution files: https://github.com/exercism/cli/issues/697


Patch:
diff --git a/cmd/download.go b/cmd/download.go
index 206ee93..031ba87 100644
--- a/cmd/download.go
+++ b/cmd/download.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	netURL "net/url"
 	"os"
 	"path/filepath"
 	"strings"
@@ -59,21 +60,19 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 	if err != nil {
 		return err
 	}
-	exercise, err := flags.GetString("exercise")
+	slug, err := flags.GetString("exercise")
 	if err != nil {
 		return err
 	}
-	if uuid == "" && exercise == "" {
+	if uuid == "" && slug == "" {
 		return errors.New("need an --exercise name or a solution --uuid")
 	}
 
-	var slug string
-	if uuid == "" {
-		slug = "latest"
-	} else {
-		slug = uuid
+	param := "latest"
+	if param == "" {
+		param = uuid
 	}
-	url := fmt.Sprintf("%s/solutions/%s", usrCfg.GetString("apibaseurl"), slug)
+	url := fmt.Sprintf("%s/solutions/%s", usrCfg.GetString("apibaseurl"), param)
 
 	client, err := api.NewClient(usrCfg.GetString("token"), usrCfg.GetString("apibaseurl"))
 	if err != nil {
@@ -97,7 +96,7 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 
 	if uuid == "" {
 		q := req.URL.Query()
-		q.Add("exercise_id", exercise)
+		q.Add("exercise_id", slug)
 		if track != "" {
 			q.Add("track_id", track)
 		}
@@ -143,35 +142,41 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 		IsRequester: payload.Solution.User.IsRequester,
 	}
 
-	dir := usrCfg.GetString("workspace")
+	root := usrCfg.GetString("workspace")
 	if solution.Team != "" {
-		dir = filepath.Join(dir, "teams", solution.Team)
+		root = filepath.Join(root, "teams", solution.Team)
 	}
 	if !solution.IsRequester {
-		dir = filepath.Join(dir, "users", solution.Handle)
+		root = filepath.Join(root, "users", solution.Handle)
 	}
-	dir = filepath.Join(dir, solution.Track)
 
-	os.MkdirAll(dir, os.FileMode(0755))
-	ws, err := workspace.New(dir)
-	if err != nil {
-		return err
+	exercise := workspace.Exercise{
+		Root:  root,
+		Track: solution.Track,
+		Slug:  solution.Exercise,
 	}
 
-	dir, err = ws.SolutionPath(solution.Exercise, solution.ID)
-	if err != nil {
+	dir := exercise.MetadataDir()
+
+	if err := os.MkdirAll(dir, os.FileMode(0755)); err != nil {
 		return err
 	}
 
-	os.MkdirAll(dir, os.FileMode(0755))
-
 	err = solution.Write(dir)
 	if err != nil {
 		return err
 	}
 
 	for _, file := range payload.Solution.Files {
-		url := fmt.Sprintf("%s%s", payload.Solution.FileDownloadBaseURL, file)
+		unparsedUrl := fmt.Sprintf("%s%s", payload.Solution.FileDownloadBaseURL, file)
+		parsedUrl, err := netURL.ParseRequestURI(unparsedUrl)
+
+		if err != nil {
+			return err
+		}
+
+		url := parsedUrl.String()
+
 		req, err := client.NewRequest("GET", url, nil)
 		if err != nil {
 			return err
diff --git a/cmd/open.go b/cmd/open.go
index 0da33b0..15166b2 100644
--- a/cmd/open.go
+++ b/cmd/open.go
@@ -1,15 +1,9 @@
 package cmd
 
 import (
-	"errors"
-	"fmt"
-
 	"github.com/exercism/cli/browser"
-	"github.com/exercism/cli/comms"
-	"github.com/exercism/cli/config"
 	"github.com/exercism/cli/workspace"
 	"github.com/spf13/cobra"
-	"github.com/spf13/viper"
 )
 
 // openCmd opens the designated exercise in the browser.
@@ -19,74 +13,16 @@ var openCmd = &cobra.Command{
 	Short:   "Open an exercise on the website.",
 	Long: `Open the specified exercise to the solution page on the Exercism website.
 
-Pass either the name of an exercise, or the path to the directory that contains
-the solution you want to see on the website.
+Pass the path to the directory that contains the solution you want to see on the website.
 	`,
 	Args: cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		cfg := config.NewConfig()
-
-		v := viper.New()
-		v.AddConfigPath(cfg.Dir)
-		v.SetConfigName("user")
-		v.SetConfigType("json")
-		// Ignore error. If the file doesn't exist, that is fine.
-		_ = v.ReadInConfig()
-
-		ws, err := workspace.New(v.GetString("workspace"))
-		if err != nil {
-			return err
-		}
-
-		paths, err := ws.Locate(args[0])
+		solution, err := workspace.NewSolution(args[0])
 		if err != nil {
 			return err
 		}
-
-		solutions, err := workspace.NewSolutions(paths)
-		if err != nil {
-			return err
-		}
-
-		if len(solutions) == 0 {
-			return nil
-		}
-
-		if len(solutions) > 1 {
-			var mine []*workspace.Solution
-			for _, s := range solutions {
-				if s.IsRequester {
-					mine = append(mine, s)
-				}
-			}
-			solutions = mine
-		}
-
-		selection := comms.NewSelection()
-		for _, solution := range solutions {
-			selection.Items = append(selection.Items, solution)
-		}
-		for {
-			prompt := `
-We found more than one. Which one did you mean?
-Type the number of the one you want to select.
-
-%s
-> `
-			option, err := selection.Pick(prompt)
-			if err != nil {
-				fmt.Println(err)
-				continue
-			}
-			solution, ok := option.(*workspace.Solution)
-			if ok {
-				browser.Open(solution.URL)
-				return nil
-			}
-			if err != nil {
-				return errors.New("should never happen")
-			}
-		}
+		browser.Open(solution.URL)
+		return nil
 	},
 }
 
diff --git a/cmd/submit.go b/cmd/submit.go
index 2028c94..5078084 100644
--- a/cmd/submit.go
+++ b/cmd/submit.go
@@ -125,26 +125,11 @@ func runSubmit(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 		exerciseDir = dir
 	}
 
-	dirs, err := ws.Locate(exerciseDir)
+	solution, err := workspace.NewSolution(exerciseDir)
 	if err != nil {
 		return err
 	}
 
-	sx, err := workspace.NewSolutions(dirs)
-	if err != nil {
-		return err
-	}
-	if len(sx) > 1 {
-		msg := `
-
-    You are submitting files belonging to different solutions.
-    Please submit the files for one solution at a time.
-
-		`
-		return errors.New(msg)
-	}
-	solution := sx[0]
-
 	if !solution.IsRequester {
 		// TODO: add test
 		msg := `
diff --git a/comms/question.go b/comms/question.go
deleted file mode 100644
index f9afd6a..0000000
--- a/comms/question.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package comms
-
-import (
-	"bufio"
-	"fmt"
-	"io"
-	"strings"
-)
-
-// Question provides an interactive session.
-type Question struct {
-	Reader       io.Reader
-	Writer       io.Writer
-	Prompt       string
-	DefaultValue string
-}
-
-// Read reads the user's input.
-func (q Question) Read(r io.Reader) (string, error) {
-	reader := bufio.NewReader(r)
-	s, err := reader.ReadString('\n')
-	if err != nil {
-		return "", err
-	}
-	s = strings.TrimSpace(s)
-	if s == "" {
-		return q.DefaultValue, nil
-	}
-	return s, nil
-}
-
-// Ask displays the prompt, then records the response.
-func (q *Question) Ask() (string, error) {
-	fmt.Fprintf(q.Writer, q.Prompt)
-	return q.Read(q.Reader)
-}
diff --git a/comms/selection.go b/comms/selection.go
deleted file mode 100644
index 5d76086..0000000
--- a/comms/selection.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package comms
-
-import (
-	"bufio"
-	"errors"
-	"fmt"
-	"io"
-	"os"
-	"strconv"
-	"strings"
-)
-
-// Selection wraps a list of items.
-// It is used for interactive communication.
-type Selection struct {
-	Items  []fmt.Stringer
-	Reader io.Reader
-	Writer io.Writer
-}
-
-// NewSelection prepares an empty collection for interactive input.
-func NewSelection() Selection {
-	return Selection{
-		Reader: os.Stdin,
-		Writer: os.Stdout,
-	}
-}
-
-// Pick lets a user interactively select an option from a list.
-func (sel Selection) Pick(prompt string) (fmt.Stringer, error) {
-	// If there's just one, then we're done here.
-	if len(sel.Items) == 1 {
-		return sel.Items[0], nil
-	}
-
-	fmt.Fprintf(sel.Writer, prompt, sel.Display())
-
-	n, err := sel.Read(sel.Reader)
-	if err != nil {
-		return nil, err
-	}
-
-	o, err := sel.Get(n)
-	if err != nil {
-		return nil, err
-	}
-	return o, nil
-}
-
-// Display shows a numbered list of the solutions to choose from.
-// The list starts at 1, since that seems better in a user interface.
-func (sel Selection) Display() string {
-	str := ""
-	for i, item := range sel.Items {
-		str += fmt.Sprintf("  [%d] %s\n", i+1, item)
-	}
-	return str
-}
-
-// Read reads the user's selection and converts it to a number.
-func (sel Selection) Read(r io.Reader) (int, error) {
-	reader := bufio.NewReader(r)
-	text, _ := reader.ReadString('\n')
-	n, err := strconv.Atoi(strings.TrimSpace(text))
-	if err != nil {
-		return 0, err
-	}
-	return n, nil
-}
-
-// Get returns the solution corresponding to the number.
-// The list starts at 1, since that seems better in a user interface.
-func (sel Selection) Get(n int) (fmt.Stringer, error) {
-	if n <= 0 || n > len(sel.Items) {
-		return nil, errors.New("we don't have that one")
-	}
-	return sel.Items[n-1], nil
-}
diff --git a/workspace/exercise.go b/workspace/exercise.go
index e8be3ec..246cf36 100644
--- a/workspace/exercise.go
+++ b/workspace/exercise.go
@@ -30,6 +30,12 @@ func (e Exercise) MetadataFilepath() string {
 	return filepath.Join(e.Filepath(), solutionFilename)
 }
 
+// MetadataDir returns the directory that the exercise metadata lives in.
+// For now this is the exercise directory.
+func (e Exercise) MetadataDir() string {
+	return e.Filepath()
+}
+
 // HasMetadata checks for the presence of an exercise metadata file.
 // If there is no such file, this may be a legacy exercise.
 // It could also be an unrelated directory.
diff --git a/workspace/workspace.go b/workspace/workspace.go
index b6a3785..0e9092e 100644
--- a/workspace/workspace.go
+++ b/workspace/workspace.go
@@ -2,7 +2,6 @@ package workspace
 
 import (
 	"errors"
-	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -99,151 +98,6 @@ func (ws Workspace) Exercises() ([]Exercise, error) {
 	return exercises, nil
 }
 
-// Locate the matching directories within the workspace.
-// This will look for an exact match on absolute or relative paths.
-// If given the base name of a directory with no path information it
-// It will look for all directories with that name, or that are
-// named with a numerical suffix.
-func (ws Workspace) Locate(exercise string) ([]string, error) {
-	// First assume it's a path.
-	dir := exercise
-
-	// If it's not an absolute path, make it one.
-	if !filepath.IsAbs(dir) {
-		var err error
-		dir, err = filepath.Abs(dir)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	// If it exists, we were right. It's a path.
-	if _, err := os.Stat(dir); err == nil {
-		if !strings.HasPrefix(dir, ws.Dir) {
-			return nil, ErrNotInWorkspace(exercise)
-		}
-
-		src, err := filepath.EvalSymlinks(dir)
-		if err == nil {
-			return []string{src}, nil
-		}
-	}
-
-	// If the argument is a path, then we should have found it by now.
-	if strings.Contains(exercise, string(os.PathSeparator)) {
-		return nil, ErrNotExist(exercise)
-	}
-
-	var paths []string
-	// Look through the entire workspace tree to find any matches.
-	walkFn := func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-
-		// If it's a symlink, follow it, then get the file info of the target.
-		if info.Mode()&os.ModeSymlink == os.ModeSymlink {
-			src, err := filepath.EvalSymlinks(path)
-			if err == nil {
-				path = src
-			}
-			info, err = os.Lstat(path)
-			if err != nil {
-				return err
-			}
-		}
-
-		if !info.IsDir() {
-			return nil
-		}
-
-		if strings.HasPrefix(filepath.Base(path), exercise) {
-			// We're trying to find any directories that match either the exact name
-			// or the name with a numeric suffix.
-			// E.g. if passed 'bat', then we should match 'bat', 'bat-2', 'bat-200',
-			// but not 'batten'.
-			suffix := strings.Replace(filepath.Base(path), exercise, "", 1)
-			if suffix == "" || rgxSerialSuffix.MatchString(suffix) {
-				paths = append(paths, path)
-			}
-		}
-		return nil
-	}
-
-	// If the workspace directory is a symlink, resolve that first.
-	root := ws.Dir
-	src, err := filepath.EvalSymlinks(root)
-	if err == nil {
-		root = src
-	}
-
-	filepath.Walk(root, walkFn)
-
-	if len(paths) == 0 {
-		return nil, ErrNotExist(exercise)
-	}
-	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)
-	}
-}
-
 // SolutionDir determines the root directory of a solution.
 // This is the directory that contains the solution metadata file.
 func (ws Workspace) SolutionDir(s string) (string, error) {


Test patch:
diff --git a/cmd/download_test.go b/cmd/download_test.go
index 2c3200c..140c8d0 100644
--- a/cmd/download_test.go
+++ b/cmd/download_test.go
@@ -166,6 +166,10 @@ func fakeDownloadServer(requestor, teamSlug string) *httptest.Server {
 		fmt.Fprint(w, "this is file 2")
 	})
 
+	mux.HandleFunc("/special-char-filename#.txt", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, "this is a special file")
+	})
+
 	mux.HandleFunc("/file-3.txt", func(w http.ResponseWriter, r *http.Request) {
 		fmt.Fprint(w, "")
 	})
@@ -202,6 +206,11 @@ func assertDownloadedCorrectFiles(t *testing.T, targetDir string) {
 			path:     filepath.Join(targetDir, "bogus-track", "bogus-exercise", "subdir", "file-2.txt"),
 			contents: "this is file 2",
 		},
+		{
+			desc:     "a file that requires URL encoding",
+			path:     filepath.Join(targetDir, "bogus-track", "bogus-exercise", "special-char-filename#.txt"),
+			contents: "this is a special file",
+		},
 	}
 
 	for _, file := range expectedFiles {
@@ -239,6 +248,7 @@ const payloadTemplate = `
 		"files": [
 			"/file-1.txt",
 			"/subdir/file-2.txt",
+			"/special-char-filename#.txt",
 			"/file-3.txt"
 		],
 		"iteration": {
diff --git a/comms/question_test.go b/comms/question_test.go
deleted file mode 100644
index 1b77dad..0000000
--- a/comms/question_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package comms
-
-import (
-	"io/ioutil"
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestQuestion(t *testing.T) {
-	testCases := []struct {
-		desc     string
-		given    string
-		fallback string
-		expected string
-	}{
-		{"records interactive response", "hello\n", "", "hello"},
-		{"responds with default if response is empty", "\n", "Fine.", "Fine."},
-		{"removes trailing \\r in addition to trailing \\", "hello\r\n", "Fine.", "hello"},
-		{"removes trailing white spaces", "hello  \n", "Fine.", "hello"},
-		{"falls back to default value", "  \n", "Default", "Default"},
-	}
-	for _, tc := range testCases {
-		t.Run(tc.desc, func(t *testing.T) {
-			q := &Question{
-				Reader:       strings.NewReader(tc.given),
-				Writer:       ioutil.Discard,
-				Prompt:       "Say something: ",
-				DefaultValue: tc.fallback,
-			}
-
-			answer, err := q.Ask()
-			assert.NoError(t, err)
-			assert.Equal(t, answer, tc.expected)
-		})
-	}
-}
diff --git a/comms/selection_test.go b/comms/selection_test.go
deleted file mode 100644
index bbf2ce1..0000000
--- a/comms/selection_test.go
+++ /dev/null
@@ -1,128 +0,0 @@
-package comms
-
-import (
-	"fmt"
-	"io/ioutil"
-	"strings"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-type thing struct {
-	name   string
-	rating int
-}
-
-func (t thing) String() string {
-	return fmt.Sprintf("%s (+%d)", t.name, t.rating)
-}
-
-var (
-	things = []thing{
-		{name: "water", rating: 10},
-		{name: "food", rating: 3},
-		{name: "music", rating: 0},
-	}
-)
-
-func TestSelectionDisplay(t *testing.T) {
-	// We have to manually add each thing to the options collection.
-	var sel Selection
-	for _, thing := range things {
-		sel.Items = append(sel.Items, thing)
-	}
-
-	display := "  [1] water (+10)\n  [2] food (+3)\n  [3] music (+0)\n"
-	assert.Equal(t, display, sel.Display())
-}
-
-func TestSelectionGet(t *testing.T) {
-	var sel Selection
-	for _, thing := range things {
-		sel.Items = append(sel.Items, thing)
-	}
-
-	_, err := sel.Get(0)
-	assert.Error(t, err)
-
-	o, err := sel.Get(1)
-	assert.NoError(t, err)
-	// We need to do a type assertion to access
-	// any non-stringer stuff.
-	t1 := o.(thing)
-	assert.Equal(t, "water", t1.name)
-
-	o, err = sel.Get(2)
-	assert.NoError(t, err)
-	t2 := o.(thing)
-	assert.Equal(t, "food", t2.name)
-
-	o, err = sel.Get(3)
-	assert.NoError(t, err)
-	t3 := o.(thing)
-	assert.Equal(t, "music", t3.name)
-
-	_, err = sel.Get(4)
-	assert.Error(t, err)
-}
-
-func TestSelectionRead(t *testing.T) {
-	var sel Selection
-	n, err := sel.Read(strings.NewReader("5"))
-	assert.NoError(t, err)
-	assert.Equal(t, 5, n)
-
-	_, err = sel.Read(strings.NewReader("abc"))
-	assert.Error(t, err)
-}
-
-func TestSelectionPick(t *testing.T) {
-	testCases := []struct {
-		desc      string
-		selection Selection
-		things    []thing
-		expected  string
-	}{
-		{
-			desc: "autoselect the only one",
-			selection: Selection{
-				// it never hits the error,
-				// because it doesn't actually do
-				// the prompt and read response.
-				Reader: strings.NewReader("BOOM!"),
-			},
-			things: []thing{
-				{"hugs", 100},
-			},
-			expected: "hugs",
-		},
-		{
-			desc: "it picks the one corresponding to the selection",
-			selection: Selection{
-				Reader: strings.NewReader("2"),
-			},
-			things: []thing{
-				{"food", 10},
-				{"water", 3},
-				{"music", 0},
-			},
-			expected: "water",
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.desc, func(t *testing.T) {
-			tc.selection.Writer = ioutil.Discard
-			for _, th := range tc.things {
-				tc.selection.Items = append(tc.selection.Items, th)
-			}
-
-			item, err := tc.selection.Pick("which one? %s")
-			assert.NoError(t, err)
-			th, ok := item.(thing)
-			assert.True(t, ok)
-			assert.Equal(t, tc.expected, th.name)
-		})
-	}
-}
diff --git a/workspace/workspace_locate_test.go b/workspace/workspace_locate_test.go
deleted file mode 100644
index 837657b..0000000
--- a/workspace/workspace_locate_test.go
+++ /dev/null
@@ -1,139 +0,0 @@
-// +build !windows
-
-package workspace
-
-import (
-	"fmt"
-	"path/filepath"
-	"runtime"
-	"sort"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestLocateErrors(t *testing.T) {
-	_, cwd, _, _ := runtime.Caller(0)
-	root := filepath.Join(cwd, "..", "..", "fixtures", "locate-exercise")
-
-	ws, err := New(filepath.Join(root, "workspace"))
-	assert.NoError(t, err)
-
-	testCases := []struct {
-		desc, arg string
-		errFn     func(error) bool
-	}{
-		{
-			desc:  "absolute path outside of workspace",
-			arg:   filepath.Join(root, "equipment", "bat"),
-			errFn: IsNotInWorkspace,
-		},
-		{
-			desc:  "absolute path in workspace not found",
-			arg:   filepath.Join(ws.Dir, "creatures", "pig"),
-			errFn: IsNotExist,
-		},
-		{
-			desc:  "relative path is outside of workspace",
-			arg:   filepath.Join("..", "fixtures", "locate-exercise", "equipment", "bat"),
-			errFn: IsNotInWorkspace,
-		},
-		{
-			desc:  "relative path in workspace not found",
-			arg:   filepath.Join("..", "fixtures", "locate-exercise", "workspace", "creatures", "pig"),
-			errFn: IsNotExist,
-		},
-		{
-			desc:  "exercise name not found in workspace",
-			arg:   "pig",
-			errFn: IsNotExist,
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.desc, func(t *testing.T) {
-			_, err := ws.Locate(tc.arg)
-			assert.True(t, tc.errFn(err), fmt.Sprintf("test: %s (arg: %s), %#v", tc.desc, tc.arg, err))
-		})
-	}
-}
-
-type locateTestCase struct {
-	desc      string
-	workspace Workspace
-	in        string
-	out       []string
-}
-
-func TestLocate(t *testing.T) {
-	_, cwd, _, _ := runtime.Caller(0)
-	root := filepath.Join(cwd, "..", "..", "fixtures", "locate-exercise")
-
-	wsPrimary, err := New(filepath.Join(root, "workspace"))
-	assert.NoError(t, err)
-
-	testCases := []locateTestCase{
-		{
-			desc:      "find absolute path within workspace",
-			workspace: wsPrimary,
-			in:        filepath.Join(wsPrimary.Dir, "creatures", "horse"),
-			out:       []string{filepath.Join(wsPrimary.Dir, "creatures", "horse")},
-		},
-		{
-			desc:      "find relative path within workspace",
-			workspace: wsPrimary,
-			in:        filepath.Join("..", "fixtures", "locate-exercise", "workspace", "creatures", "horse"),
-			out:       []string{filepath.Join(wsPrimary.Dir, "creatures", "horse")},
-		},
-		{
-			desc:      "find by name in default location",
-			workspace: wsPrimary,
-			in:        "horse",
-			out:       []string{filepath.Join(wsPrimary.Dir, "creatures", "horse")},
-		},
-		{
-			desc:      "find by name in a subtree",
-			workspace: wsPrimary,
-			in:        "fly",
-			out:       []string{filepath.Join(wsPrimary.Dir, "friends", "alice", "creatures", "fly")},
-		},
-		{
-			desc:      "don't be confused by a file named the same as an exercise",
-			workspace: wsPrimary,
-			in:        "duck",
-			out:       []string{filepath.Join(wsPrimary.Dir, "creatures", "duck")},
-		},
-		{
-			desc:      "find all the exercises with the same name",
-			workspace: wsPrimary,
-			in:        "bat",
-			out: []string{
-				filepath.Join(wsPrimary.Dir, "creatures", "bat"),
-				filepath.Join(wsPrimary.Dir, "friends", "alice", "creatures", "bat"),
-			},
-		},
-		{
-			desc:      "find copies of exercise with suffix",
-			workspace: wsPrimary,
-			in:        "crane",
-			out: []string{
-				filepath.Join(wsPrimary.Dir, "creatures", "crane"),
-				filepath.Join(wsPrimary.Dir, "creatures", "crane-2"),
-			},
-		},
-	}
-
-	testLocate(testCases, t)
-}
-
-func testLocate(testCases []locateTestCase, t *testing.T) {
-	for _, tc := range testCases {
-		dirs, err := tc.workspace.Locate(tc.in)
-
-		sort.Strings(dirs)
-		sort.Strings(tc.out)
-
-		assert.NoError(t, err, tc.desc)
-		assert.Equal(t, tc.out, dirs, tc.desc)
-	}
-}
diff --git a/workspace/workspace_symlinks_test.go b/workspace/workspace_symlinks_test.go
deleted file mode 100644
index 8371836..0000000
--- a/workspace/workspace_symlinks_test.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// +build !windows
-
-package workspace
-
-import (
-	"path/filepath"
-	"runtime"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestLocateSymlinks(t *testing.T) {
-	_, cwd, _, _ := runtime.Caller(0)
-	root := filepath.Join(cwd, "..", "..", "fixtures", "locate-exercise")
-
-	wsSymbolic, err := New(filepath.Join(root, "symlinked-workspace"))
-	assert.NoError(t, err)
-	wsPrimary, err := New(filepath.Join(root, "workspace"))
-	assert.NoError(t, err)
-
-	testCases := []locateTestCase{
-		{
-			desc:      "find absolute path within symlinked workspace",
-			workspace: wsSymbolic,
-			in:        filepath.Join(wsSymbolic.Dir, "creatures", "horse"),
-			out:       []string{filepath.Join(wsPrimary.Dir, "creatures", "horse")},
-		},
-		{
-			desc:      "find by name in a symlinked workspace",
-			workspace: wsSymbolic,
-			in:        "horse",
-			out:       []string{filepath.Join(wsPrimary.Dir, "creatures", "horse")},
-		},
-		{
-			desc:      "don't be confused by a symlinked file named the same as an exercise",
-			workspace: wsPrimary,
-			in:        "date",
-			out:       []string{filepath.Join(wsPrimary.Dir, "actions", "date")},
-		},
-		{
-			desc:      "find exercises that are symlinks",
-			workspace: wsPrimary,
-			in:        "squash",
-			out: []string{
-				filepath.Join(wsPrimary.Dir, "..", "food", "squash"),
-				filepath.Join(wsPrimary.Dir, "actions", "squash"),
-			},
-		},
-	}
-
-	testLocate(testCases, t)
-}
diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go
index 31097b6..b48f3cf 100644
--- a/workspace/workspace_test.go
+++ b/workspace/workspace_test.go
@@ -87,136 +87,6 @@ func TestWorkspaceExercises(t *testing.T) {
 	}
 }
 
-func TestSolutionPath(t *testing.T) {
-	root := filepath.Join("..", "fixtures", "solution-path", "creatures")
-	ws, err := New(root)
-	assert.NoError(t, err)
-
-	// 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) {
-	tmpDir, err := ioutil.TempDir("", "resolve-solution-path")
-	defer os.RemoveAll(tmpDir)
-	ws, err := New(tmpDir)
-	assert.NoError(t, err)
-
-	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)
-	}
-}
-
 func TestSolutionDir(t *testing.T) {
 	_, cwd, _, _ := runtime.Caller(0)
 	root := filepath.Join(cwd, "..", "..", "fixtures", "solution-dir")