Repository: exercism/cli (go)

Issue:
nextercism: implement open command
_To be merged into the nextercism branch in https://github.com/exercism/cli/pull/410_

Command name: `open` (alias `o`).
File: `cmd/open.go`.


## Open solution by name

```
$ exercism open clock
```

Given an exercise name, it will locate it in the tree under the user's workspace. If there are `clock` exercises (e.g. in multiple tracks), then the command will ask which one to open.

The command uses the functionality in the `browser` package to open the URL.

There is a hidden `.solution` file in the exercise directory, which contains a UUID. This UUID can be used to determine the website URL to open.


## Open solution by path

Given a path to an exercise, it won't search for other exercises with the same name. This will work to open solutions by other people as well.

**UPDATE** The API will provide a solution url when we download, which we'll store in the metadata file for the solution. Then the open command can pass this URL directly to the browser package.


Pull request:
[nextercism] Implement open command
This tries to be a little bit clever, but it's not yet smooth enough.

The command can open your own exercises or other people's. For other people's exercises, you need to specify the path to the exercise, as I imagine you'll quickly have a gajillion of these.

The logic here is: given the input, locate the possible matches. If there are no possible matches, we're done. If there are more than one match, we need to disambiguate. But if there are more than one, then it wasn't a search by path, it was a search by name. That means that we found everything by you _and_ other people. So we need to filter out the results that aren't yours.

Finally, provide an interactive selection, and let them pick the one that they want to open.

The error handling could use some work here, I just don't know where to go with it.

Closes #416 

Patch:
diff --git a/cmd/open.go b/cmd/open.go
index a9d1b67..3414400 100644
--- a/cmd/open.go
+++ b/cmd/open.go
@@ -2,7 +2,11 @@ package cmd
 
 import (
 	"fmt"
+	"os"
 
+	"github.com/exercism/cli/browser"
+	"github.com/exercism/cli/config"
+	"github.com/exercism/cli/workspace"
 	"github.com/spf13/cobra"
 )
 
@@ -17,7 +21,60 @@ Pass either the name of an exercise, or the path to the directory that contains
 the solution you want to see on the website.
 	`,
 	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println("open called")
+		cfg, err := config.NewUserConfig()
+		BailOnError(err)
+		ws := workspace.New(cfg.Workspace)
+
+		if len(args) != 1 {
+			// TODO: usage
+			return
+		}
+
+		paths, err := ws.Locate(args[0])
+		BailOnError(err)
+
+		var solutions []workspace.Solution
+
+		for _, path := range paths {
+			solution, err := workspace.NewSolution(path)
+			if err != nil {
+				fmt.Fprintf(os.Stderr, err.Error())
+				continue
+			}
+			solutions = append(solutions, solution)
+		}
+
+		if len(solutions) == 0 {
+			return
+		}
+
+		if len(solutions) > 1 {
+			var mine []workspace.Solution
+			for _, s := range solutions {
+				if s.IsRequester {
+					mine = append(mine, s)
+				}
+			}
+			solutions = mine
+		}
+
+		sx := workspace.Solutions(solutions)
+		for {
+			prompt := `
+We found more than one. Which one did you mean?
+Type the number of the one you want to select.
+
+%s
+> `
+			s, err := sx.Pick(prompt)
+			if err != nil {
+				fmt.Println(err)
+				continue
+			}
+			browser.Open(s.URL)
+			break
+		}
+
 	},
 }
 
diff --git a/workspace/solutions.go b/workspace/solutions.go
new file mode 100644
index 0000000..4c5c4ba
--- /dev/null
+++ b/workspace/solutions.go
@@ -0,0 +1,65 @@
+package workspace
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"strconv"
+	"strings"
+)
+
+// Solutions is a collection of solutions to interactively choose from.
+type Solutions []Solution
+
+// Pick lets a user interactively select a solution from a list.
+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
+	}
+
+	fmt.Printf(prompt, sx.Display())
+
+	n, err := sx.ReadSelection(os.Stdin)
+	if err != nil {
+		return Solution{}, err
+	}
+
+	s, err := sx.Get(n)
+	if err != nil {
+		return Solution{}, err
+	}
+	return s, 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 (sx Solutions) Display() string {
+	str := ""
+	for i, s := range sx {
+		str += fmt.Sprintf("  [%d] %s\n", i+1, s)
+	}
+	return str
+}
+
+// ReadSelection reads the user's selection and converts it to a number.
+func (sx Solutions) ReadSelection(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 (sx Solutions) Get(n int) (Solution, error) {
+	if n <= 0 || n > len(sx) {
+		return Solution{}, errors.New("can't do that")
+	}
+	return sx[n-1], nil
+}


Test patch:
diff --git a/workspace/solutions_test.go b/workspace/solutions_test.go
new file mode 100644
index 0000000..3c1cb04
--- /dev/null
+++ b/workspace/solutions_test.go
@@ -0,0 +1,36 @@
+package workspace
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSolutions(t *testing.T) {
+	solutions := []Solution{
+		{Track: "a", Exercise: "foo"},
+		{Track: "b", Exercise: "foo"},
+		{Track: "c", Exercise: "foo"},
+	}
+	sx := Solutions(solutions)
+	display := "  [1] a/foo\n  [2] b/foo\n  [3] c/foo\n"
+	assert.Equal(t, display, sx.Display())
+
+	_, err := sx.Get(0)
+	assert.Error(t, err)
+
+	a, err := sx.Get(1)
+	assert.NoError(t, err)
+	assert.Equal(t, "a", a.Track)
+
+	b, err := sx.Get(2)
+	assert.NoError(t, err)
+	assert.Equal(t, "b", b.Track)
+
+	c, err := sx.Get(3)
+	assert.NoError(t, err)
+	assert.Equal(t, "c", c.Track)
+
+	_, err = sx.Get(4)
+	assert.Error(t, err)
+}