template / commit
Merge branch 'makefile-tests' into 'main'
.gitignore | 1 + .vscode/settings.json | 3 ++ Makefile | 30 ++++++++++++ config.go | 77 +++--------------------------- config_test.go | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++ extras_test.go | 9 ++++ file.go | 13 ++--- main.go | 11 ++++- name_pattern.go | 9 ++-- postscript.go | 3 +- postscript_test.go | 45 +++++++++++++++++ profile.go | 100 ++++++++++++++++++++++++++++++-------- profile_test.go | 105 ++++++++++++++++++++++++++++++++++++++++ utils.go | 8 +--- utils_test.go | 34 +++++++++++++ 15 files changed, 467 insertions(+), 111 deletions(-) diff --git a/.gitignore b/.gitignore index 984b600..4c85f83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .git template.exe template +test diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bd1c779 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["stretchr"] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..28a2431 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +ifeq ($(OS),Windows_NT) + SHELL := pwsh.exe +else + SHELL := pwsh +endif + +.SHELLFLAGS := -Command + +BINARY_NAME := template + +.PHONY: test + +build: + go build . + +install: build + ./scripts/clean-install.ps1 + +test: build + New-Item -ItemType Directory -Force -Path ./test | out-null + go test -v ./... -coverprofile="./test/coverage.out" + +test_coverage: test + go tool cover -html="./test/coverage.out" -o "./test/coverage.html" + +test_coverage_html: test_coverage + Invoke-Item "./test/coverage.html" + +clean: + go clean diff --git a/config.go b/config.go index 7bc8769..dab0302 100644 --- a/config.go +++ b/config.go @@ -3,49 +3,11 @@ package main import ( "fmt" "os" - "strings" + "os/user" - "golang.org/x/exp/slices" "gopkg.in/yaml.v3" ) -type ProfileType string - -const ( - FileType ProfileType = "file" - FolderType ProfileType = "folder" - ScriptType ProfileType = "script" -) - -type OSType string - -const ( - Windows OSType = "Windows" - Mac OSType = "Mac" - All OSType = "All" -) - -type Profile struct { - IsDefault bool - Name string - Description string - Path string - Type ProfileType - NamePatterns []string - PostScripts []string - ScriptShell string - OS OSType - FileTemplatePath string - FileTemplate string - - // FileTemplatePath string -} - -var forbiddenProfileNames = []string{ - "list", - "get", -} - type Config struct { Profiles []Profile } @@ -141,6 +103,7 @@ Today's notes Description: "Creates a folder in the current folder in the format YYYY-MM-DD[-ProjectName][-vVersion]", Type: FolderType, ScriptShell: "pwsh", + PostScripts: []string{}, NamePatterns: []string{ "{{$version := getVersion}}" + "{{.Year}}-{{.Month}}-{{.Day}}" + @@ -188,9 +151,6 @@ Today's notes Type: ScriptType, 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{ @@ -211,35 +171,15 @@ func validateConfig(config Config) error { foundDefault := false for _, profile := range config.Profiles { - if profile.Name == "" { - return fmt.Errorf("Profile name is required") - } - if slices.Contains(forbiddenProfileNames, profile.Name) { - return fmt.Errorf("forbidden profile name '%s'", profile.Name) - } if profile.IsDefault { if foundDefault { return fmt.Errorf("multiple default profiles found") } foundDefault = true } - if profile.NamePatterns == nil || len(profile.NamePatterns) == 0 { - return fmt.Errorf("no name patterns found for profile %s", profile.Name) - } - if profile.Path != "" && strings.Contains(profile.Path, "\\") { - return fmt.Errorf("invalid path for profile %s: %s", profile.Name, profile.Path) - } - if profile.Type == "" || (profile.Type != FileType && profile.Type != FolderType && profile.Type != ScriptType) { - return fmt.Errorf("invalid profile type %s for profile %s", profile.Type, profile.Name) - } - if profile.Type != FileType && profile.FileTemplatePath != "" { - return fmt.Errorf("file template path is not supported for profile %s, which has Type=%s", profile.Name, profile.Type) - } - if profile.Type != ScriptType && profile.NamePatterns != nil && len(profile.NamePatterns) > 1 { - return fmt.Errorf("multiple name patterns are not supported for profile %s, which has Type=%s", profile.Name, profile.Type) - } - if profile.ScriptShell == "" && profile.PostScripts != nil && len(profile.PostScripts) > 0 { - return fmt.Errorf("script shell is required for profile %s", profile.Name) + error := validateProfile(profile) + if error != nil { + return fmt.Errorf("error validating profile %s: %s", profile.Name, error) } } return nil @@ -275,13 +215,10 @@ func saveConfig(path string, config Config) error { return nil } -func getOrCreateConfig(configFilePath string) (Config, error) { +func getOrCreateConfig(configFilePath string, usr user.User) (Config, error) { emptyConfig := Config{} - fullConfigPath, err := getFullPath(configFilePath) - if err != nil { - return emptyConfig, fmt.Errorf("failed to get full path for config file %v: %v", configFilePath, err) - } + fullConfigPath := getFullPath(configFilePath, usr) // try and load config config, err := loadConfig(fullConfigPath) diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..59a30ff --- /dev/null +++ b/config_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "errors" + "os" + "os/user" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSaveLoadConfig(t *testing.T) { + usr, err := user.Current() + assert.Nil(t, err) + + config := defaultConfig() + configPath := "./test/test-config.yaml" + + // save config + err = saveConfig(configPath, config) + assert.Nil(t, err, "Error saving config") + + // load config + loadedConfig, err := loadConfig(configPath) + assert.Nil(t, err, "Error loading config") + + // test config + assert.Equal(t, loadedConfig, config) + + // test load non-existent config + _, err = loadConfig("non-existent-file.yaml") + assert.NotNil(t, err, "Expected to fail to load config, but succeeded") + + // test load invalid yaml + os.WriteFile("./test/invalid-yaml.yaml", []byte("invalid yaml"), 0644) + _, err = loadConfig("./test/invalid-yaml.yaml") + assert.NotNil(t, err, "Expected to fail to parse config, but succeeded") + + // test saving config to invalid path + err = saveConfig("invalid/path/invalid-config.yaml", config) + assert.NotNil(t, err, "Expected to fail to save config, but succeeded") + + // remove config + os.Remove(configPath) + + // test default config creation + _, 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, *usr) + assert.Nil(t, err, "Error loading config") + + // test config + assert.Equal(t, loadedConfig, config) + + // verify config file exists + stat1, err := os.Stat(configPath) + assert.Nil(t, err, "Expected config file to exist, but it does not") + + // get and create config + loadedConfig, err = getOrCreateConfig(configPath, *usr) + assert.Nil(t, err, "Error loading config") + + // test config + assert.Equal(t, loadedConfig, config) + + // verify config file wasn't updated + stat2, err := os.Stat(configPath) + assert.Nil(t, err, "Expected config file to exist, but it does not") + assert.Equal(t, stat1.ModTime(), stat2.ModTime(), "Expected config file to not be modified, but it was") +} + +func TestGetOrCreateConfig(t *testing.T) { + usr, err := user.Current() + assert.Nil(t, err) + testFailure := func(configPath string) { + _, err := getOrCreateConfig(configPath, *usr) + 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) + if err != nil { + t.Errorf("Expected to validate config, but failed: %v", err) + } +} + +func TestValidateConfig(t *testing.T) { + testFailure := func(config Config, errorText string) { + err := validateConfig(config) + if err == nil { + t.Errorf("Expected to fail to validate config, but succeeded") + } + assert.Contains(t, err.Error(), errorText) + } + + testFailure(Config{}, "no profiles found") + testFailure(Config{Profiles: []Profile{}}, "no profiles found") + testFailure(Config{Profiles: []Profile{ + { + Name: "daily", + IsDefault: true, + NamePatterns: []string{"daily"}, + FileTemplate: "foo", + }, + }}, "error validating profile") + testFailure(Config{Profiles: []Profile{ + { + Name: "daily", + IsDefault: true, + NamePatterns: []string{"daily"}, + Type: FileType, + FileTemplate: "foo", + }, + { + Name: "daily2", + IsDefault: true, + NamePatterns: []string{"daily"}, + Type: FileType, + FileTemplate: "foo", + }, + }}, "multiple default profiles found") +} diff --git a/extras_test.go b/extras_test.go index 9a72542..5719ab0 100644 --- a/extras_test.go +++ b/extras_test.go @@ -49,4 +49,13 @@ func TestExtras(t *testing.T) { t.Error("Error loading extras: ", error) } assert.Equal(t, extras, loadedExtras) + + // test path errors + _, error = loadExtras("non-existent-file.yaml") + assert.NotNil(t, error, "Expected to fail to load extras, but succeeded") + + os.WriteFile(file.Name(), []byte("invalid yaml"), 0644) + + _, error = loadExtras(fileName) + assert.NotNil(t, error, "Expected to fail to parse extras, but succeeded") } diff --git a/file.go b/file.go index f604e55..e364f77 100644 --- a/file.go +++ b/file.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "os/user" "path/filepath" "strings" "text/template" @@ -29,19 +30,13 @@ func createFilePatternData(now time.Time, projectName string, name string, extra } } -func createTemplateFile(srcPath string, destPath string, projectName string, name string, extras map[string]string) error { +func createTemplateFile(srcPath string, destPath string, projectName string, name string, extras map[string]string, usr user.User) error { now := time.Now() filePatternData := createFilePatternData(now, projectName, name, extras) // standardize paths - srcPath, err := getFullPath(srcPath) - if err != nil { - return err - } - destPath, err = getFullPath(destPath) - if err != nil { - return err - } + srcPath = getFullPath(srcPath, usr) + destPath = getFullPath(destPath, usr) // parse template srcName := filepath.Base(srcPath) // srcName is required because we are sending files to ParseFiles diff --git a/main.go b/main.go index 028a555..c0ea6ab 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "os/user" "strings" "github.com/urfave/cli/v2" // imports as package "cli" @@ -17,6 +18,7 @@ func main() { var extrasFilePath string var useDefaultConfig bool var err error + var usr *user.User app := &cli.App{ Name: "template", @@ -65,12 +67,17 @@ func main() { }, Action: func(context *cli.Context) error { + usr, err = user.Current() + if err != nil { + log.Fatal(err) + } + // get config var config Config if useDefaultConfig { config = defaultConfig() } else { - config, err = getOrCreateConfig(configFilePath) + config, err = getOrCreateConfig(configFilePath, *usr) if err != nil { return err } @@ -136,7 +143,7 @@ func main() { } // process profile - err = processProfile(profile, projectName, extras) + err = processProfile(profile, projectName, extras, *usr) if err != nil { return fmt.Errorf("error processing profile: %v", err) } diff --git a/name_pattern.go b/name_pattern.go index c383b63..32fb4d3 100644 --- a/name_pattern.go +++ b/name_pattern.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "strings" "text/template" "time" @@ -42,13 +43,13 @@ func createNewNameForProfile(now time.Time, projectName string, profile Profile, template, err := template.Parse(namePattern) if err != nil { - return "", err + return "", fmt.Errorf("error parsing name pattern %d: %w", index, err) } sb := new(strings.Builder) err = template.Execute(sb, patternData) if err != nil { - return "", err + return "", fmt.Errorf("error executing name pattern %d: %w", index, err) } newName := sb.String() @@ -68,11 +69,11 @@ func createNewNameForProfile(now time.Time, projectName string, profile Profile, // if getVersion was used, but this isn't the last pattern, report an error if useIndex && !isLastPattern { - return "", errors.New("Cannot use function getVersion in a pattern that is not the last pattern") + return "", errors.New("cannot use function getVersion in a pattern that is not the last pattern") } startingVersion++ } } - return "", errors.New("Couldn't find a new name") + return "", errors.New("couldn't find a new name") } diff --git a/postscript.go b/postscript.go index dfc2024..e8cfcce 100644 --- a/postscript.go +++ b/postscript.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "text/template" ) @@ -15,7 +16,7 @@ func createPostScriptArrays(profile Profile, newName string, fullPath string, ex array := make([]string, 0) if profile.PostScripts == nil || len(profile.PostScripts) == 0 { - return nil, nil + return nil, fmt.Errorf("no post scripts specified") } patternData := PostScriptPatternData{ diff --git a/postscript_test.go b/postscript_test.go new file mode 100644 index 0000000..8ae03f6 --- /dev/null +++ b/postscript_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreatePostScriptArrays(t *testing.T) { + testFailure := func(profile Profile) { + _, err := createPostScriptArrays(profile, "test", "test", nil) + assert.NotNil(t, err, "Expected to fail to create post script arrays, but succeeded") + } + testSuccess := func(profile Profile) { + extras := map[string]string{ + "foo": "bar", + } + + postscripts, err := createPostScriptArrays(profile, "test", "full-path", extras) + assert.Nil(t, err, "Error creating postscript array") + assert.Equal(t, postscripts, []string{"echo \"test\""}) + } + + testSuccess(Profile{ + PostScripts: []string{ + "echo \"{{.Name}}\"", + }, + }) + testFailure(Profile{ + PostScripts: nil, + }) + testFailure(Profile{ + PostScripts: []string{}, + }) + testFailure(Profile{ + PostScripts: []string{ + "echo \"{{.Name\"", + }, + }) + testFailure(Profile{ + PostScripts: []string{ + "echo \"{{.NameUnknown}}\"", + }, + }) +} diff --git a/profile.go b/profile.go index 6389435..888ee70 100644 --- a/profile.go +++ b/profile.go @@ -6,12 +6,52 @@ import ( "io/ioutil" "os" "os/exec" + "os/user" "path/filepath" "runtime" "strings" "time" + + "golang.org/x/exp/slices" ) +type ProfileType string + +const ( + FileType ProfileType = "file" + FolderType ProfileType = "folder" + ScriptType ProfileType = "script" +) + +type OSType string + +const ( + Windows OSType = "Windows" + Mac OSType = "Mac" + All OSType = "All" +) + +type Profile struct { + IsDefault bool + Name string + Description string + Path string + Type ProfileType + NamePatterns []string + PostScripts []string + ScriptShell string + OS OSType + FileTemplatePath string + FileTemplate string + + // FileTemplatePath string +} + +var forbiddenProfileNames = []string{ + "list", + "get", +} + func findProfile(config Config, profileName string) (Profile, error) { if profileName == "" { // find default profile @@ -20,24 +60,18 @@ func findProfile(config Config, profileName string) (Profile, error) { return p, nil } } - } else { - // find profile by name - for _, p := range config.Profiles { - if p.Name == profileName { - 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) } - return Profile{}, errors.New("couldn't find profile") + return Profile{}, fmt.Errorf("unable to find profile '%v'", profileName) } -func processProfile(profile Profile, projectName string, extras map[string]string) error { - // validate profile - if profile.NamePatterns == nil || len(profile.NamePatterns) == 0 { - return errors.New("no name patterns specified") - } - +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 isWindows := runtime.GOOS == "windows" @@ -56,10 +90,7 @@ func processProfile(profile Profile, projectName string, extras map[string]strin } if profile.Path != "" { // change cwd to the profile's path, if one is specified - path, err := getFullPath(profile.Path) - if err != nil { - return fmt.Errorf("unable to get full path for '%v': %v", profile.Path, err) - } + path := getFullPath(profile.Path, usr) err = os.Chdir(path) if err != nil { return fmt.Errorf("unable to change directory to %v: %v", path, err) @@ -114,7 +145,7 @@ func processProfile(profile Profile, projectName string, extras map[string]strin useTemplate := profile.FileTemplate != "" if useTemplateFile { fmt.Printf("Creating file '%v' from template: %v\n", fullPath, profile.FileTemplatePath) - err := createTemplateFile(profile.FileTemplatePath, fullPath, projectName, newName, extras) + err := createTemplateFile(profile.FileTemplatePath, fullPath, projectName, newName, extras, usr) if err != nil { return fmt.Errorf("error creating template file: %v", err) } @@ -153,3 +184,34 @@ func processProfile(profile Profile, projectName string, extras map[string]strin return nil } + +func validateProfile(profile Profile) error { + if profile.Name == "" { + return fmt.Errorf("Profile name is required") + } + if slices.Contains(forbiddenProfileNames, profile.Name) { + return fmt.Errorf("forbidden profile name '%s'", profile.Name) + } + if profile.NamePatterns == nil || len(profile.NamePatterns) == 0 { + return fmt.Errorf("no name patterns found for profile %s", profile.Name) + } + if profile.Type == "" || (profile.Type != FileType && profile.Type != FolderType && profile.Type != ScriptType) { + return fmt.Errorf("invalid profile type %s for profile %s", profile.Type, profile.Name) + } + if profile.Type != FileType && profile.FileTemplatePath != "" { + return fmt.Errorf("file template path is not supported for profile %s, which has Type=%s", profile.Name, profile.Type) + } + if profile.Type != FileType && profile.FileTemplate != "" { + return fmt.Errorf("file template is not supported for profile %s, which has Type=%s", profile.Name, profile.Type) + } + if profile.Type == FileType && profile.FileTemplatePath != "" && profile.FileTemplate != "" { + return fmt.Errorf("both file template path and file template are specified for profile %s", profile.Name) + } + if profile.Type == ScriptType && profile.NamePatterns != nil && len(profile.NamePatterns) > 1 { + return fmt.Errorf("multiple name patterns are not supported for profile %s, which has Type=%s", profile.Name, profile.Type) + } + if profile.ScriptShell == "" && profile.PostScripts != nil && len(profile.PostScripts) > 0 { + return fmt.Errorf("script shell is required for profile %s", profile.Name) + } + return nil +} diff --git a/profile_test.go b/profile_test.go index b79cf7a..f2c747f 100644 --- a/profile_test.go +++ b/profile_test.go @@ -4,6 +4,107 @@ import ( "testing" ) +func TestValidateProfile(t *testing.T) { + testSuccess := func(profile Profile) { + err := validateProfile(profile) + if err != nil { + t.Errorf("Expected to validate profile '%v', but failed: %v", profile.Name, err) + } + } + testFailure := func(profile Profile) { + err := validateProfile(profile) + if err == nil { + t.Errorf("Expected to fail to validate profile '%v', but succeeded", profile.Name) + } + } + + testSuccess(Profile{ + Type: FileType, + Name: "daily", + NamePatterns: []string{"{{.Date}}"}, + }) + testSuccess(Profile{ + Type: FileType, + Name: "daily", + NamePatterns: []string{"{{.Date}}"}, + FileTemplate: "foo", + }) + testSuccess(Profile{ + Type: FileType, + Name: "daily", + NamePatterns: []string{"{{.Date}}"}, + FileTemplatePath: "foo", + }) + + // empty name + testFailure(Profile{ + Name: "", + }) + // illegal name + testFailure(Profile{ + Name: "list", + }) + testFailure(Profile{ + Name: "get", + }) + // file type with no path + testFailure(Profile{ + Type: FileType, + Name: "daily", + }) + testFailure(Profile{ + Type: FileType, + Name: "daily", + Path: "\\", + }) + // no name patterns + testFailure(Profile{ + Type: FileType, + Name: "daily", + NamePatterns: []string{}, + }) + // file template and file template path are set + testFailure(Profile{ + Type: FileType, + Name: "daily", + NamePatterns: []string{"test"}, + FileTemplatePath: "foo", + FileTemplate: "bar", + }) + // file template set with wrong type + testFailure(Profile{ + Type: FolderType, + Name: "daily", + NamePatterns: []string{"test"}, + FileTemplate: "bar", + }) + // file template path set with wrong type + testFailure(Profile{ + Type: FolderType, + Name: "daily", + NamePatterns: []string{"test"}, + FileTemplatePath: "bar", + }) + // script type with multiple name patterns + testFailure(Profile{ + Type: ScriptType, + Name: "daily", + NamePatterns: []string{"test", "test2"}, + }) + // postscripts with no shell + testFailure(Profile{ + Type: ScriptType, + Name: "daily", + NamePatterns: []string{"test"}, + PostScripts: []string{"test"}, + ScriptShell: "", + }) + + for _, profile := range defaultConfig().Profiles { + testSuccess(profile) + } +} + func TestFindProfile(t *testing.T) { config := Config{ Profiles: []Profile{ @@ -37,4 +138,8 @@ func TestFindProfile(t *testing.T) { testSuccess("notes", "notes") testFailure("non-existent") + + config.Profiles[1].IsDefault = false + testFailure("") + testFailure("non-default") } diff --git a/utils.go b/utils.go index 4cda077..7a794bb 100644 --- a/utils.go +++ b/utils.go @@ -6,11 +6,7 @@ import ( "strings" ) -func getFullPath(path string) (string, error) { - usr, error := user.Current() - if error != nil { - return "", error - } +func getFullPath(path string, usr user.User) string { dir := usr.HomeDir var newPath string if path == "~" { @@ -20,5 +16,5 @@ func getFullPath(path string) (string, error) { } else { newPath = path } - return newPath, nil + return newPath } diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..851bdca --- /dev/null +++ b/utils_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "os/user" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetFullPath(t *testing.T) { + usr, err := user.Current() + assert.Nil(t, err) + + testSuccess := func(path string, expected string) { + fullPath := getFullPath(path, *usr) + if fullPath != expected { + t.Errorf("Expected to get full path '%v' for '%v', but got '%v'", expected, path, fullPath) + } + } + // testFailure := func(path string) { + // _, err := getFullPath(path) + // if err == nil { + // t.Errorf("Expected to fail to get full path for '%v', but succeeded", path) + // } + // } + + testSuccess("C:\\", "C:\\") + testSuccess("C:\\test", "C:\\test") + testSuccess("C:\\test\\", "C:\\test\\") + testSuccess("C:\\test\\..", "C:\\test\\..") // WRONG + + testSuccess("~/", usr.HomeDir) + testSuccess("~", usr.HomeDir) +}