From de7b343d0f72e466f9334a9fec2570ab4a347c2e Mon Sep 17 00:00:00 2001 From: Oleg Balunenko Date: Tue, 5 Dec 2023 22:41:49 +0400 Subject: [PATCH 1/2] feat: Implement new puzzle boilerplate generator (#288) * feat: Add templates for new puzzles generation * feat: Implement gen command * feat: Implement new puzzle solution generation * chore: Remove deadcode * fix: Add check for empty url * fix: Add context to error * fix: Remove AOC_PUZZLE_URL from makefile * fix: Don't use default value for env variable --- .../go-tools-docker-compose.yml | 2 + internal/puzzles/solutions/new.go | 170 ++++++++++++++++++ internal/puzzles/solutions/new_test.go | 94 ++++++++++ internal/puzzles/solutions/templates/embed.go | 68 +++++++ .../solutions/templates/solution.go.tmpl | 30 ++++ .../solutions/templates/solution_test.go.tmpl | 117 ++++++++++++ .../puzzles/solutions/templates/spec.md.tmpl | 2 + 7 files changed, 483 insertions(+) create mode 100644 internal/puzzles/solutions/new.go create mode 100644 internal/puzzles/solutions/new_test.go create mode 100644 internal/puzzles/solutions/templates/embed.go create mode 100644 internal/puzzles/solutions/templates/solution.go.tmpl create mode 100644 internal/puzzles/solutions/templates/solution_test.go.tmpl create mode 100644 internal/puzzles/solutions/templates/spec.md.tmpl diff --git a/deployments/docker-compose/go-tools-docker-compose.yml b/deployments/docker-compose/go-tools-docker-compose.yml index f0e7e08e..eb5e4d3d 100755 --- a/deployments/docker-compose/go-tools-docker-compose.yml +++ b/deployments/docker-compose/go-tools-docker-compose.yml @@ -29,6 +29,8 @@ services: extends: service: tools entrypoint: /bin/sh -c 'git config --global --add safe.directory /app && ./scripts/tests/run.sh' + environment: + AOC_PUZZLE_URL: ${AOC_PUZZLE_URL} run-tests-regression: extends: diff --git a/internal/puzzles/solutions/new.go b/internal/puzzles/solutions/new.go new file mode 100644 index 00000000..49ba402b --- /dev/null +++ b/internal/puzzles/solutions/new.go @@ -0,0 +1,170 @@ +package solutions + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "text/template" + + "github.com/obalunenko/advent-of-code/internal/puzzles/solutions/templates" +) + +func createNewFromTemplate(purl string) error { + const ( + perms = 0o766 + yearLen = 4 + dayLen = 2 + ) + + pd, err := parsePuzzleURL(purl) + if err != nil { + return fmt.Errorf("parse puzzle url %q: %w", purl, err) + } + + day := strconv.Itoa(pd.day) + if len(day) < dayLen { + day = "0" + day + } + + if len(day) != dayLen { + return fmt.Errorf("invalid day: %s", day) + } + + year := strconv.Itoa(pd.year) + + if len(year) != yearLen { + return fmt.Errorf("invalid year: %s", year) + } + + params := templates.Params{ + Year: year, + Day: pd.day, + DayStr: day, + URL: purl, + } + + path := filepath.Clean(filepath.Join(year, "day"+day)) + + if err = createPuzzleDir(path, perms); err != nil { + return fmt.Errorf("failed to create puzzle dir: %w", err) + } + + testdata := filepath.Clean(filepath.Join(path, "testdata")) + + if err = createTestdata(testdata, perms); err != nil { + return fmt.Errorf("failed to create testdata: %w", err) + } + + tmplsFns := []func() (*template.Template, error){ + templates.SolutionTmpl, templates.SolutionTestTmpl, templates.SpecTmpl, + } + + for _, tmplFn := range tmplsFns { + var tmpl *template.Template + + tmpl, err = tmplFn() + if err != nil { + return fmt.Errorf("failed to get template: %w", err) + } + + if err = createFromTemplate(tmpl, path, perms, params); err != nil { + return fmt.Errorf("failed to create from template: %w", err) + } + } + + return nil +} + +func createFromTemplate(tmpl *template.Template, path string, perms os.FileMode, params templates.Params) error { + fpath := filepath.Clean(filepath.Join(path, tmpl.Name())) + + if isExist(fpath) { + return nil + } + + var content []byte + + content, err := templates.SubstituteTemplate(tmpl, params) + if err != nil { + return fmt.Errorf("failed to substitute template: %w", err) + } + + if err = os.WriteFile(fpath, content, perms); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +func createPuzzleDir(path string, perms os.FileMode) error { + if !isExist(path) { + if err := os.MkdirAll(path, perms); err != nil { + return fmt.Errorf("failed to create dir: %w", err) + } + } + + return nil +} + +func createTestdata(path string, perms os.FileMode) error { + if !isExist(path) { + if err := os.MkdirAll(path, perms); err != nil { + return fmt.Errorf("failed to create dir: %w", err) + } + } + + input := filepath.Clean(filepath.Join(path, "input.txt")) + + if !isExist(input) { + var f *os.File + + f, err := os.Create(input) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + if err = f.Close(); err != nil { + return fmt.Errorf("failed to close file: %w", err) + } + } + + return nil +} + +func isExist(path string) bool { + stat, err := os.Stat(path) + if err != nil && !os.IsNotExist(err) { + return false + } + + return stat != nil && stat.Name() != "" +} + +type puzzleDate struct { + year int + day int +} + +func parsePuzzleURL(url string) (puzzleDate, error) { + const ( + urlFmt = "https://adventofcode.com/%d/day/%d" + paramsNum = 2 + ) + + var year, day int + + n, err := fmt.Sscanf(url, urlFmt, &year, &day) + if err != nil { + return puzzleDate{}, fmt.Errorf("parse puzzle url: %w", err) + } + + if n != paramsNum { + return puzzleDate{}, fmt.Errorf("invalid puzzle url: %s", url) + } + + return puzzleDate{ + year: year, + day: day, + }, nil +} diff --git a/internal/puzzles/solutions/new_test.go b/internal/puzzles/solutions/new_test.go new file mode 100644 index 00000000..065c9275 --- /dev/null +++ b/internal/puzzles/solutions/new_test.go @@ -0,0 +1,94 @@ +package solutions + +import ( + "errors" + "testing" + + "github.com/obalunenko/getenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_createNewFromTemplate(t *testing.T) { + const envName = "AOC_PUZZLE_URL" + + purl, err := getenv.Env[string](envName) + if err != nil { + if errors.Is(err, getenv.ErrNotSet) { + t.Skipf("%s is not set", envName) + } + + t.Fatalf("failed to get environment variable[%s]: %v", envName, err) + } + + require.NoError(t, createNewFromTemplate(purl)) +} + +func Test_parsePuzzleURL(t *testing.T) { + type args struct { + url string + } + + tests := []struct { + name string + args args + wandDate puzzleDate + wantErr assert.ErrorAssertionFunc + }{ + { + name: "valid url", + args: args{ + url: "https://adventofcode.com/2022/day/1", + }, + wandDate: puzzleDate{ + year: 2022, + day: 1, + }, + wantErr: assert.NoError, + }, + { + name: "invalid url", + args: args{ + url: "https://adventofcode.com/2022", + }, + wandDate: puzzleDate{ + year: 0, + day: 0, + }, + wantErr: assert.Error, + }, + { + name: "empty url", + args: args{ + url: "", + }, + wandDate: puzzleDate{ + year: 0, + day: 0, + }, + wantErr: assert.Error, + }, + { + name: "whitespace url", + args: args{ + url: " ", + }, + wandDate: puzzleDate{ + year: 0, + day: 0, + }, + wantErr: assert.Error, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotDate, err := parsePuzzleURL(tt.args.url) + if !tt.wantErr(t, err) { + return + } + + assert.Equal(t, tt.wandDate, gotDate) + }) + } +} diff --git a/internal/puzzles/solutions/templates/embed.go b/internal/puzzles/solutions/templates/embed.go new file mode 100644 index 00000000..3757431a --- /dev/null +++ b/internal/puzzles/solutions/templates/embed.go @@ -0,0 +1,68 @@ +// Package templates contains templates for solution.go, solution_test.go and spec.md files. +package templates + +import ( + "bytes" + _ "embed" + "fmt" + "text/template" +) + +var ( + //go:embed solution.go.tmpl + solutionTmpl string + //go:embed solution_test.go.tmpl + solutionTestTmpl string + //go:embed spec.md.tmpl + specTmpl string +) + +// Params contains parameters for templates. +type Params struct { + Year string // e.g. "2023" + Day int // e.g. 2 + DayStr string // e.g. "02" + URL string // e.g. "https://adventofcode.com/2023/day/2" +} + +// SolutionTmpl returns template for solution.go file. +func SolutionTmpl() (*template.Template, error) { + tmpl, err := template.New("solution.go").Parse(solutionTmpl) + if err != nil { + return nil, fmt.Errorf("failed to parse solution template: %w", err) + } + + return tmpl, nil +} + +// SolutionTestTmpl returns template for solution_test.go file. +func SolutionTestTmpl() (*template.Template, error) { + tmpl, err := template.New("solution_test.go").Parse(solutionTestTmpl) + if err != nil { + return nil, fmt.Errorf("failed to parse solution test template: %w", err) + } + + return tmpl, nil +} + +// SpecTmpl returns template for spec.md file. +func SpecTmpl() (*template.Template, error) { + tmpl, err := template.New("spec.md").Parse(specTmpl) + if err != nil { + return nil, fmt.Errorf("failed to parse spec template: %w", err) + } + + return tmpl, nil +} + +// SubstituteTemplate substitutes template with given parameters. +func SubstituteTemplate(tmpl *template.Template, p Params) ([]byte, error) { + var buf bytes.Buffer + + err := tmpl.Execute(&buf, p) + if err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/internal/puzzles/solutions/templates/solution.go.tmpl b/internal/puzzles/solutions/templates/solution.go.tmpl new file mode 100644 index 00000000..c4b7fce1 --- /dev/null +++ b/internal/puzzles/solutions/templates/solution.go.tmpl @@ -0,0 +1,30 @@ +// Package day{{ .DayStr }} contains solution for {{ .URL }} puzzle. +package day{{ .DayStr }} + +import ( + "io" + + "github.com/obalunenko/advent-of-code/internal/puzzles" +) + +func init() { + puzzles.Register(solution{}) +} + +type solution struct{} + +func (s solution) Year() string { + return puzzles.Year{{ .Year }}.String() +} + +func (s solution) Day() string { + return puzzles.Day{{ .DayStr }}.String() +} + +func (s solution) Part1(input io.Reader) (string, error) { + return "", puzzles.ErrNotImplemented +} + +func (s solution) Part2(input io.Reader) (string, error) { + return "", puzzles.ErrNotImplemented +} diff --git a/internal/puzzles/solutions/templates/solution_test.go.tmpl b/internal/puzzles/solutions/templates/solution_test.go.tmpl new file mode 100644 index 00000000..e1fddad1 --- /dev/null +++ b/internal/puzzles/solutions/templates/solution_test.go.tmpl @@ -0,0 +1,117 @@ +package day{{ .DayStr }} + +import ( + "errors" + "io" + "path/filepath" + "testing" + "testing/iotest" + + "github.com/stretchr/testify/assert" + + "github.com/obalunenko/advent-of-code/internal/puzzles/common/utils" +) + +func Test_solution_Year(t *testing.T) { + var s solution + + want := "{{ .Year }}" + got := s.Year() + + assert.Equal(t, want, got) +} + +func Test_solution_Day(t *testing.T) { + var s solution + + want := "{{ .Day }}" + got := s.Day() + + assert.Equal(t, want, got) +} + +func Test_solution_Part1(t *testing.T) { + var s solution + + type args struct { + input io.Reader + } + + tests := []struct { + name string + args args + want string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "test example from description", + args: args{ + input: utils.ReaderFromFile(t, filepath.Join("testdata", "input.txt")), + }, + want: "8", + wantErr: assert.NoError, + }, + { + name: "", + args: args{ + input: iotest.ErrReader(errors.New("custom error")), + }, + want: "", + wantErr: assert.Error, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := s.Part1(tt.args.input) + if !tt.wantErr(t, err) { + return + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_solution_Part2(t *testing.T) { + var s solution + + type args struct { + input io.Reader + } + + tests := []struct { + name string + args args + want string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "", + args: args{ + input: utils.ReaderFromFile(t, filepath.Join("testdata", "input.txt")), + }, + want: "", + wantErr: assert.NoError, + }, + { + name: "", + args: args{ + input: iotest.ErrReader(errors.New("custom error")), + }, + want: "", + wantErr: assert.Error, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := s.Part2(tt.args.input) + if !tt.wantErr(t, err) { + return + } + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/puzzles/solutions/templates/spec.md.tmpl b/internal/puzzles/solutions/templates/spec.md.tmpl new file mode 100644 index 00000000..139597f9 --- /dev/null +++ b/internal/puzzles/solutions/templates/spec.md.tmpl @@ -0,0 +1,2 @@ + + From 639a3959ceda850cbe43e4fc9b9b3d7ae9fb3eae Mon Sep 17 00:00:00 2001 From: Oleg Balunenko Date: Tue, 5 Dec 2023 23:14:47 +0400 Subject: [PATCH 2/2] fix: Boilerplate generation (#290) --- Makefile | 4 ++++ internal/puzzles/solutions/new.go | 2 +- scripts/codegen/puzzle-boilerplate.sh | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100755 scripts/codegen/puzzle-boilerplate.sh diff --git a/Makefile b/Makefile index 63b61bbf..e8b647b5 100644 --- a/Makefile +++ b/Makefile @@ -157,5 +157,9 @@ new-version: vet test-regression build open-advent-homepage: ./scripts/browser-opener.sh -u 'https://adventofcode.com/' +gen-boilerplate: + ./scripts/codegen/puzzle-boilerplate.sh +.PHONY: gen-boilerplate + .DEFAULT_GOAL := help diff --git a/internal/puzzles/solutions/new.go b/internal/puzzles/solutions/new.go index 49ba402b..84ffeb2c 100644 --- a/internal/puzzles/solutions/new.go +++ b/internal/puzzles/solutions/new.go @@ -12,7 +12,7 @@ import ( func createNewFromTemplate(purl string) error { const ( - perms = 0o766 + perms = 0o655 yearLen = 4 dayLen = 2 ) diff --git a/scripts/codegen/puzzle-boilerplate.sh b/scripts/codegen/puzzle-boilerplate.sh new file mode 100755 index 00000000..c50d1873 --- /dev/null +++ b/scripts/codegen/puzzle-boilerplate.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -Eeuo pipefail + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIR="$(dirname "$0")" +REPO_ROOT="$(cd "${SCRIPT_DIR}" && git rev-parse --show-toplevel)" +SCRIPTS_DIR="${REPO_ROOT}/scripts" + +source "${SCRIPTS_DIR}/helpers-source.sh" + +cd ${REPO_ROOT}/internal/puzzles/solutions || exit 1 + +go test -v -run Test_createNewFromTemplate + +echo "${SCRIPT_NAME} done." +