Repository: exercism/cli
Pull request #444 (2017-08-16 03:17:00)

[nextercism] Detect the type of a path
We are pretty flexible in the type of argument that we receive. It might be
an actual path, or it might be a conceptual path: the name of an exercise that
we need to walk the workspace to find. If it's a conceptual path we might find
more than one, in which case we'll do an interactive thing to disambiguate. If
it's a real path, then we can do a lot less guessing.

This is in preparation for the submit command.
We need to figure out what the argument to the submit command is. Or if multiple, what each of them are.


Patch:
diff --git a/cli/cli.go b/cli/cli.go
index 1b91fa4..293c5a3 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -79,7 +79,7 @@ func (c *CLI) IsUpToDate() (bool, error) {
 		return false, fmt.Errorf("unable to parse current version (%s): %s", c.Version, err)
 	}
 
-	return cv.GTE(rv), nil
+	return rv.EQ(cv), nil
 }
 
 // Upgrade allows the user to upgrade to the latest version of the CLI.
diff --git a/cmd/open.go b/cmd/open.go
index 5448928..3414400 100644
--- a/cmd/open.go
+++ b/cmd/open.go
@@ -33,7 +33,7 @@ the solution you want to see on the website.
 		paths, err := ws.Locate(args[0])
 		BailOnError(err)
 
