Repository: exercism/cli
Pull request #426 (2017-08-07 04:53:29)

[nextercism] Create solution metadata
We are going to store some metadata about a solution in a hidden file.
This currently only hides the file on Linux/MacOS.
I need to work out the hidden file for Windows, presumably using
`syscall.SetFileAttributes(f, syscall.FILE_ATTRIBUTE_HIDDEN)`.

We're going to use this in the `download` (store the metadata), `submit` (find the solution id), and `open` (look up the web url) commands.

kytrinyx: OK, super not sure about the windows hidden file thing. I've never tried this before. I read the docs and the source code for the `syscall` stuff. I can't test this locally (no Windows), so I kind of expect to have gotten this wrong.

If someone would pull this down on Windows and try running the tests (wait, how do you do `go test $(go list ./... | grep -v vendor)` on windows?) that would be a great help.

If you have the spare cycles to do something like the below in a temporary folder and see what happens, that would be cool too:

```
package main

import (
	"io/ioutil"
	"os"

	"github.com/exercism/cli/visibility"
)

func main() {
	f := `can-you-see-me.txt`
	ioutil.WriteFile(f, []byte(`o hai, didn't see you there`), os.FileMode(0755))
	visibility.HideFile(f)
}
```

Then figure out if `can-you-see-me.txt` is hidden or not (I don't even know how you would find out, sorry!).
Tonkpils: Does this help? https://github.com/inconshreveable/go-update/blob/master/hide_windows.go I'm not on a windows computer but I know that works as I've tested it on it before.
kytrinyx: Oh, yeah. Interesting. I would definitely not have thought about that approach. Did you copy/paste to use it? I guess you'd have to since it's not exported. I wish they'd export it, since we're already relying on that package. We could just call it directly.
robphoenix: I'll give this a go tomorrow on my Windows laptop
kytrinyx: I added a few more commits to this one. Realized that we're going to want to display solutions to people to get them to disambiguate. E.g. when they've got three different "clock" exercises, one in Go, one in Rust, and one by some other user, if they say `exercism open clock`, we need to get them to pick. So I've fleshed out solution a little bit to give us a `String()` method that will help us with that.
kytrinyx: > I'm not sure if it's me and not knowing the right build instructions, but I had to comment out the hide_file.go contents as it was complaining about the function being redeclared. I suppose that won't be a problem with a binary will it.

I will go look at instructions about build constraints to see if I got it wrong. It shouldn't give you trouble if I do it right, I think.
kytrinyx: @robphoenix You're right. This built just fine when I just built the binary. I don't understand why it's complaining on your machine.

I'm going to go ahead and merge this, as I really need to get on with the various commands that depend on this one, but I would love to figure the build thing out before too long.

Patch:
diff --git a/visibility/hide_file.go b/visibility/hide_file.go
new file mode 100644
index 0000000..e419efe
--- /dev/null
+++ b/visibility/hide_file.go
@@ -0,0 +1,7 @@
+// +build !windows
+package visibility
+
+// HideFile is a no-op for non-Windows systems.
+func HideFile(string) error {
+	return nil
+}
diff --git a/visibility/hide_file_windows.go b/visibility/hide_file_windows.go
new file mode 100644
index 0000000..ab69889
--- /dev/null
+++ b/visibility/hide_file_windows.go
@@ -0,0 +1,27 @@
+package visibility
+
+import "syscall"
+
+// HideFile sets a Windows file's 'hidden' attribute.
+// This is the equivalent of giving a filename on
+// Linux or MacOS a leading dot (e.g. .bash_rc).
+func HideFile(path string) error {
+	// This is based on the discussion in
+	// https://www.reddit.com/r/golang/comments/5t3ezd/hidden_files_directories/
+	// but instead of duplicating all the effort to write the file, this takes
+	// the path of a written file and then flips the bit on the relevant attribute.
+	// The attributes are a bitmask (uint32), so we can't call
+	// SetFileAttributes(ptr, syscall.File_ATTRIBUTE_HIDDEN) as suggested, since
+	// that would wipe out any existing attributes.
+	ptr, err := syscall.UTF16PtrFromString(path)
+	if err != nil {
+		return err
+	}
+	attributes, err := syscall.GetFileAttributes(ptr)
+	if err != nil {
+		return err
+	}
+
+	attributes |= syscall.FILE_ATTRIBUTE_HIDDEN
+	return syscall.SetFileAttributes(ptr, attributes)
+}
diff --git a/workspace/solution.go b/workspace/solution.go
new file mode 100644
index 0000000..1a2ca0a
--- /dev/null
+++ b/workspace/solution.go
@@ -0,0 +1,73 @@
+package workspace
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/exercism/cli/visibility"
+)
+
+const solutionFilename = ".solution.json"
+
+// Solution contains metadata about a user's solution.
+type Solution struct {
+	Track       string     `json:"track"`
+	Exercise    string     `json:"exercise"`
+	ID          string     `json:"id"`
+	URL         string     `json:"url"`
+	Handle      string     `json:"handle"`
+	IsRequester bool       `json:"is_requester"`
+	SubmittedAt *time.Time `json:"submitted_at,omitempty"`
+	Dir         string     `json:"-"`
+}
+
+// NewSolution reads solution metadata from a file in the given directory.
+func NewSolution(dir string) (Solution, error) {
+	path := filepath.Join(dir, solutionFilename)
+	b, err := ioutil.ReadFile(path)
+	if err != nil {
+		return Solution{}, err
+	}
+	var s Solution
+	if err := json.Unmarshal(b, &s); err != nil {
+		return Solution{}, err
+	}
+	s.Dir = dir
+	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 {
+	return strings.Trim(strings.Replace(filepath.Base(s.Dir), s.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())
+	}
+	if !s.IsRequester && s.Handle != "" {
+		str = fmt.Sprintf("%s by @%s", str, s.Handle)
+	}
+	return str
+}
+
+// Write stores solution metadata to a file.
+func (s Solution) Write(dir string) error {
+	b, err := json.Marshal(s)
+	if err != nil {
+		return err
+	}
+	path := filepath.Join(dir, solutionFilename)
+	if err := ioutil.WriteFile(path, b, os.FileMode(0644)); err != nil {
+		return err
+	}
+	return visibility.HideFile(path)
+}


Test patch:
diff --git a/workspace/solution_test.go b/workspace/solution_test.go
new file mode 100644
index 0000000..f7ee793
--- /dev/null
+++ b/workspace/solution_test.go
@@ -0,0 +1,142 @@
+package workspace
+
+import (
+	"io/ioutil"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSolution(t *testing.T) {
+	dir, err := ioutil.TempDir("", "solution")
+	assert.NoError(t, err)
+	defer os.RemoveAll(dir)
+
+	s1 := Solution{
+		Track:       "a-track",
+		Exercise:    "bogus-exercise",
+		ID:          "abc",
+		URL:         "http://example.com",
+		Handle:      "alice",
+		IsRequester: true,
+		Dir:         dir,
+	}
+	err = s1.Write(dir)
+	assert.NoError(t, err)
+
+	s2, err := NewSolution(dir)
+	assert.NoError(t, err)
+	assert.Nil(t, s2.SubmittedAt)
+	assert.Equal(t, s1, s2)
+
+	ts := time.Date(2000, 1, 2, 3, 4, 5, 6, time.UTC)
+	s2.SubmittedAt = &ts
+
+	err = s2.Write(dir)
+	assert.NoError(t, err)
+
+	s3, err := NewSolution(dir)
+	assert.NoError(t, err)
+	assert.Equal(t, s2, s3)
+}
+
+func TestSuffix(t *testing.T) {
+	tests := []struct {
+		solution Solution
+		suffix   string
+	}{
+		{
+			solution: Solution{
+				Exercise: "bat",
+				Dir:      "",
+			},
+			suffix: "",
+		},
+		{
+			solution: Solution{
+				Exercise: "bat",
+				Dir:      "/path/to/bat",
+			},
+			suffix: "",
+		},
+		{
+			solution: Solution{
+				Exercise: "bat",
+				Dir:      "/path/to/bat-2",
+			},
+			suffix: "2",
+		},
+		{
+			solution: Solution{
+				Exercise: "bat",
+				Dir:      "/path/to/bat-200",
+			},
+			suffix: "200",
+		},
+	}
+
+	for _, test := range tests {
+		assert.Equal(t, test.suffix, test.solution.Suffix())
+	}
+}
+
+func TestSolutionString(t *testing.T) {
+	tests := []struct {
+		solution Solution
+		desc     string
+	}{
+		{
+			solution: Solution{
+				Track:    "elixir",
+				Exercise: "secret-handshake",
+				Handle:   "",
+				Dir:      "",
+			},
+			desc: "elixir/secret-handshake",
+		},
+		{
+			solution: Solution{
+				Track:       "cpp",
+				Exercise:    "clock",
+				Handle:      "alice",
+				IsRequester: true,
+			},
+			desc: "cpp/clock",
+		},
+		{
+			solution: Solution{
+				Track:       "cpp",
+				Exercise:    "clock",
+				Handle:      "alice",
+				IsRequester: true,
+				Dir:         "/path/to/clock-2",
+			},
+			desc: "cpp/clock (2)",
+		},
+		{
+			solution: Solution{
+				Track:       "fsharp",
+				Exercise:    "hello-world",
+				Handle:      "bob",
+				IsRequester: false,
+			},
+			desc: "fsharp/hello-world by @bob",
+		},
+		{
+			solution: Solution{
+				Track:       "haskell",
+				Exercise:    "allergies",
+				Handle:      "charlie",
+				IsRequester: false,
+				Dir:         "/path/to/allergies-2",
+			},
+			desc: "haskell/allergies (2) by @charlie",
+		},
+	}
+
+	for _, test := range tests {
+		assert.Equal(t, test.desc, test.solution.String())
+	}
+}