1// Copyright 2019 The CUE Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package cmd
16
17import (
18	"bytes"
19	"errors"
20	"fmt"
21	"html/template"
22	"io"
23	"io/ioutil"
24	"os"
25	"path/filepath"
26	"strings"
27
28	"github.com/spf13/cobra"
29
30	"cuelang.org/go/cue"
31	"cuelang.org/go/cue/build"
32	"cuelang.org/go/cue/format"
33	"cuelang.org/go/cue/load"
34	"cuelang.org/go/cue/parser"
35	"cuelang.org/go/internal"
36)
37
38func newAddCmd(c *Command) *cobra.Command {
39	cmd := &cobra.Command{
40		// TODO: this command is still experimental, don't show it in
41		// the documentation just yet.
42		Hidden: true,
43
44		Use:   "add <glob> [--list]",
45		Short: "bulk append to CUE files",
46		Long: `Append a common snippet of CUE to many files and commit atomically.
47`,
48		RunE: mkRunE(c, runAdd),
49	}
50
51	f := cmd.Flags()
52	f.Bool(string(flagList), false,
53		"text executed as Go template with instance info")
54	f.BoolP(string(flagDryrun), "n", false,
55		"only run simulation")
56	f.StringP(string(flagPackage), "p", "", "package to append to")
57
58	return cmd
59}
60
61func runAdd(cmd *Command, args []string) (err error) {
62	return doAdd(cmd, args)
63}
64
65func doAdd(cmd *Command, args []string) (err error) {
66	// Offsets at which to restore original files, if any, if any of the
67	// appends fail.
68	// Ideally this is placed below where it is used, but we want to make
69	// absolutely sure that the error variable used in defer is the named
70	// returned value and not some shadowed value.
71
72	originals := []originalFile{}
73	defer func() {
74		if err != nil {
75			restoreOriginals(cmd, originals)
76		}
77	}()
78
79	// build instance cache
80	builds := map[string]*build.Instance{}
81
82	getBuild := func(path string) *build.Instance {
83		if b, ok := builds[path]; ok {
84			return b
85		}
86		b := load.Instances([]string{path}, nil)[0]
87		builds[path] = b
88		return b
89	}
90
91	// determine file set.
92
93	todo := []*fileInfo{}
94
95	done := map[string]bool{}
96
97	for _, arg := range args {
98		dir, base := filepath.Split(arg)
99		dir = filepath.Clean(dir)
100		matches, err := filepath.Glob(dir)
101		if err != nil {
102			return err
103		}
104		for _, m := range matches {
105			if fi, err := os.Stat(m); err != nil || !fi.IsDir() {
106				continue
107			}
108			file := filepath.Join(m, base)
109			if done[file] {
110				continue
111			}
112			if s := filepath.ToSlash(file); strings.HasPrefix(s, "pkg/") || strings.Contains(s, "/pkg/") {
113				continue
114			}
115			done[file] = true
116			fi, err := initFile(cmd, file, getBuild)
117			if err != nil {
118				return err
119			}
120			todo = append(todo, fi)
121			b := fi.build
122			if flagList.Bool(cmd) && (b == nil) {
123				return fmt.Errorf("instance info not available for %s", fi.filename)
124			}
125		}
126	}
127
128	// Read text to be appended.
129	text, err := ioutil.ReadAll(cmd.InOrStdin())
130	if err != nil {
131		return err
132	}
133
134	var tmpl *template.Template
135	if flagList.Bool(cmd) {
136		tmpl, err = template.New("append").Parse(string(text))
137		if err != nil {
138			return err
139		}
140	}
141
142	for _, fi := range todo {
143		if tmpl == nil {
144			fi.contents.Write(text)
145			continue
146		}
147		if err := tmpl.Execute(fi.contents, fi.build); err != nil {
148			return err
149		}
150	}
151
152	if flagDryrun.Bool(cmd) {
153		stdout := cmd.OutOrStdout()
154		for _, fi := range todo {
155			fmt.Fprintln(stdout, "---", fi.filename)
156			_, _ = io.Copy(stdout, fi.contents)
157		}
158		return nil
159	}
160
161	// All verified. Execute the todo plan
162	for _, fi := range todo {
163		fo, err := fi.appendAndCheck()
164		if err != nil {
165			return err
166		}
167		originals = append(originals, fo)
168	}
169
170	// Verify resulting builds
171	for _, fi := range todo {
172		builds = map[string]*build.Instance{}
173
174		b := getBuild(fi.buildArg)
175		if b.Err != nil {
176			return b.Err
177		}
178		i := cue.Build([]*build.Instance{b})[0]
179		if i.Err != nil {
180			return i.Err
181		}
182		if err := i.Value().Validate(); err != nil {
183			return i.Err
184		}
185	}
186
187	return nil
188}
189
190type originalFile struct {
191	filename string
192	contents []byte
193}
194
195func restoreOriginals(cmd *Command, originals []originalFile) {
196	for _, fo := range originals {
197		if err := fo.restore(); err != nil {
198			fmt.Fprintln(cmd.Stderr(), "Error restoring file: ", err)
199		}
200	}
201}
202
203func (fo *originalFile) restore() error {
204	if len(fo.contents) == 0 {
205		return os.Remove(fo.filename)
206	}
207	return ioutil.WriteFile(fo.filename, fo.contents, 0644)
208}
209
210type fileInfo struct {
211	filename string
212	buildArg string
213	contents *bytes.Buffer
214	build    *build.Instance
215}
216
217func initFile(cmd *Command, file string, getBuild func(path string) *build.Instance) (todo *fileInfo, err error) {
218	defer func() {
219		if err != nil {
220			err = fmt.Errorf("init file: %v", err)
221		}
222	}()
223	dir := filepath.Dir(file)
224	todo = &fileInfo{file, dir, &bytes.Buffer{}, nil}
225
226	if fi, err := os.Stat(file); err != nil {
227		if !os.IsNotExist(err) {
228			return nil, err
229		}
230		// File does not exist
231		b := getBuild(dir)
232		todo.build = b
233		pkg := flagPackage.String(cmd)
234		if pkg != "" {
235			// TODO: do something more intelligent once the package name is
236			// computed on a module basis, even for empty directories.
237			b.PkgName = pkg
238			b.Err = nil
239		} else {
240			pkg = b.PkgName
241		}
242		if pkg == "" {
243			return nil, errors.New("must specify package using -p for new files")
244		}
245		todo.buildArg = file
246		fmt.Fprintf(todo.contents, "package %s\n\n", pkg)
247	} else {
248		if fi.IsDir() {
249			return nil, fmt.Errorf("cannot append to directory %s", file)
250		}
251
252		f, err := parser.ParseFile(file, nil)
253		if err != nil {
254			return nil, err
255		}
256		if _, pkgName, _ := internal.PackageInfo(f); pkgName != "" {
257			if pkg := flagPackage.String(cmd); pkg != "" && pkgName != pkg {
258				return nil, fmt.Errorf("package mismatch (%s vs %s) for file %s", pkgName, pkg, file)
259			}
260			todo.build = getBuild(dir)
261		} else {
262			if pkg := flagPackage.String(cmd); pkg != "" {
263				return nil, fmt.Errorf("file %s has no package clause but package %s requested", file, pkg)
264			}
265			todo.build = getBuild(file)
266			todo.buildArg = file
267		}
268	}
269	return todo, nil
270}
271
272func (fi *fileInfo) appendAndCheck() (fo originalFile, err error) {
273	// Read original file
274	b, err := ioutil.ReadFile(fi.filename)
275	if err == nil {
276		fo.filename = fi.filename
277		fo.contents = b
278	} else if !os.IsNotExist(err) {
279		return originalFile{}, err
280	}
281
282	if !bytes.HasSuffix(b, []byte("\n")) {
283		b = append(b, '\n')
284	}
285	b = append(b, fi.contents.Bytes()...)
286
287	b, err = format.Source(b)
288	if err != nil {
289		return originalFile{}, err
290	}
291
292	if err = ioutil.WriteFile(fi.filename, b, 0644); err != nil {
293		// Just in case, attempt to restore original file.
294		_ = fo.restore()
295		return originalFile{}, err
296	}
297
298	return fo, nil
299}
300