-		var solutions []*workspace.Solution
+		var solutions []workspace.Solution
 
 		for _, path := range paths {
 			solution, err := workspace.NewSolution(path)
@@ -49,7 +49,7 @@ the solution you want to see on the website.
 		}
 
 		if len(solutions) > 1 {
-			var mine []*workspace.Solution
+			var mine []workspace.Solution
 			for _, s := range solutions {
 				if s.IsRequester {
 					mine = append(mine, s)
diff --git a/cmd/version.go b/cmd/version.go
index 965561c..3269cec 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -3,7 +3,6 @@ package cmd
 import (
 	"fmt"
 
-	"github.com/exercism/cli/cli"
 	"github.com/spf13/cobra"
 )
 
@@ -11,9 +10,6 @@ import (
 // It follows semantic versioning.
 const Version = "3.0.0-alpha.1"
 
-// checkLatest flag for version command.
-var checkLatest bool
-
 // versionCmd outputs the version of the CLI.
 var versionCmd = &cobra.Command{
 	Use:     "version",
@@ -21,50 +17,14 @@ var versionCmd = &cobra.Command{
 	Short:   "Version outputs the version of CLI.",
 	Long: `Version outputs the version of the exercism binary that is in use.
 
-To check for the latest available version, call the command with the
+To see if there is a more recent version available, call the command with the
 --latest flag.
 	`,
-
 	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println(currentVersion())
-
-		if checkLatest {
-			c := cli.New(Version)
-			l, err := checkForUpdate(c)
-			BailOnError(err)
-
-			fmt.Println(l)
-		}
-
-		return
+		fmt.Println(Version)
 	},
 }
 
-// currentVersion returns a formatted version string for the Exercism CLI.
-func currentVersion() string {
-	return fmt.Sprintf("exercism version %s", Version)
-}
-
-// checkForUpdate verifies if the CLI is running the latest version.
-// If the client is out of date, the function returns upgrade instructions.
-func checkForUpdate(c *cli.CLI) (string, error) {
-
-	ok, err := c.IsUpToDate()
-	if err != nil {
-		return "", err
-	}
-
-	if ok {
-		return "Your CLI version is up to date.", nil
-	}
-
-	// Anything but ok is out of date.
-	msg := fmt.Sprintf("A new CLI version is available. Run `exercism upgrade` to update to %s", c.LatestRelease.Version())
-	return msg, nil
-
-}
-
 func init() {
 	RootCmd.AddCommand(versionCmd)
-	versionCmd.Flags().BoolVarP(&checkLatest, "latest", "l", false, "check latest available version")
 }
diff --git a/config/user_config.go b/config/user_config.go
index 4940dde..8022a8b 100644
--- a/config/user_config.go
+++ b/config/user_config.go
@@ -81,17 +81,12 @@ func (cfg *UserConfig) resolve(path string) string {
 	if path == "" {
 		return ""
 	}
-	if strings.HasPrefix(path, "~/") {
-		path = strings.Replace(path, "~/", "", 1)
-		return filepath.Join(cfg.Home, path)
+	if strings.HasPrefix(path, "~"+string(os.PathSeparator)) {
+		return strings.Replace(path, "~", cfg.Home, 1)
 	}
 	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/detect-path-type/a-dir/.keep b/fixtures/detect-path-type/a-dir/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/fixtures/detect-path-type/a-file.txt b/fixtures/detect-path-type/a-file.txt
new file mode 100644
index 0000000..4dd1ef7
--- /dev/null
+++ b/fixtures/detect-path-type/a-file.txt
@@ -0,0 +1,1 @@
+This is a file.
diff --git a/fixtures/detect-path-type/symlinked-dir b/fixtures/detect-path-type/symlinked-dir
new file mode 120000
index 0000000..81398e2
--- /dev/null
+++ b/fixtures/detect-path-type/symlinked-dir
@@ -0,0 +1,1 @@
+a-dir
\ No newline at end of file
diff --git a/fixtures/detect-path-type/symlinked-file.txt b/fixtures/detect-path-type/symlinked-file.txt
new file mode 120000
index 0000000..ee26a05
--- /dev/null
+++ b/fixtures/detect-path-type/symlinked-file.txt
@@ -0,0 +1,1 @@
+a-file.txt
\ No newline at end of file
diff --git a/workspace/path_type.go b/workspace/path_type.go
new file mode 100644
index 0000000..7d140ea
--- /dev/null
+++ b/workspace/path_type.go
@@ -0,0 +1,61 @@
+package workspace
+
+import (
+	"os"
+	"path/filepath"
+)
+
+// PathType is either a path to a dir or file, or the name of an exercise.
+type PathType int
+
+const (
+	// TypeExerciseID is the name of an exercise.
+	TypeExerciseID PathType = iota
+	// TypeDir is a relative or absolute path to a directory.
+	TypeDir
+	// TypeFile is a relative or absolute path to a file.
+	TypeFile
+)
+
+// DetectPathType determines whether the given path is a directory, a file, or the name of an exercise.
+func DetectPathType(path string) (PathType, error) {
+	// If it's not an absolute path, make it one.
+	if !filepath.IsAbs(path) {
+		var err error
+		path, err = filepath.Abs(path)
+		if err != nil {
+			return -1, err
+		}
+	}
+
+	// If it doesn't exist, then it's an exercise name.
+	// We'll have to walk the workspace to find it.
+	if _, err := os.Stat(path); err != nil {
+		return TypeExerciseID, nil
+	}
+
+	// We found it. It's an actual path of some sort.
+	info, err := os.Lstat(path)
+	if err != nil {
+		return -1, err
+	}
+
+	// If it's a symlink, resolve it.
+	if info.Mode()&os.ModeSymlink == os.ModeSymlink {
+		src, err := filepath.EvalSymlinks(path)
+		if err != nil {
+			return -1, err
+		}
+		path = src
+		// Overwrite the symlinked info with the source info.
+		info, err = os.Lstat(path)
+		if err != nil {
+			return -1, err
+		}
+	}
+
+	if info.IsDir() {
+		return TypeDir, nil
+	}
+	return TypeFile, nil
+}
diff --git a/workspace/solution.go b/workspace/solution.go
index 519ccbf..1a2ca0a 100644
--- a/workspace/solution.go
+++ b/workspace/solution.go
@@ -27,28 +27,28 @@ type Solution struct {
 }
 
 // NewSolution reads solution metadata from a file in the given directory.
-func NewSolution(dir string) (*Solution, error) {
+func NewSolution(dir string) (Solution, error) {
 	path := filepath.Join(dir, solutionFilename)
 	b, err := ioutil.ReadFile(path)
 	if err != nil {
-		return &Solution{}, err
+		return Solution{}, err
 	}
 	var s Solution
 	if err := json.Unmarshal(b, &s); err != nil {
-		return &Solution{}, err
+		return Solution{}, err
 	}
 	s.Dir = dir
-	return &s, nil
+	return s, 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 {
+func (s Solution) Suffix() string {
 	return strings.Trim(strings.Replace(filepath.Base(s.Dir), s.Exercise, "", 1), "-.")
 }
 
-func (s *Solution) String() string {
+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())
@@ -60,7 +60,7 @@ func (s *Solution) String() string {
 }
 
 // Write stores solution metadata to a file.
-func (s *Solution) Write(dir string) error {
+func (s Solution) Write(dir string) error {
 	b, err := json.Marshal(s)
 	if err != nil {
 		return err
@@ -69,6 +69,5 @@ func (s *Solution) Write(dir string) error {
 	if err := ioutil.WriteFile(path, b, os.FileMode(0644)); err != nil {
 		return err
 	}
-	s.Dir = dir
 	return visibility.HideFile(path)
 }
diff --git a/workspace/solutions.go b/workspace/solutions.go
index 5b97709..4c5c4ba 100644
--- a/workspace/solutions.go
+++ b/workspace/solutions.go
@@ -11,10 +11,10 @@ import (
 )
 
 // Solutions is a collection of solutions to interactively choose from.
-type Solutions []*Solution
+type Solutions []Solution
 
 // Pick lets a user interactively select a solution from a list.
-func (sx Solutions) Pick(prompt string) (*Solution, error) {
+func (sx Solutions) Pick(prompt string) (Solution, error) {
 	// If there's just one, then we're done here.
 	if len(sx) == 1 {
 		return sx[0], nil
@@ -24,12 +24,12 @@ func (sx Solutions) Pick(prompt string) (*Solution, error) {
 
 	n, err := sx.ReadSelection(os.Stdin)
 	if err != nil {
-		return &Solution{}, err
+		return Solution{}, err
 	}
 
 	s, err := sx.Get(n)
 	if err != nil {
-		return &Solution{}, err
+		return Solution{}, err
 	}
 	return s, nil
 }
@@ -57,9 +57,9 @@ func (sx Solutions) ReadSelection(r io.Reader) (int, error) {
 
 // Get returns the solution corresponding to the number.
 // The list starts at 1, since that seems better in a user interface.
-func (sx Solutions) Get(n int) (*Solution, error) {
+func (sx Solutions) Get(n int) (Solution, error) {
 	if n <= 0 || n > len(sx) {
-		return &Solution{}, errors.New("can't do that")
+		return Solution{}, errors.New("can't do that")
 	}
 	return sx[n-1], nil
 }


Test patch:
diff --git a/cli/cli_test.go b/cli/cli_test.go
index a525974..0ea8aca 100644
--- a/cli/cli_test.go
+++ b/cli/cli_test.go
@@ -15,30 +15,8 @@ func TestIsUpToDate(t *testing.T) {
 		releaseTag string
 		ok         bool
 	}{
-		{
-			// It returns false for versions less than release.
-			cliVersion: "1.0.0",
-			releaseTag: "v1.0.1",
-			ok:         false,
-		},
-		{
-			// It returns false for pre-release versions of release.
-			cliVersion: "1.0.1-alpha.1",
-			releaseTag: "v1.0.1",
-			ok:         false,
-		},
-		{
-			// It returns true for versions equal to release.
-			cliVersion: "2.0.1",
-			releaseTag: "v2.0.1",
-			ok:         true,
-		},
-		{
-			// It returns true for versions greater than release.
-			cliVersion: "2.0.2",
-			releaseTag: "v2.0.1",
-			ok:         true,
-		},
+		{"1.0.0", "v1.0.1", false},
+		{"2.0.1", "v2.0.1", true},
 	}
 
 	for _, test := range tests {
diff --git a/cmd/version_test.go b/cmd/version_test.go
deleted file mode 100644
index d023428..0000000
--- a/cmd/version_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package cmd
-
-import (
-	"fmt"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-
-	"github.com/exercism/cli/cli"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestCurrentVersion(t *testing.T) {
-	expected := fmt.Sprintf("exercism version %s", Version)
-
-	actual := currentVersion()
-	assert.Equal(t, expected, actual)
-}
-
-func TestVersionUpdateCheck(t *testing.T) {
-	fakeEndpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprintln(w, `{"tag_name": "v2.0.0"}`)
-	})
-	ts := httptest.NewServer(fakeEndpoint)
-	defer ts.Close()
-	cli.LatestReleaseURL = ts.URL
-
-	tests := []struct {
-		version  string
-		expected string
-	}{
-		{
-			// It returns new version available for versions older than latest.
-			version:  "1.0.0",
-			expected: "A new CLI version is available. Run `exercism upgrade` to update to 2.0.0",
-		},
-		{
-			// It returns up to date for versions matching latest.
-			version:  "2.0.0",
-			expected: "Your CLI version is up to date.",
-		},
-		{
-			// It returns up to date for versions newer than latest.
-			version:  "2.0.1",
-			expected: "Your CLI version is up to date.",
-		},
-	}
-
-	for _, test := range tests {
-		c := &cli.CLI{
-			Version: test.version,
-		}
-
-		actual, err := checkForUpdate(c)
-
-		assert.NoError(t, err)
-		assert.NotEmpty(t, actual)
-		assert.Equal(t, test.expected, actual)
-	}
-}
diff --git a/config/user_config_darwin_test.go b/config/user_config_darwin_test.go
new file mode 100644
index 0000000..5dcecd2
--- /dev/null
+++ b/config/user_config_darwin_test.go
@@ -0,0 +1,33 @@
+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 f917cf4..8e0e3e8 100644
--- a/config/user_config_test.go
+++ b/config/user_config_test.go
@@ -1,62 +1,35 @@
-// +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)
-	}
-}
+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)
+}
diff --git a/config/user_config_windows_test.go b/config/user_config_windows_test.go
index 62c8255..cc7caed 100644
--- a/config/user_config_windows_test.go
+++ b/config/user_config_windows_test.go
@@ -1,62 +1,9 @@
 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, filepath.Join(cfg.Home, "a"), cfg.Workspace)
-}
+import "testing"
 
 func TestNormalizeWorkspace(t *testing.T) {
-	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)
-	}
+	// 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.")
 }
diff --git a/workspace/path_type_test.go b/workspace/path_type_test.go
new file mode 100644
index 0000000..232634c
--- /dev/null
+++ b/workspace/path_type_test.go
@@ -0,0 +1,62 @@
+package workspace
+
+import (
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestDetectPathType(t *testing.T) {
+	_, cwd, _, _ := runtime.Caller(0)
+	root := filepath.Join(cwd, "..", "..", "fixtures", "detect-path-type")
+
+	tests := []struct {
+		desc string
+		path string
+		pt   PathType
+	}{
+		{
+			desc: "absolute dir",
+			path: filepath.Join(root, "a-dir"),
+			pt:   TypeDir,
+		},
+		{
+			desc: "relative dir",
+			path: filepath.Join("..", "fixtures", "detect-path-type", "a-dir"),
+			pt:   TypeDir,
+		},
+		{
+			desc: "symlinked dir",
+			path: filepath.Join(root, "symlinked-dir"),
+			pt:   TypeDir,
+		},
+		{
+			desc: "absolute file",
+			path: filepath.Join(root, "a-file.txt"),
+			pt:   TypeFile,
+		},
+		{
+			desc: "relative file",
+			path: filepath.Join("..", "fixtures", "detect-path-type", "a-file.txt"),
+			pt:   TypeFile,
+		},
+		{
+			desc: "symlinked file",
+			path: filepath.Join(root, "symlinked-file.txt"),
+			pt:   TypeFile,
+		},
+		{
+			desc: "exercise ID",
+			path: "a-file",
+			pt:   TypeExerciseID,
+		},
+	}
+
+	for _, test := range tests {
+		pt, err := DetectPathType(test.path)
+		assert.NoError(t, err, test.desc)
+		assert.Equal(t, test.pt, pt, test.desc)
+	}
+}
diff --git a/workspace/solution_test.go b/workspace/solution_test.go
index fb62109..f7ee793 100644
--- a/workspace/solution_test.go
+++ b/workspace/solution_test.go
@@ -14,7 +14,7 @@ func TestSolution(t *testing.T) {
 	assert.NoError(t, err)
 	defer os.RemoveAll(dir)
 
-	s1 := &Solution{
+	s1 := Solution{
 		Track:       "a-track",
 		Exercise:    "bogus-exercise",
 		ID:          "abc",
diff --git a/workspace/solutions_test.go b/workspace/solutions_test.go
index 27175d4..3c1cb04 100644
--- a/workspace/solutions_test.go
+++ b/workspace/solutions_test.go
@@ -7,7 +7,7 @@ import (
 )
 
 func TestSolutions(t *testing.T) {
-	solutions := []*Solution{
+	solutions := []Solution{
 		{Track: "a", Exercise: "foo"},
 		{Track: "b", Exercise: "foo"},
 		{Track: "c", Exercise: "foo"},