From c2e9fe5d1ded1806d474b8e1b868952790114c82 Mon Sep 17 00:00:00 2001 From: Highlander Paiva Date: Fri, 30 Aug 2024 12:03:53 -0300 Subject: [PATCH] feat: initial release of Go AOC library - Implement core functionalities for executing Advent of Code challenges. - Add `Run` function to manage challenge execution with configurable options. - Provide flexible I/O management through the `IOManager` interface. - Implement automatic clipboard copying of results, with disable option. - Document code and usage examples to resemble Go's standard library style. - Create comprehensive README for installation, usage, and customization guidance. --- .gitignore | 103 +++++++++++++++++++++ .golangci.yml | 127 +++++++++++++++++++++++++ .pre-commit-config.yaml | 39 ++++++++ CHANGELOG.md | 14 +++ README.md | 199 +++++++++++++++++++++++++++++++++++++++- aoc.go | 25 +++++ errors.go | 63 +++++++++++++ go.mod | 3 + go.sum | 0 io.go | 165 +++++++++++++++++++++++++++++++++ io_test.go | 157 +++++++++++++++++++++++++++++++ mock/mock.go | 58 ++++++++++++ runner.go | 168 +++++++++++++++++++++++++++++++++ runner_test.go | 115 +++++++++++++++++++++++ 14 files changed, 1235 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 aoc.go create mode 100644 errors.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 io.go create mode 100644 io_test.go create mode 100644 mock/mock.go create mode 100644 runner.go create mode 100644 runner_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d79034 --- /dev/null +++ b/.gitignore @@ -0,0 +1,103 @@ +### Go template +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### GoLand template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +internal/**/*.txt diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..dcab6eb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,127 @@ +run: + timeout: 5m + modules-download-mode: readonly + tests: false + +linters: + disable-all: true + enable: + - gofmt + - goimports + - govet + - errcheck + - gosimple + - ineffassign + - staticcheck + - typecheck + - unused + - asciicheck + - bidichk + - bodyclose + - containedctx + - contextcheck + - cyclop + - decorder + - depguard + - dogsled + - dupl + - durationcheck + - errchkjson + - errname + - errorlint + - copyloopvar + - forbidigo + - forcetypeassert + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - godot + - godox + - err113 + - gofumpt + - goheader + - gomoddirectives + - gomodguard + - goprintffuncname + - grouper + - importas + - lll + - maintidx + - makezero + - misspell + - nakedret + - nestif + - nilerr + - nilnil + - nlreturn + - noctx + - nolintlint + - prealloc + - predeclared + - rowserrcheck + - sqlclosecheck + - stylecheck + - tagliatelle + - tenv + - testpackage + - thelper + - tparallel + - unconvert + - unparam + - varnamelen + - wastedassign + - whitespace + - wsl + +linters-settings: + lll: + line-length: 200 + dogsled: + max-blank-identifiers: 3 + tagliatelle: + case: + rules: + json: snake + mapstructure: snake + dupl: + ## https://github.com/golangci/golangci-lint/issues/1372 Issue + threshold: 250 + funlen: + lines: 100 + statements: 75 + cyclop: + max-complexity: 15 + forbidigo: + # Forbid the following identifiers + forbid: + - ^logger.Debug.*$ # -- forbid use of Print statements because they are likely just for debugging + - ^spew.Dump$ # -- forbid dumping detailed data to stdout + - ^ginkgo.F[A-Z].*$ # -- forbid ginkgo focused commands (used for debug issues) + varnamelen: + min-name-length: 2 + depguard: + rules: + main: + allow: + - $gostd + - github.com/hvpaiva + - github.com/pkg/errors + +issues: + exclude-rules: + - path: scripts/skeleton/tmpl + linters: + - godox + - path: _test\.go + linters: + - funlen + - errchkjson + - goerr113 + - dupl + - maintidx + - contextcheck + - goconst + exclude-dirs: + - internal/test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9e05642 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +repos: + # Go mod tidy. + - repo: local + hooks: + - id: go-mod-tidy + name: Execute go mod tidy + stages: [commit] + entry: sh -c 'go mod tidy && git add go.mod go.sum' + pass_filenames: false + always_run: true + language: system + + # Ensure that the code is nicely formatted. + - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: v2.14.0 + hooks: + - id: pretty-format-golang + args: + - --autofix + + # Execute go linters. + - repo: https://github.com/golangci/golangci-lint + rev: v1.60.3 + hooks: + - id: golangci-lint + entry: golangci-lint run + args: + - --max-issues-per-linter=0 + - --max-same-issues=0 + - --config=.golangci.yml + - --allow-parallel-runners=true + + # Conventional commits + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.3.0 + hooks: + - id: conventional-pre-commit + stages: [ commit-msg ] + args: [ ] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..efe5727 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## [1.0.0] - 2024-08-30 + +### Added + +- Full implementation of the Go AOC library for running Advent of Code challenges. +- Core functionality encapsulated in the `Run` function for executing challenge parts. +- IOManager interface for handling input and output flexibly. +- `DefaultConsoleManager` for standard console-based I/O operations. +- Option to specify challenge parts through command-line flags, environment variables, or programmatic configuration. +- Clipboard support to automatically copy challenge results, with an option to disable this feature. +- Comprehensive in-code documentation to align with Go's standard library style. +- Detailed README covering installation, basic usage, configuration options, error handling, and customization. diff --git a/README.md b/README.md index 305c219..63d846a 100644 --- a/README.md +++ b/README.md @@ -1 +1,198 @@ -# goaoc \ No newline at end of file +# Go AOC (Advent of Code) + +**Go AOC** is a Go library designed to simplify the process of running **Advent of Code** challenges. It streamlines +input/output handling and lets you manage challenge execution with ease. + +## Table of Contents +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Usage](#usage) + - [Basic Example](#basic-example) + - [Defining Custom Challenges](#defining-custom-challenges) + - [Providing the Part Parameter](#providing-the-part-parameter) + - [Configuration Options](#configuration-options) + - [Clipboard Support](#clipboard-support) +- [IO Manager](#io-manager) + - [Environment](#environment) +- [Error Handling](#error-handling) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [License](#license) + +## Installation + +To install the library, you can use the following command: + +```bash +go get -u github.com/hvpaiva/goaoc +``` + +## Quick Start + +To quickly integrate Go AOC in your workflow, execute a simple challenge: + +```go +package main + +import ( + "log" + + "github.com/hvpaiva/goaoc" +) + +func main() { + err := goaoc.Run("yourInputData", partOne, partTwo, goaoc.WithPart(1)) + if err != nil { + log.Fatalf("Run failed: %v", err) + } +} + +func partOne(input string) int { + // Implement your algorithm for part one here +} + +func partTwo(input string) int { + // Implement your algorithm for part two here +} +``` + +## Usage + +### Basic Example + +Here's how to use Go AOC in a project: + +```go +package main + +import ( + "log" + + "github.com/hvpaiva/goaoc" +) + +func main() { + err := goaoc.Run("example input", partOne, partTwo) + if err != nil { + log.Fatalf("Error running challenge: %v", err) + } +} + +func partOne(input string) int { + // Logic for part one + return len(input) +} + +func partTwo(input string) int { + // Logic for part two (e.g., double length) + return len(input) * 2 +} +``` + +### Defining Custom Challenges + +Challenge functions should receive a `string` input and return an `int`. Design purposes or parsing can be done within +these functions. + +### Providing the `part` Parameter + +Multiple strategies exist for specifying the challenge part: + +1. **Using a Flag**: You can pass the `--part` flag when running the challenge. Valid values are `1` or `2`. + ```bash + go run main.go --part=1 + ``` + +2. **Using an Environment Variable**: Set the `GOAOC_CHALLENGE_PART` environment variable to `1` or `2`. + ```bash + export GOAOC_CHALLENGE_PART=2 + go run main.go + ``` + +3. **Through Standard Input**: If neither a flag nor an environment variable is provided, the program will prompt you to +input the part number via the console. + ```bash + Which part do you want to run? (1/2) + > 1 + ``` + +4. **Using a Function Parameter**: Directly specify the part by using the `goaoc.WithPart(part)` option when calling `goaoc.Run`. + +```go +goaoc.Run(input, partOne, partTwo, goaoc.WithPart(1)) +``` + +### Configuration Options + +`goaoc.Run` supports configurations via options like: + +- **WithPart(part challenge.Part)**: Specifies the part of the challenge to run (1 or 2). +- **WithManager(env io.Env)**: Sets up custom [IO Manager](#io-manager). + +### Clipboard Support + +Auto-copies results to clipboard—useful for quick submission. + +> Disable using `GOAOC_DISABLE_COPY_CLIPBOARD=true`. + +## IO Manager + +Implement custom input/output handling using your own `IOManager`: + +```go +type customManager struct {} + +func (m *customManager) Read(arg string) (string, error) { + // Custom input logic +} + +func (m *customManager) Write(output string) error { + // Custom output logic +} + +customManager := &customManager{} +goaoc.Run(input, do, doAgain, goaoc.WithManager(customManager)) +``` + +### Environment + +Alter the default environment setting for `DefaultConsoleManager`: + +```go +var customEnv = goaoc.Env{ + Stdin: bytes.NewBufferString(""), + Stdout: new(bytes.Buffer), + Args: []string{}, +} + +goaoc.Run(input, do, doAgain, goaoc.WithManager(goaoc.DefaultConsoleManager{Env: customEnv})) + +``` + +## Error Handling + +The `Run` function propagates errors for handling: + +```go +if err := goaoc.Run(input, do, doAgain); err != nil { + log.Fatal(err) +} +``` + +> Note: All errors in internal flow are returned in goaoc.Run functions. Except for copying to clipboard, which just +> logs the error, but does not break the execution. The errors are also all typed, so you can check the type of the error. + +## Troubleshooting + +If you encounter issues, consider: +- Checking file permissions for clipboard commands. +- Validating environment paths. +- Inspecting error messages for guidance. + +## Contributing + +Contributions are welcome! Please feel free to submit a pull request or open an issue on GitHub. + +## License + +This project is licensed under the Apache 2.0 License. See the [LICENSE](./LICENSE) file for details. diff --git a/aoc.go b/aoc.go new file mode 100644 index 0000000..3de8a98 --- /dev/null +++ b/aoc.go @@ -0,0 +1,25 @@ +package goaoc + +// Challenge represents the function signature expected for both parts of a given challenge. +// Each Challenge function receives a string input (raw challenge data) and returns an int result. +type Challenge func(string) int + +// Part is an enumeration representing which part of the Advent of Code challenge to execute. +// Valid values are 1 and 2, corresponding to the problem statement's divisions. +type Part int + +// NewPart constructs a Part from an integer. Returns an error if the part number is not valid (not 1 or 2). +// +// Example: +// +// part, err := NewPart(2) +// if err != nil { +// log.Fatal(err) // 'err' will contain 'invalid part' message if not 1 or 2 +// } +func NewPart(p int) (Part, error) { + if p != 1 && p != 2 { + return Part(0), InvalidPartError{Part: p} + } + + return Part(p), nil +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..1624b38 --- /dev/null +++ b/errors.go @@ -0,0 +1,63 @@ +package goaoc + +import ( + "errors" + "fmt" +) + +// InvalidPartError indicates an error that occurs when an invalid part number +// is specified. Valid part numbers are 1 and 2. +type InvalidPartError struct { + Part int +} + +// Error implements the error interface for InvalidPartError. +// It returns a descriptive error message suitable for logging and debugging. +func (e InvalidPartError) Error() string { + return fmt.Sprintf("invalid part: %d. The valid parts are (1/2)", e.Part) +} + +// ErrInvalidPartType indicates an error that occurs when an invalid part type +// is specified. Valid part type is int. +var ErrInvalidPartType = errors.New("invalid part type. The part type allowed is int") + +// ErrMissingPart indicates that no part was specified when it is required. +// This error typically occurs during input parsing when the part number +// is expected to be provided by some means (flag, input, etc.). +var ErrMissingPart = errors.New("no part specified, please provide a valid part") + +// IOReadError indicates a failure during input operations, such as reading +// from a file or receiving input from the console. The underlying error +// can be retrieved for detailed inspection if necessary. +type IOReadError struct { + Err error +} + +// Error implements the error interface for IOReadError. +// It provides a message indicating an I/O read failure. +func (e IOReadError) Error() string { + return fmt.Sprintf("failed to read input: %v", e.Err) +} + +// Unwrap allows access to the underlying error, following Go 1.13's error unwrapper design. +func (e IOReadError) Unwrap() error { + return e.Err +} + +// IOWriteError indicates a failure during output operations, such as writing +// to a file or console. The underlying error +// can be retrieved for detailed inspection if necessary. +type IOWriteError struct { + Err error +} + +// Error implements the error interface for IOWriteError. +// It provides a message indicating an I/O write failure. +func (e IOWriteError) Error() string { + return fmt.Sprintf("failed to write input: %v", e.Err) +} + +// Unwrap allows access to the underlying error, following Go 1.13's error unwrapper design. +func (e IOWriteError) Unwrap() error { + return e.Err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8dc2e73 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/hvpaiva/goaoc + +go 1.23.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/io.go b/io.go new file mode 100644 index 0000000..bd31306 --- /dev/null +++ b/io.go @@ -0,0 +1,165 @@ +package goaoc + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" +) + +// Env struct embodies the input/output streams and command-line arguments used by IO managers. +// It provides flexibility in I/O handling by abstracting standard input and output mechanisms. +type Env struct { + // Stdin is the input stream from which data can be read. + // It typically corresponds to os.Stdin but can be overridden for testing or alternative input methods. + Stdin io.Reader + + // Stdout is the output stream to which data can be written. + // By default, it's set to os.Stdout but can be redirected to capture output programmatically or in tests. + Stdout io.Writer + + // Args holds command-line arguments, minus the program name. + // This slice allows the passing and manipulation of additional parameters through the command line. + Args []string +} + +var defaultConsoleEnv = Env{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Args: os.Args[1:], +} + +// DefaultConsoleManager manages I/O via the default console, implementing IOManager. +type DefaultConsoleManager struct { + Env Env +} + +// NewConsoleManager initializes a new DefaultConsoleManager with standard console streams. +func NewConsoleManager() DefaultConsoleManager { + return DefaultConsoleManager{ + Env: defaultConsoleEnv, + } +} + +// Read derives arguments like 'part' from various sources (flags, environment, or stdin). +// It returns errors if flag parsing fails or stdin input cannot be retrieved. +func (m DefaultConsoleManager) Read(arg string) (part string, err error) { + if arg != "part" { + return "", nil + } + + checks := []func() (string, error){ + func() (string, error) { return getPartInFlag(m.Env) }, + getPartInEnv, + func() (string, error) { return getPartInStdin(m.Env) }, + } + + for _, check := range checks { + part, err = check() + if err != nil { + return "", err + } + + if part != "" { + return part, nil + } + } + + return part, IOReadError{Err: ErrMissingPart} +} + +// Write outputs the result to console and optionally copies to clipboard if not disabled by GOAOC_DISABLE_COPY_CLIPBOARD. +// Errors can arise from console output failures or clipboard command errors. +func (m DefaultConsoleManager) Write(result string) error { + if _, err := fmt.Fprintf(m.Env.Stdout, "The challenge result is %s\n", result); err != nil { + return IOWriteError{Err: err} + } + + toClipboard(result, m.Env.Stdout) + + return nil +} + +// getPartInFlag attempts to parse the 'part' option from command-line flags. +// It supports standard flags only and returns errors if parsing fails. +func getPartInFlag(env Env) (part string, err error) { + fs := flag.NewFlagSet("goaoc", flag.ContinueOnError) + fs.SetOutput(env.Stdout) + + fs.Usage = func() { + _, err = fmt.Fprintf(fs.Output(), "Usage: %s [options]\n", fs.Name()) + + fs.PrintDefaults() + } + + fs.StringVar(&part, "part", "", "Part of the challenge, valid values are (1/2)") + + if err = fs.Parse(env.Args); err != nil { + return "", IOReadError{Err: err} + } + + return part, nil +} + +// getPartInEnv retrieves the 'part' from environment variables returned as a simple string. +func getPartInEnv() (string, error) { + part := os.Getenv("GOAOC_CHALLENGE_PART") + + return part, nil +} + +// getPartInStdin queries stdin to get which part the user wishes to run. Useful in interactive console mode. +// Returns errors for invalid or empty inputs. +func getPartInStdin(env Env) (string, error) { + var part string + + _, err := fmt.Fprintln(env.Stdout, "Which part do you want to run? (1/2)") + if err != nil { + return "", err + } + + _, err = fmt.Fscanln(env.Stdin, &part) + if err != nil && errors.Is(err, io.EOF) { + return "", IOReadError{Err: ErrMissingPart} + } + + return part, nil +} + +// toClipboard tries to copy the given value to the system clipboard. Skips copying if the environment is set to not copy. +// Errors while executing the clipboard command are printed but do not stop the program. +func toClipboard(value string, stdout io.Writer) { + envVar := os.Getenv("GOAOC_DISABLE_COPY_CLIPBOARD") + if envVar == "true" { + return + } + + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("pbcopy") + case "linux": + cmd = exec.Command("xclip", "-selection", "clipboard") + case "windows": + cmd = exec.Command("clip") + default: + _, _ = fmt.Fprintf(stdout, "unsupported OS: %s", runtime.GOOS) + + return + } + + cmd.Stdin = strings.NewReader(value) + + if err := cmd.Run(); err != nil { + _, _ = fmt.Fprintf(stdout, "copy to clipboard command failed: %v", err) + + return + } + + _, _ = fmt.Fprintf(stdout, "Copied to clipboard: %s\n", value) +} diff --git a/io_test.go b/io_test.go new file mode 100644 index 0000000..2aa51e8 --- /dev/null +++ b/io_test.go @@ -0,0 +1,157 @@ +package goaoc + +import ( + "bytes" + "errors" + "io" + "os" + "reflect" + "strings" + "testing" +) + +func mockEnv(args []string, input string, output io.Writer) Env { + return Env{ + Stdin: bytes.NewBufferString(input), + Stdout: output, + Args: args, + } +} + +type failingWriter struct{} + +func (f *failingWriter) Write(_ []byte) (n int, err error) { + return 0, errors.New("write failed") +} + +func TestRead(t *testing.T) { + testCases := []struct { + name string + env Env + expect string + expectErr string + }{ + {"PartFromFlag", mockEnv([]string{"-part=1"}, "", new(bytes.Buffer)), "1", ""}, + {"PartFromEnv", mockEnv([]string{}, "", new(bytes.Buffer)), "2", ""}, + {"PartFromStdin", mockEnv([]string{}, "1\n", new(bytes.Buffer)), "1", ""}, + {"PartFromStdinFailStdout", mockEnv([]string{}, "1\n", &failingWriter{}), "1", "write failed"}, + {"PartFromStdinFailEmpty", mockEnv([]string{}, "", new(bytes.Buffer)), "", "failed to read input: no part specified, please provide a valid part"}, + {"FlagProvidedButNotDefined", mockEnv([]string{"--test"}, "0", new(bytes.Buffer)), "", "failed to read input: flag provided but not defined: -test"}, + {"FlagProvidedButNotDefinedFailedStdout", mockEnv([]string{"--test"}, "0", &failingWriter{}), "", "failed to read input: flag provided but not defined: -test"}, + {"EmptyRead", mockEnv([]string{}, "", new(bytes.Buffer)), "", ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + manager := DefaultConsoleManager{Env: tc.env} + + if tc.name == "PartFromEnv" { + _ = os.Setenv("GOAOC_CHALLENGE_PART", "2") + defer func() { + err := os.Unsetenv("GOAOC_CHALLENGE_PART") + if err != nil { + t.Fatalf("Unexpected error while unsetting environment variable: %v", err) + } + }() + } + + part, err := manager.Read("part") + if tc.name == "EmptyRead" { + part, err = manager.Read("") + + } + if tc.expectErr != "" { + if err == nil || err.Error() != tc.expectErr { + t.Fatalf("Expected error '%s', but got: %v", tc.expectErr, err) + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if part != tc.expect { + t.Errorf("Expected part %s, but got %s", tc.expect, part) + } + } + }) + } +} + +func TestToClipboard(t *testing.T) { + env := mockEnv([]string{}, "", new(bytes.Buffer)) + manager := DefaultConsoleManager{Env: env} + + testCases := []struct { + name string + output string + }{ + {"Working", "Copied to clipboard: test value"}, + {"Deactivated", ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _ = os.Setenv("GOAOC_DISABLE_COPY_CLIPBOARD", "false") + if tc.name == "Deactivated" { + _ = os.Setenv("GOAOC_DISABLE_COPY_CLIPBOARD", "true") + } + + toClipboard("test value", env.Stdout) + + output := manager.Env.Stdout.(*bytes.Buffer).String() + if !strings.Contains(output, tc.output) { + t.Errorf("Expected clipboard message, but got: %s", output) + } + }) + } +} + +func TestOutput(t *testing.T) { + env := mockEnv([]string{}, "", new(bytes.Buffer)) + manager := DefaultConsoleManager{Env: env} + _ = os.Setenv("GOAOC_DISABLE_COPY_CLIPBOARD", "false") + + err := manager.Write("42") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := env.Stdout.(*bytes.Buffer).String() + expectedOutput := "The challenge result is 42\nCopied to clipboard: 42\n" + if output != expectedOutput { + t.Errorf("Expected output '%s', but got '%s'", expectedOutput, output) + } +} + +func TestSelectPartErrors(t *testing.T) { + _ = os.Unsetenv("GOAOC_CHALLENGE_PART") + + mockEnv := mockEnv([]string{}, "", new(bytes.Buffer)) + + manager := DefaultConsoleManager{Env: mockEnv} + + _, err := manager.Read("part") + if err == nil || err.Error() != "failed to read input: no part specified, please provide a valid part" { + t.Fatalf("Expected 'failed to read input: no part specified, please provide a valid part' error, but got: %v", err) + } +} + +func TestOutputWriterFails(t *testing.T) { + manager := DefaultConsoleManager{ + Env: Env{ + Stdout: &failingWriter{}, + }, + } + + err := manager.Write("42") + if err == nil || err.Error() != "failed to write input: write failed" { + t.Fatalf("Expected 'failed to write input: write failed' error, but got: %v", err) + } +} + +func TestNewConsoleManager(t *testing.T) { + manager := NewConsoleManager() + + if !reflect.DeepEqual(manager.Env, defaultConsoleEnv) { + t.Errorf("expected Stdin to be %v, but got %v", os.Stdin, manager.Env.Stdin) + } +} diff --git a/mock/mock.go b/mock/mock.go new file mode 100644 index 0000000..8e4b3f5 --- /dev/null +++ b/mock/mock.go @@ -0,0 +1,58 @@ +package mock + +import ( + "bytes" + + "github.com/hvpaiva/goaoc" +) + +type Manager struct { + env goaoc.Env + part string + errSelectPart error + errOutput error +} + +func NewBufferEnv() goaoc.Env { + return goaoc.Env{ + Stdin: bytes.NewBufferString(""), + Stdout: new(bytes.Buffer), + Args: []string{}, + } +} + +func NewManager(part string, errSelectPart, errOutput error) Manager { + return Manager{ + env: NewBufferEnv(), + part: part, + errSelectPart: errSelectPart, + errOutput: errOutput, + } +} + +func (m *Manager) Read(_ string) (string, error) { + return m.part, m.errSelectPart +} + +func (m *Manager) Write(result string) error { + if m.errOutput != nil { + return m.errOutput + } + + _, err := m.env.Stdout.Write([]byte(m.formatResult(result))) + + return err +} + +func (m *Manager) formatResult(result string) string { + return "The challenge result is " + result + "\n" +} + +func (m *Manager) GetStdout() string { + value, ok := m.env.Stdout.(*bytes.Buffer) + if !ok { + return "" + } + + return value.String() +} diff --git a/runner.go b/runner.go new file mode 100644 index 0000000..9ecd65d --- /dev/null +++ b/runner.go @@ -0,0 +1,168 @@ +// Package goaoc provides a framework to facilitate running Advent of Code challenges. +// This package encompasses utilities for handling input and outputs, selecting challenge parts, +// and executing them with configurable options. +// +// # Overview +// +// The goaoc package is designed to simplify the execution of Advent of Code challenges +// by abstracting I/O operations and enabling easy switching between parts 1 and 2 +// of each challenge. The main entry point for executing a challenge is the Run function. +// +// # Basic Usage +// +// To run a challenge, you need to provide input data and two functions implementing +// the Challenge type, each corresponding to part 1 and part 2 of the challenge. +// +// Example: +// +// err := Run("yourInputData", part1Func, part2Func, WithPart(1)) +// if err != nil { +// log.Fatal(err) +// } +// +// Additional RunOptions such as WithManager and WithPart allow customization of +// input/output management and challenge part selection, respectively. +package goaoc + +import ( + "strconv" +) + +// runOptions holds the configurations needed for running a challenge. +// It includes the IOManager for handling input/output and the challenge Part. +type runOptions struct { + manager IOManager + part Part +} + +// RunOption is a functional option type for configuring runOptions. +// It allows the user to customize aspects of the Run function. +type RunOption func(options *runOptions) error + +// IOManager is an interface that abstracts the process of reading and writing data. +// It allows for different implementations to manage input and output according to varying needs, such as +// console-based, file-based, or even network-based I/O. +type IOManager interface { + // Write writes the result string to an output destination. + // Implementations must handle errors that occur during the write operation, such as IO errors. + // Example: + // err := manager.Write("result data") + // if err != nil { + // log.Println("Failed to write result:", err) + // } + Write(result string) error + + // Read retrieves a value based on the given argument string. + // It's typically used to fetch configuration settings like which part of a challenge to run. + // Errors may result from issues such as missing data or failed parse attempts. + // Example: + // arg, err := manager.Read("part") + // if err != nil { + // log.Println("Failed to read argument:", err) + // } + Read(arg string) (string, error) +} + +// Run executes given Challenge functions partOne and partTwo, based on the input provided +// and optional configurations. It writes output via the configured IOManager. +// +// Example: +// +// err := Run("123", func(input string) int { return len(input) }, func(input string) int { return len(input) * 2 }, WithPart(1)) +// if err != nil { +// log.Fatal(err) +// } +// +// By default, output is written to the console, but you can change this by providing different IOManagers. +// +// Possible errors include option injection failures, I/O errors, and invalid part errors. +func Run(input string, partOne, partTwo Challenge, options ...RunOption) error { + var opts runOptions + if err := injectOptions(&opts, options...); err != nil { + return err + } + + result := executeChallenge(input, partOne, partTwo, opts.part) + + if err := opts.manager.Write(strconv.Itoa(result)); err != nil { + return err + } + + return nil +} + +// WithManager creates a RunOption to set the custom IOManager. +// Use this to override the default console-based manager. +// +// Example: +// +// manager := NewCustomManager() +// err := Run(inputData, part1Func, part2Func, WithManager(manager)) +func WithManager(manager IOManager) RunOption { + return func(options *runOptions) error { + options.manager = manager + + return nil + } +} + +// WithPart creates a RunOption to specify which part of the challenge to run (part 1 or 2). +// This is particularly useful when you want to determine the part dynamically. +// +// Example: +// +// err := Run(inputData, part1Func, part2Func, WithPart(2)) +func WithPart(part int) RunOption { + return func(options *runOptions) error { + options.part = Part(part) + + return nil + } +} + +// executeChallenge applies the appropriate Challenge function based on the selected part. +// It returns the result of the challenge execution. +func executeChallenge(input string, partOne, partTwo Challenge, part Part) (result int) { + switch part { + case 1: + result = partOne(input) + case 2: + result = partTwo(input) + default: + // Though should never reach, it is good for future-proofing + panic(ErrMissingPart) + } + + return result +} + +// injectOptions applies the functional options to configure runOptions. +// It defaults the IOManager to a console manager and resolves the challenge part from input if not set. +func injectOptions(opts *runOptions, options ...RunOption) error { + for _, option := range options { + _ = option(opts) + } + + if opts.manager == nil { + opts.manager = NewConsoleManager() + } + + if opts.part == 0 { + partStr, err := opts.manager.Read("part") + if err != nil { + return err + } + + part, err := strconv.Atoi(partStr) + if err != nil { + return ErrInvalidPartType + } + + opts.part, err = NewPart(part) + if err != nil { + return err + } + } + + return nil +} diff --git a/runner_test.go b/runner_test.go new file mode 100644 index 0000000..a80233e --- /dev/null +++ b/runner_test.go @@ -0,0 +1,115 @@ +package goaoc_test + +import ( + "errors" + "testing" + + "github.com/hvpaiva/goaoc" + "github.com/hvpaiva/goaoc/mock" +) + +func TestRunWithInvalidParts(t *testing.T) { + testCases := []struct { + name string + part string + expectErr string + }{ + {"PartNotSpecified", "0", "invalid part: 0. The valid parts are (1/2)"}, + {"WrongPartDefined", "3", "invalid part: 3. The valid parts are (1/2)"}, + {"WrongPartTypeString", "ss", "invalid part type. The part type allowed is int"}, + {"WrongPartTypeEmpty", "", "invalid part type. The part type allowed is int"}, + {"WrongPartTypeStillString", "true", "invalid part type. The part type allowed is int"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mok := mock.NewManager(tc.part, nil, nil) + err := goaoc.Run("input", mockPartOne, mockPartTwo, goaoc.WithManager(&mok)) + + if err == nil || err.Error() != tc.expectErr { + t.Fatalf("Expected error '%s', but got: %v", tc.expectErr, err) + } + }) + } +} + +func TestRunWithErrors(t *testing.T) { + testCases := []struct { + name string + part string + selectErr error + outputErr error + expectErr string + }{ + {"SelectingPartError", "2", errors.New("error when calling Read"), nil, "error when calling Read"}, + {"OutputError", "1", nil, errors.New("output failed"), "output failed"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mok := mock.NewManager(tc.part, tc.selectErr, tc.outputErr) + err := goaoc.Run("input", mockPartOne, mockPartTwo, goaoc.WithManager(&mok)) + + if err == nil || err.Error() != tc.expectErr { + t.Fatalf("Expected error '%s', but got: %v", tc.expectErr, err) + } + }) + } +} + +func TestRunWithValidPart(t *testing.T) { + testCases := []struct { + name string + part string + expectedOutput string + copiedValue string + }{ + {"PartOne", "1", "The challenge result is 42\n", "42"}, + {"PartTwo", "2", "The challenge result is 24\n", "24"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mok := mock.NewManager(tc.part, nil, nil) + err := goaoc.Run("input", mockPartOne, mockPartTwo, goaoc.WithManager(&mok)) + + if err != nil { + t.Fatalf("Unexpected error when part is valid: %v", err) + } + + output := mok.GetStdout() + expectedOutput := tc.expectedOutput + if output != expectedOutput { + t.Errorf("Expected output '%s', but got '%s'", expectedOutput, output) + } + }) + } +} + +func TestRunWithDefaultManager(t *testing.T) { + testCases := []struct { + name string + part int + }{ + {"PartOne", 1}, + {"PartTwo", 2}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := goaoc.Run("input", mockPartOne, mockPartTwo, goaoc.WithPart(tc.part)) + + if err != nil { + t.Fatalf("Unexpected error when part is valid: %v", err) + } + }) + } +} + +func mockPartOne(_ string) int { + return 42 +} + +func mockPartTwo(_ string) int { + return 24 +}