template / commit
Merge branch 'direct-output' into 'main'
.vscode/launch.json | 12 ++++- .vscode/settings.json | 3 ++ README.md | 43 ++++++++++----- config_test.go | 64 ++++++++++------------ default_config.go | 143 ++++++++++++++++++++++++++------------------------ file.go | 33 ++++++++++-- file_test.go | 41 +++++++++++---- main.go | 26 ++++++--- monkey-patch.go | 15 ++++++ profile.go | 95 +++++++++++++++++++++------------ profile_test.go | 70 +++++++++++------------- 11 files changed, 330 insertions(+), 215 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 13d3f1a..882bd7c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,7 +4,6 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { "name": "Launch tests", "type": "go", @@ -72,6 +71,17 @@ "daily" ] }, + { + "name": "Run: template log", + "type": "go", + "request": "launch", + "mode": "auto", + "program": ".", + "args": [ + "log" + ], + "console": "integratedTerminal" + }, { "name": "Run: template work test-project w/ in-memory defaults", "type": "go", diff --git a/.vscode/settings.json b/.vscode/settings.json index 3e2495f..851e655 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,15 @@ { "cSpell.words": [ "bashdb", + "filetemplate", "logrus", "pwsh", "runpostscriptonfailure", "scriptshell", "sirupsen", + "snakecase", "stretchr", + "targetdirectory", "urfave" ] } \ No newline at end of file diff --git a/README.md b/README.md index 9cb0997..7f91302 100644 --- a/README.md +++ b/README.md @@ -25,29 +25,29 @@ This is one of the profiles that comes with the tool. isdefault: false isdisabled: false type: file - path: ~/DailyNotes - namepatterns: - - '{{$version := getVersion}}{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName | snakecase }}{{end}}{{if (gt $version 1)}}v{{$version}}{{end}}.md' runpostscriptonfailure: true postscripts: - code . {{ if .FullPath }}"{{.FullPath}}"{{else}}(ls -t | head -n1){{ end }} - file: - template: |- - # {{.FileNameWithoutExtension}} + namepatterns: + - '{{$version := getVersion}}{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName | snakecase }}{{end}}{{if (gt $version 1)}}v{{$version}}{{end}}.md' + targetdirectory: ~/DailyNotes + filetemplate: |- + # {{.FileNameWithoutExtension}} - Today's notes for {{.Year}}-{{.Month}}-{{.Day}}. + Today's notes for {{.Year}}-{{.Month}}-{{.Day}}. - ## Todo + ## Todo - 1. Check email - 2. Check chat - 3. Check calendar - 4. Check yesterday's notes - 5. Have fun! - 6. ADD MORE ITEMS HERE + 1. Check email + 2. Check chat + 3. Check calendar + 4. Check yesterday's notes + 5. Have fun! + 6. ADD MORE ITEMS HERE ``` Using the above configuration: + ```sh > $ template daily "Cool Thought" Profile: daily @@ -64,6 +64,21 @@ This will cause VSCode to open the `~\DailyNotes` folder as a workspace, and the The above example also uses the [`snakecase`](https://github.com/Masterminds/sprig/blob/master/docs/strings.md#user-content-snakecase) template function. This is one of the [sprig template functions](https://github.com/Masterminds/sprig/blob/master/docs/index.md) that are exposed by template. +File-based profiles (`type: file`) can accept piped input, and this input will be piped to the created file. +In this instance, the profile's file template field is ignored. +Here's an example of piping the results of `ps` to a file with an auto-generated name: + +```sh +> $ echo "$(ps)" | go run . log "ps-info" +Profile: log +Project name: ps-info +Loaded config from /Users/pavel/.template.yaml +Loaded user config from /Users/pavel/.template.user.yaml, combining with global config +Creating file: 2022-12-10-00-05-21-ps-info.log +Executing post script: sh -c code . 2022-12-10-00-05-21-ps-info.log +Post script had no output +``` + Note that user profiles are combined with the default ones. This is so you can run `template reset` to reset `~/.template.yaml`, but keep your local profiles/changes as-is. diff --git a/config_test.go b/config_test.go index 683694d..c1a4093 100644 --- a/config_test.go +++ b/config_test.go @@ -115,53 +115,43 @@ func TestValidateConfig(t *testing.T) { Name: "daily", IsDefault: true, NamePatterns: []ParameterizedText{"daily"}, - File: File{ - Template: "foo", - }, + FileTemplate: "foo", }, }}, "error validating profile") 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, + TargetDirectory: "foo", + FileTemplate: "foo", }, { - Name: "daily2", - IsDefault: true, - NamePatterns: []ParameterizedText{"daily"}, - Type: FileType, - Path: "foo", - File: File{ - Template: "foo", - }, + Name: "daily2", + IsDefault: true, + NamePatterns: []ParameterizedText{"daily"}, + Type: FileType, + TargetDirectory: "foo", + FileTemplate: "foo", }, }}, "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, + TargetDirectory: "foo", + FileTemplate: "foo", }, { - Name: "daily", - IsDefault: true, - NamePatterns: []ParameterizedText{"daily"}, - Type: FileType, - Path: "foo", - File: File{ - Template: "foo", - }, + Name: "daily", + IsDefault: true, + NamePatterns: []ParameterizedText{"daily"}, + Type: FileType, + TargetDirectory: "foo", + FileTemplate: "foo", }, }}, "duplicate profile name 'daily'") } @@ -178,7 +168,7 @@ profiles: // load config _, err = loadConfig("./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") + assert.Contains(t, err.Error(), "error validating profile : profile name is required") } func TestSaveConfig(t *testing.T) { @@ -202,7 +192,7 @@ func TestMergeConfigs(t *testing.T) { assert.Contains(t, err.Error(), errorText) } testFailure(Config{}, Config{}, "no profiles found") - testFailure(Config{Profiles: []Profile{{}}}, Config{}, "error validating config a: error validating profile : Profile name is required") + testFailure(Config{Profiles: []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] diff --git a/default_config.go b/default_config.go index c93437f..fa4e813 100644 --- a/default_config.go +++ b/default_config.go @@ -4,10 +4,10 @@ func defaultConfig() Config { config := Config{ Profiles: []Profile{ { - Name: "daily", - Description: "Creates a MD file in ~/DailyNotes folder in the format YYYY-MM-DD[-vX].md", - Path: "~/DailyNotes", - Type: FileType, + Name: "daily", + Description: "Creates a MD file in ~/DailyNotes folder in the format YYYY-MM-DD[-vX].md", + TargetDirectory: "~/DailyNotes", + Type: FileType, NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + "{{.Year}}-{{.Month}}-{{.Day}}" + @@ -15,8 +15,7 @@ func defaultConfig() Config { "{{if (gt $version 1)}}v{{$version}}{{end}}" + ".md", }, - File: File{ - Template: `# {{.FileNameWithoutExtension}} + FileTemplate: `# {{.FileNameWithoutExtension}} Today's notes for {{.Year}}-{{.Month}}-{{.Day}}. @@ -28,18 +27,17 @@ Today's notes for {{.Year}}-{{.Month}}-{{.Day}}. 4. Check yesterday's notes 5. Have fun! 6. ADD MORE ITEMS HERE`, - }, PostScripts: []ParameterizedText{ "code . {{ if .FullPath }}\"{{.FullPath}}\"{{else}}(ls -t | head -n1){{ end }}", }, RunPostscriptOnFailure: true, }, { - Name: "note", - Description: "Creates a MD file in ~/Notes in the format YYYY-MM-DD[-ProjectName][-vVersion].md", - OS: "All", - Path: "~/Notes", - Type: FileType, + Name: "note", + Description: "Creates a MD file in ~/Notes in the format YYYY-MM-DD[-ProjectName][-vVersion].md", + OS: "All", + TargetDirectory: "~/Notes", + Type: FileType, NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + "{{.Year}}-{{.Month}}-{{.Day}}" + @@ -52,12 +50,12 @@ Today's notes for {{.Year}}-{{.Month}}-{{.Day}}. }, }, { - Name: "note-here", - Description: "Creates a MD file in the current folder in the format YYYY-MM-DD[-HH[-MM[-SS]]][-ProjectName][-vVersion].md", - OS: "All", - Type: FileType, - IsDefault: true, - Path: ".", // current folder + Name: "note-here", + Description: "Creates a MD file in the current folder in the format YYYY-MM-DD[-HH[-MM[-SS]]][-ProjectName][-vVersion].md", + OS: "All", + Type: FileType, + IsDefault: true, + TargetDirectory: ".", // current folder NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + "{{.Year}}-{{.Month}}-{{.Day}}" + @@ -70,7 +68,12 @@ Today's notes for {{.Year}}-{{.Month}}-{{.Day}}. "{{if (gt $version 1)}}n{{$version}}{{end}}" + ".md", "{{$version := getVersion}}" + - "{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Second}}" + + "{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}" + + "{{if .ProjectName}}-{{.ProjectName}}{{end}}" + + "{{if (gt $version 1)}}n{{$version}}{{end}}" + + ".md", + "{{$version := getVersion}}" + + "{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}" + "{{if .ProjectName}}-{{.ProjectName}}{{end}}" + "{{if (gt $version 1)}}n{{$version}}{{end}}" + ".md", @@ -80,12 +83,11 @@ Today's notes for {{.Year}}-{{.Month}}-{{.Day}}. }, }, { - Name: "blog", - Description: "Creates a MD file in the ~/Blog folder in the format YYYY-MM-DD[-ProjectName][-vVersion].md", - OS: "All", - Path: "~/Blog", - File: File{ - Template: `# {{.ProjectName}} + Name: "blog", + Description: "Creates a MD file in the ~/Blog folder in the format YYYY-MM-DD[-ProjectName][-vVersion].md", + OS: "All", + TargetDirectory: "~/Blog", + FileTemplate: `# {{.ProjectName}} Created by template, on {{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}. @@ -97,7 +99,6 @@ cspell:words nanite ## Writing `, - }, Type: FileType, NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + @@ -111,11 +112,11 @@ cspell:words nanite }, }, { - 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, + Name: "work-win", + Description: "Creates a folder in the c:/work folder in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]", + OS: "Windows", + TargetDirectory: "C:/work", + Type: FolderType, NamePatterns: []ParameterizedText{ "{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName}}{{end}}", "{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}{{if .ProjectName}}-{{.ProjectName}}{{end}}", @@ -127,12 +128,11 @@ cspell:words nanite }, }, { - Name: "therapy", - Description: "Creates a MD file in the ~/Therapy folder in the format YYYY-MM-DD[-vVersion].md", - OS: "All", - Path: "~/Therapy", - File: File{ - Template: `# {{if .ProjectName}}{{.ProjectName}}{{else}}Therapy notes{{end}} + Name: "therapy", + Description: "Creates a MD file in the ~/Therapy folder in the format YYYY-MM-DD[-vVersion].md", + OS: "All", + TargetDirectory: "~/Therapy", + FileTemplate: `# {{if .ProjectName}}{{.ProjectName}}{{else}}Therapy notes{{end}} {{if .ProjectName}}Therapy notes on {{.ProjectName}}{{else}}Therapy notes{{end}} @@ -144,7 +144,6 @@ Start writing here... Sincerely, me `, - }, Type: FileType, NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + @@ -157,10 +156,10 @@ Sincerely, me }, }, { - Name: "work", - Description: "Creates a folder in the ~/Work folder in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]", - Path: "~/Work", - Type: FolderType, + Name: "work", + Description: "Creates a folder in the ~/Work folder in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]", + TargetDirectory: "~/Work", + Type: FolderType, NamePatterns: []ParameterizedText{ "{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName}}{{end}}", "{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}{{if .ProjectName}}-{{.ProjectName}}{{end}}", @@ -173,25 +172,20 @@ Sincerely, me }, Files: []FileInFolder{ { - File: File{ - Template: `# {{.ProjectName}} + FileTemplate: `# {{.ProjectName}} Work notes on {{.ProjectName}} `, - }, Name: "README.md", }, { - File: File{ - Template: `clear + FileTemplate: `clear echo "Let's test {{.ProjectName}}!"`, - }, Name: "repro.sh", }, { - File: File{ - Template: `{ + FileTemplate: `{ "version": "0.2.0", "configurations": [ { @@ -205,17 +199,16 @@ echo "Let's test {{.ProjectName}}!"`, ] } `, - }, Name: ".vscode/launch.json", }, }, }, { - Name: "folder", - Description: "Creates a folder in the current folder in the format YYYY-MM-DD[-ProjectName][-vVersion]", - Type: FolderType, - Path: ".", // current folder - PostScripts: []ParameterizedText{}, + Name: "folder", + Description: "Creates a folder in the current folder in the format YYYY-MM-DD[-ProjectName][-vVersion]", + Type: FolderType, + TargetDirectory: ".", // current folder + PostScripts: []ParameterizedText{}, NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + "{{.Year}}-{{.Month}}-{{.Day}}" + @@ -224,10 +217,10 @@ echo "Let's test {{.ProjectName}}!"`, }, }, { - Name: "create-react-app", - 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", - Path: ".", // current folder + Name: "create-react-app", + 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", + TargetDirectory: ".", // current folder NamePatterns: []ParameterizedText{ "{{$version := getVersion}}" + "create-react-app" + @@ -240,10 +233,10 @@ echo "Let's test {{.ProjectName}}!"`, }, }, { - Name: "git", - Description: "Creates a folder in the ~/code folder in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]", - Path: "~/code", - Type: FolderType, + Name: "git", + Description: "Creates a folder in the ~/code folder in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]", + TargetDirectory: "~/code", + Type: FolderType, NamePatterns: []ParameterizedText{ "{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName}}{{end}}", "{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}{{if .ProjectName}}-{{.ProjectName}}{{end}}", @@ -256,11 +249,11 @@ echo "Let's test {{.ProjectName}}!"`, }, }, { - Name: "git-clone", - Description: "DISABLED - Clones a repository into a new folder under ~/code in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]", - IsDisabled: true, - Path: "~/code", - Type: FolderType, + Name: "git-clone", + Description: "DISABLED - Clones a repository into a new folder under ~/code in the format YYYY-MM-DD[-hh][-mm][-ss][-ProjectName]", + IsDisabled: true, + TargetDirectory: "~/code", + Type: FolderType, NamePatterns: []ParameterizedText{ "{{.Year}}-{{.Month}}-{{.Day}}{{if .ProjectName}}-{{.ProjectName}}{{end}}", "{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}{{if .ProjectName}}-{{.ProjectName}}{{end}}", @@ -272,6 +265,18 @@ echo "Let's test {{.ProjectName}}!"`, "code .", }, }, + { + Name: "log", + Description: "Creates a log file in the current folder", + Type: FileType, + NamePatterns: []ParameterizedText{ + "{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}{{if .ProjectName}}-{{.ProjectName}}{{end}}.log", + }, + TargetDirectory: ".", + PostScripts: []ParameterizedText{ + "code . {{.FullPath}}", + }, + }, }, } diff --git a/file.go b/file.go index bc1c208..2b451fe 100644 --- a/file.go +++ b/file.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "fmt" "os" "strings" @@ -11,15 +12,37 @@ import ( func createFile( prePatternData PrePatternData, - file File, - fullPath string) error { - useTemplate := file.Template != "" - if useTemplate { + fileTemplate ParameterizedText, + fullPath string, + pipeInputScanner *bufio.Scanner) error { + + useTemplate := fileTemplate != "" + useInputPipe := pipeInputScanner != nil + + if useInputPipe { + log.WithFields(log.Fields{"fullPath": fullPath}).Debug("Creating file from input pipe content", fullPath) + + file, err := monkey.OSCreate(fullPath) + if err != nil { + return fmt.Errorf("error creating file: %v", err) + } + defer file.Close() + + writer := bufio.NewWriter(file) + for pipeInputScanner.Scan() { + line := pipeInputScanner.Text() + _, err := writer.WriteString(line + "\n") + if err != nil { + return fmt.Errorf("error writing file: %v", err) + } + } + writer.Flush() + } else if useTemplate { postPatternData := createPostPatternData(fullPath, prePatternData) log.WithFields(log.Fields{"fullPath": fullPath}).Debug("Creating file from template string\n", fullPath) // parse template - template, err := template.New("text-template").Parse(string(file.Template)) + template, err := template.New("text-template").Parse(string(fileTemplate)) if err != nil { return fmt.Errorf("error parsing template text: %v", err) } diff --git a/file_test.go b/file_test.go index afda356..7a75ed5 100644 --- a/file_test.go +++ b/file_test.go @@ -1,7 +1,9 @@ package main import ( + "bufio" "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -12,9 +14,18 @@ func TestCreateFile(t *testing.T) { path := "~/test/out/test.txt" prePatternData := testPrePatternData() expectedText := "test-project-2022-04-20-16-20-69-value1" + var createdFileName = "" // monkey patch applyTestMonkeyPatch(t, monkeyPatch{ + OSCreate: func(filename string) (*os.File, error) { + assert.Equal(t, path, filename) + file, err := os.CreateTemp("./test", "test") + assert.Nil(t, err, "error creating temp file") + assert.True(t, createdFileName == "", "createFileName should be empty") + createdFileName = file.Name() + return file, nil + }, OSWriteFile: func(filename string, data []byte, perm os.FileMode) error { assert.Equal(t, path, filename) assert.Equal(t, perm, os.ModePerm) @@ -24,21 +35,31 @@ func TestCreateFile(t *testing.T) { }) // test error path - err := createFile(prePatternData, File{ - Template: "{{.ProjectName", - }, path) + err := createFile(prePatternData, "{{.ProjectName", path, nil) assert.NotNil(t, err) - // test happy path - err = createFile(prePatternData, File{ - Template: "{{.ProjectName}}-{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}-{{.Extras.key1}}", - }, path) + // test happy path with file template + err = createFile(prePatternData, "{{.ProjectName}}-{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}-{{.Extras.key1}}", path, nil) + assert.Nil(t, err) + + // test happy path with pipe input + inputText := `line1 +line2 +line3 +` + expectedText = inputText + reader := strings.NewReader(inputText) + pipeInputScanner := bufio.NewScanner(reader) + err = createFile(prePatternData, "{{.ProjectName}}-{{.Year}}-{{.Month}}-{{.Day}}-{{.Hour}}-{{.Minute}}-{{.Second}}-{{.Extras.key1}}", path, pipeInputScanner) assert.Nil(t, err) + assert.NotNil(t, createdFileName) + bytes, err := os.ReadFile(createdFileName) + assert.Nil(t, err, "error reading file "+createdFileName) + outputText := string(bytes) + assert.Equal(t, inputText, outputText) // test empty template expectedText = "" - err = createFile(prePatternData, File{ - Template: "", - }, path) + err = createFile(prePatternData, "", path, nil) assert.Nil(t, err) } diff --git a/main.go b/main.go index 852cf0a..2a8abd0 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "fmt" "os" "os/user" @@ -146,11 +147,11 @@ func main() { Name: "reset", Usage: "reset the global template config to default profiles", Action: func(c *cli.Context) error { - settings, err := createSettings(argumentValues, c) + usr, err := user.Current() if err != nil { - return fmt.Errorf("error creating settings: %v", err) + log.Fatal(err) } - fullConfigPath := settings.fullConfigPath + fullConfigPath := getFullPath(argumentValues.configFilePath, *usr) fmt.Printf("Resetting config at '%v' to default\n", fullConfigPath) err = saveConfig(fullConfigPath, defaultConfig()) @@ -187,6 +188,7 @@ func createSettings( if err != nil { return Settings{}, fmt.Errorf("error getting env var values: %w", err) } + settings := Settings{ ArgumentValues: argumentValues, EnvVarValues: envVarValues, @@ -210,8 +212,7 @@ func createSettings( case "panic": log.SetLevel(log.PanicLevel) default: - fmt.Printf("invalid log level specified %v, using info", settings.logLevel) - log.SetLevel(log.InfoLevel) + return Settings{}, fmt.Errorf("invalid log level specified %v, using info", settings.logLevel) } log.SetOutput(os.Stdout) // log.SetFormatter(&log.JSONFormatter{}) @@ -260,9 +261,9 @@ func createSettings( config, err = loadConfig(fullConfigPath) if err != nil { return settings, fmt.Errorf("error loading config from %v: %w", fullConfigPath, err) - } else { - fmt.Printf("Loaded config from %v\n", fullConfigPath) } + fmt.Printf("Loaded config from %v\n", fullConfigPath) + // load user config, if present fullUserConfigPath := getFullPath(settings.userConfigPath, *usr) _, err = os.Stat(fullUserConfigPath) @@ -325,8 +326,17 @@ func mainLogic(settings Settings) error { return fmt.Errorf("error finding profile: %v", err) } + var pipeInputScanner *bufio.Scanner = nil + stat, err := os.Stdin.Stat() + if err != nil { + return fmt.Errorf("error getting stdin stat: %w", err) + } + if stat.Size() > 0 { + pipeInputScanner = bufio.NewScanner(os.Stdin) + } + // process profile - errors := processProfile(profile, settings.projectName, settings.extras, *settings.usr) + errors := processProfile(profile, settings.projectName, settings.extras, *settings.usr, pipeInputScanner) if len(errors) > 0 { return fmt.Errorf("error processing profile: %v", errors) } diff --git a/monkey-patch.go b/monkey-patch.go index 26a6ec9..d0b0796 100644 --- a/monkey-patch.go +++ b/monkey-patch.go @@ -10,6 +10,7 @@ import ( type monkeyPatch struct { ExecCommand func(name string, arg ...string) *exec.Cmd + OSCreate func(name string) (*os.File, error) OSWriteFile func(filename string, data []byte, perm os.FileMode) error OSGetwd func() (string, error) OSChdir func(dir string) error @@ -22,6 +23,9 @@ type monkeyPatchCalls struct { name string arg []string } + OSCreateCalls []struct { + name string + } OSWriteFileCalls []struct { filename string data []byte @@ -45,6 +49,9 @@ func applyTestMonkeyPatch(t *testing.T, monkeyImpl monkeyPatch) *monkeyPatchCall name string arg []string }{}, + OSCreateCalls: []struct { + name string + }{}, OSWriteFileCalls: []struct { filename string data []byte @@ -67,6 +74,13 @@ func applyTestMonkeyPatch(t *testing.T, monkeyImpl monkeyPatch) *monkeyPatchCall }{name, arg}) return monkeyImpl.ExecCommand(name, arg...) }, + OSCreate: func(name string) (*os.File, error) { + assert.NotNil(t, monkeyImpl.OSCreate, "OSCreate is not implemented") + results.OSCreateCalls = append(results.OSCreateCalls, struct { + name string + }{name}) + return monkeyImpl.OSCreate(name) + }, OSWriteFile: func(filename string, data []byte, perm os.FileMode) error { assert.NotNil(t, monkeyImpl.OSWriteFile, "OSWriteFile is not implemented") results.OSWriteFileCalls = append(results.OSWriteFileCalls, struct { @@ -106,6 +120,7 @@ func applyTestMonkeyPatch(t *testing.T, monkeyImpl monkeyPatch) *monkeyPatchCall func defaultMonkeyPatch() monkeyPatch { return monkeyPatch{ ExecCommand: exec.Command, + OSCreate: os.Create, OSWriteFile: os.WriteFile, OSGetwd: os.Getwd, OSChdir: os.Chdir, diff --git a/profile.go b/profile.go index 705c66e..5f727a0 100644 --- a/profile.go +++ b/profile.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "fmt" "os" @@ -21,6 +22,12 @@ const ( ScriptType ProfileType = "script" ) +var allProfileTypes = []ProfileType{ + FileType, + FolderType, + ScriptType, +} + type OSType string const ( @@ -31,13 +38,9 @@ const ( type ParameterizedText string -type File struct { - Template ParameterizedText `yaml:"template,omitempty"` -} - type FileInFolder struct { - File `yaml:"file,omitempty"` - Name ParameterizedText + Name ParameterizedText + FileTemplate ParameterizedText `yaml:"file,omitempty"` } type Profile struct { @@ -46,13 +49,15 @@ type Profile struct { IsDefault bool IsDisabled bool Type ProfileType - Path string `yaml:"path,omitempty"` - OS OSType `yaml:"os,omitempty"` - NamePatterns []ParameterizedText - Files []FileInFolder `yaml:"files,omitempty"` + OS OSType `yaml:"os,omitempty"` RunPostscriptOnFailure bool `yaml:"runpostscriptonfailure,omitempty"` PostScripts []ParameterizedText `yaml:"postscripts,omitempty"` - File `yaml:"file,omitempty"` + + NamePatterns []ParameterizedText + + TargetDirectory string `yaml:"targetdirectory,omitempty"` + FileTemplate ParameterizedText `yaml:"filetemplate,omitempty"` + Files []FileInFolder `yaml:"files,omitempty"` } var forbiddenProfileNames = []string{ @@ -60,7 +65,7 @@ var forbiddenProfileNames = []string{ "get", } -func processProfile(profile Profile, projectName string, extras map[string]string, usr user.User) []error { +func processProfile(profile Profile, projectName string, extras map[string]string, usr user.User, pipeInputScanner *bufio.Scanner) []error { if profile.IsDisabled { return []error{fmt.Errorf("Profile is disabled, doing nothing")} } @@ -74,17 +79,20 @@ func processProfile(profile Profile, projectName string, extras map[string]strin (profileOs == "Mac" && !isWindows) if !shouldRun { 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), + 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), } } + // determine if we're dealing with files or directories + profileType := profile.Type + cwd, err := monkey.OSGetwd() if err != nil { return []error{fmt.Errorf("unable to get current working directory: %v", err)} } - if profile.Path != "" { + if profile.TargetDirectory != "" { // change cwd to the profile's path, if one is specified - path := getFullPath(profile.Path, usr) + path := getFullPath(profile.TargetDirectory, usr) err = monkey.OSChdir(path) if err != nil { return []error{fmt.Errorf("unable to change directory (1) to %v: %v", path, err)} @@ -92,14 +100,17 @@ func processProfile(profile Profile, projectName string, extras map[string]strin cwd = path } - // determine if we're dealing with files or directories - profileType := profile.Type - errors := []error{} fullPath := "" now := time.Now() prePatternData := createPrePatternData(now, projectName, extras) + if profileType == FileType || profileType == FolderType { + if pipeInputScanner != nil && profileType != FileType { + errors = append(errors, fmt.Errorf("cannot use pipe input with non-file type profile")) + return errors + } + // get all entities in current directory files, err := monkey.OSReadDir(cwd) if err != nil { @@ -131,7 +142,7 @@ func processProfile(profile Profile, projectName string, extras map[string]strin } if err == nil && newName != "" { - fullPath, err = createEntity(now, profile, cwd, newName, projectName, extras, prePatternData) + fullPath, err = createEntity(now, profile, cwd, newName, projectName, extras, prePatternData, pipeInputScanner) if err != nil { if swallowPrePostScriptErrors { fmt.Printf("Error creating entity, but will attempt to run post scripts: %v", err) @@ -210,7 +221,8 @@ func createEntity( newName string, projectName string, extras map[string]string, - prePatternData PrePatternData) (string, error) { + prePatternData PrePatternData, + pipeInputScanner *bufio.Scanner) (string, error) { profileType := profile.Type fullPath := filepath.Join(cwd, newName) @@ -248,7 +260,7 @@ func createEntity( } } fmt.Printf("Creating sub file: %v\n", subFilePath) - err = createFile(filePrePatternData, file.File, subFilePath) + err = createFile(filePrePatternData, file.FileTemplate, subFilePath, nil) if err != nil { return "", fmt.Errorf("error creating file %v: %v", subFilePath, err) } @@ -256,7 +268,7 @@ func createEntity( } } else if profileType == FileType { fmt.Printf("Creating file: %v\n", fullPath) - err := createFile(prePatternData, profile.File, fullPath) + err := createFile(prePatternData, profile.FileTemplate, fullPath, pipeInputScanner) if err != nil { return "", err } @@ -267,12 +279,15 @@ func createEntity( } func validateProfile(profile *Profile) error { + // check name if profile.Name == "" { - return fmt.Errorf("Profile name is required") + return fmt.Errorf("profile name is required") } if slices.Contains(forbiddenProfileNames, profile.Name) { return fmt.Errorf("forbidden profile name '%s'", profile.Name) } + + // check name patterns if profile.NamePatterns == nil || len(profile.NamePatterns) == 0 { return fmt.Errorf("namePatterns is required") } @@ -281,23 +296,37 @@ func validateProfile(profile *Profile) error { return fmt.Errorf("name pattern is required for pattern index %v of profile %v", index, profile.Name) } } - if profile.Type == "" || (profile.Type != FileType && profile.Type != FolderType && profile.Type != ScriptType) { + + // check type + if profile.Type == "" || !slices.Contains(allProfileTypes, profile.Type) { 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) + + // check FileType profile + if profile.Type == FileType { + if profile.TargetDirectory == "" { + return fmt.Errorf("targetDirectory 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) + + // check FolderType profile + if profile.Type == FolderType { + if profile.TargetDirectory == "" { + return fmt.Errorf("targetDirectory is not specified 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) + + // check ScriptType profile + if profile.Type == ScriptType { + if 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) + } } + + // set files to empty array if nil if profile.Files == nil { profile.Files = []FileInFolder{} } + return nil } diff --git a/profile_test.go b/profile_test.go index e661e64..c34f84d 100644 --- a/profile_test.go +++ b/profile_test.go @@ -26,24 +26,22 @@ func TestValidateProfile(t *testing.T) { } testSuccess(Profile{ - Type: FileType, - Name: "daily", - NamePatterns: []ParameterizedText{"{{.Date}}"}, - Path: "~/test/dir", + Type: FileType, + Name: "daily", + NamePatterns: []ParameterizedText{"{{.Date}}"}, + TargetDirectory: "~/test/dir", }) testSuccess(Profile{ - Type: FileType, - Name: "daily", - NamePatterns: []ParameterizedText{"{{.Date}}"}, - File: File{ - Template: "foo", - }, - Path: "~/test/dir", + Type: FileType, + Name: "daily", + NamePatterns: []ParameterizedText{"{{.Date}}"}, + FileTemplate: "foo", + TargetDirectory: "~/test/dir", }) testFailure(Profile{ Name: "", - }, "Profile name is required") + }, "profile name is required") testFailure(Profile{ Name: "list", }, "forbidden profile name 'list'") @@ -58,21 +56,19 @@ func TestValidateProfile(t *testing.T) { Type: FileType, Name: "daily", NamePatterns: []ParameterizedText{"blah"}, - }, "path is not specified for profile daily, which has Type=file") + }, "targetDirectory 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") + }, "targetDirectory is not specified for profile daily, which has Type=folder") testFailure(Profile{ Type: FolderType, Name: "daily", NamePatterns: []ParameterizedText{"test"}, - File: File{ - Template: "bar", - }, - }, "file template is not supported for profile daily, which has Type=folder") + FileTemplate: "bar", + }, "targetDirectory is not specified for profile daily, which has Type=folder") testFailure(Profile{ Type: ScriptType, Name: "daily", @@ -82,7 +78,7 @@ func TestValidateProfile(t *testing.T) { Type: FileType, Name: "daily", NamePatterns: []ParameterizedText{"test", "test2"}, - }, "path is not specified for profile daily, which has Type=file") + }, "targetDirectory is not specified for profile daily, which has Type=file") testFailure(Profile{ Type: FileType, Name: "daily", @@ -163,23 +159,19 @@ func TestCreateEntity(t *testing.T) { Type: FolderType, Files: []FileInFolder{ { - Name: "readme.md", - File: File{ - Template: "blah", - }, + Name: "readme.md", + FileTemplate: "blah", }, { - Name: "test.txt", - File: File{ - Template: "foo", - }, + Name: "test.txt", + FileTemplate: "foo", }, }, } newName := "new-name" expectedFullPath := filepath.Clean("~/test/dir/" + newName) - fullPath, err := createEntity(now, profile, cwd, newName, projectName, extras, patternData) + fullPath, err := createEntity(now, profile, cwd, newName, projectName, extras, patternData, nil) assert.Nil(t, err) assert.Equal(t, expectedFullPath, fullPath) assert.Equal(t, 1, len(monkeyPatchCalls.OSChdirCalls)) @@ -199,14 +191,12 @@ func TestCreateEntity(t *testing.T) { }) profile := Profile{ - Type: FileType, - File: File{ - Template: "blah", - }, + Type: FileType, + FileTemplate: "blah", } newName := "new-name.md" expectedFullPath := filepath.Clean("~/test/dir/" + newName) - fullPath, err := createEntity(now, profile, cwd, newName, projectName, extras, patternData) + fullPath, err := createEntity(now, profile, cwd, newName, projectName, extras, patternData, nil) assert.Nil(t, err) assert.Equal(t, expectedFullPath, fullPath) assert.Equal(t, 1, len(monkeyPatchCalls.OSWriteFileCalls)) @@ -223,7 +213,7 @@ func TestCreateEntity(t *testing.T) { } newName := "new-name.md" expectedFullPath := filepath.Clean("~/test/dir/" + newName) - fullPath, err := createEntity(now, profile, cwd, newName, projectName, extras, patternData) + fullPath, err := createEntity(now, profile, cwd, newName, projectName, extras, patternData, nil) assert.Nil(t, err) assert.Equal(t, expectedFullPath, fullPath) } @@ -258,6 +248,10 @@ func TestProcessProfile(t *testing.T) { shell, shellArgs := shellCommandAndArgs("echo") return exec.Command(shell, shellArgs...) }, + OSCreate: func(name string) (file *os.File, err error) { + dir := os.TempDir() + return os.CreateTemp(dir, "test") + }, OSChdir: func(dir string) error { return nil }, OSGetwd: func() (dir string, err error) { return cwd, nil }, OSReadDir: func(dirname string) ([]os.DirEntry, error) { @@ -285,7 +279,7 @@ func TestProcessProfile(t *testing.T) { testSuccess := func(profile Profile) { configureMonkeyPatching() - errors := processProfile(profile, projectName, extras, *usr) + errors := processProfile(profile, projectName, extras, *usr, nil) assert.Equal(t, 0, len(errors)) for _, error := range errors { assert.Nil(t, error) @@ -293,7 +287,7 @@ func TestProcessProfile(t *testing.T) { } testFailure := func(profile Profile, errorCount int, errorStr string) { configureMonkeyPatching() - errors := processProfile(profile, projectName, extras, *usr) + errors := processProfile(profile, projectName, extras, *usr, nil) assert.Equal(t, errorCount, len(errors)) allErrorsText := "" for _, err := range errors { @@ -313,7 +307,7 @@ func TestProcessProfile(t *testing.T) { 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.") + 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") @@ -327,7 +321,7 @@ func TestProcessProfile(t *testing.T) { profile.NamePatterns = []ParameterizedText{"file3"} profile.PostScripts = []ParameterizedText{"echo {{.ProjectName}}-{{.Year}}"} - profile.Path = "~/test/dir" + profile.TargetDirectory = "~/test/dir" testSuccess(profile) assert.Equal(t, 1, len(monkeyPatchCalls.ExecCommandCalls), "calls to ExecCommand") assert.Equal(t, 1, len(monkeyPatchCalls.OSChdirCalls), "calls to OSChdir")