template / commit
Adding tests, getting code coverage to be reasonable for most files (main.go is at 0%), and might be there for a bit.
.vscode/launch.json | 12 ++-- .vscode/settings.json | 8 ++- config.go | 4 +- config_test.go | 76 +++++++++++++++++++- main.go | 37 +++++++--- monkey-patch.go | 4 -- profile.go | 59 ++++++++------- profile_test.go | 194 ++++++++++++++++++++++++++++++++++---------------- 8 files changed, 279 insertions(+), 115 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 8602236..13d3f1a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,7 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { "name": "Launch tests", "type": "go", @@ -72,7 +73,7 @@ ] }, { - "name": "Run: template work test-project --use-defaults", + "name": "Run: template work test-project w/ in-memory defaults", "type": "go", "request": "launch", "mode": "auto", @@ -80,8 +81,10 @@ "args": [ "work", "test-project", - "--use-defaults" ], + "env": { + "TEMPLATE_USE_DEFAULT_CONFIG": "true", + }, }, { "name": "Run: template --profile work --project testProj", @@ -123,7 +126,6 @@ "--log-level", "debug", ] - ] - } -] + } + ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index ee0aba7..3e2495f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,12 @@ { "cSpell.words": [ + "bashdb", + "logrus", "pwsh", "runpostscriptonfailure", "scriptshell", - "stretchr" + "sirupsen", + "stretchr", + "urfave" ] -} +} \ No newline at end of file diff --git a/config.go b/config.go index afd393b..7d6fd30 100644 --- a/config.go +++ b/config.go @@ -88,11 +88,11 @@ func saveConfig(path string, config Config) error { func mergeConfigs(a Config, b Config) (Config, error) { err := validateConfig(&a) if err != nil { - return Config{}, err + return Config{}, fmt.Errorf("error validating config a: %s", err) } err = validateConfig(&b) if err != nil { - return Config{}, err + return Config{}, fmt.Errorf("error validating config b: %s", err) } merged := Config{ diff --git a/config_test.go b/config_test.go index 2453a0e..aac86ff 100644 --- a/config_test.go +++ b/config_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" ) +const configPath string = "./test/test-config.yaml" + // Compares YAML representation of two objects func compareObjectYaml(t *testing.T, expected, actual interface{}) { expectedYaml, err := getYaml(expected) @@ -21,7 +23,6 @@ func compareObjectYaml(t *testing.T, expected, actual interface{}) { func TestSaveLoadConfig(t *testing.T) { config := defaultConfig() - configPath := "./test/test-config.yaml" // save config err := saveConfig(configPath, config) @@ -35,7 +36,7 @@ func TestSaveLoadConfig(t *testing.T) { compareObjectYaml(t, loadedConfig, config) // test load non-existent config - _, err = loadConfig("non-existent-file.yaml") + _, err = loadConfig("./test/non-existent-file.yaml") assert.NotNil(t, err, "Expected to fail to load config, but succeeded") // test load invalid yaml @@ -133,4 +134,75 @@ func TestValidateConfig(t *testing.T) { }, }, }}, "multiple default profiles found") + testFailure(Config{Profiles: []Profile{ + { + Name: "daily", + IsDefault: true, + NamePatterns: []ParameterizedText{"daily"}, + Type: FileType, + Path: "foo", + File: File{ + Template: "foo", + }, + }, + { + Name: "daily", + IsDefault: true, + NamePatterns: []ParameterizedText{"daily"}, + Type: FileType, + Path: "foo", + File: File{ + Template: "foo", + }, + }, + }}, "duplicate profile name 'daily'") +} + +func TestLoadConfig(t *testing.T) { + bytes := []byte(` +profiles: + - name: +`) + os.WriteFile("./test/invalid-config.yaml", bytes, 0644) + + // load config + _, err := loadConfig("./test/invalid-config.yaml") + assert.NotNil(t, err, "Expected to fail to load config, but succeeded") + assert.Contains(t, err.Error(), "error validating profile : Profile name is required") +} + +func TestSaveConfig(t *testing.T) { + config := Config{Profiles: []Profile{}} + err := saveConfig("./test/invalid-config.yaml", config) + assert.NotNil(t, err, "Expected to fail to save config, but succeeded") + assert.Contains(t, err.Error(), "no profiles found") +} + +func TestMergeConfigs(t *testing.T) { + defaultConfig := defaultConfig() + testSuccess := func(config1 Config, config2 Config) Config { + config, err := mergeConfigs(config1, config2) + // compareObjectYaml(t, mergedConfig, expected) + assert.Nil(t, err, "Expected to merge configs, but failed") + return config + } + testFailure := func(config1 Config, config2 Config, errorText string) { + _, err := mergeConfigs(config1, config2) + assert.NotNil(t, err, "Expected to fail to merge configs, but succeeded") + assert.Contains(t, err.Error(), errorText) + } + testFailure(Config{}, Config{}, "no profiles found") + testFailure(Config{Profiles: []Profile{Profile{}}}, Config{}, "error validating config a: error validating profile : Profile name is required") + testFailure(Config{Profiles: []Profile{defaultConfig.Profiles[0]}}, Config{}, "error validating config b: no profiles found") + + profile1 := defaultConfig.Profiles[0] + profile2 := defaultConfig.Profiles[1] + testSuccess(Config{Profiles: []Profile{profile1}}, Config{Profiles: []Profile{profile2}}) + + profile1_updated := profile1 + profile1_updated.Description += " This profile has been updated!" + + result := testSuccess(Config{Profiles: []Profile{profile1}}, Config{Profiles: []Profile{profile1_updated}}) + assert.Equal(t, 1, len(result.Profiles)) + assert.Equal(t, profile1_updated.Name, result.Profiles[0].Name) } diff --git a/main.go b/main.go index b6aaf1f..852cf0a 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/user" + "strconv" "strings" log "github.com/sirupsen/logrus" @@ -22,10 +23,13 @@ type ArgumentValues struct { userConfigPath string extrasFilePath string logLevel string - useDefaults bool +} +type EnvVarValues struct { + useDefaultConfig bool } type Settings struct { ArgumentValues + EnvVarValues extras map[string]string fullConfigPath string config Config @@ -88,13 +92,6 @@ func main() { Value: defaultLogLevel, Destination: &argumentValues.logLevel, }, - &cli.BoolFlag{ - Name: "use-defaults", - Aliases: []string{"d"}, - Usage: "use default values for config", - Value: false, - Destination: &argumentValues.useDefaults, - }, }, Commands: []*cli.Command{ { @@ -186,8 +183,13 @@ func createSettings( argumentValues ArgumentValues, context *cli.Context, ) (Settings, error) { + envVarValues, err := readEnvVars() + if err != nil { + return Settings{}, fmt.Errorf("error getting env var values: %w", err) + } settings := Settings{ ArgumentValues: argumentValues, + EnvVarValues: envVarValues, } // read args @@ -231,7 +233,7 @@ func createSettings( fmt.Println("Project name:", settings.projectName) // extras strings - _, err := os.Stat(settings.extrasFilePath) + _, err = os.Stat(settings.extrasFilePath) if err == nil { extras, err := loadExtras(settings.extrasFilePath) if err != nil { @@ -252,7 +254,7 @@ func createSettings( settings.fullConfigPath = fullConfigPath var config Config - if settings.useDefaults { + if settings.useDefaultConfig { config = defaultConfig() } else { config, err = loadConfig(fullConfigPath) @@ -282,6 +284,21 @@ func createSettings( return settings, nil } +func readEnvVars() (EnvVarValues, error) { + envVarValues := EnvVarValues{} + + useDefaultConfigText := os.Getenv("TEMPLATE_USE_DEFAULT_CONFIG") + if useDefaultConfigText != "" { + useDefaultConfig, err := strconv.ParseBool(useDefaultConfigText) + if err != nil { + return envVarValues, fmt.Errorf("error parsing env var TEMPLATE_USE_DEFAULT_CONFIG: \"%s\" %w", useDefaultConfigText, err) + } + envVarValues.useDefaultConfig = useDefaultConfig + } + + return envVarValues, nil +} + func findProfile(settings Settings) (Profile, error) { var profile Profile = Profile{} var err error diff --git a/monkey-patch.go b/monkey-patch.go index 97c20d9..26a6ec9 100644 --- a/monkey-patch.go +++ b/monkey-patch.go @@ -8,10 +8,6 @@ import ( "github.com/stretchr/testify/assert" ) -type monkeyOsDirEntry interface { - Name() string - IsDir() bool -} type monkeyPatch struct { ExecCommand func(name string, arg ...string) *exec.Cmd OSWriteFile func(filename string, data []byte, perm os.FileMode) error diff --git a/profile.go b/profile.go index d04edae..705c66e 100644 --- a/profile.go +++ b/profile.go @@ -62,8 +62,7 @@ var forbiddenProfileNames = []string{ func processProfile(profile Profile, projectName string, extras map[string]string, usr user.User) []error { if profile.IsDisabled { - fmt.Println("Profile is disabled, doing nothing") - return nil + return []error{fmt.Errorf("Profile is disabled, doing nothing")} } // decide if the profile should run on this OS @@ -74,11 +73,9 @@ func processProfile(profile Profile, projectName string, extras map[string]strin (profileOs == "Windows" && isWindows) || (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 + return []error{ + fmt.Errorf("This profile is not supported on this OS, doing nothing. Profile OS: %s, system OS: %s. Supported OS values: Windows, Mac, All", profileOs, runtime.GOOS), + } } cwd, err := monkey.OSGetwd() @@ -147,31 +144,33 @@ func processProfile(profile Profile, projectName string, extras map[string]strin } // execute PostScript, if present - postPatternData := createPostPatternData(fullPath, prePatternData) - postScripts, err := createPostScriptArrays(profile, postPatternData) - if err != nil { - errors = append(errors, fmt.Errorf("unable to create post scripts: %v", err)) - return errors - } - for _, postScript := range postScripts { - - shell, args := shellCommandAndArgs(postScript) - var out bytes.Buffer - fmt.Printf("Executing post script: %v %v\n", shell, strings.Join(args, " ")) - cmd := monkey.ExecCommand(shell, args...) - cmd.Stdout = &out - err := cmd.Run() - output := out.String() - if output != "" { - fmt.Printf("Post script output: %v\n", output) - } else { - fmt.Printf("Post script had no output\n") - } + if profile.PostScripts != nil && len(profile.PostScripts) > 0 { + postPatternData := createPostPatternData(fullPath, prePatternData) + postScripts, err := createPostScriptArrays(profile, postPatternData) if err != nil { - // post scripts can fail (TODO: investigate why), so for now we'll just log the error + errors = append(errors, fmt.Errorf("unable to create post scripts: %v", err)) + return errors + } + for _, postScript := range postScripts { - fmt.Printf("Error executing post script '%v': %v\n", - fmt.Sprintf("%v %v", shell, strings.Trim(fmt.Sprint(args), "[]")), err) + shell, args := shellCommandAndArgs(postScript) + var out bytes.Buffer + fmt.Printf("Executing post script: %v %v\n", shell, strings.Join(args, " ")) + cmd := monkey.ExecCommand(shell, args...) + cmd.Stdout = &out + err := cmd.Run() + output := out.String() + if output != "" { + fmt.Printf("Post script output: %v\n", output) + } else { + fmt.Printf("Post script had no output\n") + } + if err != nil { + // post scripts can fail (TODO: investigate why), so for now we'll just log the error + + fmt.Printf("Error executing post script '%v': %v\n", + fmt.Sprintf("%v %v", shell, strings.Trim(fmt.Sprint(args), "[]")), err) + } } } diff --git a/profile_test.go b/profile_test.go index f75e0ac..d276b89 100644 --- a/profile_test.go +++ b/profile_test.go @@ -1,10 +1,12 @@ package main import ( + "io/fs" "os" "os/exec" "os/user" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -17,11 +19,10 @@ func TestValidateProfile(t *testing.T) { t.Errorf("Expected to validate profile '%v', but failed: %v", profile.Name, err) } } - testFailure := func(profile Profile, testName string) { + testFailure := func(profile Profile, expectedErrStr string) { err := validateProfile(&profile) - if err == nil { - t.Errorf("%v - Expected to fail to validate profile '%v', but succeeded", testName, profile.Name) - } + assert.NotNil(t, err, "Expected to fail to validate profile '%v', but succeeded") + assert.Contains(t, err.Error(), expectedErrStr, "Expected error to contain '%v', but it didn't", expectedErrStr) } testSuccess(Profile{ @@ -42,22 +43,28 @@ func TestValidateProfile(t *testing.T) { testFailure(Profile{ Name: "", - }, "empty name") + }, "Profile name is required") testFailure(Profile{ Name: "list", - }, "illegal name") + }, "forbidden profile name 'list'") testFailure(Profile{ Name: "get", - }, "illegal name") + }, "forbidden profile name 'get'") testFailure(Profile{ Type: FileType, Name: "daily", - }, "missing path") + }, "namePatterns is required") testFailure(Profile{ - Type: FileType, - Name: "daily", - }, "invalid path") + Type: FileType, + Name: "daily", + NamePatterns: []ParameterizedText{"blah"}, + }, "path is not specified for profile daily, which has Type=file") + testFailure(Profile{ + Type: FolderType, + Name: "daily", + NamePatterns: []ParameterizedText{"test"}, + }, "path is required for profile daily, which has Type=folder") testFailure(Profile{ Type: FolderType, Name: "daily", @@ -65,12 +72,22 @@ func TestValidateProfile(t *testing.T) { File: File{ Template: "bar", }, - }, "file in folder") + }, "file template is not supported for profile daily, which has Type=folder") testFailure(Profile{ Type: ScriptType, Name: "daily", NamePatterns: []ParameterizedText{"test", "test2"}, - }, "multiple name patterns") + }, "multiple name patterns are not supported for profile daily, which has Type=script") + testFailure(Profile{ + Type: FileType, + Name: "daily", + NamePatterns: []ParameterizedText{"test", "test2"}, + }, "path is not specified for profile daily, which has Type=file") + testFailure(Profile{ + Type: FileType, + Name: "daily", + NamePatterns: []ParameterizedText{"test", ""}, + }, "name pattern is required for pattern index 1 of profile daily") for _, profile := range defaultConfig().Profiles { testSuccess(profile) @@ -218,68 +235,125 @@ func TestCreateEntity(t *testing.T) { testScript() } +type testFile struct { + name string + isDir bool +} + +func (f testFile) Name() string { return f.name } +func (f testFile) IsDir() bool { return f.isDir } +func (f testFile) Type() fs.FileMode { return 0 } +func (f testFile) Info() (fs.FileInfo, error) { return nil, nil } + func TestProcessProfile(t *testing.T) { cwd := filepath.Clean("~/test/dir") - monkeyPatchCalls := applyTestMonkeyPatch(t, monkeyPatch{ - ExecCommand: func(command string, args ...string) *exec.Cmd { - return exec.Command("echo", "foo") - }, - OSChdir: func(dir string) error { return nil }, - OSGetwd: func() (dir string, err error) { return cwd, nil }, - OSReadDir: func(dirname string) ([]os.DirEntry, error) { - return []os.DirEntry{}, nil - }, - }) + createDirEntry := func(name string, isDir bool) os.DirEntry { + entry := testFile{name: name, isDir: isDir} + return entry + } + var monkeyPatchCalls *monkeyPatchCalls + configureMonkeyPatching := func() { + monkeyPatchCalls = applyTestMonkeyPatch(t, monkeyPatch{ + ExecCommand: func(command string, args ...string) *exec.Cmd { + return exec.Command("echo") + }, + OSChdir: func(dir string) error { return nil }, + OSGetwd: func() (dir string, err error) { return cwd, nil }, + OSReadDir: func(dirname string) ([]os.DirEntry, error) { + return []os.DirEntry{ + createDirEntry("dir1", true), + createDirEntry("dir2", true), + createDirEntry("file1", false), + createDirEntry("file2", false), + }, nil + }, + OSWriteFile: func(filename string, data []byte, perm os.FileMode) error { + return nil + }, + }) + } projectName := "project-name" extras := map[string]string{ "foo": "bar", } - testSuccess := func() { - profile := Profile{ - Path: cwd, - NamePatterns: []ParameterizedText{ - "{{.ProjectName}}-{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}", - }, - RunPostscriptOnFailure: true, - PostScripts: []ParameterizedText{ - "echo 'hi'", - }, - } + usr, err := user.Current() + assert.Nil(t, err) + assert.NotNil(t, usr) - usr, err := user.Current() - assert.Nil(t, err) - assert.NotNil(t, usr) + testSuccess := func(profile Profile) { + configureMonkeyPatching() errors := processProfile(profile, projectName, extras, *usr) assert.Equal(t, 0, len(errors)) - assert.Equal(t, 1, len(monkeyPatchCalls.ExecCommandCalls)) - assert.Equal(t, 1, len(monkeyPatchCalls.OSChdirCalls)) - assert.Equal(t, 1, len(monkeyPatchCalls.OSGetwdCalls)) - assert.Equal(t, 0, len(monkeyPatchCalls.OSReadDirCalls)) + for _, error := range errors { + assert.Nil(t, error) + } } - testFailure := func() { - profile := Profile{ - Path: cwd, - NamePatterns: []ParameterizedText{ - "{{.ProjectName", - }, - // RunPostscriptOnFailure: true, - PostScripts: []ParameterizedText{ - "echo 'hi'", - }, - Type: FileType, + testFailure := func(profile Profile, errorCount int, errorStr string) { + configureMonkeyPatching() + errors := processProfile(profile, projectName, extras, *usr) + assert.Equal(t, errorCount, len(errors)) + allErrorsText := "" + for _, err := range errors { + allErrorsText += err.Error() } + assert.Contains(t, allErrorsText, errorStr) + } + testSingleFailure := func(profile Profile, errorStr string) { + testFailure(profile, 1, errorStr) + } - usr, err := user.Current() - assert.Nil(t, err) - assert.NotNil(t, usr) - - errors := processProfile(profile, projectName, extras, *usr) - assert.Equal(t, 1, len(errors)) + // this profile will be built up from failing to eventually passing + currentOS := getCurrentOs() + profile := Profile{ + IsDisabled: true, } + testSingleFailure(profile, "Profile is disabled, doing nothing") + profile.IsDisabled = false + profile.OS = getOtherOs(currentOS) + testSingleFailure(profile, "This profile is not supported on this OS, doing nothing.") + profile.OS = currentOS + // testSingleFailure(profile, "unable to create post scripts: no post scripts specified") - testSuccess() + profile.NamePatterns = []ParameterizedText{} + profile.Type = FileType + testSingleFailure(profile, "couldn't find a good new name") - testFailure() + profile.NamePatterns = []ParameterizedText{"file1"} + profile.RunPostscriptOnFailure = true + testSingleFailure(profile, "couldn't find a good new name") + + profile.NamePatterns = []ParameterizedText{"file3"} + profile.PostScripts = []ParameterizedText{"echo {{.ProjectName}}-{{.Year}}"} + profile.Path = "~/test/dir" + testSuccess(profile) + assert.Equal(t, 1, len(monkeyPatchCalls.ExecCommandCalls), "calls to ExecCommand") + assert.Equal(t, 1, len(monkeyPatchCalls.OSChdirCalls), "calls to OSChdir") + assert.Equal(t, 1, len(monkeyPatchCalls.OSGetwdCalls), "calls to OSGetwd") + assert.Equal(t, 1, len(monkeyPatchCalls.OSReadDirCalls), "calls to OSReadDir") + assert.Equal(t, 1, len(monkeyPatchCalls.OSWriteFileCalls), "calls to OSWriteFile") + + profile.PostScripts = []ParameterizedText{"echo ''"} + testSuccess(profile) + assert.Equal(t, 1, len(monkeyPatchCalls.ExecCommandCalls), "calls to ExecCommand") + assert.Equal(t, 1, len(monkeyPatchCalls.OSChdirCalls), "calls to OSChdir") + assert.Equal(t, 1, len(monkeyPatchCalls.OSGetwdCalls), "calls to OSGetwd") + assert.Equal(t, 1, len(monkeyPatchCalls.OSReadDirCalls), "calls to OSReadDir") + assert.Equal(t, 1, len(monkeyPatchCalls.OSWriteFileCalls), "calls to OSWriteFile") +} + +func getCurrentOs() OSType { + os := runtime.GOOS + if os == "windows" { + return "Windows" + } else { + return "Mac" + } +} +func getOtherOs(currentOs OSType) OSType { + if currentOs == "windows" { + return "Mac" + } + return "Windows" }