template / commit
Refactored a bit:
README.md | 7 ++- config_test.go | 2 + default_config.go | 14 ++++- defaultconfig_test.go | 14 +++++ main.go | 168 +++++++++++++++++++++++++++++++++++--------------- name_pattern.go | 23 +++---- name_pattern_test.go | 9 ++- profile.go | 95 +++++++++++++++++----------- profile_test.go | 39 +++++------- 9 files changed, 236 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index b6e09c9..5310c5e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ template ## Description -This CLI creates files and folders with generated names, for example `2022-09-26-fhl-2023.md` (generator on September 26, 2023, with the project name "fhl-2023"). +This CLI creates files and folders with generated templated names, for example `2022-09-26-fhl-2023.md` (generator on September 26, 2023, with the project name "fhl-2023"). It's also possible to control the contents of the files: you can specify a template file or an inline template to set the file contents. This started as a hackathon project that I used to learn Go (golang) and to automate a repetitive task: creation of time-stamped files and folders. @@ -151,7 +151,10 @@ The CLI should also not be too opinionated. Aside from `go`, there are no other Here is the future feature list: -1. Support for passing in a list of template values for custom values in the template. Use might look like this: `template work form123 --id=456 --corpOffice=SEA`. The passed-in extra values will be placed somewhere inside the template. Some value keys will not be allowed, like "time". +1. Remove dependency on pwsh - is there a better approach to making `template` a cross-platform CLI? +2. Publish Homebrew version of app +3. Publish Chocolatey version of app +4. Support for passing in a list of template values for custom values in the template. Use might look like this: `template work form123 --id=456 --corpOffice=SEA`. The passed-in extra values will be placed somewhere inside the template. Some value keys will not be allowed, like "time". ## Contributing diff --git a/config_test.go b/config_test.go index 4163c89..46103ae 100644 --- a/config_test.go +++ b/config_test.go @@ -116,6 +116,7 @@ func TestValidateConfig(t *testing.T) { IsDefault: true, NamePatterns: []ParameterizedText{"daily"}, Type: FileType, + Path: "foo", File: File{ Template: "foo", }, @@ -125,6 +126,7 @@ func TestValidateConfig(t *testing.T) { IsDefault: true, NamePatterns: []ParameterizedText{"daily"}, Type: FileType, + Path: "foo", File: File{ Template: "foo", }, diff --git a/default_config.go b/default_config.go index aa6d0de..cc5e942 100644 --- a/default_config.go +++ b/default_config.go @@ -53,6 +53,7 @@ Today's notes for {{.Year}}-{{.Month}}-{{.Day}}. OS: "All", Type: FileType, IsDefault: true, + Path: ".", // current folder NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + "{{.Year}}-{{.Month}}-{{.Day}}" + @@ -130,7 +131,7 @@ cspell:words nanite OS: "All", Path: "~/Therapy", File: File{ - Template: `# {{.FilenameWithoutExtension}} + Template: `# {{if .ProjectName}}{{.ProjectName}}{{else}}Therapy notes{{end}} {{if .ProjectName}}Therapy notes on {{.ProjectName}}{{else}}Therapy notes{{end}} @@ -150,9 +151,16 @@ Sincerely, me "{{if (gt $version 1)}}v{{$version}}{{end}}" + ".md", }, + }, + { + Name: "therapy-open", + Description: "Open the ~/Therapy folder in VS Code", + OS: "All", + Path: "~/Therapy", + Type: ScriptType, ScriptShell: "pwsh", PostScripts: []ParameterizedText{ - "code . \"{{.FileName}}\"", + "code .", }, }, { @@ -195,6 +203,7 @@ echo "Let's test {{.ProjectName}}!"`, Name: "folder", Description: "Creates a folder in the current folder in the format YYYY-MM-DD[-ProjectName][-vVersion]", Type: FolderType, + Path: ".", // current folder ScriptShell: "pwsh", PostScripts: []ParameterizedText{}, NamePatterns: []ParameterizedText{ @@ -209,6 +218,7 @@ echo "Let's test {{.ProjectName}}!"`, Type: FolderType, Description: "Creates a folder in the current folder in the format create-react-app[-ProjectName][-vVersion], runs `npx create-react-app` to initialize the folder, and opens the folder in VSCode", ScriptShell: "pwsh", + Path: ".", // current folder NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + "create-react-app" + diff --git a/defaultconfig_test.go b/defaultconfig_test.go new file mode 100644 index 0000000..b06ada8 --- /dev/null +++ b/defaultconfig_test.go @@ -0,0 +1,14 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultConfig(t *testing.T) { + + config := defaultConfig() + err := validateConfig(&config) + assert.Nil(t, err, "Error validating default config") +} diff --git a/main.go b/main.go index 51a8044..3f56d33 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,10 @@ type ArgumentValues struct { } type Settings struct { ArgumentValues - extras map[string]string + extras map[string]string + fullConfigPath string + config Config + usr *user.User } func main() { @@ -85,11 +88,92 @@ func main() { Destination: &argumentValues.logLevel, }, }, + Commands: []*cli.Command{ + { + Name: "list", + Usage: "list available profiles", + Action: func(c *cli.Context) error { + settings, err := createSettings(argumentValues, c) + if err != nil { + return fmt.Errorf("error creating settings: %v", err) + } + err = setConfig(&settings) + if err != nil { + return fmt.Errorf("error setting config: %w", err) + } + + config := settings.config + // list profiles + fmt.Println("Available profiles:") + for _, p := range config.Profiles { + fmt.Printf(" %v: %v\n", p.Name, p.Description) + } + return nil + }, + }, + { + Name: "get", + Usage: "get a profile", + Action: func(c *cli.Context) error { + settings, err := createSettings(argumentValues, c) + if err != nil { + return fmt.Errorf("error creating settings: %v", err) + } + err = setConfig(&settings) + if err != nil { + return fmt.Errorf("error setting config: %w", err) + } + + config := settings.config + + // get profile information + index, err := findProfileIndex(config.Profiles, settings.profile) + if err != nil { + return fmt.Errorf("profile '%v' not found", settings.profile) + } + profile := config.Profiles[index] + + writer := strings.Builder{} + encoder := yaml.NewEncoder(&writer) + err = encoder.Encode(profile) + if err != nil { + return fmt.Errorf("error encoding profile: %v", err) + } + + yamlText := writer.String() + fmt.Printf("Profile '%v':\n", settings.projectName) + fmt.Println(yamlText) + return nil + }, + }, + { + Name: "reset", + Usage: "reset the global template config to default profiles", + Action: func(c *cli.Context) error { + settings, err := createSettings(argumentValues, c) + if err != nil { + return fmt.Errorf("error creating settings: %v", err) + } + fullConfigPath := settings.fullConfigPath + + fmt.Printf("Resetting config at '%v' to default\n", fullConfigPath) + err = saveConfig(fullConfigPath, defaultConfig()) + if err != nil { + return fmt.Errorf("error saving default config: %v", err) + } + return nil + }, + }, + }, Action: func(context *cli.Context) error { - settings, err := parseArguments(argumentValues, context) + settings, err := createSettings(argumentValues, context) if err != nil { return fmt.Errorf("error parsing arguments: %w", err) } + err = setConfig(&settings) + if err != nil { + return fmt.Errorf("error setting config: %w", err) + } err = mainLogic(settings) if err != nil { return fmt.Errorf("error in main logic: %w", err) @@ -103,7 +187,7 @@ func main() { } } -func parseArguments( +func createSettings( argumentValues ArgumentValues, context *cli.Context, ) (Settings, error) { @@ -161,17 +245,24 @@ func parseArguments( settings.extras = extras } - return settings, nil -} - -func mainLogic(settings Settings) error { + // get user usr, err := user.Current() if err != nil { log.Fatal(err) } + settings.usr = usr // get config from default path fullConfigPath := getFullPath(settings.configFilePath, *usr) + settings.fullConfigPath = fullConfigPath + + return settings, nil +} + +func setConfig(settings *Settings) error { + fullConfigPath := settings.fullConfigPath + usr := settings.usr + config, err := loadConfig(fullConfigPath) if err != nil { return fmt.Errorf("error loading config from %v: %w", fullConfigPath, err) @@ -197,63 +288,38 @@ func mainLogic(settings Settings) error { } } - // list profiles - if settings.profile == "list" { - fmt.Println("Available profiles:") - for _, p := range config.Profiles { - fmt.Printf(" %v: %v\n", p.Name, p.Description) - } - return nil - } - - // get profile information - if settings.profile == "get" { - 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) - err = encoder.Encode(profile) - if err != nil { - return fmt.Errorf("error encoding profile: %v", err) - } - - yamlText := writer.String() - fmt.Printf("Profile '%v':\n", settings.projectName) - fmt.Println(yamlText) - return nil - } - - // reset config to the default - if settings.profile == "reset" { - fmt.Printf("Resetting config at '%v' to default\n", fullConfigPath) - err = saveConfig(fullConfigPath, defaultConfig()) - if err != nil { - return fmt.Errorf("error saving default config: %v", err) - } - return nil - } + settings.config = config + return nil +} - // find profile - var profile Profile +func findProfile(settings Settings) (Profile, error) { + var profile Profile = Profile{} + var err error + config := settings.config if settings.profile == "" { profile, err = findDefaultProfile(config.Profiles) if err != nil { - return fmt.Errorf("no profile specified and no default profile found") + return profile, 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) + return profile, fmt.Errorf("error finding profile: %v", err) } profile = config.Profiles[index] } + return profile, nil +} + +func mainLogic(settings Settings) error { + // find profile + profile, err := findProfile(settings) + if err != nil { + return fmt.Errorf("error finding profile: %v", err) + } // process profile - errors := processProfile(profile, settings.projectName, settings.extras, *usr) + errors := processProfile(profile, settings.projectName, settings.extras, *settings.usr) if len(errors) > 0 { return fmt.Errorf("error processing profile: %v", errors) } diff --git a/name_pattern.go b/name_pattern.go index 9e60573..2f30d6f 100644 --- a/name_pattern.go +++ b/name_pattern.go @@ -12,9 +12,10 @@ import ( func createNewNameForPatterns( now time.Time, + patternData PrePatternData, projectName string, namePatterns []ParameterizedText, - extras map[string]string, existingEntities []string) (string, PrePatternData, error) { + extras map[string]string, existingEntities []string) (string, error) { for index, namePattern := range namePatterns { startingVersion := 1 @@ -26,28 +27,18 @@ func createNewNameForPatterns( }, } template := template.New("template-name").Funcs(funcMap) - patternData := PrePatternData{ - Year: now.Format("2006"), - Month: now.Format("01"), - Day: now.Format("02"), - Hour: now.Format("15"), - Minute: now.Format("04"), - Second: now.Format("05"), - ProjectName: projectName, - Extras: extras, - } for { template, err := template.Parse(string(namePattern)) if err != nil { - return "", patternData, fmt.Errorf("error parsing name pattern %d: %w", index, 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 "", patternData, fmt.Errorf("error executing name pattern %d: %w", index, err) + return "", fmt.Errorf("error executing name pattern %d: %w", index, err) } newName := sb.String() @@ -57,7 +48,7 @@ func createNewNameForPatterns( // if no conflict, use this name if !alreadyExists { - return newName, patternData, nil + return newName, nil } // if index is not being used (template did not invoke getVersion) // then we're done @@ -67,11 +58,11 @@ func createNewNameForPatterns( // if getVersion was used, but this isn't the last pattern, report an error if useIndex && !isLastPattern { - return "", patternData, 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 "", PrePatternData{}, errors.New("couldn't find a new name") + return "", errors.New("couldn't find a new name") } diff --git a/name_pattern_test.go b/name_pattern_test.go index 4cf5db5..9168364 100644 --- a/name_pattern_test.go +++ b/name_pattern_test.go @@ -29,7 +29,8 @@ func TestCreateNewName(t *testing.T) { testHappyPath := func(projectName string, expectedName string, existingEntities []string) func(t *testing.T) { return func(t *testing.T) { - newName, prePatternData, err := createNewNameForPatterns(now, projectName, profile.NamePatterns, extras, existingEntities) + prePatternData := createPrePatternData(now, projectName, extras) + newName, err := createNewNameForPatterns(now, prePatternData, projectName, profile.NamePatterns, extras, existingEntities) if err != nil { t.Error("Error creating new name: ", err) } @@ -62,7 +63,8 @@ func TestCreateNewName(t *testing.T) { } testErrorPath := func(projectName string, existingEntities []string) func(t *testing.T) { return func(t *testing.T) { - _, _, err := createNewNameForPatterns(now, projectName, profile.NamePatterns, extras, existingEntities) + prePatternData := createPrePatternData(now, projectName, extras) + _, err := createNewNameForPatterns(now, prePatternData, projectName, profile.NamePatterns, extras, existingEntities) if err == nil { t.Error("Expected error but got none") } @@ -100,7 +102,8 @@ func TestCreateNewName_Index(t *testing.T) { testHappyPath := func(expectedName string, existingEntities []string) func(t *testing.T) { return func(t *testing.T) { - newName, prePatternData, err := createNewNameForPatterns(now, "test-project", profile.NamePatterns, extras, existingEntities) + prePatternData := createPrePatternData(now, "test-project", extras) + newName, err := createNewNameForPatterns(now, prePatternData, "test-project", profile.NamePatterns, extras, existingEntities) if err != nil { t.Error("Error creating new name: ", err) } diff --git a/profile.go b/profile.go index 55b70a4..edede6c 100644 --- a/profile.go +++ b/profile.go @@ -90,51 +90,54 @@ func processProfile(profile Profile, projectName string, extras map[string]strin cwd = path } - // get all entities in current directory - files, err := monkey.OSReadDir(cwd) - if err != nil { - return []error{err} - } - // determine if we're dealing with files or directories profileType := profile.Type - // filter entities to just files or directories - var entities []string - for _, file := range files { - isDir := file.IsDir() - if isDir == (profileType == FolderType) { - entities = append(entities, file.Name()) - } - } - - // generate a new name based on time, profile, and existing entities - now := time.Now() errors := []error{} - newName, prePatternData, err := createNewNameForPatterns(now, projectName, profile.NamePatterns, extras, entities) - swallowPrePostScriptErrors := profile.RunPostscriptOnFailure && profile.PostScripts != nil && len(profile.PostScripts) > 0 + fullPath := "" + now := time.Now() + prePatternData := createPrePatternData(now, projectName, extras) + if profileType == FileType || profileType == FolderType { + // get all entities in current directory + files, err := monkey.OSReadDir(cwd) + if err != nil { + return []error{err} + } - // check newName is created - if newName == "" { - if swallowPrePostScriptErrors { - fmt.Println("No new name was created, but will attempt to run post scripts") - } else { - errors = append(errors, fmt.Errorf("couldn't find a good new name")) - return errors + // filter entities to just files or directories + var entities []string + for _, file := range files { + isDir := file.IsDir() + if isDir == (profileType == FolderType) { + entities = append(entities, file.Name()) + } } - } - fullPath := "" - if err == nil && newName != "" { - fullPath, err = createEntity(now, profile, cwd, newName, projectName, extras, prePatternData) - if err != nil { + // generate a new name based on time, profile, and existing entities + newName, err := createNewNameForPatterns(now, prePatternData, projectName, profile.NamePatterns, extras, entities) + swallowPrePostScriptErrors := profile.RunPostscriptOnFailure && profile.PostScripts != nil && len(profile.PostScripts) > 0 + + // check newName is created + if newName == "" { if swallowPrePostScriptErrors { - fmt.Printf("Error creating entity, but will attempt to run post scripts: %v", err) + fmt.Println("No new name was created, but will attempt to run post scripts") } else { - errors = append(errors, fmt.Errorf("unable to create entity: %v", err)) + errors = append(errors, fmt.Errorf("couldn't find a good new name")) return errors } } + + if err == nil && newName != "" { + fullPath, err = createEntity(now, profile, cwd, newName, projectName, extras, prePatternData) + if err != nil { + if swallowPrePostScriptErrors { + fmt.Printf("Error creating entity, but will attempt to run post scripts: %v", err) + } else { + errors = append(errors, fmt.Errorf("unable to create entity: %v", err)) + return errors + } + } + } } // execute PostScript, if present @@ -170,6 +173,20 @@ func processProfile(profile Profile, projectName string, extras map[string]strin return nil } +func createPrePatternData(now time.Time, projectName string, extras map[string]string) PrePatternData { + prePatternData := PrePatternData{ + Year: now.Format("2006"), + Month: now.Format("01"), + Day: now.Format("02"), + Hour: now.Format("15"), + Minute: now.Format("04"), + Second: now.Format("05"), + ProjectName: projectName, + Extras: extras, + } + return prePatternData +} + func createEntity( now time.Time, profile Profile, @@ -196,7 +213,8 @@ func createEntity( if len(profile.Files) > 0 { fmt.Printf("Creating %v files in folder: %v\n", len(profile.Files), fullPath) for index, file := range profile.Files { - fileName, filePrePatternData, err := createNewNameForPatterns(now, projectName, []ParameterizedText{file.Name}, extras, []string{}) + filePrePatternData := createPrePatternData(now, projectName, extras) + fileName, err := createNewNameForPatterns(now, filePrePatternData, projectName, []ParameterizedText{file.Name}, extras, []string{}) if err != nil { return "", fmt.Errorf("error creating file name for file index %v : %v", index, err) } @@ -227,15 +245,18 @@ func validateProfile(profile *Profile) error { 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.Template != "" { return fmt.Errorf("file template is not supported for profile %s, which has Type=%s", profile.Name, profile.Type) } + if profile.Type == FileType && profile.Path == "" { + return fmt.Errorf("path is not specified for profile %s, which has Type=%s", profile.Name, profile.Type) + } + if profile.Type == FolderType && profile.Path == "" { + return fmt.Errorf("path is required 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) } diff --git a/profile_test.go b/profile_test.go index c70ed53..fa6d41b 100644 --- a/profile_test.go +++ b/profile_test.go @@ -18,10 +18,10 @@ func TestValidateProfile(t *testing.T) { t.Errorf("Expected to validate profile '%v', but failed: %v", profile.Name, err) } } - testFailure := func(profile Profile) { + testFailure := func(profile Profile, testName string) { err := validateProfile(&profile) if err == nil { - t.Errorf("Expected to fail to validate profile '%v', but succeeded", profile.Name) + t.Errorf("%v - Expected to fail to validate profile '%v', but succeeded", testName, profile.Name) } } @@ -29,6 +29,7 @@ func TestValidateProfile(t *testing.T) { Type: FileType, Name: "daily", NamePatterns: []ParameterizedText{"{{.Date}}"}, + Path: "~/test/dir", }) testSuccess(Profile{ Type: FileType, @@ -37,36 +38,27 @@ func TestValidateProfile(t *testing.T) { File: File{ Template: "foo", }, + Path: "~/test/dir", }) - // empty name testFailure(Profile{ Name: "", - }) - // illegal name + }, "empty name") testFailure(Profile{ Name: "list", - }) + }, "illegal name") testFailure(Profile{ Name: "get", - }) - // file type with no path + }, "illegal name") testFailure(Profile{ Type: FileType, Name: "daily", - }) + }, "missing path") testFailure(Profile{ Type: FileType, Name: "daily", - Path: "\\", - }) - // no name patterns - testFailure(Profile{ - Type: FileType, - Name: "daily", - NamePatterns: []ParameterizedText{}, - }) - // file template set with wrong type + }, "invalid path") + testFailure(Profile{ Type: FolderType, Name: "daily", @@ -74,21 +66,19 @@ func TestValidateProfile(t *testing.T) { File: File{ Template: "bar", }, - }) - // script type with multiple name patterns + }, "file in folder") testFailure(Profile{ Type: ScriptType, Name: "daily", NamePatterns: []ParameterizedText{"test", "test2"}, - }) - // postscripts with no shell + }, "multiple name patterns") testFailure(Profile{ Type: ScriptType, Name: "daily", NamePatterns: []ParameterizedText{"test"}, PostScripts: []ParameterizedText{"test"}, ScriptShell: "", - }) + }, "missing script shell") for _, profile := range defaultConfig().Profiles { testSuccess(profile) @@ -281,7 +271,7 @@ func TestProcessProfile(t *testing.T) { assert.Equal(t, 1, len(monkeyPatchCalls.ExecCommandCalls)) assert.Equal(t, 1, len(monkeyPatchCalls.OSChdirCalls)) assert.Equal(t, 1, len(monkeyPatchCalls.OSGetwdCalls)) - assert.Equal(t, 1, len(monkeyPatchCalls.OSReadDirCalls)) + assert.Equal(t, 0, len(monkeyPatchCalls.OSReadDirCalls)) } testFailure := func() { profile := Profile{ @@ -294,6 +284,7 @@ func TestProcessProfile(t *testing.T) { "echo 'hi'", }, ScriptShell: shell, + Type: FileType, } usr, err := user.Current()