fuzzyworld

template / commit

Lots of small-ish changes:

ae89a42ad33e97ce3b8e9cbc77608a6eeeb9d69c · Pavel Safronov · 2022-11-01 21:55:30 -0700 · parents 7985cf29381e

Lots of small-ish changes:
- Added support for user template files. This setting is exposed through a new parameter that has a default value of "~/.template.user.yaml". This file is combined with the "global" config: same-name profiles in user config replace profiles in global config. That's the merge.
- Added more config validation in more places.
- Standardized the name of the error variable to `err` across the project
- Added a `make all` target that builds, runs tests, then installs the project locally
- Added more information when a profile doesn't work on the current system
- Postscript output is now captured and logged
- Removed the no longer used (and "overloaded") findProfile function
 Makefile          |   2 ++
 config.go         | 106 ++++++++++++++++++++++++++++++++++--------------------
 config_test.go    |  22 ++++--------
 default_config.go |  37 +++++++++----------
 extras.go         |  14 ++++----
 extras_test.go    |  32 ++++++++---------
 main.go           |  85 ++++++++++++++++++++++++++++---------------
 profile.go        |  30 +++++-----------
 profile_test.go   |  17 ++++++---
 9 files changed, 198 insertions(+), 147 deletions(-)

diff --git a/Makefile b/Makefile
index c5736b3..7163fb6 100644
--- a/Makefile
+++ b/Makefile
@@ -12,6 +12,8 @@ name = ""
 
 .PHONY: test
 
+all: build test install
+
 build:
 	go build .
 
diff --git a/config.go b/config.go
index e514851..afd393b 100644
--- a/config.go
+++ b/config.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 
+	"golang.org/x/exp/slices"
 	"gopkg.in/yaml.v3"
 )
 
@@ -16,18 +17,29 @@ func validateConfig(config *Config) error {
 		return fmt.Errorf("no profiles found")
 	}
 
