Repository: exercism/cli
Pull request #736 (2018-09-15 11:31:32)

Rename 'solution metadata' to 'exercise metadata'
Renaming `workspace.Solution` (and `solution.json`) was previously discussed here:
https://github.com/exercism/cli/pull/630#issuecomment-408201751
https://github.com/exercism/cli/issues/607#issuecomment-411601559

This change is a pure rename. 

I chose `workspace.ExerciseMetadata`, opting for `Exercise` over `Solution` because I believe Exercise more intuitively describes the metadata.

kytrinyx: > I believe Exercise more intuitively describes the metadata.

I agree. Giving this a review now, but I'd love to get @nywilken input on it as well (even though it's a pure rename, thus not complicated, it's an _important_ rename to get right, I think!)
jdsutherland: I think the issues have been addressed.

When changing these short var names I noticed something unrelated to the rename of this PR:

```diff
-func (ws Workspace) ExerciseDir(s string) (string, error) {
-	if !strings.HasPrefix(s, ws.Dir) {
+func (ws Workspace) ExerciseDir(path string) (string, error) {
+	if !strings.HasPrefix(path, ws.Dir) {
 		return "", errors.New("not in workspace")
 	}
 
-	path := s
+	currentPath := path
 	for {
-		if path == ws.Dir {
+		if currentPath == ws.Dir {
 			return "", errMissingMetadata
 		}
-		if _, err := os.Lstat(path); os.IsNotExist(err) {
+		if _, err := os.Lstat(currentPath); os.IsNotExist(err) {
 			return "", err
 		}
-		if _, err := os.Lstat(filepath.Join(path, metadataFilepath)); err == nil {
-			return path, nil
+		if _, err := os.Lstat(filepath.Join(currentPath, metadataFilepath)); err == nil {
+			return currentPath, nil
 		}
-		if _, err := os.Lstat(filepath.Join(path, legacyMetadataFilename)); err == nil {
-			return path, nil
+		if _, err := os.Lstat(filepath.Join(currentPath, legacyMetadataFilename)); err == nil {
+			return currentPath, nil
 		}
-		path = filepath.Dir(path)
+		currentPath = filepath.Dir(currentPath)
 	}
 }
```

I think `s` is ambiguous here. Do you think this is worth changing? If so, should it be part of this PR or separate?
nywilken: @jdsutherland @kytrinyx I think the names make sense. `ExerciseMetadataCollection` seems right right to me; it is easily understood what it represents. 

However, do we actually need it? I seems like we only have this type being used in a test. In fact, the constructor is so simple that I would say when we need to handle multiple paths we can achieve that functionality without a custom type. In short, I see we remove it!
jdsutherland: @nywilken Thanks for the review. The feedback gives the impression that it was viewed through the lens of additions rather than a pure rename. I think your suggestions are spot on though.

Looking at the history, `ExerciseMetadataCollection` (previously `workspace.Solutions`) was added by @kytrinyx as a way to implement interactive selection but it seems that was moved to a comms package that was eventually removed in https://github.com/exercism/cli/commit/1bb612f326ba3b87588c7c19ff0128eb539c922d. Maybe `workspace.Solutions` should have been removed as part of those changes?
nywilken: > The feedback gives the impression that it was viewed through the lens of additions rather than a pure rename.

I have the tendency to do this a bit, otherwise I’m left thinking that I may have missed something. 

>  I think the suggestions are spot on though.

Glad to hear that. The refactor work you got goning on here is good so happy to provide guidance anyway possible. 
kytrinyx: > I think `s` is ambiguous here. Do you think this is worth changing? If so, should it be part of this PR?

@jdsutherland I think `s` is fine here, though `path` is clearer. If you'd like to change it, I think it should go in a separate PR.

> However, do we actually need it? I seems like we only have this type being used in a test.

@nywilken That's a great catch! I have been simplifying and refactoring, and didn't notice that we don't use it yet.

@jdsutherland how would you feel about deleting it in a separate PR to be merged before this one? (I can also make the change, but not today or tomorrow).
nywilken: > Looking at the history, ExerciseMetadataCollection (previously workspace.Solutions) was added by @kytrinyx as a way to implement interactive selection but it looks like this was moved to a comms package that was eventually removed in 1bb612f. Maybe workspace.Solutions should have been removed as part of those changes?

I thought that was the case. I grokked the code to make sure there were no other usage of this type, but didn’t say out right to remove. In case, there was something I was missing. Prior to our current implementation we had the ability to do an interactive submit which handled directories and multiple files so this made sense then. Now that we have a simple submit, let’s remove the ExerciseMetadatCollection type along with its tests.  

BTW thanks for pulling up the history. 
nywilken: > how would you feel about deleting it in a separate PR to be merged before this one?

