pkg/tool/file: support file-related tasks

Updates #39

Change-Id: I0fc36d7414b76e9a741bf3d2616653f96fc462bd
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/1925
Reviewed-by: Marcel van Lohuizen <mpvl@google.com>
diff --git a/pkg/tool/file/file.cue b/pkg/tool/file/file.cue
index 1275236..ebad2a5 100644
--- a/pkg/tool/file/file.cue
+++ b/pkg/tool/file/file.cue
@@ -12,66 +12,52 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package os
-
-import "tool"
+package file
 
 // Read reads the contents of a file.
-Read: tool.Task & {
-	_kind: "tool/file.Read"
+Read: {
+	kind: "tool/file.Read"
 
 	// filename names the file to read.
-	filename: string
+	filename: !=""
 
 	// contents is the read contents. If the contents are constraint to bytes
 	// (the default), the file is read as is. If it is constraint to a string,
 	// the contents are checked to be valid UTF-8.
 	contents: *bytes | string
-
-	// if body is given, the file contents are parsed as JSON and unified with
-	// the specified CUE value.
-	body?: _
-}
-
-// Create writes contents to the given file.
-Create: tool.Task & {
-	_kind: "tool/file.Create"
-
-	// filename names the file to write.
-	filename: string
-
-	// permissions defines the permissions to use if the file does not yet exist.
-	permissions: int
-
-	// overwrite defines whether an existing file may be overwritten.
-	overwrite: *false | true
-
-	// contents specifies the bytes to be written.
-	contents: bytes | string
 }
 
 // Append writes contents to the given file.
-Append: tool.Task & {
+Append: {
+	kind: "tool/file.Append"
+
 	// filename names the file to append.
-	filename: string
+	filename: !=""
 
 	// permissions defines the permissions to use if the file does not yet exist.
-	permissions: int
+	permissions: int | *0o644
 
 	// contents specifies the bytes to be written.
 	contents: bytes | string
 }
 
-Dir: tool.Task & {
-	_kind: "tool/file.Dir"
+// Create writes contents to the given file.
+Create: {
+	kind: "tool/file.Create"
 
-	path: string
-	dir: [...string]
+	// filename names the file to write.
+	filename: !=""
+
+	// permissions defines the permissions to use if the file does not yet exist.
+	permissions: int | *0o644
+
+	// contents specifies the bytes to be written.
+	contents: bytes | string
 }
 
-Glob: tool.Task & {
-	_kind: "tool/file.Glob"
+Glob: {
+	kind: "tool/file.Glob"
 
-	glob: string
-	files <Filename>: string
+	glob: !=""
+	files: [...string]
 }
diff --git a/pkg/tool/file/file.go b/pkg/tool/file/file.go
new file mode 100644
index 0000000..e5673cc
--- /dev/null
+++ b/pkg/tool/file/file.go
@@ -0,0 +1,98 @@
+// Copyright 2019 CUE Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package file provides file operations for cue tasks.
+package file
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"cuelang.org/go/cue"
+	"cuelang.org/go/internal/task"
+)
+
+func init() {
+	task.Register("tool/file.Read", newReadCmd)
+	task.Register("tool/file.Append", newAppendCmd)
+	task.Register("tool/file.Create", newCreateCmd)
+	task.Register("tool/file.Glob", newGlobCmd)
+}
+
+func newReadCmd(v cue.Value) (task.Runner, error)   { return &cmdRead{}, nil }
+func newAppendCmd(v cue.Value) (task.Runner, error) { return &cmdAppend{}, nil }
+func newCreateCmd(v cue.Value) (task.Runner, error) { return &cmdCreate{}, nil }
+func newGlobCmd(v cue.Value) (task.Runner, error)   { return &cmdGlob{}, nil }
+
+type cmdRead struct{}
+type cmdAppend struct{}
+type cmdCreate struct{}
+type cmdGlob struct{}
+
+func lookupStr(v cue.Value, str string) string {
+	str, _ = v.Lookup(str).String()
+	return str
+}
+
+func (c *cmdRead) Run(ctx *task.Context, v cue.Value) (res interface{}, err error) {
+	b, err := ioutil.ReadFile(lookupStr(v, "filename"))
+	if err != nil {
+		return nil, err
+	}
+	update := map[string]interface{}{"contents": b}
+
+	switch v.Lookup("contents").IncompleteKind() &^ cue.BottomKind {
+	case cue.BytesKind:
+	case cue.StringKind:
+		update["contents"] = string(b)
+	}
+	return update, nil
+}
+
+func (c *cmdAppend) Run(ctx *task.Context, v cue.Value) (res interface{}, err error) {
+	filename := lookupStr(v, "filename")
+	mode, err := v.Lookup("permissions").Int64()
+	if err != nil {
+		return nil, err
+	}
+
+	f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, os.FileMode(mode))
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	b, _ := v.Lookup("contents").Bytes()
+	if _, err := f.Write(b); err != nil {
+		return nil, err
+	}
+	return nil, nil
+}
+
+func (c *cmdCreate) Run(ctx *task.Context, v cue.Value) (res interface{}, err error) {
+	filename := lookupStr(v, "filename")
+	mode, err := v.Lookup("permissions").Int64()
+	if err != nil {
+		return nil, err
+	}
+
+	b, _ := v.Lookup("contents").Bytes()
+	return nil, ioutil.WriteFile(filename, b, os.FileMode(mode))
+}
+
+func (c *cmdGlob) Run(ctx *task.Context, v cue.Value) (res interface{}, err error) {
+	m, err := filepath.Glob(lookupStr(v, "glob"))
+	return m, err
+}
diff --git a/pkg/tool/file/file_test.go b/pkg/tool/file/file_test.go
new file mode 100644
index 0000000..d8531b3
--- /dev/null
+++ b/pkg/tool/file/file_test.go
@@ -0,0 +1,137 @@
+// Copyright 2019 CUE Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package file
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"reflect"
+	"testing"
+
+	"cuelang.org/go/cue"
+	"cuelang.org/go/cue/parser"
+	"cuelang.org/go/cue/token"
+	"cuelang.org/go/internal"
+)
+
+func parse(t *testing.T, kind, expr string) cue.Value {
+	t.Helper()
+	fset := token.NewFileSet()
+
+	x, err := parser.ParseExpr(fset, "test", expr)
+	if err != nil {
+		t.Fatal(err)
+	}
+	i, err := cue.FromExpr(fset, x)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return internal.UnifyBuiltin(i.Value(), kind).(cue.Value)
+}
+func TestRead(t *testing.T) {
+	v := parse(t, "tool/file.Read", `{filename: "testdata/input.foo"}`)
+	got, err := (*cmdRead).Run(nil, nil, v)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := map[string]interface{}{"contents": []byte("This is a test.")}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("got %v; want %v", got, want)
+	}
+
+	v = parse(t, "tool/file.Read", `{
+		filename: "testdata/input.foo"
+		contents: string
+	}`)
+	got, err = (*cmdRead).Run(nil, nil, v)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want = map[string]interface{}{"contents": "This is a test."}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("got %v; want %v", got, want)
+	}
+}
+
+func TestAppend(t *testing.T) {
+	f, err := ioutil.TempFile("", "filetest")
+	if err != nil {
+		t.Fatal(err)
+	}
+	name := f.Name()
+	defer os.Remove(name)
+	f.Close()
+
+	v := parse(t, "tool/file.Append", fmt.Sprintf(`{
+		filename: "%s"
+		contents: "This is a test."
+	}`, name))
+	_, err = (*cmdAppend).Run(nil, nil, v)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	b, err := ioutil.ReadFile(name)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if got, want := string(b), "This is a test."; got != want {
+		t.Errorf("got %v; want %v", got, want)
+	}
+}
+
+func TestCreate(t *testing.T) {
+	f, err := ioutil.TempFile("", "filetest")
+	if err != nil {
+		t.Fatal(err)
+	}
+	name := f.Name()
+	defer os.Remove(name)
+	f.Close()
+
+	v := parse(t, "tool/file.Create", fmt.Sprintf(`{
+		filename: "%s"
+		contents: "This is a test."
+	}`, name))
+	_, err = (*cmdCreate).Run(nil, nil, v)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	b, err := ioutil.ReadFile(name)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if got, want := string(b), "This is a test."; got != want {
+		t.Errorf("got %v; want %v", got, want)
+	}
+}
+
+func TestGlob(t *testing.T) {
+	v := parse(t, "tool/file.Glob", fmt.Sprintf(`{
+		glob: "testdata/input.*"
+	}`))
+	got, err := (*cmdGlob).Run(nil, nil, v)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if want := []string{"testdata/input.foo"}; !reflect.DeepEqual(got, want) {
+		t.Errorf("got %v; want %v", got, want)
+	}
+}
diff --git a/pkg/tool/file/testdata/input.foo b/pkg/tool/file/testdata/input.foo
new file mode 100644
index 0000000..273c1a9
--- /dev/null
+++ b/pkg/tool/file/testdata/input.foo
@@ -0,0 +1 @@
+This is a test.
\ No newline at end of file