+	names := []string{}
 	foundDefault := false
 	for index, profile := range config.Profiles {
 		profile := profile
+
+		// check for duplicate names
+		if slices.Contains(names, profile.Name) {
+			return fmt.Errorf("duplicate profile name '%v'", profile.Name)
+		}
+		names = append(names, profile.Name)
+
+		// check for multiple default profiles
 		if profile.IsDefault {
 			if foundDefault {
 				return fmt.Errorf("multiple default profiles found")
 			}
 			foundDefault = true
 		}
-		error := validateProfile(&profile)
-		if error != nil {
-			return fmt.Errorf("error validating profile %s: %s", profile.Name, error)
+
+		// validate and possibly fix profile
+		err := validateProfile(&profile)
+		if err != nil {
+			return fmt.Errorf("error validating profile %s: %s", profile.Name, err)
 		}
 		config.Profiles[index] = profile
 	}
@@ -35,64 +47,82 @@ func validateConfig(config *Config) error {
 }
 
 func loadConfig(path string) (Config, error) {
-	file, error := os.ReadFile(path)
-	if error != nil {
-		return Config{}, error
+	file, err := os.ReadFile(path)
+	if err != nil {
+		return Config{}, err
 	}
 
 	var config Config
-	error = yaml.Unmarshal(file, &config)
-	if error != nil {
-		return Config{}, error
+	err = yaml.Unmarshal(file, &config)
+	if err != nil {
+		return Config{}, err
+	}
+
+	err = validateConfig(&config)
+	if err != nil {
+		return Config{}, err
 	}
 
 	return config, nil
 }
 
 func saveConfig(path string, config Config) error {
-	file, error := os.Create(path)
-	if error != nil {
-		return error
+	err := validateConfig(&config)
+	if err != nil {
+		return err
+	}
+	file, err := os.Create(path)
+	if err != nil {
+		return err
 	}
 	defer file.Close()
 	encoder := yaml.NewEncoder(file)
-	error = encoder.Encode(config)
-	if error != nil {
-		return error
+	err = encoder.Encode(config)
+	if err != nil {
+		return err
 	}
 
 	return nil
 }
 
-func getOrCreateConfig(configFilePath string) (Config, error) {
-	emptyConfig := Config{}
-
-	// try and load config
-	config, err := loadConfig(configFilePath)
+func mergeConfigs(a Config, b Config) (Config, error) {
+	err := validateConfig(&a)
+	if err != nil {
+		return Config{}, err
+	}
+	err = validateConfig(&b)
 	if err != nil {
-		fmt.Printf("No config present at '%v', creating and using sample config\n", configFilePath)
+		return Config{}, err
+	}
 
-		// create config and save
-		err := saveConfig(configFilePath, defaultConfig())
-		if err != nil {
-			return emptyConfig, fmt.Errorf("error creating config: %v", err)
+	merged := Config{
+		Profiles: a.Profiles,
+	}
+	for _, profile := range b.Profiles {
+		index, err := findProfileIndex(merged.Profiles, profile.Name)
+		if err == nil {
+			merged.Profiles[index] = profile
+		} else {
+			merged.Profiles = append(merged.Profiles, profile)
 		}
+	}
+	return merged, nil
+}
 
-		// load config
-		config, err = loadConfig(configFilePath)
-		if err != nil {
-			return emptyConfig, fmt.Errorf("error loading config: %v", err)
+func findDefaultProfile(profiles []Profile) (Profile, error) {
+	for _, p := range profiles {
+		if p.IsDefault {
+			return p, nil
 		}
-	} else {
-		// log successful load
-		fmt.Printf("Config loaded from '%v'\n", configFilePath)
 	}
+	return Profile{}, fmt.Errorf("unable to find default profile")
+}
 
-	// validate config
-	err = validateConfig(&config)
-	if err != nil {
-		return emptyConfig, fmt.Errorf("error validating config: %v", err)
+func findProfileIndex(profiles []Profile, profileName string) (int, error) {
+	for index, p := range profiles {
+		if p.Name == profileName {
+			return index, nil
+		}
 	}
-
-	return config, nil
+	return 0, fmt.Errorf("unable to find profile '%v'", profileName)
 }
diff --git a/config_test.go b/config_test.go
index b7b7577..01a7528 100644
--- a/config_test.go
+++ b/config_test.go
@@ -44,9 +44,12 @@ func TestSaveLoadConfig(t *testing.T) {
 	_, err = os.Stat(configPath)
 	assert.True(t, errors.Is(err, os.ErrNotExist), "Expected config file to not exist, but it does")
 
-	// get and create config
-	loadedConfig, err = getOrCreateConfig(configPath)
-	assert.Nil(t, err, "Error loading config")
+	// fail to get config
+	_, err = loadConfig(configPath)
+	assert.NotNil(t, err, "Expected to fail to load config, but succeeded")
+
+	// create default config
+	err = saveConfig(configPath, defaultConfig())
 
 	// test config
 	assert.Equal(t, loadedConfig, config)
@@ -56,7 +59,7 @@ func TestSaveLoadConfig(t *testing.T) {
 	assert.Nil(t, err, "Expected config file to exist, but it does not")
 
 	// get and create config
-	loadedConfig, err = getOrCreateConfig(configPath)
+	loadedConfig, err = loadConfig(configPath)
 	assert.Nil(t, err, "Error loading config")
 
 	// test config
@@ -68,17 +71,6 @@ func TestSaveLoadConfig(t *testing.T) {
 	assert.Equal(t, stat1.ModTime(), stat2.ModTime(), "Expected config file to not be modified, but it was")
 }
 
-func TestGetOrCreateConfig(t *testing.T) {
-	testFailure := func(configPath string) {
-		_, err := getOrCreateConfig(configPath)
-		if err == nil {
-			t.Errorf("Expected to fail to get or create config, but succeeded")
-		}
-	}
-
-	testFailure("invalid/path/invalid-config.yaml")
-}
-
 func TestValidateDefaultConfig(t *testing.T) {
 	config := defaultConfig()
 	err := validateConfig(&config)
diff --git a/default_config.go b/default_config.go
index 35981c4..aa6d0de 100644
--- a/default_config.go
+++ b/default_config.go
@@ -107,23 +107,23 @@ cspell:words nanite
 					"code . \"{{.FileName}}\"",
 				},
 			},
-			// {
-			// 	Name:        "work",
-			// 	Description: "Creates a folder in the c:/work folder in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]",
-			// 	OS:          "Windows",
-			// 	Path:        "C:/work",
-			// 	Type:        FolderType,
-			// 	NamePatterns: []string{
-			// 		"{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
-			// 		"{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
-			// 		"{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
-			// 		"{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
-			// 	},
-			// 	ScriptShell: "pwsh",
-			// 	PostScripts: []string{
-			// 		"explorer \"{{.Path}}\"",
-			// 	},
-			// },
+			{
+				Name:        "work-win",
+				Description: "Creates a folder in the c:/work folder in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]",
+				OS:          "Windows",
+				Path:        "C:/work",
+				Type:        FolderType,
+				NamePatterns: []ParameterizedText{
+					"{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
+					"{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
+					"{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
+					"{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
+				},
+				ScriptShell: "pwsh",
+				PostScripts: []ParameterizedText{
+					"explorer \"{{.Path}}\"",
+				},
+			},
 			{
 				Name:        "therapy",
 				Description: "Creates a MD file in the ~/Therapy folder in the format YYYY-MM-DD[-vVersion].md",
@@ -240,13 +240,14 @@ echo "Let's test {{.ProjectName}}!"`,
 			},
 			{
 				Name:        "test",
-				Description: "Echoes the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]",
+				Description: "Echoes formatted date and time, and project name if it is provided",
 				Type:        ScriptType,
 				NamePatterns: []ParameterizedText{
 					"{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName}}{{end}}",
 				},
 				ScriptShell: "pwsh",
 				PostScripts: []ParameterizedText{
+					"echo \"{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName}}{{end}}\"",
 					"echo \"Name={{.FileName}}\"",
 					"echo \"Path={{.FullPath}}\"",
 					"echo \"Value1={{.Extras.value1}}\"",
diff --git a/extras.go b/extras.go
index 2ecd856..087ae1e 100644
--- a/extras.go
+++ b/extras.go
@@ -10,19 +10,19 @@ import (
 func loadExtras(path string) (map[string]string, error) {
 	emptyMap := make(map[string]string)
 
-	file, error := os.ReadFile(path)
-	if error != nil {
-		if os.IsNotExist(error) {
+	file, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
 			fmt.Printf("extras file %s does not exist, extras not being used\n", path)
 			return emptyMap, nil
 		}
-		return emptyMap, error
+		return emptyMap, err
 	}
 
 	result := make(map[string]string)
-	error = yaml.Unmarshal(file, &result)
-	if error != nil {
-		return emptyMap, error
+	err = yaml.Unmarshal(file, &result)
+	if err != nil {
+		return emptyMap, err
 	}
 
 	return result, nil
diff --git a/extras_test.go b/extras_test.go
index b252313..5fe946c 100644
--- a/extras_test.go
+++ b/extras_test.go
@@ -16,9 +16,9 @@ func TestExtras(t *testing.T) {
 		"customer": "abc",
 	}
 
-	file, error := os.CreateTemp("", "extras.yaml")
-	if error != nil {
-		t.Error("Error creating temp file: ", error)
+	file, err := os.CreateTemp("", "extras.yaml")
+	if err != nil {
+		t.Error("Error creating temp file: ", err)
 	}
 
 	// cleanup code
@@ -29,34 +29,34 @@ func TestExtras(t *testing.T) {
 
 	// write extras to file
 	encoder := yaml.NewEncoder(file)
-	error = encoder.Encode(extras)
-	if error != nil {
-		t.Error("Error encoding extras: ", error)
+	err = encoder.Encode(extras)
+	if err != nil {
+		t.Error("Error encoding extras: ", err)
 	}
 
 	// close file
-	error = file.Close()
-	if error != nil {
-		t.Error("Error closing file: ", error)
+	err = file.Close()
+	if err != nil {
+		t.Error("Error closing file: ", err)
 	}
 
 	// file name
 	fileName := file.Name()
 
 	// happy path
-	loadedExtras, error := loadExtras(fileName)
-	if error != nil {
-		t.Error("Error loading extras: ", error)
+	loadedExtras, err := loadExtras(fileName)
+	if err != nil {
+		t.Error("Error loading extras: ", err)
 	}
 	assert.Equal(t, extras, loadedExtras)
 
 	// test path errors
-	extras, error = loadExtras("non-existent-file.yaml")
-	assert.Nil(t, error, "Expected to load extras")
+	extras, err = loadExtras("non-existent-file.yaml")
+	assert.Nil(t, err, "Expected to load extras")
 	assert.Empty(t, extras, "Expected to load empty extras")
 
 	os.WriteFile(file.Name(), []byte("invalid yaml"), 0644)
 
-	_, error = loadExtras(fileName)
-	assert.NotNil(t, error, "Expected to fail to parse extras, but succeeded")
+	_, err = loadExtras(fileName)
+	assert.NotNil(t, err, "Expected to fail to parse extras, but succeeded")
 }
diff --git a/main.go b/main.go
index b6aedbd..2a9468a 100644
--- a/main.go
+++ b/main.go
@@ -11,12 +11,15 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
+const defaultUserConfigPath = "~/.template.user.yaml"
+const defaultConfigPath = "~/.template.yaml"
+
 type ArgumentValues struct {
-	profile          string
-	projectName      string
-	configFilePath   string
-	extrasFilePath   string
-	useDefaultConfig bool
+	profile        string
+	projectName    string
+	configFilePath string
+	userConfigPath string
+	extrasFilePath string
 }
 type Settings struct {
 	ArgumentValues
@@ -55,9 +58,16 @@ func main() {
 				Name:        "config-yaml",
 				Aliases:     []string{"c"},
 				Usage:       "path to the config yaml file",
-				Value:       "~/.template.yaml",
+				Value:       defaultConfigPath,
 				Destination: &argumentValues.configFilePath,
 			},
+			&cli.PathFlag{
+				Name:        "user-config-yaml",
+				Aliases:     []string{"u"},
+				Usage:       "path to the user config yaml file",
+				Value:       defaultUserConfigPath,
+				Destination: &argumentValues.userConfigPath,
+			},
 			&cli.PathFlag{
 				Name:        "extras-yaml",
 				Aliases:     []string{"e"},
@@ -65,13 +75,6 @@ func main() {
 				Value:       "~/.template-extras.yaml",
 				Destination: &argumentValues.extrasFilePath,
 			},
-			&cli.BoolFlag{
-				Name:        "default-config",
-				Aliases:     []string{"d"},
-				Usage:       "use the default hard-coded config (testing only)",
-				Value:       false,
-				Destination: &argumentValues.useDefaultConfig,
-			},
 		},
 		Action: func(context *cli.Context) error {
 			settings, err := parseArguments(argumentValues, context)
@@ -117,12 +120,15 @@ func parseArguments(
 	}
 	fmt.Println("Project name:", settings.projectName)
 
-	// extra features
-	extras, err := loadExtras(settings.extrasFilePath)
-	if err != nil {
-		return settings, fmt.Errorf("error loading extras from %v: %w", settings.extrasFilePath, err)
+	// extras strings
+	_, err := os.Stat(settings.extrasFilePath)
+	if err == nil {
+		extras, err := loadExtras(settings.extrasFilePath)
+		if err != nil {
+			return settings, fmt.Errorf("error loading extras from %v: %w", settings.extrasFilePath, err)
+		}
+		settings.extras = extras
 	}
-	settings.extras = extras
 
 	return settings, nil
 }
@@ -133,15 +139,28 @@ func mainLogic(settings Settings) error {
 		log.Fatal(err)
 	}
 
-	// get config
+	// get config from default path
 	fullConfigPath := getFullPath(settings.configFilePath, *usr)
-	var config Config
-	if settings.useDefaultConfig {
-		config = defaultConfig()
+	config, err := loadConfig(fullConfigPath)
+	if err != nil {
+		return fmt.Errorf("error loading config from %v: %w", fullConfigPath, err)
 	} else {
-		config, err = getOrCreateConfig(fullConfigPath)
+		fmt.Printf("Loaded config from %v\n", fullConfigPath)
+	}
+
+	// load user config, if present
+	fullUserConfigPath := getFullPath(settings.userConfigPath, *usr)
+	_, err = os.Stat(fullUserConfigPath)
+	if err == nil {
+		userConfig, err := loadConfig(fullUserConfigPath)
 		if err != nil {
-			return err
+			fmt.Printf("error loading user config from %v: %v\n", fullUserConfigPath, err)
+		} else {
+			fmt.Printf("loaded user config from %v, combining with global config\n", fullUserConfigPath)
+			config, err = mergeConfigs(config, userConfig)
+			if err != nil {
+				return fmt.Errorf("error merging configs: %w", err)
+			}
 		}
 	}
 
@@ -157,10 +176,11 @@ func mainLogic(settings Settings) error {
 	// get profile information
 	if settings.profile == "get" {
 		fmt.Printf("Profile '%v':\n", settings.projectName)
-		profile, err := findProfile(config, settings.projectName)
+		index, err := findProfileIndex(config.Profiles, settings.projectName)
 		if err != nil {
 			return fmt.Errorf("profile '%v' not found", settings.projectName)
 		}
+		profile := config.Profiles[index]
 
 		writer := strings.Builder{}
 		encoder := yaml.NewEncoder(&writer)
@@ -185,9 +205,18 @@ func mainLogic(settings Settings) error {
 	}
 
 	// find profile
-	profile, err := findProfile(config, settings.profile)
-	if err != nil {
-		return fmt.Errorf("error finding profile: %v", err)
+	var profile Profile
+	if settings.profile == "" {
+		profile, err = findDefaultProfile(config.Profiles)
+		if err != nil {
+			return fmt.Errorf("no profile specified and no default profile found")
+		}
+	} else {
+		index, err := findProfileIndex(config.Profiles, settings.profile)
+		if err != nil {
+			return fmt.Errorf("error finding profile: %v", err)
+		}
+		profile = config.Profiles[index]
 	}
 
 	// process profile
diff --git a/profile.go b/profile.go
index 00dc2c3..1fff7f4 100644
--- a/profile.go
+++ b/profile.go
@@ -1,7 +1,7 @@
 package main
 
 import (
-	"errors"
+	"bytes"
 	"fmt"
 	"os"
 	"os/user"
@@ -60,25 +60,6 @@ var forbiddenProfileNames = []string{
 	"get",
 }
 
-func findProfile(config Config, profileName string) (Profile, error) {
-	if profileName == "" {
-		// find default profile
-		for _, p := range config.Profiles {
-			if p.IsDefault {
-				return p, nil
-			}
-		}
-		return Profile{}, errors.New("no default profile found")
-	}
-	// find profile by name
-	for _, p := range config.Profiles {
-		if p.Name == profileName {
-			return p, nil
-		}
-	}
-	return Profile{}, fmt.Errorf("unable to find profile '%v'", profileName)
-}
-
 func processProfile(profile Profile, projectName string, extras map[string]string, usr user.User) []error {
 	// decide if the profile should run on this OS
 	profileOs := profile.OS
@@ -89,6 +70,9 @@ func processProfile(profile Profile, projectName string, extras map[string]strin
 		(profileOs == "Mac" && !isWindows)
 	if !shouldRun {
 		fmt.Println("This profile is not supported by this OS")
+		fmt.Println("Supported OS values: Windows, Mac, All")
+		fmt.Println("Current OS:", runtime.GOOS)
+		fmt.Println("Profile OS:", profileOs)
 		return nil
 	}
 
@@ -167,7 +151,11 @@ func processProfile(profile Profile, projectName string, extras map[string]strin
 		shell := profile.ScriptShell
 		fmt.Printf("Executing post script: %v %v\n", shell, strings.Join(allArgs, " "))
 		cmd := monkey.ExecCommand(shell, allArgs...)
-		if err := cmd.Run(); err != nil {
+		var out bytes.Buffer
+		cmd.Stdout = &out
+		err := cmd.Run()
+		fmt.Printf("Post script output: %v", out.String())
+		if err != nil {
 			// post scripts tend to fail (TODO: investigate why), so for now we'll just log the error
 
 			fmt.Printf("Error executing post script '%v': %v\n",
diff --git a/profile_test.go b/profile_test.go
index 683aac4..c70ed53 100644
--- a/profile_test.go
+++ b/profile_test.go
@@ -95,7 +95,7 @@ func TestValidateProfile(t *testing.T) {
 	}
 }
 
-func TestFindProfile(t *testing.T) {
+func TestFindProfileIndex(t *testing.T) {
 	config := Config{
 		Profiles: []Profile{
 			{
@@ -108,25 +108,26 @@ func TestFindProfile(t *testing.T) {
 		},
 	}
 	testSuccess := func(profileName string, expectedProfileName string) {
-		profile, err := findProfile(config, profileName)
+		index, err := findProfileIndex(config.Profiles, profileName)
 		if err != nil {
 			t.Errorf("Failed to find profile '%v': %v", profileName, err)
 		}
+		profile := config.Profiles[index]
 		if profile.Name != expectedProfileName {
 			t.Errorf("Asked for profile '%v', expected '%v', got '%v'", profileName, expectedProfileName, profile.Name)
 		}
 	}
 	testFailure := func(profileName string) {
-		_, err := findProfile(config, profileName)
+		_, err := findProfileIndex(config.Profiles, profileName)
 		if err == nil {
 			t.Errorf("Expected to fail to find profile '%v', but succeeded", profileName)
 		}
 	}
 
-	testSuccess("", "notes")
 	testSuccess("daily", "daily")
 	testSuccess("notes", "notes")
 
+	testFailure("")
 	testFailure("non-existent")
 
 	config.Profiles[1].IsDefault = false
@@ -134,6 +135,14 @@ func TestFindProfile(t *testing.T) {
 	testFailure("non-default")
 }
 
+func TestFindDefaultProfile(t *testing.T) {
+	_, err := findDefaultProfile(defaultConfig().Profiles)
+	assert.Nil(t, err, "Expected to find default profile, but failed")
+
+	_, err = findDefaultProfile([]Profile{})
+	assert.NotNil(t, err, "Expected to fail to find default profile, but succeeded")
+}
+
 func TestCreateEntity(t *testing.T) {
 	// setup
 	now := testNow()