@jdsutherland I’ll submit a PR for the deletion and work with with @kytrinyx to have it merged prior to this one. 
nywilken: @kytrinyx @jdsutherland sorry for the confusion. @jdsutherland if you have the bandwidth right now to delete the type please do. I thought that was your response to @kytrinyx. Otherwise I can get to it later tonight. My apologies. 
nywilken: > @nywilken I'll make a PR for the deletion now.

:bowing_man: 
nywilken: @jdsutherland I merged #737 you should be able to rebase onto master to get the latest changes. 
jdsutherland: @nywilken done

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c9d737..fdefa21 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ The exercism CLI follows [semantic versioning](http://semver.org/).
 ----------------
 
 ## Next Release
-* **Your contribution here**
+* [#736](https://github.com/exercism/cli/pull/736) Metadata file .solution.json renamed to metadata.json - [@jdsutherland]
 
 ## v3.0.9 (2018-08-29)
 * [#720](https://github.com/exercism/cli/pull/720) Make the timeout configurable globally - [@kytrinyx]
@@ -389,19 +389,19 @@ All changes by [@msgehard]
 * Implement login and logout
 * Build on Travis
 
-[@Dparker1990]: https://github.com/Dparker1990
-[@LegalizeAdulthood]: https://github.com/LegalizeAdulthood
-[@Tonkpils]: https://github.com/Tonkpils
-[@TrevorBramble]: https://github.com/TrevorBramble
+[@alebaffa]: https://github.com/alebaffa
+[@AlexWheeler]: https://github.com/AlexWheeler
 [@ambroff]: https://github.com/ambroff
 [@andrewsardone]: https://github.com/andrewsardone
 [@anxiousmodernman]: https://github.com/anxiousmodernman
+[@beanieboi]: https://github.com/beanieboi
 [@blackerby]: https://github.com/blackerby
 [@broady]: https://github.com/broady
 [@ccnp123]: https://github.com/ccnp123
 [@cookrn]: https://github.com/cookrn
 [@daveyarwood]: https://github.com/daveyarwood
 [@devonestes]: https://github.com/devonestes
+[@Dparker1990]: https://github.com/Dparker1990
 [@djquan]: https://github.com/djquan
 [@dmmulroy]: https://github.com/dmmulroy
 [@dpritchett]: https://github.com/dpritchett
@@ -414,18 +414,22 @@ All changes by [@msgehard]
 [@hjljo]: https://github.com/hjljo
 [@isbadawi]: https://github.com/isbadawi
 [@jdsutherland]: https://github.com/jdsutherland
+[@jbaiter]: https://github.com/jbaiter
 [@jgsqware]: https://github.com/jgsqware
 [@jish]: https://github.com/jish
 [@jppunnett]: https://github.com/jppunnett
 [@kytrinyx]: https://github.com/kytrinyx
 [@lcowell]: https://github.com/lcowell
+[@LegalizeAdulthood]: https://github.com/LegalizeAdulthood
 [@manusajith]: https://github.com/manusajith
 [@morphatic]: https://github.com/morphatic
+[@mrageh]: https://github.com/mrageh
 [@msgehard]: https://github.com/msgehard
 [@narqo]: https://github.com/narqo
 [@neslom]: https://github.com/neslom
 [@nf]: https://github.com/nf
 [@nilbus]: https://github.com/nilbus
+[@nywilken]: https://github.com/nywilken
 [@petertseng]: https://github.com/petertseng
 [@pminten]: https://github.com/pminten
 [@queuebit]: https://github.com/queuebit
@@ -433,5 +437,7 @@ All changes by [@msgehard]
 [@rprouse]: https://github.com/rprouse
 [@simonjefford]: https://github.com/simonjefford
 [@srt32]: https://github.com/srt32
+[@Tonkpils]: https://github.com/Tonkpils
+[@TrevorBramble]: https://github.com/TrevorBramble
 [@williandrade]: https://github.com/williandrade
 [@zabawaba99]: https://github.com/zabawaba99
diff --git a/cmd/download.go b/cmd/download.go
index c0139e8..fd54052 100644
--- a/cmd/download.go
+++ b/cmd/download.go
@@ -132,7 +132,7 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 		}
 	}
 
-	solution := workspace.Solution{
+	metadata := workspace.ExerciseMetadata{
 		AutoApprove: payload.Solution.Exercise.AutoApprove,
 		Track:       payload.Solution.Exercise.Track.ID,
 		Team:        payload.Solution.Team.Slug,
@@ -144,17 +144,17 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 	}
 
 	root := usrCfg.GetString("workspace")
-	if solution.Team != "" {
-		root = filepath.Join(root, "teams", solution.Team)
+	if metadata.Team != "" {
+		root = filepath.Join(root, "teams", metadata.Team)
 	}
-	if !solution.IsRequester {
-		root = filepath.Join(root, "users", solution.Handle)
+	if !metadata.IsRequester {
+		root = filepath.Join(root, "users", metadata.Handle)
 	}
 
 	exercise := workspace.Exercise{
 		Root:  root,
-		Track: solution.Track,
-		Slug:  solution.Exercise,
+		Track: metadata.Track,
+		Slug:  metadata.Exercise,
 	}
 
 	dir := exercise.MetadataDir()
@@ -163,7 +163,7 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 		return err
 	}
 
-	err = solution.Write(dir)
+	err = metadata.Write(dir)
 	if err != nil {
 		return err
 	}
@@ -204,7 +204,7 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 		// Work around a path bug due to an early design decision (later reversed) to
 		// allow numeric suffixes for exercise directories, allowing people to have
 		// multiple parallel versions of an exercise.
-		pattern := fmt.Sprintf(`\A.*[/\\]%s-\d*/`, solution.Exercise)
+		pattern := fmt.Sprintf(`\A.*[/\\]%s-\d*/`, metadata.Exercise)
 		rgxNumericSuffix := regexp.MustCompile(pattern)
 		if rgxNumericSuffix.MatchString(file) {
 			file = string(rgxNumericSuffix.ReplaceAll([]byte(file), []byte("")))
@@ -214,10 +214,10 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 		file = strings.Replace(file, "\\", "/", -1)
 
 		relativePath := filepath.FromSlash(file)
-		dir := filepath.Join(solution.Dir, filepath.Dir(relativePath))
+		dir := filepath.Join(metadata.Dir, filepath.Dir(relativePath))
 		os.MkdirAll(dir, os.FileMode(0755))
 
-		f, err := os.Create(filepath.Join(solution.Dir, relativePath))
+		f, err := os.Create(filepath.Join(metadata.Dir, relativePath))
 		if err != nil {
 			return err
 		}
@@ -228,7 +228,7 @@ func runDownload(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 		}
 	}
 	fmt.Fprintf(Err, "\nDownloaded to\n")
-	fmt.Fprintf(Out, "%s\n", solution.Dir)
+	fmt.Fprintf(Out, "%s\n", metadata.Dir)
 	return nil
 }
 
diff --git a/cmd/open.go b/cmd/open.go
index 15166b2..4d04245 100644
--- a/cmd/open.go
+++ b/cmd/open.go
@@ -17,11 +17,11 @@ Pass the path to the directory that contains the solution you want to see on the
 	`,
 	Args: cobra.ExactArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		solution, err := workspace.NewSolution(args[0])
+		metadata, err := workspace.NewExerciseMetadata(args[0])
 		if err != nil {
 			return err
 		}
-		browser.Open(solution.URL)
+		browser.Open(metadata.URL)
 		return nil
 	},
 }
diff --git a/cmd/submit.go b/cmd/submit.go
index 6655c38..bfd55f1 100644
--- a/cmd/submit.go
+++ b/cmd/submit.go
@@ -109,7 +109,7 @@ func runSubmit(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 
 	var exerciseDir string
 	for _, arg := range args {
-		dir, err := ws.SolutionDir(arg)
+		dir, err := ws.ExerciseDir(arg)
 		if err != nil {
 			if workspace.IsMissingMetadata(err) {
 				return errors.New(msgMissingMetadata)
@@ -136,12 +136,12 @@ func runSubmit(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 	if verbose, _ := flags.GetBool("verbose"); verbose {
 		fmt.Fprintf(os.Stderr, migrationStatus.String())
 	}
-	solution, err := workspace.NewSolution(exerciseDir)
+	metadata, err := workspace.NewExerciseMetadata(exerciseDir)
 	if err != nil {
 		return err
 	}
 
-	if !solution.IsRequester {
+	if !metadata.IsRequester {
 		// TODO: add test
 		msg := `
 
@@ -151,7 +151,7 @@ func runSubmit(cfg config.Config, flags *pflag.FlagSet, args []string) error {
         %s download --exercise=%s --track=%s
 
 		`
-		return fmt.Errorf(msg, BinaryName, solution.Exercise, solution.Track)
+		return fmt.Errorf(msg, BinaryName, metadata.Exercise, metadata.Track)
 	}
 
 	exercise.Documents = make([]workspace.Document, 0, len(args))
@@ -226,7 +226,7 @@ func runSubmit(cfg config.Config, flags *pflag.FlagSet, args []string) error {
 	if err != nil {
 		return err
 	}
-	url := fmt.Sprintf("%s/solutions/%s", usrCfg.GetString("apibaseurl"), solution.ID)
+	url := fmt.Sprintf("%s/solutions/%s", usrCfg.GetString("apibaseurl"), metadata.ID)
 	req, err := client.NewRequest("PATCH", url, body)
 	if err != nil {
 		return err
@@ -251,11 +251,11 @@ func runSubmit(cfg config.Config, flags *pflag.FlagSet, args []string) error {
     %s
 `
 	suffix := "View it at:\n\n    "
-	if solution.AutoApprove {
+	if metadata.AutoApprove {
 		suffix = "You can complete the exercise and unlock the next core exercise at:\n"
 	}
 	fmt.Fprintf(Err, msg, suffix)
-	fmt.Fprintf(Out, "    %s\n\n", solution.URL)
+	fmt.Fprintf(Out, "    %s\n\n", metadata.URL)
 	return nil
 }
 
diff --git a/fixtures/is-solution-path/broken/.exercism/solution.json b/fixtures/is-solution-path/broken/.exercism/metadata.json
similarity index 100%
rename from fixtures/is-solution-path/broken/.exercism/solution.json
rename to fixtures/is-solution-path/broken/.exercism/metadata.json
diff --git a/fixtures/is-solution-path/yepp/.exercism/solution.json b/fixtures/is-solution-path/yepp/.exercism/metadata.json
similarity index 100%
rename from fixtures/is-solution-path/yepp/.exercism/solution.json
rename to fixtures/is-solution-path/yepp/.exercism/metadata.json
diff --git a/fixtures/solution-dir/workspace/exercise/.exercism/solution.json b/fixtures/solution-dir/workspace/exercise/.exercism/metadata.json
similarity index 100%
rename from fixtures/solution-dir/workspace/exercise/.exercism/solution.json
rename to fixtures/solution-dir/workspace/exercise/.exercism/metadata.json
diff --git a/fixtures/solution-path/creatures/gazelle-2/.exercism/solution.json b/fixtures/solution-path/creatures/gazelle-2/.exercism/metadata.json
similarity index 100%
rename from fixtures/solution-path/creatures/gazelle-2/.exercism/solution.json
rename to fixtures/solution-path/creatures/gazelle-2/.exercism/metadata.json
diff --git a/fixtures/solution-path/creatures/gazelle-3/.exercism/solution.json b/fixtures/solution-path/creatures/gazelle-3/.exercism/metadata.json
similarity index 100%
rename from fixtures/solution-path/creatures/gazelle-3/.exercism/solution.json
rename to fixtures/solution-path/creatures/gazelle-3/.exercism/metadata.json
diff --git a/fixtures/solution-path/creatures/gazelle/.exercism/solution.json b/fixtures/solution-path/creatures/gazelle/.exercism/metadata.json
similarity index 100%
rename from fixtures/solution-path/creatures/gazelle/.exercism/solution.json
rename to fixtures/solution-path/creatures/gazelle/.exercism/metadata.json
diff --git a/fixtures/solutions/alpha/.exercism/solution.json b/fixtures/solutions/alpha/.exercism/metadata.json
similarity index 100%
rename from fixtures/solutions/alpha/.exercism/solution.json
rename to fixtures/solutions/alpha/.exercism/metadata.json
diff --git a/fixtures/solutions/bravo/.exercism/solution.json b/fixtures/solutions/bravo/.exercism/metadata.json
similarity index 100%
rename from fixtures/solutions/bravo/.exercism/solution.json
rename to fixtures/solutions/bravo/.exercism/metadata.json
diff --git a/fixtures/solutions/charlie/.exercism/solution.json b/fixtures/solutions/charlie/.exercism/metadata.json
similarity index 100%
rename from fixtures/solutions/charlie/.exercism/solution.json
rename to fixtures/solutions/charlie/.exercism/metadata.json
diff --git a/workspace/exercise.go b/workspace/exercise.go
index 34dc315..d87aa61 100644
--- a/workspace/exercise.go
+++ b/workspace/exercise.go
@@ -42,7 +42,7 @@ func (e Exercise) MetadataFilepath() string {
 
 // LegacyMetadataFilepath is the absolute path to the legacy exercise metadata.
 func (e Exercise) LegacyMetadataFilepath() string {
-	return filepath.Join(e.Filepath(), legacySolutionFilename)
+	return filepath.Join(e.Filepath(), legacyMetadataFilename)
 }
 
 // MetadataDir returns the directory that the exercise metadata lives in.
diff --git a/workspace/solution.go b/workspace/exercise_metadata.go
similarity index 52%
rename from workspace/solution.go
rename to workspace/exercise_metadata.go
index a5722d6..48d6c93 100644
--- a/workspace/solution.go
+++ b/workspace/exercise_metadata.go
@@ -10,14 +10,14 @@ import (
 	"time"
 )
 
-const solutionFilename = "solution.json"
-const legacySolutionFilename = ".solution.json"
+const metadataFilename = "metadata.json"
+const legacyMetadataFilename = ".solution.json"
 const ignoreSubdir = ".exercism"
 
-var metadataFilepath = filepath.Join(ignoreSubdir, solutionFilename)
+var metadataFilepath = filepath.Join(ignoreSubdir, metadataFilename)
 
-// Solution contains metadata about a user's solution.
-type Solution struct {
+// ExerciseMetadata contains metadata about a user's exercise.
+type ExerciseMetadata struct {
 	Track       string     `json:"track"`
 	Exercise    string     `json:"exercise"`
 	ID          string     `json:"id"`
@@ -30,41 +30,41 @@ type Solution struct {
 	AutoApprove bool       `json:"auto_approve"`
 }
 
-// NewSolution reads solution metadata from a file in the given directory.
-func NewSolution(dir string) (*Solution, error) {
+// NewExerciseMetadata reads exercise metadata from a file in the given directory.
+func NewExerciseMetadata(dir string) (*ExerciseMetadata, error) {
 	b, err := ioutil.ReadFile(filepath.Join(dir, metadataFilepath))
 	if err != nil {
-		return &Solution{}, err
+		return nil, err
 	}
-	var s Solution
-	if err := json.Unmarshal(b, &s); err != nil {
-		return &Solution{}, err
+	var metadata ExerciseMetadata
+	if err := json.Unmarshal(b, &metadata); err != nil {
+		return nil, err
 	}
-	s.Dir = dir
-	return &s, nil
+	metadata.Dir = dir
+	return &metadata, 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 {
-	return strings.Trim(strings.Replace(filepath.Base(s.Dir), s.Exercise, "", 1), "-.")
+func (em *ExerciseMetadata) Suffix() string {
+	return strings.Trim(strings.Replace(filepath.Base(em.Dir), em.Exercise, "", 1), "-.")
 }
 
-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())
+func (em *ExerciseMetadata) String() string {
+	str := fmt.Sprintf("%s/%s", em.Track, em.Exercise)
+	if em.Suffix() != "" {
+		str = fmt.Sprintf("%s (%s)", str, em.Suffix())
 	}
-	if !s.IsRequester && s.Handle != "" {
-		str = fmt.Sprintf("%s by @%s", str, s.Handle)
+	if !em.IsRequester && em.Handle != "" {
+		str = fmt.Sprintf("%s by @%s", str, em.Handle)
 	}
 	return str
 }
 
-// Write stores solution metadata to a file.
-func (s *Solution) Write(dir string) error {
-	b, err := json.Marshal(s)
+// Write stores exercise metadata to a file.
+func (em *ExerciseMetadata) Write(dir string) error {
+	b, err := json.Marshal(em)
 	if err != nil {
 		return err
 	}
@@ -75,15 +75,15 @@ func (s *Solution) Write(dir string) error {
 	if err = ioutil.WriteFile(metadataAbsoluteFilepath, b, os.FileMode(0600)); err != nil {
 		return err
 	}
-	s.Dir = dir
+	em.Dir = dir
 	return nil
 }
 
 // PathToParent is the relative path from the workspace to the parent dir.
-func (s *Solution) PathToParent() string {
+func (em *ExerciseMetadata) PathToParent() string {
 	var dir string
-	if !s.IsRequester {
+	if !em.IsRequester {
 		dir = filepath.Join("users")
 	}
-	return filepath.Join(dir, s.Track)
+	return filepath.Join(dir, em.Track)
 }
diff --git a/workspace/workspace.go b/workspace/workspace.go
index 78c20b6..875f902 100644
--- a/workspace/workspace.go
+++ b/workspace/workspace.go
@@ -8,7 +8,7 @@ import (
 	"strings"
 )
 
-var errMissingMetadata = errors.New("no solution metadata file found")
+var errMissingMetadata = errors.New("no exercise metadata file found")
 
 // IsMissingMetadata verifies the type of error.
 func IsMissingMetadata(err error) bool {
@@ -95,9 +95,9 @@ func (ws Workspace) Exercises() ([]Exercise, error) {
 	return exercises, nil
 }
 
-// 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) {
+// ExerciseDir determines the root directory of an exercise.
+// This is the directory that contains the exercise metadata file.
+func (ws Workspace) ExerciseDir(s string) (string, error) {
 	if !strings.HasPrefix(s, ws.Dir) {
 		return "", errors.New("not in workspace")
 	}
@@ -113,7 +113,7 @@ func (ws Workspace) SolutionDir(s string) (string, error) {
 		if _, err := os.Lstat(filepath.Join(path, metadataFilepath)); err == nil {
 			return path, nil
 		}
-		if _, err := os.Lstat(filepath.Join(path, legacySolutionFilename)); err == nil {
+		if _, err := os.Lstat(filepath.Join(path, legacyMetadataFilename)); err == nil {
 			return path, nil
 		}
 		path = filepath.Dir(path)


Test patch:
diff --git a/cmd/download_test.go b/cmd/download_test.go
index f9aeace..0f08da5 100644
--- a/cmd/download_test.go
+++ b/cmd/download_test.go
@@ -144,13 +144,13 @@ func TestDownload(t *testing.T) {
 
 		dir := filepath.Join(targetDir, "bogus-track", "bogus-exercise")
 		b, err := ioutil.ReadFile(workspace.NewExerciseFromDir(dir).MetadataFilepath())
-		var s workspace.Solution
-		err = json.Unmarshal(b, &s)
+		var metadata workspace.ExerciseMetadata
+		err = json.Unmarshal(b, &metadata)
 		assert.NoError(t, err)
 
-		assert.Equal(t, "bogus-track", s.Track)
-		assert.Equal(t, "bogus-exercise", s.Exercise)
-		assert.Equal(t, tc.requester, s.IsRequester)
+		assert.Equal(t, "bogus-track", metadata.Track)
+		assert.Equal(t, "bogus-exercise", metadata.Exercise)
+		assert.Equal(t, tc.requester, metadata.IsRequester)
 	}
 }
 
diff --git a/cmd/submit_symlink_test.go b/cmd/submit_symlink_test.go
index ccf3878..8d193cd 100644
--- a/cmd/submit_symlink_test.go
+++ b/cmd/submit_symlink_test.go
@@ -44,7 +44,7 @@ func TestSubmitFilesInSymlinkedPath(t *testing.T) {
 	dir := filepath.Join(dstDir, "bogus-track", "bogus-exercise")
 	os.MkdirAll(dir, os.FileMode(0755))
 
-	writeFakeSolution(t, dir, "bogus-track", "bogus-exercise")
+	writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise")
 
 	v := viper.New()
 	v.Set("token", "abc123")
diff --git a/cmd/submit_test.go b/cmd/submit_test.go
index 956633f..b2e38d9 100644
--- a/cmd/submit_test.go
+++ b/cmd/submit_test.go
@@ -70,7 +70,7 @@ func TestSubmitNonExistentFile(t *testing.T) {
 	assert.Regexp(t, "cannot be found", err.Error())
 }
 
-func TestSubmitExerciseWithoutSolutionMetadataFile(t *testing.T) {
+func TestSubmitExerciseWithoutMetadataFile(t *testing.T) {
 	tmpDir, err := ioutil.TempDir("", "no-metadata-file")
 	defer os.RemoveAll(tmpDir)
 	assert.NoError(t, err)
@@ -147,7 +147,7 @@ func TestSubmitFiles(t *testing.T) {
 
 	dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise")
 	os.MkdirAll(filepath.Join(dir, "subdir"), os.FileMode(0755))
-	writeFakeSolution(t, dir, "bogus-track", "bogus-exercise")
+	writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise")
 
 	file1 := filepath.Join(dir, "file-1.txt")
 	err = ioutil.WriteFile(file1, []byte("This is file 1."), os.FileMode(0755))
@@ -186,7 +186,7 @@ func TestSubmitFiles(t *testing.T) {
 	assert.Equal(t, "This is the readme.", submittedFiles["README.md"])
 }
 
-func TestLegacySolutionMetadataMigration(t *testing.T) {
+func TestLegacyMetadataMigration(t *testing.T) {
 	oldOut := Out
 	oldErr := Err
 	Out = ioutil.Discard
@@ -207,14 +207,14 @@ func TestLegacySolutionMetadataMigration(t *testing.T) {
 	dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise")
 	os.MkdirAll(dir, os.FileMode(0755))
 
-	solution := &workspace.Solution{
+	metadata := &workspace.ExerciseMetadata{
 		ID:          "bogus-solution-uuid",
 		Track:       "bogus-track",
 		Exercise:    "bogus-exercise",
 		URL:         "http://example.com/bogus-url",
 		IsRequester: true,
 	}
-	b, err := json.Marshal(solution)
+	b, err := json.Marshal(metadata)
 	assert.NoError(t, err)
 	exercise := workspace.NewExerciseFromDir(dir)
 	err = ioutil.WriteFile(exercise.LegacyMetadataFilepath(), b, os.FileMode(0600))
@@ -271,7 +271,7 @@ func TestSubmitWithEmptyFile(t *testing.T) {
 	dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise")
 	os.MkdirAll(dir, os.FileMode(0755))
 
-	writeFakeSolution(t, dir, "bogus-track", "bogus-exercise")
+	writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise")
 
 	v := viper.New()
 	v.Set("token", "abc123")
@@ -317,7 +317,7 @@ func TestSubmitWithEnormousFile(t *testing.T) {
 	dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise")
 	os.MkdirAll(dir, os.FileMode(0755))
 
-	writeFakeSolution(t, dir, "bogus-track", "bogus-exercise")
+	writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise")
 
 	v := viper.New()
 	v.Set("token", "abc123")
@@ -360,7 +360,7 @@ func TestSubmitFilesForTeamExercise(t *testing.T) {
 
 	dir := filepath.Join(tmpDir, "teams", "bogus-team", "bogus-track", "bogus-exercise")
 	os.MkdirAll(filepath.Join(dir, "subdir"), os.FileMode(0755))
-	writeFakeSolution(t, dir, "bogus-track", "bogus-exercise")
+	writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise")
 
 	file1 := filepath.Join(dir, "file-1.txt")
 	err = ioutil.WriteFile(file1, []byte("This is file 1."), os.FileMode(0755))
@@ -409,7 +409,7 @@ func TestSubmitOnlyEmptyFile(t *testing.T) {
 	dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise")
 	os.MkdirAll(dir, os.FileMode(0755))
 
-	writeFakeSolution(t, dir, "bogus-track", "bogus-exercise")
+	writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise")
 
 	v := viper.New()
 	v.Set("token", "abc123")
@@ -435,11 +435,11 @@ func TestSubmitFilesFromDifferentSolutions(t *testing.T) {
 
 	dir1 := filepath.Join(tmpDir, "bogus-track", "bogus-exercise-1")
 	os.MkdirAll(dir1, os.FileMode(0755))
-	writeFakeSolution(t, dir1, "bogus-track", "bogus-exercise-1")
+	writeFakeMetadata(t, dir1, "bogus-track", "bogus-exercise-1")
 
 	dir2 := filepath.Join(tmpDir, "bogus-track", "bogus-exercise-2")
 	os.MkdirAll(dir2, os.FileMode(0755))
-	writeFakeSolution(t, dir2, "bogus-track", "bogus-exercise-2")
+	writeFakeMetadata(t, dir2, "bogus-track", "bogus-exercise-2")
 
 	file1 := filepath.Join(dir1, "file-1.txt")
 	err = ioutil.WriteFile(file1, []byte("This is file 1."), os.FileMode(0755))
@@ -510,7 +510,7 @@ func TestSubmitRelativePath(t *testing.T) {
 	dir := filepath.Join(tmpDir, "bogus-track", "bogus-exercise")
 	os.MkdirAll(dir, os.FileMode(0755))
 
-	writeFakeSolution(t, dir, "bogus-track", "bogus-exercise")
+	writeFakeMetadata(t, dir, "bogus-track", "bogus-exercise")
 
 	v := viper.New()
 	v.Set("token", "abc123")
@@ -534,14 +534,14 @@ func TestSubmitRelativePath(t *testing.T) {
 	assert.Equal(t, "This is a file.", submittedFiles["file.txt"])
 }
 
-func writeFakeSolution(t *testing.T, dir, trackID, exerciseSlug string) {
-	solution := &workspace.Solution{
+func writeFakeMetadata(t *testing.T, dir, trackID, exerciseSlug string) {
+	metadata := &workspace.ExerciseMetadata{
 		ID:          "bogus-solution-uuid",
 		Track:       trackID,
 		Exercise:    exerciseSlug,
 		URL:         "http://example.com/bogus-url",
 		IsRequester: true,
 	}
-	err := solution.Write(dir)
+	err := metadata.Write(dir)
 	assert.NoError(t, err)
 }
diff --git a/workspace/solution_test.go b/workspace/exercise_metadata_test.go
similarity index 70%
rename from workspace/solution_test.go
rename to workspace/exercise_metadata_test.go
index 7c1d1e0..75fe819 100644
--- a/workspace/solution_test.go
+++ b/workspace/exercise_metadata_test.go
@@ -9,12 +9,12 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestSolution(t *testing.T) {
+func TestExerciseMetadata(t *testing.T) {
 	dir, err := ioutil.TempDir("", "solution")
 	assert.NoError(t, err)
 	defer os.RemoveAll(dir)
 
-	s1 := &Solution{
+	em1 := &ExerciseMetadata{
 		Track:       "a-track",
 		Exercise:    "bogus-exercise",
 		ID:          "abc",
@@ -23,53 +23,53 @@ func TestSolution(t *testing.T) {
 		IsRequester: true,
 		Dir:         dir,
 	}
-	err = s1.Write(dir)
+	err = em1.Write(dir)
 	assert.NoError(t, err)
 
-	s2, err := NewSolution(dir)
+	em2, err := NewExerciseMetadata(dir)
 	assert.NoError(t, err)
-	assert.Nil(t, s2.SubmittedAt)
-	assert.Equal(t, s1, s2)
+	assert.Nil(t, em2.SubmittedAt)
+	assert.Equal(t, em1, em2)
 
 	ts := time.Date(2000, 1, 2, 3, 4, 5, 6, time.UTC)
-	s2.SubmittedAt = &ts
+	em2.SubmittedAt = &ts
 
-	err = s2.Write(dir)
+	err = em2.Write(dir)
 	assert.NoError(t, err)
 
-	s3, err := NewSolution(dir)
+	em3, err := NewExerciseMetadata(dir)
 	assert.NoError(t, err)
-	assert.Equal(t, s2, s3)
+	assert.Equal(t, em2, em3)
 }
 
 func TestSuffix(t *testing.T) {
 	testCases := []struct {
-		solution Solution
+		metadata ExerciseMetadata
 		suffix   string
 	}{
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Exercise: "bat",
 				Dir:      "",
 			},
 			suffix: "",
 		},
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Exercise: "bat",
 				Dir:      "/path/to/bat",
 			},
 			suffix: "",
 		},
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Exercise: "bat",
 				Dir:      "/path/to/bat-2",
 			},
 			suffix: "2",
 		},
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Exercise: "bat",
 				Dir:      "/path/to/bat-200",
 			},
@@ -78,20 +78,20 @@ func TestSuffix(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		testName := "Suffix of '" + tc.solution.Dir + "' should be " + tc.suffix
+		testName := "Suffix of '" + tc.metadata.Dir + "' should be " + tc.suffix
 		t.Run(testName, func(t *testing.T) {
-			assert.Equal(t, tc.suffix, tc.solution.Suffix(), testName)
+			assert.Equal(t, tc.suffix, tc.metadata.Suffix(), testName)
 		})
 	}
 }
 
-func TestSolutionString(t *testing.T) {
+func TestExerciseMetadataString(t *testing.T) {
 	testCases := []struct {
-		solution Solution
+		metadata ExerciseMetadata
 		desc     string
 	}{
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Track:    "elixir",
 				Exercise: "secret-handshake",
 				Handle:   "",
@@ -100,7 +100,7 @@ func TestSolutionString(t *testing.T) {
 			desc: "elixir/secret-handshake",
 		},
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Track:       "cpp",
 				Exercise:    "clock",
 				Handle:      "alice",
@@ -109,7 +109,7 @@ func TestSolutionString(t *testing.T) {
 			desc: "cpp/clock",
 		},
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Track:       "cpp",
 				Exercise:    "clock",
 				Handle:      "alice",
@@ -119,7 +119,7 @@ func TestSolutionString(t *testing.T) {
 			desc: "cpp/clock (2)",
 		},
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Track:       "fsharp",
 				Exercise:    "hello-world",
 				Handle:      "bob",
@@ -128,7 +128,7 @@ func TestSolutionString(t *testing.T) {
 			desc: "fsharp/hello-world by @bob",
 		},
 		{
-			solution: Solution{
+			metadata: ExerciseMetadata{
 				Track:       "haskell",
 				Exercise:    "allergies",
 				Handle:      "charlie",
@@ -142,7 +142,7 @@ func TestSolutionString(t *testing.T) {
 	for _, tc := range testCases {
 		testName := "should stringify to '" + tc.desc + "'"
 		t.Run(testName, func(t *testing.T) {
-			assert.Equal(t, tc.desc, tc.solution.String())
+			assert.Equal(t, tc.desc, tc.metadata.String())
 		})
 	}
 }
diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go
index 322f04e..59c76a8 100644
--- a/workspace/workspace_test.go
+++ b/workspace/workspace_test.go
@@ -88,7 +88,7 @@ func TestWorkspaceExercises(t *testing.T) {
 	}
 }
 
-func TestSolutionDir(t *testing.T) {
+func TestExerciseDir(t *testing.T) {
 	_, cwd, _, _ := runtime.Caller(0)
 	root := filepath.Join(cwd, "..", "..", "fixtures", "solution-dir")
 
@@ -130,7 +130,7 @@ func TestSolutionDir(t *testing.T) {
 	}
 
 	for _, test := range tests {
-		dir, err := ws.SolutionDir(test.path)
+		dir, err := ws.ExerciseDir(test.path)
 		if !test.ok {
 			assert.Error(t, err, test.path)
 			continue