Repository: exercism/cli
Pull request #439 (2017-08-10 09:06:30)

[nextercism] Figure out where to download an exercise to
Nextercism is going to let people have separate solutions exploring the same problem.
Each solution has its own ID, and can contain any number of iterations. So someone might be exploring an object oriented approach and a functional approach to the same exercise, at the same time.

They will be able to do this in directories that are numbered serially, so the first one
would be 'thingy', then the second would be 'thingy-2'.

When downloading an exercise, this means that we need to figure out which of the existing
directories for the exercise of the same name is the one that we want to write to.

If it's none of them, then we need to make a new directory.

I really wasn't sure about the naming (which is why this doesn't pass golint right now).


Patch:
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/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/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)
+	}
+}