Repository: exercism/cli
Pull request #682 (2018-07-25 22:38:43)

Detect all exercises in a workspace
This is the first step in fixing a number of things:

- Get rid of the super complicated workspace Locate()
- Stop allowing exercise directories that are named with a numeric suffix
- Implement an 'exercism doctor' command which will migrate
  all unmigrated exercises by downloading the solution metadata for it


Patch:
diff --git a/workspace/exercise.go b/workspace/exercise.go
new file mode 100644
index 0000000..e8be3ec
--- /dev/null
+++ b/workspace/exercise.go
@@ -0,0 +1,45 @@
+package workspace
+
+import (
+	"os"
+	"path"
+	"path/filepath"
+)
+
+// Exercise is an implementation of a problem in a track.
+type Exercise struct {
+	Root  string
+	Track string
+	Slug  string
+}
+
+// Path is the normalized relative path.
+// It always has forward slashes, regardless
+// of the operating system.
+func (e Exercise) Path() string {
+	return path.Join(e.Track, e.Slug)
+}
+
+// Filepath is the absolute path on the filesystem.
+func (e Exercise) Filepath() string {
+	return filepath.Join(e.Root, e.Track, e.Slug)
+}
+
+// MetadataFilepath is the absolute path to the exercise metadata.
+func (e Exercise) MetadataFilepath() string {
+	return filepath.Join(e.Filepath(), solutionFilename)
+}
+
+// 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.
+func (e Exercise) HasMetadata() (bool, error) {
+	_, err := os.Lstat(e.MetadataFilepath())
+	if os.IsNotExist(err) {
+		return false, nil
+	}
+	if err == nil {
+		return true, nil
+	}
+	return false, err
+}
diff --git a/workspace/workspace.go b/workspace/workspace.go
index c05a0c7..b6a3785 100644
--- a/workspace/workspace.go
+++ b/workspace/workspace.go
@@ -3,6 +3,7 @@ package workspace
 import (
 	"errors"
 	"fmt"
+	"io/ioutil"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -38,6 +39,66 @@ func New(dir string) (Workspace, error) {
 	return Workspace{Dir: dir}, nil
 }
 
+// PotentialExercises are a first-level guess at the user's exercises.
+// It looks at the workspace structurally, and guesses based on
+// the location of the directory. E.g. any top level directory
+// within the workspace (except 'users') is assumed to be a
+// track, and any directory within there again is assumed to
+// be an exercise.
+func (ws Workspace) PotentialExercises() ([]Exercise, error) {
+	exercises := []Exercise{}
+
+	topInfos, err := ioutil.ReadDir(ws.Dir)
+	if err != nil {
+		return nil, err
+	}
+	for _, topInfo := range topInfos {
+		if !topInfo.IsDir() {
+			continue
+		}
+
+		if topInfo.Name() == "users" {
+			continue
+		}
+
+		subInfos, err := ioutil.ReadDir(filepath.Join(ws.Dir, topInfo.Name()))
+		if err != nil {
+			return nil, err
+		}
+
+		for _, subInfo := range subInfos {
+			if !subInfo.IsDir() {
+				continue
+			}
+
+			exercises = append(exercises, Exercise{Track: topInfo.Name(), Slug: subInfo.Name(), Root: ws.Dir})
+		}
+	}
+
+	return exercises, nil
+}
+
+// Exercises returns the user's exercises within the workspace.
+// This doesn't find legacy exercises where the metadata is missing.
+func (ws Workspace) Exercises() ([]Exercise, error) {
+	candidates, err := ws.PotentialExercises()
+	if err != nil {
+		return nil, err
+	}
+
+	exercises := make([]Exercise, 0, len(candidates))
+	for _, candidate := range candidates {
+		ok, err := candidate.HasMetadata()
+		if err != nil {
+			return nil, err
+		}
+		if ok {
+			exercises = append(exercises, candidate)
+		}
+	}
+	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


Test patch:
diff --git a/workspace/exercise_test.go b/workspace/exercise_test.go
new file mode 100644
index 0000000..83b7940
--- /dev/null
+++ b/workspace/exercise_test.go
@@ -0,0 +1,35 @@
+package workspace
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestHasMetadata(t *testing.T) {
+	ws, err := ioutil.TempDir("", "fake-workspace")
+	defer os.RemoveAll(ws)
+	assert.NoError(t, err)
+
+	exerciseA := Exercise{Root: ws, Track: "bogus-track", Slug: "apple"}
+	exerciseB := Exercise{Root: ws, Track: "bogus-track", Slug: "banana"}
+
+	err = os.MkdirAll(filepath.Join(exerciseA.Filepath()), os.FileMode(0755))
+	assert.NoError(t, err)
+	err = os.MkdirAll(filepath.Join(exerciseB.Filepath()), os.FileMode(0755))
+	assert.NoError(t, err)
+
+	err = ioutil.WriteFile(exerciseA.MetadataFilepath(), []byte{}, os.FileMode(0600))
+	assert.NoError(t, err)
+
+	ok, err := exerciseA.HasMetadata()
+	assert.NoError(t, err)
+	assert.True(t, ok)
+
+	ok, err = exerciseB.HasMetadata()
+	assert.NoError(t, err)
+	assert.False(t, ok)
+}
diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go
index 8b041e0..31097b6 100644
--- a/workspace/workspace_test.go
+++ b/workspace/workspace_test.go
@@ -5,11 +5,88 @@ import (
 	"os"
 	"path/filepath"
 	"runtime"
+	"sort"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 )
 
+func TestWorkspacePotentialExercises(t *testing.T) {
+	tmpDir, err := ioutil.TempDir("", "walk")
+	defer os.RemoveAll(tmpDir)
+	assert.NoError(t, err)
+
+	a1 := filepath.Join(tmpDir, "track-a", "exercise-one")
+	b1 := filepath.Join(tmpDir, "track-b", "exercise-one")
+	b2 := filepath.Join(tmpDir, "track-b", "exercise-two")
+
+	// It should ignore other people's exercises.
+	alice := filepath.Join(tmpDir, "users", "alice", "track-a", "exercise-one")
+
+	// It should ignore nested dirs within exercises.
+	nested := filepath.Join(a1, "subdir", "deeper-dir", "another-deep-dir")
+
+	for _, path := range []string{a1, b1, b2, alice, nested} {
+		err := os.MkdirAll(path, os.FileMode(0755))
+		assert.NoError(t, err)
+	}
+
+	ws, err := New(tmpDir)
+	assert.NoError(t, err)
+
+	exercises, err := ws.PotentialExercises()
+	assert.NoError(t, err)
+	if assert.Equal(t, 3, len(exercises)) {
+		paths := make([]string, len(exercises))
+		for i, e := range exercises {
+			paths[i] = e.Path()
+		}
+
+		sort.Strings(paths)
+		assert.Equal(t, paths[0], "track-a/exercise-one")
+		assert.Equal(t, paths[1], "track-b/exercise-one")
+		assert.Equal(t, paths[2], "track-b/exercise-two")
+	}
+}
+
+func TestWorkspaceExercises(t *testing.T) {
+	tmpDir, err := ioutil.TempDir("", "walk-with-metadata")
+	defer os.RemoveAll(tmpDir)
+	assert.NoError(t, err)
+
+	a1 := filepath.Join(tmpDir, "track-a", "exercise-one")
+	a2 := filepath.Join(tmpDir, "track-a", "exercise-two") // no metadata
+	b1 := filepath.Join(tmpDir, "track-b", "exercise-one")
+	b2 := filepath.Join(tmpDir, "track-b", "exercise-two")
+
+	for _, path := range []string{a1, a2, b1, b2} {
+		err := os.MkdirAll(path, os.FileMode(0755))
+		assert.NoError(t, err)
+
+		if path != a2 {
+			err = ioutil.WriteFile(filepath.Join(path, solutionFilename), []byte{}, os.FileMode(0600))
+			assert.NoError(t, err)
+		}
+	}
+
+	ws, err := New(tmpDir)
+	assert.NoError(t, err)
+
+	exercises, err := ws.Exercises()
+	assert.NoError(t, err)
+	if assert.Equal(t, 3, len(exercises)) {
+		paths := make([]string, len(exercises))
+		for i, e := range exercises {
+			paths[i] = e.Path()
+		}
+
+		sort.Strings(paths)
+		assert.Equal(t, paths[0], "track-a/exercise-one")
+		assert.Equal(t, paths[1], "track-b/exercise-one")
+		assert.Equal(t, paths[2], "track-b/exercise-two")
+	}
+}
+
 func TestSolutionPath(t *testing.T) {
 	root := filepath.Join("..", "fixtures", "solution-path", "creatures")
 	ws, err := New(root)