intellij: add localconfig helper

Adds a little helper tool which merges a watcherTasks template with the local
project config. This restores the functionality lost in D658.

Also cured me of any remaining nostalgic feelings towards XML.

Test Plan:
Deleted all watchers, ran the script, re-opened project,
watchers are back and functional. Local watchers with the same name got
overwritten. Additional watchers were untouched.

X-Origin-Diff: phab/D661
GitOrigin-RevId: 83f7c1506476378145781c816d776fd451aed40c
diff --git a/intellij/localconfig/watchers/filewatchers.go b/intellij/localconfig/watchers/filewatchers.go
new file mode 100644
index 0000000..f31a26a
--- /dev/null
+++ b/intellij/localconfig/watchers/filewatchers.go
@@ -0,0 +1,160 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// 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 watchers
+
+import (
+	"encoding/xml"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+)
+
+const (
+	fileWatchersPath = ".ijwb/.idea/watcherTasks.xml"
+	templatePath     = "intellij/localconfig/data/watcherTasks.xml"
+)
+
+type config struct {
+	XMLName   xml.Name  `xml:"project"`
+	Version   string    `xml:"version,attr"`
+	Component component `xml:"component"`
+}
+
+type component struct {
+	Name        string       `xml:"name,attr"`
+	TaskOptions []taskOption `xml:"TaskOptions"`
+}
+
+type taskOption struct {
+	IsEnabled string `xml:"isEnabled,attr"`
+	Option    []struct {
+		Name  string `xml:"name,attr"`
+		Value string `xml:"value,attr,omitempty"`
+		Data  string `xml:",innerxml"`
+	} `xml:"option"`
+	Envs struct {
+		Env []struct {
+			Name  string `xml:"name,attr"`
+			Value string `xml:"value,attr"`
+		} `xml:"env"`
+	} `xml:"envs"`
+}
+
+func buildConfig(options []taskOption) *config {
+	return &config{
+		XMLName: xml.Name{Local: "project"},
+		Version: "4",
+		Component: component{
+			Name:        "ProjectTasksOptions",
+			TaskOptions: options,
+		},
+	}
+}
+
+func readConfig(filename string) (cfg *config, err error) {
+	b, err := ioutil.ReadFile(filename)
+	if err != nil {
+		return nil, fmt.Errorf("failed reading file: %w", err)
+	}
+
+	err = xml.Unmarshal(b, &cfg)
+	if err != nil {
+		return nil, fmt.Errorf("failed deserializing XML: %w", err)
+	}
+
+	return
+}
+
+func (cfg *config) atomicWriteFile(filename string) error {
+	b, err := xml.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		return fmt.Errorf("failed to serialize: %w", err)
+	}
+
+	// Atomic write is needed, IntelliJ has inotify watches on its config and reloads (but not applies) instantly.
+	tmpPath := filename + ".tmp"
+	defer os.Remove(tmpPath)
+	if err := ioutil.WriteFile(tmpPath, []byte(xml.Header+string(b)), 0664); err != nil {
+		return fmt.Errorf("failed to write: %w", err)
+	}
+	if err := os.Rename(tmpPath, filename); err != nil {
+		return fmt.Errorf("failed to rename: %w", err)
+	}
+
+	return nil
+}
+
+// RewriteConfig adds our watchers to projectDir's watchers config, overwriting existing entries with the same name.
+func RewriteConfig(projectDir string) error {
+	template, err := readConfig(path.Join(projectDir, templatePath))
+	if err != nil {
+		return fmt.Errorf("failed reading template config: %w", err)
+	}
+
+	if template.Version != "4" {
+		return fmt.Errorf("unknown template config version: %s", template.Version)
+	}
+
+	// Read existing tasks, if any.
+	tasks := make(map[string]taskOption)
+	cfg, err := readConfig(path.Join(projectDir, fileWatchersPath))
+
+	switch {
+	case err == nil:
+		// existing config, read tasks
+		if cfg.Version != "4" {
+			return fmt.Errorf("unknown watchers config version: %s", cfg.Version)
+		}
+		for _, v := range cfg.Component.TaskOptions {
+			for _, o := range v.Option {
+				if o.Name == "name" {
+					tasks[o.Value] = v
+				}
+			}
+		}
+	case os.IsNotExist(err):
+		// no existing config - continue with empty tasks
+	default:
+		// error is non-nil and not an ENOENT
+		return fmt.Errorf("failed reading existing config: %w", err)
+	}
+
+	// Overwrite "our" entries, identified by name.
+	for _, v := range template.Component.TaskOptions {
+		for _, o := range v.Option {
+			if o.Name == "name" {
+				tasks[o.Value] = v
+			}
+		}
+	}
+
+	// Build new configuration
+	options := make([]taskOption, 0, len(tasks))
+	for _, t := range tasks {
+		options = append(options, t)
+	}
+
+	out := buildConfig(options)
+
+	err = out.atomicWriteFile(path.Join(projectDir, fileWatchersPath))
+	if err != nil {
+		return fmt.Errorf("failed writing to output file: %w", err)
+	}
+
+	return nil
+}