diff --git a/.env b/.env index c48dbf7..d696b07 100644 --- a/.env +++ b/.env @@ -1,8 +1,8 @@ # Pinned Docker image versions -ALPINE_IMAGE_VERSION=alpine:3.18.3 -GOLANG_IMAGE_VERSION=golang:1.21-alpine3.18 +ALPINE_IMAGE=alpine:3.20 +GOLANG_IMAGE=golang:1.23.1-alpine3.20 # Pinned Go tooling versions -GOFUMPT_VERSION=v0.5.0 -GOLANGCI_LINT_VERSION=v1.55.1 -PKGSITE_VERSION=v0.0.0-20231009172822-5f0513d53cff +GOFUMPT_VERSION=v0.7.0 +GOLANGCI_LINT_VERSION=v1.60.3 +PKGSITE_VERSION=v0.0.0-20240910173546-47024e57924e diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index baa2f03..0000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: Build - -on: [ push, pull_request, workflow_dispatch ] - -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version-file: 'go.mod' - - - name: Build - run: make build/cli diff --git a/.github/workflows/release.yaml b/.github/workflows/gh-release.yaml similarity index 55% rename from .github/workflows/release.yaml rename to .github/workflows/gh-release.yaml index 69f8058..072a92b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/gh-release.yaml @@ -1,4 +1,4 @@ -name: Release +name: GitHub Release on: push: @@ -11,12 +11,14 @@ permissions: jobs: build: + name: release runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 + distribute: - uses: ./.github/workflows/dist.yaml + uses: ./.github/workflows/go-release.yaml diff --git a/.github/workflows/go-build.yaml b/.github/workflows/go-build.yaml new file mode 100644 index 0000000..58cf547 --- /dev/null +++ b/.github/workflows/go-build.yaml @@ -0,0 +1,21 @@ +name: Go Build + +on: + push: + workflow_dispatch: + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Build CLI + run: make build/cli \ No newline at end of file diff --git a/.github/workflows/go-lint.yaml b/.github/workflows/go-lint.yaml new file mode 100644 index 0000000..566b348 --- /dev/null +++ b/.github/workflows/go-lint.yaml @@ -0,0 +1,25 @@ +name: Go Lint +on: + push: + workflow_dispatch: + +permissions: + contents: read + +jobs: + golangci: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - uses: falti/dotenv-action@v1.1.2 + id: dotenv + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: ${{ steps.dotenv.outputs.GOLANGCI_LINT_VERSION }} + args: --timeout 5m diff --git a/.github/workflows/dist.yaml b/.github/workflows/go-release.yaml similarity index 90% rename from .github/workflows/dist.yaml rename to .github/workflows/go-release.yaml index 45fbc7b..449be95 100644 --- a/.github/workflows/dist.yaml +++ b/.github/workflows/go-release.yaml @@ -1,11 +1,11 @@ -name: Distribute +name: Go Release on: workflow_call: jobs: distribute: - name: Distribute + name: distribute runs-on: ubuntu-latest strategy: matrix: @@ -17,8 +17,9 @@ jobs: goos: darwin - goarch: arm64 goos: windows + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: wangyoucao577/go-release-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/go-test.yaml similarity index 55% rename from .github/workflows/test.yaml rename to .github/workflows/go-test.yaml index 14baa39..53ec2f3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/go-test.yaml @@ -1,20 +1,22 @@ -name: Test +name: Go Test -on: [ push, workflow_dispatch ] +on: + push: + workflow_dispatch: jobs: test: + name: test runs-on: ubuntu-latest - name: Test steps: - name: Check out code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Run tests run: | - make test/full TOOLS= + make test GO=go diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml deleted file mode 100644 index d0928b3..0000000 --- a/.github/workflows/lint.yaml +++ /dev/null @@ -1,58 +0,0 @@ -name: Lint -on: [ push, pull_request ] - -permissions: - contents: read - # Optional: allow read access to pull request. Use with `only-new-issues` option. - pull-requests: read - -jobs: - golangci-lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@v3 - - - name: Read .env file - id: dotenv - uses: falti/dotenv-action@v1.0.4 - - - name: Setup Go - uses: actions/setup-go@v4 - with: - go-version-file: 'go.mod' - cache: false - - - name: golangci-lint - uses: golangci/golangci-lint-action@v3 - with: - # Require: The version of golangci-lint to use. - # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. - # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. - version: ${{ steps.dotenv.outputs.GOLANGCI_LINT_VERSION }} - - # Optional: working directory, useful for monorepos - # working-directory: somedir - - # Optional: golangci-lint command line arguments. - # - # Note: By default, the `.golangci.yml` file should be at the root of the repository. - # The location of the configuration file can be changed by using `--config=` - # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 - - # Optional: show only new issues if it's a pull request. The default value is `false`. - only-new-issues: true - - # Optional: if set to true, then all caching functionality will be completely disabled, - # takes precedence over all other caching options. - # skip-cache: true - - # Optional: if set to true, then the action won't cache or restore ~/go/pkg. - # skip-pkg-cache: true - - # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. - # skip-build-cache: true - - # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. - # install-mode: "goinstall" diff --git a/.gitignore b/.gitignore index 5839d47..c143874 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ +# OS generated .DS_Store +# IDE's .idea/ .ideavimrc +.vscode/ +# Build bin/ +tmp/ vendor/ -/tmp - -coverage.* \ No newline at end of file +# Testing +coverage.* diff --git a/.golangci.yml b/.golangci.yml index f3fb22f..f340d40 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,7 @@ # This code is licensed under the terms of the MIT license https://opensource.org/license/mit # Copyright (c) 2021 Marat Reymers -## Golden config for golangci-lint v1.55.2 +## Golden config for golangci-lint v1.61.0 # # This is the best config for golangci-lint based on my experience and opinion. # It is very strict, but not extremely strict. @@ -25,6 +25,11 @@ linters-settings: # Default: 0.0 package-average: 10.0 + dupl: + # Tokens count to trigger issue. + # Default: 150 + threshold: 150 + errcheck: # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. # Such cases aren't reported by default. @@ -39,7 +44,8 @@ linters-settings: - map exhaustruct: - # List of regular expressions to exclude struct packages and names from check. + # List of regular expressions to exclude struct packages and their names from checks. + # Regular expressions must match complete canonical struct package/name/structname. # Default: [] exclude: # std libs @@ -98,24 +104,11 @@ linters-settings: # Default: true skipRecvDeref: false - gomnd: - # List of function patterns to exclude from analysis. - # Values always ignored: `time.Date`, - # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, - # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. - # Default: [] - ignored-functions: - - flag.Arg - - flag.Duration.* - - flag.Float.* - - flag.Int.* - - flag.Uint.* - - os.Chmod - - os.Mkdir.* - - os.OpenFile - - os.WriteFile - - prometheus.ExponentialBuckets.* - - prometheus.LinearBuckets + goimports: + # A comma-separated list of prefixes, which, if set, checks import paths + # with the given prefixes are grouped after 3rd-party packages. + # Default: "" + local-prefixes: github.com/dannyhinshaw/converge gomodguard: blocked: @@ -132,8 +125,8 @@ linters-settings: reason: "satori's package is not maintained" - github.com/gofrs/uuid: recommendations: - - github.com/google/uuid - reason: "gofrs' package is not go module" + - github.com/gofrs/uuid/v5 + reason: "gofrs' package was not go module before v5" govet: # Enable all analyzers. @@ -151,6 +144,53 @@ linters-settings: # Default: false strict: true + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + + ireturn: + # List of interfaces to allow. + # Lists of the keywords and regular expressions matched to interface or package names can be used. + # `allow` and `reject` settings cannot be used at the same time. + # + # Keywords: + # - `empty` for `interface{}` + # - `error` for errors + # - `stdlib` for standard library + # - `anon` for anonymous interfaces + # - `generic` for generic interfaces added in go 1.18 + # + # Default: [anon, error, empty, stdlib] + allow: + - anon + - empty + - error + - generic + - stdlib + # You can specify idiomatic endings for interface + - (or|er)$ + + mnd: + # List of function patterns to exclude from analysis. + # Values always ignored: `time.Date`, + # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, + # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. + # Default: [] + ignored-functions: + - args.Error + - flag.Arg + - flag.Duration.* + - flag.Float.* + - flag.Int.* + - flag.Uint.* + - os.Chmod + - os.Mkdir.* + - os.OpenFile + - os.WriteFile + - prometheus.ExponentialBuckets.* + - prometheus.LinearBuckets + nakedret: # Make an issue if func has more lines of code than this setting, and it has naked returns. # Default: 30 @@ -167,18 +207,131 @@ linters-settings: # Default: false require-specific: true + perfsprint: + # Optimizes into strings concatenation. + # Default: true + strconcat: false + rowserrcheck: # database/sql is always checked # Default: [] packages: - github.com/jmoiron/sqlx + revive: + # When set to false, ignores files with "GENERATED" header, similar to golint. + # See https://github.com/mgechev/revive#available-rules for details. + # Default: false + ignore-generated-header: true + # Sets the default severity. + # See https://github.com/mgechev/revive#configuration + # Default: warning + severity: error + # Enable all available rules. + # Default: false + enable-all-rules: true + # Sets the default failure confidence. + # This means that linting errors with less than 0.8 confidence will be ignored. + # Default: 0.8 + confidence: 0.8 + rules: + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#add-constant + - name: add-constant + severity: warning + disabled: true + exclude: [ "" ] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#argument-limit + - name: argument-limit + arguments: [ 6 ] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#cognitive-complexity + - name: cognitive-complexity + severity: warning + disabled: false + exclude: [ "TEST" ] + arguments: [ 15 ] + - name: cyclomatic + severity: warning + disabled: false + exclude: [ "" ] + arguments: [ 15 ] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#early-return + - name: early-return + severity: warning + disabled: false + exclude: [ "" ] + arguments: + - preserveScope + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#function-length + - name: function-length + severity: warning + disabled: false + exclude: [ "TEST" ] + arguments: [ 50, 0 ] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#line-length-limit + - name: line-length-limit + severity: warning + disabled: true # using lll instead + exclude: [ "" ] + arguments: [ 80 ] + - name: exported + arguments: + - checkPrivateReceivers + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unhandled-error + - name: unhandled-error + severity: warning + disabled: false + exclude: [ "" ] + arguments: + - "fmt.Print" + - "fmt.Printf" + - "fmt.Println" + - "strings.Builder.WriteString" + - name: unused-parameter + exclude: [ "TEST" ] + # TEST is a magic word in revive to filter out test files, + # no special handling needed in golangci-lint + - name: unused-receiver + disabled: true + - name: var-naming + disabled: true # nice in theory, but unable to customize enough for edge cases. + + stylecheck: + # STxxxx checks in https://staticcheck.io/docs/configuration/options/#checks + # Default: ["*"] + # checks: [ "all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022" ] + # https://staticcheck.io/docs/configuration/options/#dot_import_whitelist + # Default: ["github.com/mmcloughlin/avo/build", "github.com/mmcloughlin/avo/operand", "github.com/mmcloughlin/avo/reg"] + dot-import-whitelist: + - fmt + # https://staticcheck.io/docs/configuration/options/#initialisms + # Default: ["ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IP", "JSON", "QPS", "RAM", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "GID", "UID", "UUID", "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS", "SIP", "RTP", "AMQP", "DB", "TS"] + # initialisms: [ "ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IP", "JSON", "QPS", "RAM", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "GID", "UID", "UUID", "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS", "SIP", "RTP", "AMQP", "DB", "TS" ] + # https://staticcheck.io/docs/configuration/options/#http_status_code_whitelist + # Default: ["200", "400", "404", "500"] + # http-status-code-whitelist: [ "200", "400", "404", "500" ] + tenv: # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. # Default: false all: true + wrapcheck: + # An array of strings that specify substrings of signatures to ignore. + # If this set, it will override the default set of ignored signatures. + # See https://github.com/tomarrell/wrapcheck#configuration for more information. + # Default: [".Errorf(", "errors.New(", "errors.Unwrap(", "errors.Join(", ".Wrap(", ".Wrapf(", ".WithMessage(", ".WithMessagef(", ".WithStack("] + ignoreSigs: + - .Errorf( + - errors.New( + - errors.Unwrap( + - errors.Join( + - .Wrap( + - .Wrapf( + - .WithMessage( + - .WithMessagef( + - .WithStack( + - (context.Context).Err() linters: disable-all: true @@ -196,14 +349,15 @@ linters: - asciicheck # checks that your code does not contain non-ASCII identifiers - bidichk # checks for dangerous unicode character sequences - bodyclose # checks whether HTTP response body is closed successfully + - canonicalheader # checks whether net/http.Header uses canonical header + - copyloopvar # detects places where loop variables are copied (Go 1.22+) - cyclop # checks function and package cyclomatic complexity - dupl # tool for code clone detection - durationcheck # checks for two durations multiplied together - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 - - execinquery # checks query string in Query function which reads your Go src files and warning it finds - exhaustive # checks exhaustiveness of enum switch statements - - exportloopref # checks for pointers to enclosing loop variables + - fatcontext # detects nested contexts in loops - forbidigo # forbids identifiers - funlen # tool for detection of long functions - gocheckcompilerdirectives # validates go compiler directive comments (//go:) @@ -216,15 +370,16 @@ linters: - gocyclo # computes and checks the cyclomatic complexity of functions - godot # checks if comments end in a period - goimports # in addition to fixing imports, goimports also formats your code in the same style as gofmt - - gomnd # detects magic numbers - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - gomodguard # allow and block lists linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations - goprintffuncname # checks that printf-like functions are named with f at the end - gosec # inspects source code for security problems + - intrange # finds places where for loops could make use of an integer range - lll # reports long lines - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) - makezero # finds slice declarations with non-zero initial length - mirror # reports wrong mirror patterns of bytes/strings usage + - mnd # detects magic numbers - musttag # enforces field tags in (un)marshaled structs - nakedret # finds naked returns in functions greater than a specified function length - nestif # reports deeply nested if statements @@ -241,7 +396,8 @@ linters: - reassign # checks that package variables are not reassigned - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint - rowserrcheck # checks whether Err of rows is checked successfully - - sloglint # ensure consistent code style when using log/slog + #- sloglint # ensure consistent code style when using log/slog # NOTE(@danny): Not using slog since this is a CLI. + - spancheck # checks for mistakes with OpenTelemetry/Census spans - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - stylecheck # is a replacement for golint - tenv # detects using os.Setenv instead of t.Setenv since Go1.17 @@ -260,15 +416,15 @@ linters: #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized #- gci # controls golang package import order and makes it always deterministic #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega - #- godox # detects FIXME, TODO and other comment keywords + - godox # detects FIXME, TODO and other comment keywords #- goheader # checks is file header matches to pattern #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters #- interfacebloat # checks the number of methods inside an interface - #- ireturn # accept interfaces, return concrete types + - ireturn # accept interfaces, return concrete types #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated #- tagalign # checks that struct tags are well aligned #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope - #- wrapcheck # checks that errors returned from external packages are wrapped + - wrapcheck # checks that errors returned from external packages are wrapped #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event ## disabled @@ -277,9 +433,11 @@ linters: #- depguard # [replaced by gomodguard] checks if package imports are in a list of acceptable packages #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) #- dupword # [useless without config] checks for duplicate words in the source code + #- err113 # [too strict] checks the errors handling expressions #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted + #- execinquery # [deprecated] checks query string in Query function which reads your Go src files and warning it finds + #- exportloopref # [not necessary from Go 1.22] checks for pointers to enclosing loop variables #- forcetypeassert # [replaced by errcheck] finds forced type assertions - #- goerr113 # [too strict] checks the errors handling expressions #- gofmt # [replaced by goimports] checks whether code was gofmt-ed #- gofumpt # [replaced by goimports, gofumports is not available yet] checks whether code was gofumpt-ed #- gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase @@ -293,25 +451,13 @@ linters: #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines - ## deprecated - #- deadcode # [deprecated, replaced by unused] finds unused code - #- exhaustivestruct # [deprecated, replaced by exhaustruct] checks if all struct's fields are initialized - #- golint # [deprecated, replaced by revive] golint differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes - #- ifshort # [deprecated] checks that your code uses short syntax for if-statements whenever possible - #- interfacer # [deprecated] suggests narrower interface types - #- maligned # [deprecated, replaced by govet fieldalignment] detects Go structs that would take less memory if their fields were sorted - #- nosnakecase # [deprecated, replaced by revive var-naming] detects snake case of variable naming and function name - #- scopelint # [deprecated, replaced by exportloopref] checks for unpinned variables in go programs - #- structcheck # [deprecated, replaced by unused] finds unused struct fields - #- varcheck # [deprecated, replaced by unused] finds unused global variables and constants - issues: # Maximum count of issues with the same text. # Set to 0 to disable. # Default: 3 max-same-issues: 50 - + exclude-use-default: false exclude-rules: - source: "(noinspection|TODO)" linters: [ godot ] @@ -326,3 +472,18 @@ issues: - gosec - noctx - wrapcheck + - linters: + - revive + source: "// Deprecated: " + # Exclude `lll` issues for long lines with `go:generate`. + - linters: + - lll + source: "//nolint:" + # Which dirs to exclude: issues from them won't be reported. + # Can use regexp here: `generated.*`, regexp is applied on full path, + # including the path prefix if one is set. + # Default dirs are skipped independently of this option's value (see exclude-dirs-use-default). + # "/" will be replaced by current OS file path separator to properly work on Windows. + # Default: [] + exclude-dirs: + - tmp* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d12cb4e..95895bc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,48 +1,39 @@ -# When `docker compose` commands are ran, -# these "_VERSION" ARGS are populated by -# the .env file in the root directory, and -# made available at image build time by the -# service.build.args fields set in the -# compose.yaml file. -ARG ALPINE_IMAGE_VERSION -ARG GOLANG_IMAGE_VERSION +# ARGs provide default versions for base images and tooling used in this multi-stage build. +# These values are primarily defined in the root .env file for consistency across builds. +# If no value is passed during build, these defaults will be used. +ARG ALPINE_IMAGE +ARG GOLANG_IMAGE ARG GOFUMPT_VERSION ARG GOLANGCI_LINT_VERSION ARG PKGSITE_VERSION # Create pinned alpine base image -ARG ALPINE_IMAGE_VERSION -FROM $ALPINE_IMAGE_VERSION as alpine-builder +FROM ${ALPINE_IMAGE} AS alpine-builder # Create pinned golang base image -ARG GOLANG_IMAGE_VERSION -FROM $GOLANG_IMAGE_VERSION as golang-builder +FROM ${GOLANG_IMAGE} AS golang-builder +RUN apk add --no-cache git gcc musl-dev -# gofumpt used for stricter gofmt code formatting. -FROM alpine-builder as gofumpt +# gofumpt stage: used for stricter Go code formatting, an extension of gofmt. +FROM golang-builder AS gofumpt ARG GOFUMPT_VERSION -RUN wget -nv -O /bin/gofumpt \ - https://github.com/mvdan/gofumpt/releases/download/$GOFUMPT_VERSION/gofumpt_${GOFUMPT_VERSION}_linux_arm64 \ - && chmod +x /bin/gofumpt +RUN go install mvdan.cc/gofumpt@$GOFUMPT_VERSION -# golangci-lint is used for Go code linting. -FROM alpine-builder as golangci +# golangci-lint stage: used for linting Go code, pulled from the official repository. +FROM alpine-builder AS golangci ARG GOLANGCI_LINT_VERSION +# Download and install golangci-lint using the official install script (recommended). RUN wget -nv -O - https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ | sh -s $GOLANGCI_LINT_VERSION -# pkgsite is used for generating the pkg.go.dev site. -FROM golang-builder as pkgsite +# pkgsite stage: used to generate documentation for Go packages. +FROM golang-builder AS pkgsite ARG PKGSITE_VERSION RUN go install golang.org/x/pkgsite/cmd/pkgsite@$PKGSITE_VERSION # tools target stage contains all tool binaries from the preceeding build stages. FROM golang-builder as tools - -RUN apk add --no-cache gcc musl-dev - -COPY --from=gofumpt /bin/gofumpt /usr/bin +COPY --from=gofumpt /go/bin/gofumpt /usr/bin COPY --from=golangci /bin/golangci-lint /usr/bin COPY --from=pkgsite /go/bin/pkgsite /usr/bin - WORKDIR /workspace diff --git a/Makefile b/Makefile index bb4e632..0b489fb 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,16 @@ +# ///////////////////////////////////////////////////////////// +# Makefile +# \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + # Set the default command of the Makefile to be # the "help" command (printing the command docs). # So the following commands will both print the docs: # `make` and `make help`. .DEFAULT_GOAL := help +# Set the name of the app +APP_NAME := converge + # COMPOSE contains the base Docker Compose command for running various # binaries in the tools Compose service as ephemeral containers. COMPOSE = docker compose @@ -19,11 +26,6 @@ COMPOSE = docker compose # at play with developer machines to ensure consistency across all engineering. TOOLS ?= $(COMPOSE) run --rm --service-ports tools -# CGO contains the base Docker Compose command for -# running various Go tools in the tools Compose service -# as ephemeral containers with CGO_ENABLED=1. -CGO ?= $(COMPOSE) run --rm --service-ports -e CGO_ENABLED=1 tools go - # GOFUMPT contains the base Go command for running gofumpt # defaulting to running it in the tools container. GOFUMPT ?= $(TOOLS) gofumpt @@ -47,16 +49,12 @@ GO ?= $(TOOLS) go # It can be overridden by setting the GOTEST environment variable. GOTEST ?= $(GO) test -# CGOTEST contains the base Go command for running tests with CGO_ENABLED=1. -# It can be overridden by setting the CGOTEST environment variable. -CGOTEST ?= $(CGO) test - # GOVET contains the base Go command for running go vet. # It can be overridden by setting the GOVET environment variable. GOVET ?= $(GO) vet -# TEST_TIMEOUT contains the default timeout value for running tests. -TEST_TIMEOUT =- timeout 1m +# Set and export the $GOPATH env var if not already set. +export GOPATH ?= $(shell go env GOPATH) ################### # Main Targets # @@ -64,7 +62,7 @@ TEST_TIMEOUT =- timeout 1m .PHONY: all ## runs all the things -all: build verify test +all: tidy fmt/fix verify build/cli test/full .PHONY: deps ## checks to make sure all general are present on the machine for use in other make targets @@ -80,31 +78,29 @@ deps: exit 1; \ else \ go mod download; \ - echo "Make: all dependencies installed... nice."; \ fi .PHONY: build ## builds all binaries/images -build: build/cli build/compose - @echo "Make: building binaries and resources..." +build: build/cli compose/build .PHONY: build/cli ## builds the converge CLI binary build/cli: @mkdir -p bin - @VERSION=$$(git describe --tags --always || echo "(dev)") && \ - echo "building converge $$VERSION" && \ - go build -v -trimpath -ldflags "-X main.version=$$VERSION" -o bin/converge + @VERSION=$$(git describe --tags --always || echo "(dev)") \ + && echo "building $(APP_NAME) $$VERSION" \ + && go build -v -trimpath -ldflags "-X main.version=$$VERSION" -o bin/$(APP_NAME) -.PHONY: build/compose +.PHONY: compose/build ## builds resources -build/compose: deps - $(COMPOSE) build --no-cache +compose/build: deps + @$(COMPOSE) build --no-cache .PHONY: compose/clean ## cleans up resources compose/clean: - $(COMPOSE) down --rmi=all --remove-orphans --volumes + @$(COMPOSE) down --rmi=all --remove-orphans --volumes ######################## # Linting/Verify # @@ -117,12 +113,12 @@ verify: lint vet .PHONY: lint ## runs all code linters lint: - $(GOLINT) run + @$(GOLINT) run .PHONY: vet ## runs go vet on all source files vet: - $(GOVET) ./... + @$(GOVET) ./... ################ # Format # @@ -131,48 +127,51 @@ vet: .PHONY: fmt/check ## checks code formatting on all source files and errors if bad formatting is detected fmt/check: - $(GOFUMPT) -extra -d . + @$(GOFUMPT) -extra -d . .PHONY: fmt/fix ## runs gofumpt code formatter on all source files and fixes any formatting issues fmt/fix: - $(GOFUMPT) -extra -l -w . + @$(GOFUMPT) -extra -l -w . ################ # Test # ################ +# Default to running tests with the -race flag enabled, +# but allow the user to disable it by setting the RACE +# environment variable to false. e.g.: `RACE=false make test` +RACE ?= true +RACE_FLAG := $(if $(filter true,$(RACE)),-race,) +TEST_FLAGS := -v -timeout 1m +COVER_FLAGS := + .PHONY: test -## runs all Go unit tests +## runs all the Go unit tests in the repo test: - $(GOTEST) -v ./... + @$(GOTEST) $(RACE_FLAG) $(TEST_FLAGS) $(COVER_FLAGS) ./... .PHONY: test/cover ## runs all the tests with coverage enabled -test/cover: - $(GOTEST) -v ./... -coverprofile=coverage.out -covermode=atomic +test/cover: COVER_FLAGS = -coverprofile=coverage.out -covermode=atomic +test/cover: test -.PHONY: test/cover/html +.PHONY: test/full ## runs all the tests with coverage enabled and opens the coverage report in the browser -test/cover/html: test/cover - $(GO) tool cover -html=coverage.out -o coverage.html +test/full: test/cover + @$(GO) tool cover -html=coverage.out -o coverage.html @open coverage.html -.PHONY: test/race -## runs all tests with the race detector enabled -test/race: - $(CGOTEST) -v -race ./... - -.PHONY: test/full -## runs all the tests with coverage and race detector enabled -test/full: - $(CGOTEST) -v -race ./... -coverprofile=coverage.out -covermode=atomic - ################ # Release # ################ +.PHONY: tidy +## runs go mod tidy +tidy: + @$(GO) mod tidy + .PHONY: release ## Issues a new release with git tag. Example usage: make release VERSION=v1.0.0 release: @@ -184,6 +183,7 @@ release: git tag $(VERSION) git push origin $(VERSION) + ################ # Halp # ################ @@ -193,7 +193,7 @@ release: docs: @echo "once server is running, visit the following url in your browser:" @echo "http://localhost:3030" - $(PKGSITE) -http=0.0.0.0:3030 + @$(PKGSITE) -http=0.0.0.0:3030 .PHONY: help ## prints out the help documentation (also will be printed by simply running `make` command with no arg) diff --git a/README.md b/README.md index a932ca6..ea55917 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ files is beneficial. - Efficiently merges multiple Go source files from a specified directory into a single consolidated file. - Allows exclusion of specific files from the merging process. - Supports an optional timeout setting for the merge operation. -- Employs a configurable worker pool for enhanced concurrency management. ## Installation @@ -31,43 +30,24 @@ go install github.com/dannyhinshaw/converge ## Usage -Run Converge with the command below, specifying the source directory -and (optionally) the output file: +Reference the `help` command for detailed usage instructions: ```bash -converge --file= +converge --help ``` -## Options - - -f, --file Path to the output file where the merged content will be written; - defaults to stdout if not specified. - -v Enable verbose logging for debugging purposes. - -h, --help Show this help message and exit. - --version Show version information. - -t, --timeout Maximum time (in seconds) before cancelling the merge operation; - if not specified, the command runs until completion. - -w, --workers Maximum number of concurrent workers in the worker pool. - -e, --exclude Comma-separated list of filenames to exclude from merging. - ## Example To merge all Go files in the 'src' directory into 'merged.go': ```bash -converge ./src --file=./merged.go +converge --dir=./src --output=./merged.go ``` To merge all Go files in the 'src' directory and pipe to clipboard: ```bash -converge ./src | pbcopy -``` - -For more detailed usage instructions, refer to the tool's help message: - -```bash -converge --help +converge --dir=./src | pbcopy ``` ## License diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..c0030a2 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,214 @@ +// Package cmd provides the main entry point for the converge CLI tool. +// It sets up the necessary command-line flags, handles user input, and +// triggers the converge operation with the given options. +// The package also manages logging, error handling, and execution flow. +package cmd + +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/spf13/cobra" + + "github.com/dannyhinshaw/converge/cmd/converge" + "github.com/dannyhinshaw/converge/internal/gonverge" + "github.com/dannyhinshaw/converge/internal/olog" +) + +// dopeASCII is just a dope ASCII art string. +const dopeASCII = ` +┏┏┓┏┓┓┏┏┓┏┓┏┓┏┓ +┗┗┛┛┗┗┛┗ ┛ ┗┫┗ + ┛ + +` + +// defaultTimeout is the default amount of time before +// cancelling the converge operation. +const defaultTimeout = 15 * time.Second + +// usageTemplate is a utility function for replacing the default usage +// template with any custom usage template in a central location. +func usageTemplate(c cobra.Command) string { + return dopeASCII + c.UsageTemplate() +} + +// NewRoot creates the root command for the converge CLI tool. +// It sets up the command-line flags, initializes logging, and +// runs the converge operation with the provided options. +func NewRoot(version string) *cobra.Command { + var rootCmd cmd + c := cobra.Command{ + Version: version, + Use: "converge [flags]", + Short: "Merge multiple Go source files into a single file", + Long: ` +Converge merges multiple Go source files from a directory into a single file. + +By default, the tool does not process directories recursively. You can specify +the source directory with the --dir flag and an output file using --output. If +no output file is provided, the result will be printed to stdout. You can exclude +files by providing regular expressions with the --exclude flag. + +The result is formatted according to Go's standard "gofmt" style. +`, + Args: cobra.MaximumNArgs(0), + Run: func(cmd *cobra.Command, _ []string) { + ctx, cancel := context.WithTimeout(cmd.Context(), rootCmd.timeout) + defer cancel() + + // Default to only logging errors. + lvl := olog.LevelError + if rootCmd.verbose { + lvl = olog.LevelDebug + } + + lg := olog.NewLogger(lvl). + WithName("converge") + + lg.Info("Starting converge operation...") + lg.Debug("Verbose logging enabled.") + + rootCmd.lg = lg.WithName("rootCmd") + if err := rootCmd.run(ctx); err != nil { + lg.Error("failed to run command:", err) + return + } + + // Only print success message if an outfile was provided. + // This is to prevent the success message from being printed + // when the converged code output is written to stdout. + if rootCmd.outfile != "" { + lg.Info("Converge operation completed successfully.") + } + }, + } + + bindFlags(&c, &rootCmd) + c.SetUsageTemplate( + usageTemplate(c), + ) + + return &c +} + +// bindFlags handles binding the command-line flags to the root command. +func bindFlags(c *cobra.Command, rootCmd *cmd) { + fs := c.Flags() + + fs.StringVarP(&rootCmd.dir, + "dir", "d", ".", + "The directory containing Go files to merge", + ) + fs.StringVarP(&rootCmd.outfile, + "output", "o", "./converged.go", + "File to write the merged Go code", + ) + fs.StringSliceVarP(&rootCmd.exclude, + "exclude", "e", nil, + "Regular expressions for filenames to exclude from merging", + ) + fs.DurationVarP(&rootCmd.timeout, + "timeout", "t", defaultTimeout, + "Maximum duration before canceling the operation (e.g., '5s', '1m')", + ) + fs.BoolVarP(&rootCmd.verbose, + "verbose", "v", false, + "Enable verbose logging for debugging purposes", + ) + // Note(@danny): In the future add a flag that allows users + // to configure words to replace in the converged file. + // Also, add ability to remove duplicate imports, types, + // functions (etc), from the converged file. +} + +// cmd holds the command-line options and utilities +// for running the converge command. +type cmd struct { + // lg is the logger for the command. + lg olog.LevelLogger + + // dir is the source directory containing + // Go source files to be converged. + dir string + + // outfile is the path to the output file where the + // converged content will be written; defaults to + // stdout if not specified. + outfile string + + // exclude is a list of regex patterns to be used for + // excluding files from converge if they match. + exclude []string + + // timeout is the maximum time (in seconds) before + // cancelling the converge operation. + timeout time.Duration + + // verbose enables verbose logging + // for debugging purposes. + verbose bool +} + +// run executes the converge command. +func (c *cmd) run(ctx context.Context) error { + c.lg.Debug("Starting converge command") + + // Create the converger that will handle + // the low level processing of the files. + converger, err := createConverger(c.lg.WithName("converger"), c.exclude) + if err != nil { + return fmt.Errorf("failed to create converger: %w", err) + } + + // Create the command that will run the converger + // and write the output to the specified file. + convergeCmd := createCommand(converger, c.dir, c.outfile) + if err = convergeCmd.Run(ctx); err != nil { + return fmt.Errorf("failed to run command: %w", err) + } + + c.lg.Debug("Converge command completed successfully.") + + if c.outfile != "" { + c.lg.Infof("Successfully merged '%s' into '%s'.", c.dir, c.outfile) + } + + return nil +} + +// createCommand creates a new converge.Command with the given options. +func createCommand(converger converge.FileConverger, dir, outFile string) *converge.Command { + var cmdOpts []converge.Option + if outFile != "" { + cmdOpts = append(cmdOpts, converge.WithDstFile(outFile)) + } + return converge.NewCommand(converger, dir, cmdOpts...) +} + +// createConverger creates a new gonverge.GoFileConverger by handling +// which options to set and passed into the converger. +func createConverger(lg olog.LevelLogger, ex []string) (*gonverge.GoFileConverger, error) { + var gonvOpts []gonverge.Option + if lg != nil { + gonvOpts = append(gonvOpts, gonverge.WithLogger( + lg.WithName("gonverge"), + )) + } + + var excludes []regexp.Regexp + for _, e := range ex { + re, err := regexp.Compile(e) + if err != nil { + return nil, fmt.Errorf("failed to compile regex: %w", err) + } + excludes = append(excludes, *re) + } + if len(excludes) > 0 { + gonvOpts = append(gonvOpts, gonverge.WithExcludes(excludes)) + } + + return gonverge.NewGoFileConverger(gonvOpts...), nil +} diff --git a/cmd/converge.go b/cmd/converge.go deleted file mode 100644 index 6401393..0000000 --- a/cmd/converge.go +++ /dev/null @@ -1,154 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "sync" -) - -// FileConverger is a type that can converge multiple files into one. -type FileConverger interface { - // ConvergeFiles converges all files in the given directory and - // package into one and writes the result to the given output. - ConvergeFiles(ctx context.Context, src string, w io.Writer) error -} - -// Converge holds all the dependencies for the converge command -// and is used to validate, build, and run the command. -type Converge struct { - mu sync.Mutex - - // fc is the file converger to use. - fc FileConverger - - // src is the directory to read files from. - src string - - // dst is the destination file for the output, - // if one was provided. - dst string - - // writer is the destination for the output. - writer io.Writer -} - -// NewCommand returns a new Converge Converge. -func NewCommand(fc FileConverger, src string, opts ...Option) *Converge { - c := Converge{ - fc: fc, - src: src, - } - - for _, opt := range opts { - opt(&c) - } - - return &c -} - -// Run runs the converge command. -func (c *Converge) Run(ctx context.Context) error { - if err := c.build(); err != nil { - return fmt.Errorf("failed to build i/o resources: %w", err) - } - if err := c.validate(); err != nil { - return fmt.Errorf("failed to validate i/o resources: %w", err) - } - - return c.fc.ConvergeFiles(ctx, c.src, c.writer) -} - -// build handles getting the full path to the source directory and destination file (if supplied). -// If the destination file is not supplied, it will validate the writer provided is not nil. -// If the writer is nil, it will default to os.Stdout. -// -// In the case where both the destination file and writer are supplied, the destination file -// will take precedence and the writer will be ignored. -// -// It's important that build is ran before validate, because validate depends on the full path -// to the source directory and destination file (if supplied). -func (c *Converge) build() error { - c.mu.Lock() - defer c.mu.Unlock() - - var err error - if c.src, err = filepath.Abs(c.src); err != nil { - return fmt.Errorf("failed to get absolute path to source directory %s: %w", c.src, err) - } - - // No dest file or writer supplied, - // just default to os.Stdout. - if c.writer == nil { - c.writer = os.Stdout - } - if c.dst == "" { - return nil - } - - // Destination file supplied, so we'll need the - // absolute path to it for validation and writing. - if c.dst, err = filepath.Abs(c.dst); err != nil { - return fmt.Errorf("failed to get absolute path to destination file %s: %w", c.dst, err) - } - if c.writer, err = os.Create(c.dst); err != nil { - return fmt.Errorf("failed to create destination file %s: %w", c.dst, err) - } - - return nil -} - -// validate performs an initial check to make sure that the src and dst -// arguments are for objects that actually exist on the users system before kicking -// off the full-blown converge operation. -func (c *Converge) validate() error { - c.mu.Lock() - defer c.mu.Unlock() - - errCh := make(chan error) - - go func() { - defer close(errCh) - if err := validateSrcDir(c.src); err != nil { - errCh <- err - return - } - if err := validateDstFile(c.dst); err != nil { - errCh <- err - } - }() - - if err, ok := <-errCh; ok { - return err - } - - return nil -} - -// validateSrcDir makes sure that the source directory exists, is a directory, -// and that we have permission to read from it. -func validateSrcDir(src string) error { - switch srcInfo, err := os.Stat(src); { - case err != nil && !os.IsNotExist(err): - return fmt.Errorf("failed to access source %s: %w", src, err) - case err == nil && !srcInfo.IsDir(): - return fmt.Errorf("source %s is not a directory", src) - default: - return nil - } -} - -// validateDstFile makes sure that if the destination file already exists; -// it is not a directory, and we have permission to write to it. -func validateDstFile(dst string) error { - switch dstInfo, err := os.Stat(dst); { - case err != nil && !os.IsNotExist(err): - return fmt.Errorf("failed to access destination file %s: %w", dst, err) - case err == nil && dstInfo.IsDir(): - return fmt.Errorf("destination file %s is a directory", dst) - default: - return nil - } -} diff --git a/cmd/converge/converge.go b/cmd/converge/converge.go new file mode 100644 index 0000000..4750ee4 --- /dev/null +++ b/cmd/converge/converge.go @@ -0,0 +1,183 @@ +// Package converge provides the command structure for the "converge" CLI tool. +// This command uses a FileConverger interface to merge multiple files from +// a source directory into a single file. The design allows for future support +// of different file types by implementing additional FileConverger variants. +package converge + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" +) + +// FileConverger is a type that can converge multiple files into one. +type FileConverger interface { + // ConvergeFiles converges all files in the given directory and + // package into one and writes the result to the given output. + ConvergeFiles(ctx context.Context, dir string, w io.Writer) error +} + +// Command holds the configuration and dependencies for the "converge" command. +// If a destination file (dst) is specified, it takes precedence over the writer. +// Otherwise, output defaults to os.Stdout or the provided writer. +type Command struct { + // dir is the directory to read files from. + dir string + + // dst is the destination file for the output, + // if one was provided. + dst string + + // fc is the file converger to use. + fc FileConverger + + // writer is the destination for the output. + writer io.Writer +} + +// NewCommand returns a new Command with standard defaults. +func NewCommand(fc FileConverger, dir string, opts ...Option) *Command { + c := Command{ + fc: fc, + dir: dir, + dst: "", + writer: os.Stdout, + } + for _, opt := range opts { + opt(&c) + } + return &c +} + +// Option is a function that configures a Command. +type Option func(*Command) + +// WithWriter sets the writer to use for the output. +func WithWriter(w io.Writer) Option { + return func(c *Command) { + c.writer = w + } +} + +// WithDstFile sets the destination file to use for the output. +func WithDstFile(dst string) Option { + return func(c *Command) { + c.dst = dst + } +} + +// Run runs the converge command. +func (c *Command) Run(ctx context.Context) error { + if err := c.build(); err != nil { + return fmt.Errorf("failed to build converge command: %w", err) + } + if err := c.validate(); err != nil { + return fmt.Errorf("failed to validate converge command: %w", err) + } + if err := c.fc.ConvergeFiles(ctx, c.dir, c.writer); err != nil { + return fmt.Errorf("failed to converge files: %w", err) + } + return nil +} + +// build prepares the command for execution by converting paths to absolute paths, +// setting up the writer, and ensuring the output destination is valid. +// +// It must be run before validate since validate depends on these paths. +func (c *Command) build() error { + var err error + if c.dir, err = filepath.Abs(c.dir); err != nil { + return fmt.Errorf("failed to get absolute path to source directory %s: %w", c.dir, err) + } + + // No dest file or writer supplied, + // just default to os.Stdout. + if c.writer == nil { + c.writer = os.Stdout + } + if c.dst == "" { + return nil + } + + // Destination file supplied, so we'll need the + // absolute path to it for validation and writing. + if c.dst, err = filepath.Abs(c.dst); err != nil { + return fmt.Errorf("failed to get absolute path to destination file %s: %w", c.dst, err) + } + if c.writer, err = os.Create(c.dst); err != nil { + return fmt.Errorf("failed to create destination file %s: %w", c.dst, err) + } + + return nil +} + +// validate performs an initial check to make sure that the src and dst +// arguments are for objects that actually exist on the users system before kicking +// off the full-blown converge operation. +func (c *Command) validate() error { + var ( + merr error + wg sync.WaitGroup + errCh = make(chan error, 1) + ) + + validators := []func(){ + func() { + defer wg.Done() + if err := validateSrcDir(c.dir); err != nil { + errCh <- err + } + }, + func() { + defer wg.Done() + if err := validateDstFile(c.dst); err != nil { + errCh <- err + } + }, + } + + for _, fn := range validators { + wg.Add(1) + go fn() + } + + wg.Wait() + close(errCh) + + // Drain the error channel and join + // all errors into one. + for err := range errCh { + merr = errors.Join(merr, err) + } + return merr +} + +// validateSrcDir checks that the source directory exists, is a directory, +// and that the user has permission to read from it. +func validateSrcDir(src string) error { + switch srcInfo, err := os.Stat(src); { + case err != nil && !os.IsNotExist(err): + return fmt.Errorf("failed to access source %s: %w", src, err) + case err == nil && !srcInfo.IsDir(): + return fmt.Errorf("source %s is not a directory", src) + default: + return nil + } +} + +// validateDstFile ensures that the destination file is not a directory and +// checks for write permissions. If the file doesn't exist, no error is returned. +func validateDstFile(dst string) error { + switch dstInfo, err := os.Stat(dst); { + case err != nil && !os.IsNotExist(err): + return fmt.Errorf("failed to access destination file %s: %w", dst, err) + case err == nil && dstInfo.IsDir(): + return fmt.Errorf("destination file %s is a directory", dst) + default: + return nil + } +} diff --git a/cmd/converge_test.go b/cmd/converge/converge_test.go similarity index 65% rename from cmd/converge_test.go rename to cmd/converge/converge_test.go index 21e6e9e..8c99e8c 100644 --- a/cmd/converge_test.go +++ b/cmd/converge/converge_test.go @@ -1,58 +1,61 @@ -package cmd_test +package converge_test import ( "bytes" "context" "os" "path/filepath" - "sync" + "regexp" "testing" - "time" - "github.com/dannyhinshaw/converge/cmd" + "github.com/stretchr/testify/require" + + "github.com/dannyhinshaw/converge/cmd/converge" "github.com/dannyhinshaw/converge/internal/gonverge" ) func TestConverge_Run(t *testing.T) { + reExclude := regexp.MustCompile("exclude.go") + tests := map[string]struct { - setup func() (*cmd.Converge, func()) + setup func() (*converge.Command, func()) err bool }{ "BasicConvergence": { - setup: func() (*cmd.Converge, func()) { + setup: func() (*converge.Command, func()) { srcDir, cleanupSrc := createTempDirWithFiles(t, map[string]string{ "file1.go": "package main\nfunc main() {}", // Add more files as needed }) fc := gonverge.NewGoFileConverger() - opt := cmd.WithWriter(bytes.NewBuffer(nil)) - cmdRunner := cmd.NewCommand(fc, srcDir, opt) + opt := converge.WithWriter(bytes.NewBuffer(nil)) + cmdRunner := converge.NewCommand(fc, srcDir, opt) return cmdRunner, cleanupSrc }, err: false, }, "InvalidSourceDirectory": { - setup: func() (*cmd.Converge, func()) { + setup: func() (*converge.Command, func()) { fc := gonverge.NewGoFileConverger() - opt := cmd.WithWriter(bytes.NewBuffer(nil)) - cmdRunner := cmd.NewCommand(fc, "/invalid/dir", opt) + opt := converge.WithWriter(bytes.NewBuffer(nil)) + cmdRunner := converge.NewCommand(fc, "/invalid/dir", opt) return cmdRunner, func() {} }, err: true, }, "OutputToFile": { - setup: func() (*cmd.Converge, func()) { + setup: func() (*converge.Command, func()) { srcDir, cleanupSrc := createTempDirWithFiles(t, map[string]string{ "file1.go": "package main\nfunc main() {}", }) outFile, cleanupOut := createTempFile(t) - opt := cmd.WithDstFile(outFile.Name()) + opt := converge.WithDstFile(outFile.Name()) fc := gonverge.NewGoFileConverger() - cmdRunner := cmd.NewCommand(fc, srcDir, opt) + cmdRunner := converge.NewCommand(fc, srcDir, opt) return cmdRunner, func() { cleanupSrc() @@ -62,18 +65,18 @@ func TestConverge_Run(t *testing.T) { err: false, }, "ExclusionListEffectiveness": { - setup: func() (*cmd.Converge, func()) { + setup: func() (*converge.Command, func()) { srcDir, cleanupSrc := createTempDirWithFiles(t, map[string]string{ "file1.go": "package main\nfunc main() {}", "exclude.go": "// This file should be excluded", }) - opt := gonverge.WithExcludes([]string{ - "exclude.go", + opt := gonverge.WithExcludes([]regexp.Regexp{ + *reExclude, }) fc := gonverge.NewGoFileConverger(opt) - cmdRunner := cmd.NewCommand(fc, srcDir) + cmdRunner := converge.NewCommand(fc, srcDir) return cmdRunner, cleanupSrc }, @@ -88,46 +91,31 @@ func TestConverge_Run(t *testing.T) { err := converger.Run(context.Background()) if tc.err && err == nil { - t.Errorf("Converge.Run() error = %v, wantErr %v", err, tc.err) + t.Errorf("Command.Run() error = %v, wantErr %v", err, tc.err) } }) } } func TestConverge_ContextCancellation(t *testing.T) { + r := require.New(t) + srcDir, cleanupSrc := createTempDirWithFiles(t, map[string]string{ "file1.go": "package main\nfunc main() {}", }) defer cleanupSrc() fc := gonverge.NewGoFileConverger() - opt := cmd.WithWriter(bytes.NewBuffer(nil)) - cmdRunner := cmd.NewCommand(fc, srcDir, opt) + opt := converge.WithWriter(bytes.NewBuffer(nil)) + cmdRunner := converge.NewCommand(fc, srcDir, opt) ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - var wg sync.WaitGroup - wg.Add(1) - - // Run the command in a separate goroutine. - go func() { - defer wg.Done() - _ = cmdRunner.Run(ctx) - }() - - // Cancel the context after a brief delay to simulate interruption. - time.Sleep(100 * time.Millisecond) cancel() - // Wait for the command to complete. - wg.Wait() - - // Try to run the command again after cancellation. + // Try to run the command after cancellation. // We expect an error due to context cancellation. - if err := cmdRunner.Run(ctx); err == nil { - t.Errorf("Expected error due to context cancellation, but got nil") - } + err := cmdRunner.Run(ctx) + r.ErrorIs(err, context.Canceled) } // createTempFile creates a single temp file, returning the file pointer and a cleanup function. @@ -137,7 +125,7 @@ func createTempFile(t *testing.T) (*os.File, func()) { if err != nil { t.Fatalf("Failed to create temp file: %v", err) } - return file, func() { os.Remove(file.Name()) } + return file, func() { _ = os.Remove(file.Name()) } } // createTempDirWithFiles creates a temp directory with the given files. @@ -155,5 +143,5 @@ func createTempDirWithFiles(t *testing.T, files map[string]string) (string, func } } - return dir, func() { os.RemoveAll(dir) } + return dir, func() { _ = os.RemoveAll(dir) } } diff --git a/cmd/options.go b/cmd/options.go deleted file mode 100644 index d0e05a7..0000000 --- a/cmd/options.go +++ /dev/null @@ -1,20 +0,0 @@ -package cmd - -import "io" - -// Option is a function that configures a Converge. -type Option func(*Converge) - -// WithWriter sets the writer to use for the output. -func WithWriter(w io.Writer) Option { - return func(c *Converge) { - c.writer = w - } -} - -// WithDstFile sets the destination file to use for the output. -func WithDstFile(dst string) Option { - return func(c *Converge) { - c.dst = dst - } -} diff --git a/compose.yaml b/compose.yaml index c8e30d7..7b578c6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,13 +1,13 @@ services: tools: - container_name: tools - image: img/tools:latest + container_name: converge_tools + image: img/converge-tools build: context: . dockerfile: Dockerfile args: - ALPINE_IMAGE_VERSION: $ALPINE_IMAGE_VERSION - GOLANG_IMAGE_VERSION: $GOLANG_IMAGE_VERSION + ALPINE_IMAGE: $ALPINE_IMAGE + GOLANG_IMAGE: $GOLANG_IMAGE GOFUMPT_VERSION: $GOFUMPT_VERSION GOLANGCI_LINT_VERSION: $GOLANGCI_LINT_VERSION PKGSITE_VERSION: $PKGSITE_VERSION @@ -15,11 +15,11 @@ services: - "3030:3030" volumes: - ./:/workspace - - go-cache:/go/.cache + - go_cache:/go/.cache - $GOPATH/pkg/mod:/go/pkg/mod volumes: - go-cache: + go_cache: networks: tools: diff --git a/go.mod b/go.mod index fa05062..531253e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,16 @@ module github.com/dannyhinshaw/converge -go 1.21.2 +go 1.23.0 + +require ( + github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4353b61 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/cli.go b/internal/cli/cli.go deleted file mode 100644 index db5222f..0000000 --- a/internal/cli/cli.go +++ /dev/null @@ -1,130 +0,0 @@ -package cli - -import ( - "flag" - "fmt" -) - -// App is the CLI application struct. -type App struct { - // SrcDir is the path to the source directory - // containing Go source files to be converged. - SrcDir string - - // OutFile is the path to the output file where the - // converged content will be written; defaults to - // stdout if not specified. - OutFile string - - // Packages is a list of specific Packages to include - // in the converged file. - Packages string - - // Exclude is a comma-separated list of regex patterns - // that will be used to Exclude files from converging. - Exclude string - - // Workers is the maximum number of concurrent Workers - // in the worker pool. - Workers int - - // Timeout is the maximum time (in seconds) before - // cancelling the converge operation. - Timeout int - - // VerboseLog enables verbose logging - // for debugging purposes. - VerboseLog bool - - // ShowVersion shows version information and exits. - ShowVersion bool -} - -// NewApp creates a new CLI application struct with the arguments parsed. -func NewApp() App { - var a App - - // OutFile flag (short and long version) - flag.StringVar(&a.OutFile, "f", "", "Output file for merged content; defaults to stdout if not specified") - flag.StringVar(&a.OutFile, "file", "", "") - - // Packages flag (short and long version) - flag.StringVar(&a.Packages, "p", "", "Comma-separated list of packages to include") - flag.StringVar(&a.Packages, "pkg", "", "") - - // Exclude flag (short and long version) - flag.StringVar(&a.Exclude, "e", "", "Comma-separated list of regex patterns to exclude") - flag.StringVar(&a.Exclude, "exclude", "", "") - - // Workers flag (short and long version) - flag.IntVar(&a.Workers, "w", 0, "Maximum number of workers to use for file processing") - flag.IntVar(&a.Workers, "workers", 0, "") - - // Timeout flag (short and long version) - flag.IntVar(&a.Timeout, "t", 0, "Maximum time in seconds before cancelling the operation") - flag.IntVar(&a.Timeout, "timeout", 0, "") - - // Verbose flag (short version only) - flag.BoolVar(&a.VerboseLog, "v", false, "Enable verbose logging") - - // Version flag (long version only) - flag.BoolVar(&a.ShowVersion, "version", false, "Show version information and exit") - - // Custom usage message - flag.Usage = func() { - fmt.Println(a.Usage()) //nolint:forbidigo // not debugging - flag.PrintDefaults() - } - flag.Parse() - - return a -} - -// ParseSrcDir parses the source directory from the positional arguments. -func (a App) ParseSrcDir() (string, error) { - if flag.NArg() < 1 { - return "", fmt.Errorf("source directory is required") - } - - return flag.Arg(0), nil -} - -// Usage returns the usage help message. -func (a App) Usage() string { - return ` - -┏┏┓┏┓┓┏┏┓┏┓┏┓┏┓ -┗┗┛┛┗┗┛┗ ┛ ┗┫┗ - ┛ - -Usage: converge [options] - -The converge tool provides ways to 'converge' multiple Go source files into a single file. -By default it does not converge files in subdirectories and ignores test files (_test.go). - -Arguments: - Path to the directory containing Go source files to be converged. - -Options: - -f, --file Path to the output file where the converged content will be written; - defaults to stdout if not specified. - -p, --pkg List of specific packages to include in the converged file. - Note that if you converge multiple packages the converged file will - not be compilable. - -t, --timeout Maximum time (in seconds) before cancelling the converge operation; - if not specified, the command runs until completion. - -w, --workers Maximum number of concurrent workers in the worker pool. - -e, --exclude Comma-separated list of regex patterns to exclude from converging. - -v Enable verbose logging for debugging purposes. - -h, --help Show this help message and exit. - --version Show version information. - -Examples: - converge . -o converged.go All Go files in current dir into 'converged.go' - converge . -p included_test,included All Go files with package name included_test or included. - converge . -v Run with verbose logging enabled. - converge . -t 60 Run with a timeout of 60 seconds. - converge . -w 4 Run using a maximum of 4 workers. - converge . -e "file1.go,pattern(.*).go" Run while excluding 'file1.go' and 'file2.go'. -` -} diff --git a/internal/gonverge/gofile.go b/internal/gonverge/gofile.go index b6f6f28..80c1c97 100644 --- a/internal/gonverge/gofile.go +++ b/internal/gonverge/gofile.go @@ -1,14 +1,19 @@ package gonverge import ( + "fmt" "go/format" "strings" ) -// goFile is structure that holds the contents of a Go file, -// and can be used to generate a new Go file. +// goFile represents the contents of a Go source file, +// including its package name, imports, and code. +// +// This struct is used to aggregate multiple Go files into +// a single file, maintaining proper syntax and formatting. type goFile struct { - // pkgName is the name of the package that the file belongs to. + // pkgName is the name of the package + // that the file belongs to. pkgName string // imports is a set of all imports for the file. @@ -25,12 +30,15 @@ func newGoFile() *goFile { } } -// addImport adds the given import to the set of imports. +// addImport adds the given import to the set of imports, +// ensuring no duplicate imports are added. This is important +// when merging multiple Go files that may have overlapping dependencies. func (f *goFile) addImport(importLine string) { f.imports[importLine] = struct{}{} } -// appendCode appends the literal code to the code block. +// appendCode adds a line of Go code to the current file. Each +// line is appended with a newline character to maintain proper syntax. func (f *goFile) appendCode(code string) { f.code.WriteString(code) f.code.WriteString("\n") @@ -79,16 +87,31 @@ func (f *goFile) buildImports() string { // FormatCode formats the code in the goFile and returns the result. func (f *goFile) FormatCode() ([]byte, error) { + // Use a strings.Builder to build + // the newly converged Go file. var builder strings.Builder + + // Write the package name. builder.WriteString("package ") builder.WriteString(f.pkgName) builder.WriteString("\n\n") + + // Write the imports. if len(f.imports) > 0 { imports := f.buildImports() builder.WriteString(imports) } + + // Write the code. builder.WriteString(f.code.String()) // Use go/format to format the code in standard gofmt style. - return format.Source([]byte(builder.String())) + // Note(@danny): We should also allow the user to specify + // using gofumpt or other formatters. + b, err := format.Source([]byte(builder.String())) + if err != nil { + return nil, fmt.Errorf("failed to format code: %w", err) + } + + return b, nil } diff --git a/internal/gonverge/gonverge.go b/internal/gonverge/gonverge.go index 8e469d6..2283cf6 100644 --- a/internal/gonverge/gonverge.go +++ b/internal/gonverge/gonverge.go @@ -1,39 +1,81 @@ +// Package gonverge provides a tool for merging multiple Go files into one. +// This is particularly useful for simplifying Go codebases by combining +// related Go source files while preserving proper package structure and imports. package gonverge import ( "context" "fmt" "io" + "regexp" "runtime" "sync" - "github.com/dannyhinshaw/converge/internal/logger" + "github.com/dannyhinshaw/converge/internal/olog" ) -// GoFileConverger is a struct that converges multiple Go files into one. +// maxWorkers is the maximum amount of workers to use for processing files. +const maxWorkers = 32 + +// debugLogger represents a logger that only logs debug messages. +type debugLogger interface { + // Debugf logs a formatted debug message. + Debugf(format string, v ...any) + + // Debug logs a debug message. + Debug(v ...any) + + // WithName returns a new logger with the given name. + WithName(name string) olog.LevelLogger +} + +// GoFileConverger is responsible for merging multiple Go source files +// into a single file. It uses a worker pool to process files in parallel, +// respecting exclusion patterns and logging progress. The result is a single, +// well-formatted Go file. type GoFileConverger struct { - // MaxWorkers determines the maximum amount + // workers determines the maximum amount // of file workers that will be created to // process all files in the given directory. - MaxWorkers int + workers int + + // exclude is a map of regular expressions + // to apply to file names for exclusion. + exclude map[string]regexp.Regexp - // Packages is a list of packages to include in the output. - // If empty, converger will default to top-level NON-TEST - // package in the given directory. - Packages []string + // lg is the logger to use for logging. + lg debugLogger + + // fpCh is the channel to send file paths to. + // fpCh is buffered so consumers can finish + // processing their files after the producer + // has closed the channel. + fpCh chan string - // Excludes is a list of files to exclude from merging. - Excludes []string + // resCh is the channel that delivers processed files. + resCh chan *goFile - // Logger is the logger to use for logging. - Logger logger.LevelLogger + // errCh is the channel that concurrent functions + // can send errors to for + errCh chan error } -// NewGoFileConverger creates a new GoFileConverger with sensible defaults. +// NewGoFileConverger creates a new GoFileConverger with sensible defaults, +// including a no-op logger. To enable logging, use the WithLogger option. func NewGoFileConverger(opts ...Option) *GoFileConverger { + // Upper limit of 32 workers. + workers := runtime.NumCPU() + if workers > maxWorkers { + workers = maxWorkers + } + gfc := GoFileConverger{ - MaxWorkers: runtime.NumCPU(), - Logger: &logger.NoopLogger{}, + workers: workers, + exclude: make(map[string]regexp.Regexp), + fpCh: make(chan string, workers), + resCh: make(chan *goFile), + errCh: make(chan error), + lg: olog.NewNoopLogger(), } for _, opt := range opts { @@ -43,41 +85,82 @@ func NewGoFileConverger(opts ...Option) *GoFileConverger { return &gfc } +// Option is a functional option for the GoFileConverger. +type Option func(*GoFileConverger) + +// WithLogger sets the logger for the GoFileConverger. +func WithLogger(lg debugLogger) Option { + return func(gfc *GoFileConverger) { + gfc.lg = lg + } +} + +// WithExcludes allows the caller to specify a list of regular +// expressions that define which files should be excluded from +// the merging process. This is useful for excluding test files +// or specific files in a directory. +func WithExcludes(excludes []regexp.Regexp) Option { + return func(gfc *GoFileConverger) { + for _, e := range excludes { + gfc.exclude[e.String()] = e + } + } +} + +// WithMaxWorkers sets the maximum amount of workers to use and +// adjusts the file producer channel accordingly. +func WithMaxWorkers(maxWorkers int) Option { + return func(gfc *GoFileConverger) { + gfc.workers = maxWorkers + gfc.fpCh = make(chan string, maxWorkers) + } +} + // ConvergeFiles converges all Go files in the given directory and // package into one and writes the result to the given output. -func (gfc *GoFileConverger) ConvergeFiles(ctx context.Context, src string, w io.Writer) error { - // fpCh is buffered so consumers can finish - // processing their files after the producer - // has closed the channel. - fpCh := make(chan string, gfc.MaxWorkers) - resCh := make(chan *goFile) - errCh := make(chan error) - var wg sync.WaitGroup +func (c *GoFileConverger) ConvergeFiles(ctx context.Context, dir string, w io.Writer) error { + var ( + producerWG sync.WaitGroup + consumerWG sync.WaitGroup + ) + + lg := c.lg.WithName("ConvergeFiles") // Start consumer worker pool - for i := 0; i < gfc.MaxWorkers; i++ { - wg.Add(1) + lg.Debugf("Starting %d consumer workers", c.workers) + for range c.workers { + consumerWG.Add(1) go func() { - defer wg.Done() - consumer := newFileConsumer(fpCh, resCh, errCh) + defer consumerWG.Done() + consumer := newFileConsumer(c.fpCh, c.resCh, c.errCh) consumer.consume(ctx) }() } // Setup and start producer - producer := newFileProducer(gfc.Logger, gfc.Excludes, gfc.Packages, fpCh, errCh) - go producer.produce(src) + lg.Debugf("Producing files in directory: %s", dir) + producerWG.Add(1) + go func() { + defer producerWG.Done() + defer close(c.fpCh) // Close only after producer is done + + producer := newFileProducer(c.lg, c.exclude, c.fpCh, c.errCh) + + c.lg.Debugf("Starting file producer for directory: %s", dir) + producer.produce(dir) + }() - // Wait for all consumers to finish - // then close the results and errors channels. - // This will cause the buildFile function to return. + // Wait for the producer and consumers + // to finish before closing channels go func() { - wg.Wait() - close(resCh) + producerWG.Wait() + consumerWG.Wait() + close(c.resCh) + close(c.errCh) }() // Build the Go file from the results. - outFile, err := gfc.buildFile(ctx, errCh, resCh) + outFile, err := c.buildFile(ctx) if err != nil { return fmt.Errorf("failed to buildFile file converger: %w", err) } @@ -98,18 +181,18 @@ func (gfc *GoFileConverger) ConvergeFiles(ctx context.Context, src string, w io. } // buildFile handles running the converger and returning the result or an error. -func (gfc *GoFileConverger) buildFile(ctx context.Context, errCh <-chan error, resCh <-chan *goFile) (*goFile, error) { +func (c *GoFileConverger) buildFile(ctx context.Context) (*goFile, error) { gf := newGoFile() for { select { case <-ctx.Done(): return nil, ctx.Err() - case err, ok := <-errCh: + case err, ok := <-c.errCh: if !ok { return gf, nil } return nil, err - case f, ok := <-resCh: + case f, ok := <-c.resCh: if !ok { return gf, nil } diff --git a/internal/gonverge/gonverge_test.go b/internal/gonverge/gonverge_test.go index 88fab4d..7480a73 100644 --- a/internal/gonverge/gonverge_test.go +++ b/internal/gonverge/gonverge_test.go @@ -5,108 +5,113 @@ import ( "context" "os" "path/filepath" + "regexp" "testing" + "github.com/stretchr/testify/assert" + "github.com/dannyhinshaw/converge/internal/gonverge" ) func TestGoFileConverger_ConvergeFiles(t *testing.T) { - // Define test cases + a := assert.New(t) + + excludeRe := regexp.MustCompile("exclude.go") + tests := map[string]struct { - setup func() (string, func()) + files map[string]string + excludes []regexp.Regexp expected string - excludes []string err bool }{ "EmptyDirectory": { - setup: func() (string, func()) { - dir := createTempDirWithFiles(t, map[string]string{}) - return dir, func() { os.RemoveAll(dir) } - }, + files: nil, expected: "", - err: false, }, "SingleFile": { - setup: func() (string, func()) { - files := map[string]string{"file.go": "package main\nfunc main() {}"} - dir := createTempDirWithFiles(t, files) - return dir, func() { os.RemoveAll(dir) } + files: map[string]string{ + "file.go": "package main\nfunc main() {}", }, expected: "package main\n\nfunc main() {}\n", - err: false, }, "MultipleFiles": { - setup: func() (string, func()) { - files := map[string]string{ - "file1.go": "package main\nfunc func1() {}", - "file2.go": "package main\nfunc func2() {}", - } - dir := createTempDirWithFiles(t, files) - return dir, func() { os.RemoveAll(dir) } + files: map[string]string{ + "file1.go": "package main\nfunc func1() {}", + "file2.go": "package main\nfunc func2() {}", }, expected: "package main\n\nfunc func1() {}\nfunc func2() {}\n", - err: false, }, "MultipleFilesWithExclusion": { - setup: func() (string, func()) { - files := map[string]string{ - "file1.go": "package main\nfunc func1() {}", - "file2.go": "package main\nfunc func2() {}", - "exclude.go": "package main\nfunc exclude() {}", - } - dir := createTempDirWithFiles(t, files) - return dir, func() { os.RemoveAll(dir) } + files: map[string]string{ + "file1.go": "package main\nfunc func1() {}", + "file2.go": "package main\nfunc func2() {}", + "exclude.go": "package main\nfunc exclude() {}", }, expected: "package main\n\nfunc func1() {}\nfunc func2() {}\n", - excludes: []string{"exclude.go"}, - err: false, + excludes: []regexp.Regexp{*excludeRe}, }, "MultipleFilesWithExclusionButNoFile": { - setup: func() (string, func()) { - files := map[string]string{ - "file1.go": "package main\nfunc func1() {}", - "file2.go": "package main\nfunc func2() {}", - } - dir := createTempDirWithFiles(t, files) - return dir, func() { os.RemoveAll(dir) } + files: map[string]string{ + "file1.go": "package main\nfunc func1() {}", + "file2.go": "package main\nfunc func2() {}", + }, + expected: "package main\n\nfunc func1() {}\nfunc func2() {}\n", + excludes: []regexp.Regexp{*excludeRe}, + }, + "MultipleFilesWithNonGoFiles": { + files: map[string]string{ + "file1.go": "package main\nfunc func1() {}", + "file.txt": "This is a text file", + "file2.go": "package main\nfunc func2() {}", }, expected: "package main\n\nfunc func1() {}\nfunc func2() {}\n", - excludes: []string{"exclude.go"}, - err: false, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - dir, cleanup := tc.setup() - defer cleanup() + dir := createTempDirWithFiles(t, tc.files) + defer func() { + if err := os.RemoveAll(dir); err != nil { + t.Fatalf("Failed to remove temp dir: %v", err) + } + }() + // Use only one worker to make the test deterministic. opts := []gonverge.Option{ gonverge.WithMaxWorkers(1), } - if len(tc.excludes) > 0 { opts = append(opts, gonverge.WithExcludes(tc.excludes)) } - - converger := gonverge.NewGoFileConverger(opts...) + converger := gonverge.NewGoFileConverger( + opts..., + ) var output bytes.Buffer - err := converger.ConvergeFiles(context.Background(), dir, &output) - if tc.err && err == nil { - t.Fatalf("ConvergeFiles() error = %v, wantErr %v", err, tc.err) - } - if got := output.String(); got != tc.expected { - t.Errorf("ConvergeFiles() got = %v, want %v", got, tc.expected) - } + err := converger.ConvergeFiles(context.Background(), dir, &output) + a.False(tc.err && err == nil) + a.Equal(tc.expected, output.String()) }) } } +func TestGoFileConverger_DirectoryNotFound(t *testing.T) { + a := assert.New(t) + + dir := "/non-existent-directory" + converger := gonverge.NewGoFileConverger() + + var output bytes.Buffer + err := converger.ConvergeFiles(context.Background(), dir, &output) + a.Error(err) +} + // createTempDirWithFiles creates a temporary directory with the given files for testing. func createTempDirWithFiles(t *testing.T, files map[string]string) string { t.Helper() + dir, err := os.MkdirTemp("", "gonverge_test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) diff --git a/internal/gonverge/options.go b/internal/gonverge/options.go deleted file mode 100644 index 50ad216..0000000 --- a/internal/gonverge/options.go +++ /dev/null @@ -1,34 +0,0 @@ -package gonverge - -import "github.com/dannyhinshaw/converge/internal/logger" - -// Option is a functional option for the GoFileConverger. -type Option func(*GoFileConverger) - -// WithLogger sets the logger for the GoFileConverger. -func WithLogger(logger logger.LevelLogger) Option { - return func(gfc *GoFileConverger) { - gfc.Logger = logger - } -} - -// WithPackages sets the list of packages to include in the output. -func WithPackages(packages []string) Option { - return func(gfc *GoFileConverger) { - gfc.Packages = packages - } -} - -// WithExcludes sets the list of files to exclude from merging. -func WithExcludes(excludes []string) Option { - return func(gfc *GoFileConverger) { - gfc.Excludes = excludes - } -} - -// WithMaxWorkers sets the maximum amount of workers to use. -func WithMaxWorkers(maxWorkers int) Option { - return func(gfc *GoFileConverger) { - gfc.MaxWorkers = maxWorkers - } -} diff --git a/internal/gonverge/processor.go b/internal/gonverge/processor.go index 828aa8b..20a3bc0 100644 --- a/internal/gonverge/processor.go +++ b/internal/gonverge/processor.go @@ -22,7 +22,7 @@ const ( const ( // tokenPkgDecl is the token for a package declaration line. - tokenPkgDecl string = `package ` + tokenPkgDecl = `package ` // tokenImport is the token for import declarations. tokenImport = `import` @@ -52,6 +52,7 @@ type fileProcessor struct { func newFileProcessor(filePath string) *fileProcessor { return &fileProcessor{ filePath: filePath, + state: procStateCoding, } } @@ -70,23 +71,30 @@ func (p *fileProcessor) process() (*goFile, error) { case strings.HasPrefix(line, tokenPkgDecl): res.pkgName = strings.TrimPrefix(line, tokenPkgDecl) p.state = procStateCoding + case strings.HasPrefix(line, tokenImportMultiStart): p.state = procStateImporting + case strings.HasPrefix(line, tokenImportMono): res.addImport(strings.TrimPrefix(line, tokenImport)) + case p.importing() && strings.HasSuffix(line, tokenImportMultiFinish): p.state = procStateCoding + case p.importing(): if line == "" { continue } res.addImport(line) + case p.coding(): res.appendCode(line) + default: res.appendCode(line) } } + if err = scanner.Err(); err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } diff --git a/internal/gonverge/worker.go b/internal/gonverge/worker.go index 94b575c..b8483fc 100644 --- a/internal/gonverge/worker.go +++ b/internal/gonverge/worker.go @@ -3,32 +3,22 @@ package gonverge import ( "context" "fmt" - "go/parser" - "go/token" "io/fs" "os" "path/filepath" "regexp" "strings" - - "github.com/dannyhinshaw/converge/internal/logger" ) // fileProducer walks a directory and sends all file paths // to the given channel for the consumer to process. type fileProducer struct { - // excludes is a map of files to exclude from processing. - + // excludes is a map of regular expressions + // to apply to file names for exclusion. excludes map[string]regexp.Regexp - // packages is a set of packages to include - // in the output. If empty, converger will - // default to top-level NON-TEST package in - // the given directory. - packages map[string]struct{} - - // log is the logger to use for logging. - log logger.LevelLogger + // lg is the lg to use for logging. + lg debugLogger // fpCh is the channel to send file paths to. fpCh chan<- string @@ -37,52 +27,34 @@ type fileProducer struct { errCh chan<- error } -// newFileProducer returns a new fileProducer. -func newFileProducer(ll logger.LevelLogger, excludes []string, - packages []string, fpCh chan<- string, errCh chan<- error) *fileProducer { - ll = ll.WithGroup("file_producer") - - packageSet := make(map[string]struct{}, len(packages)) - for _, pkg := range packages { - packageSet[pkg] = struct{}{} - } - - excludeSet := make(map[string]regexp.Regexp) - for _, exclude := range excludes { - if exclude == "" { - continue - } - if _, ok := excludeSet[exclude]; ok { - continue - } - - re, err := regexp.Compile(exclude) - if err != nil { - errCh <- fmt.Errorf("error compiling exclude regex: %w", err) - continue - } - if re == nil { - errCh <- fmt.Errorf("error compiling exclude regex: regex is nil") - continue - } - excludeSet[exclude] = *re - } - +// newFileProducer handles the creation of a new fileProducer. +func newFileProducer(lg debugLogger, ex map[string]regexp.Regexp, fc chan<- string, ec chan<- error) *fileProducer { return &fileProducer{ - log: ll, - fpCh: fpCh, - errCh: errCh, - excludes: excludeSet, - packages: packageSet, + lg: lg, + fpCh: fc, + errCh: ec, + excludes: ex, } } // produce walks the given directory and sends all file paths // to the fpCh channel for the consumer to process. func (fp *fileProducer) produce(dir string) { - defer close(fp.fpCh) + lg := fp.lg.WithName("walkDir") + lg.Debug("Producing files in directory:", dir) - err := fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, err error) error { + if err := fp.walkDir(dir); err != nil { + fp.errCh <- fmt.Errorf("error walking directory: %w", err) + } +} + +// walkDir walks the given directory and sends all file paths +// to the fpCh channel for the consumer to process. +func (fp *fileProducer) walkDir(dir string) error { + lg := fp.lg.WithName("walkDir") + lg.Debug("Walking directory:", dir) + + return fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, err error) error { //nolint:wrapcheck // Low level error doesn't need wrapped any further. if err != nil { return fmt.Errorf("error walking directory: %w", err) } @@ -97,78 +69,42 @@ func (fp *fileProducer) produce(dir string) { fullPath := filepath.Join(dir, path) if !fp.validFile(info.Name(), fullPath) { - fp.log.Info("file is not valid", "fullPath", fullPath) + lg.Debug("file path is not valid:", fullPath) return nil } - fp.log.Info("file is valid", "fullPath", fullPath) + lg.Debug("file path is valid:", fullPath) fp.fpCh <- fullPath return nil }) - if err != nil { - fp.errCh <- err - } } // validFile checks that the file is a valid *non-test* Go file. +// If the pkgSet is empty, it will default to the top-level +// Go files that are *not* test files. +// +// However, if the pkgSet is not empty, it will include all +// files that have a package name that is in the set. +// This behavior essentially allows for the user to specify +// the package names they want to include, including test files +// with the package name in the set. func (fp *fileProducer) validFile(name, fullPath string) bool { - checkPkgs := len(fp.packages) > 0 + lg := fp.lg.WithName("validFile") + lg.Debugf("Validating package %s at: %s", name, fullPath) + + if !strings.HasSuffix(name, ".go") { + return false + } // Check if the file should be excluded from processing. for _, re := range fp.excludes { if re.MatchString(name) { - fp.log.Info("file %s excluded from processing", name) + lg.Debug("File excluded from processing:", name) return false } } - // Packages specified overrides all other checks. - if checkPkgs { - if fp.validPackage(fullPath) { - fp.log.Info("file is valid package", "name", name) - return true - } - return false - } - - // Only process Go files - if !strings.HasSuffix(name, ".go") { - return false - } - - // Ignore test files by default. - // NOTE: If you need to converge test files, you can - // do so by specifying the package name in the packages - // option. This will override the default behavior. - if strings.HasSuffix(name, "_test.go") { - return false - } - - return true -} - -// validPackage checks that the file is a valid Go file and that -// the package name is in the set of packages to include. -func (fp *fileProducer) validPackage(fullPath string) bool { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, fullPath, nil, parser.PackageClauseOnly) - if err != nil { - fp.log.Info("error parsing file %s: %v", fullPath, err) - return false - } - - if node == nil || node.Name == nil { - fp.log.Info("error parsing file %s: %v", fullPath, err) - return false - } - - name := node.Name.Name - if _, ok := fp.packages[name]; !ok { - fp.log.Info("package not in packages set", "name", name, "set", fp.packages) - return false - } - return true } @@ -187,11 +123,11 @@ type fileConsumer struct { } // newFileConsumer returns a new fileConsumer. -func newFileConsumer(fpCh <-chan string, resCh chan<- *goFile, errCh chan error) *fileConsumer { +func newFileConsumer(fc <-chan string, rc chan<- *goFile, ec chan error) *fileConsumer { return &fileConsumer{ - fpCh: fpCh, - resCh: resCh, - errCh: errCh, + fpCh: fc, + resCh: rc, + errCh: ec, } } @@ -213,13 +149,28 @@ func (fc *fileConsumer) consume(ctx context.Context) { if !ok { return } - proc := newFileProcessor(fp) - res, err := proc.process() + res, err := fc.processFile(fp) if err != nil { - fc.errCh <- fmt.Errorf("error processing file: %w", err) + fc.errCh <- err return } fc.resCh <- res } } } + +// processFile processes the given file path and returns the +// processed result or an error if one occurred. +func (fc *fileConsumer) processFile(fp string) (*goFile, error) { + proc := newFileProcessor(fp) + if proc == nil { + return nil, fmt.Errorf("failed to create fileProcessor for file: %s", fp) + } + + res, err := proc.process() + if err != nil { + return nil, fmt.Errorf("error processing file: %w", err) + } + + return res, nil +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go deleted file mode 100644 index 499cd93..0000000 --- a/internal/logger/logger.go +++ /dev/null @@ -1,115 +0,0 @@ -package logger - -import ( - "log/slog" - "os" -) - -// LevelLogger is a logger that supports different levels of logging. -type LevelLogger interface { - // Debug handles logging the given message at the debug level. - Debug(msg string, args ...any) - - // Info handles logging the given message at the info level. - Info(msg string, args ...any) - - // Warn handles logging the given message at the warn level. - Warn(msg string, args ...any) - - // Error handles logging the given message at the error level. - Error(msg string, args ...any) - - // WithGroup creates a new child/group Logger from the current logger. - WithGroup(group string) LevelLogger - - // Verbose returns true if the logger is in verbose mode. - Verbose() bool -} - -// Logger is a wrapper around slog.Logger. -type Logger struct { - // Level is the log Level to use for - // filtering log verbosity. - Level slog.Level - - // Slogger is the underlying slog.Logger - // that is used for logging. - Slogger *slog.Logger - - // handler is the handler to use for - // the loggers structured output. - handler slog.Handler - - // parent is the parent logger to use - // for creating the Slogger as a child - // in the New function. - parent *slog.Logger -} - -// New returns a new Logger with the given name and level. -func New(name string, opts ...Option) *Logger { - // Base logger defaults to error level. - logger := Logger{ - Level: slog.LevelError, - } - - // Apply optional configurations. - for _, opt := range opts { - opt(&logger) - } - - // If no handler was provided, default to - // a text handler that writes to stderr. - if logger.handler == nil { - logger.handler = slog.NewTextHandler( - os.Stderr, &slog.HandlerOptions{ - Level: logger.Level, - }) - } - - // If a parent logger was provided, use it - // to create the Slogger as a child. - var root *slog.Logger - if logger.parent != nil { - root = logger.parent - } else { - root = slog.New(logger.handler) - } - - // Create a new logger with the given name. - logger.Slogger = root.WithGroup(name) - - return &logger -} - -// Debug handles proxying the given message to the underlying slog.Logger at the debug level. -func (l *Logger) Debug(msg string, args ...any) { - l.Slogger.Debug(msg, args...) -} - -// Info handles proxying the given message to the underlying slog.Logger at the info level. -func (l *Logger) Info(msg string, args ...any) { - l.Slogger.Info(msg, args...) -} - -// Warn handles proxying the given message to the underlying slog.Logger at the warn level. -func (l *Logger) Warn(msg string, args ...any) { - l.Slogger.Warn(msg, args...) -} - -// Error handles proxying the given message to the underlying slog.Logger at the error level. -func (l *Logger) Error(msg string, args ...any) { - l.Slogger.Error(msg, args...) -} - -// WithGroup creates a new child/group Logger from the current logger. -func (l *Logger) WithGroup(group string) LevelLogger { - return &Logger{ - Slogger: l.Slogger.WithGroup(group), - } -} - -// Verbose returns true if the logger is in verbose mode. -func (l *Logger) Verbose() bool { - return l.Level == slog.LevelDebug -} diff --git a/internal/logger/noop.go b/internal/logger/noop.go deleted file mode 100644 index 6727a05..0000000 --- a/internal/logger/noop.go +++ /dev/null @@ -1,26 +0,0 @@ -package logger - -// NoopLogger is a logger that does nothing. -type NoopLogger struct{} - -// Debug handles logging the given message at the debug level. -func (l *NoopLogger) Debug(msg string, args ...any) {} //nolint:revive //unused - -// Info handles logging the given message at the info level. -func (l *NoopLogger) Info(msg string, args ...any) {} //nolint:revive //unused - -// Warn handles logging the given message at the warn level. -func (l *NoopLogger) Warn(msg string, args ...any) {} //nolint:revive //unused - -// Error handles logging the given message at the error level. -func (l *NoopLogger) Error(msg string, args ...any) {} //nolint:revive //unused - -// WithGroup creates a new child/group Logger from the current logger. -func (l *NoopLogger) WithGroup(group string) LevelLogger { //nolint:revive //unused - return l -} - -// Verbose returns true if the logger is in verbose mode. -func (l *NoopLogger) Verbose() bool { - return false -} diff --git a/internal/logger/options.go b/internal/logger/options.go deleted file mode 100644 index 47e11e0..0000000 --- a/internal/logger/options.go +++ /dev/null @@ -1,22 +0,0 @@ -package logger - -import "log/slog" - -type Option func(*Logger) - -// WithHandler sets the handler for the logger. -func WithHandler(h slog.Handler) Option { - return func(l *Logger) { - l.handler = h - } -} - -// WithVerbose sets the logger to verbose mode by way of -// setting the log level to debug. -func WithVerbose(v bool) Option { - return func(l *Logger) { - if v { - l.Level = slog.LevelDebug - } - } -} diff --git a/internal/olog/noop.go b/internal/olog/noop.go new file mode 100644 index 0000000..4ce8648 --- /dev/null +++ b/internal/olog/noop.go @@ -0,0 +1,32 @@ +package olog + +// NoopLogger implements the LevelLogger interface but discards all log messages. +type NoopLogger struct{} + +// NewNoopLogger returns a new NoopLogger. +func NewNoopLogger() NoopLogger { + return NoopLogger{} +} + +// Debugf does nothing. +func (NoopLogger) Debugf(string, ...any) {} + +// Debug does nothing. +func (NoopLogger) Debug(...any) {} + +// Infof does nothing. +func (NoopLogger) Infof(string, ...any) {} + +// Info does nothing. +func (NoopLogger) Info(...any) {} + +// Errorf does nothing. +func (NoopLogger) Errorf(string, ...any) {} + +// Error does nothing. +func (NoopLogger) Error(...any) {} + +// WithName returns the NoopLogger, unaltered. +func (l NoopLogger) WithName(string) LevelLogger { + return l +} diff --git a/internal/olog/olog.go b/internal/olog/olog.go new file mode 100644 index 0000000..29fd811 --- /dev/null +++ b/internal/olog/olog.go @@ -0,0 +1,218 @@ +// Package olog provides a simple leveled logger designed +// for command-line tools and minimalistic applications. +// It avoids JSON or structured logging to maintain a lightweight +// and human-readable format, suitable for CLI UX. +package olog + +import ( + "fmt" + "io" + "log" + "os" +) + +// Level represents the logging level, which determines +// the severity and importance of log messages. +type Level int + +const ( + // LevelDebug is for debug messages. + LevelDebug Level = iota + + // LevelInfo is for info messages. + LevelInfo + + // LevelError is for error messages. + LevelError +) + +// levelName is the string representation of a logging level. +// +// Each log level string is padded to be the same length to ensure +// log messages are aligned correctly when printed. +// The longest level name is "error", so shorter names like "info" +// are padded with spaces to match the length of "error". +// This ensures consistent alignment across all logs. +type levelName = string + +const ( + // debugLevel is the string representation of the debug level. + // It is not padded because "debug" is already as long as "error". + debugLevel levelName = "debug" + + // infoLevel is the string representation of the info level. + // It is padded with a space to match the length of "error". + infoLevel levelName = "info " + + // errorLevel is the string representation of the error level. + errorLevel levelName = "error" +) + +// String returns the padded string representation of the logging level. +// This ensures that logs align consistently, even when using different log levels. +func (l Level) String() string { + switch l { + case LevelDebug: + return debugLevel + case LevelInfo: + return infoLevel + case LevelError: + return errorLevel + default: + return errorLevel + } +} + +// LevelLogger is an interface for a leveled logger implementation. +type LevelLogger interface { + // Debugf logs a formatted debug message. + Debugf(format string, v ...any) + + // Debug logs a debug message. + Debug(v ...any) + + // Infof logs a formatted info message. + Infof(format string, v ...any) + + // Info logs an info message. + Info(v ...any) + + // Errorf logs a formatted error message. + Errorf(format string, v ...any) + + // Error logs an error message. + Error(v ...any) + + // WithName returns a new logger instance with a specific + // name prefix applied to all log messages. + WithName(name string) LevelLogger +} + +// Logger is a simple logger that can be used to log messages at different levels. +type Logger struct { + // logger is the underlying Go standard library logger. + logger *log.Logger + + // name is an optional identifier included in all log messages. + name string + + // level defines the log level threshold. + level Level + + // callDepth specifies the stack depth for file/line reporting. + callDepth int +} + +// NewLogger creates a new Logger. +func NewLogger(lvl Level, opts ...Option) Logger { + var flags int + if lvl == LevelDebug { + flags = log.Lshortfile + } + + lg := Logger{ + logger: log.New(os.Stderr, "", flags), + level: lvl, + callDepth: 3, //nolint:mnd // 3 is the call depth to log from. + } + + for _, opt := range opts { + opt(&lg) + } + + return lg +} + +// Option defines a function type that configures the Logger. +type Option func(*Logger) + +// WithWriter returns an Option that sets the writer for the logger. +func WithWriter(w io.Writer) Option { + return func(l *Logger) { + l.logger.SetOutput(w) + } +} + +// Debugf logs a formatted debug message if the logger is set to LevelDebug. +// It will not output anything if the logger level is higher than LevelDebug. +func (l Logger) Debugf(format string, v ...any) { + if l.level == LevelDebug { + l.logf(LevelDebug, format, v...) + } +} + +// Debug logs a debug message if the logger is set to LevelDebug. +// It will not output anything if the logger level is higher than LevelDebug. +func (l Logger) Debug(v ...any) { + if l.level == LevelDebug { + l.log(LevelDebug, v...) + } +} + +// Infof logs a formatted info message if the logger is set to LevelInfo or lower. +// It will not output anything if the logger level is higher than LevelInfo. +func (l Logger) Infof(format string, v ...any) { + if l.level <= LevelInfo { + l.logf(LevelInfo, format, v...) + } +} + +// Info logs an info message if the logger is set to LevelInfo or lower. +// It will not output anything if the logger level is higher than LevelInfo. +func (l Logger) Info(v ...any) { + if l.level <= LevelInfo { + l.log(LevelInfo, v...) + } +} + +// Errorf logs a formatted error message. +func (l Logger) Errorf(format string, v ...any) { + l.logf(LevelError, format, v...) +} + +// Error logs an error message. +func (l Logger) Error(v ...any) { + l.log(LevelError, v...) +} + +// WithName returns a new logger with the given name. +func (l Logger) WithName(name string) LevelLogger { + c := l.clone() + c.name = name + return c +} + +// log logs a message at the given level. +func (l Logger) log(lvl Level, v ...any) { + msg := fmt.Sprintln(v...) + + if l.name != "" { + msg = "[" + lvl.String() + "] [" + l.name + "]: " + msg + } else { + msg = "[" + lvl.String() + "]: " + msg + } + + if l.level == LevelDebug { + // Include call depth to show code + // line reference in verbose mode. + _ = l.logger.Output(l.callDepth, msg) + } else { + // Directly log without call depth, + // omitting code line reference. + l.logger.Print(msg) + } +} + +// logf logs a formatted message at the given level. +func (l Logger) logf(lvl Level, format string, v ...any) { + l.log(lvl, fmt.Sprintf(format, v...)) +} + +// clone returns a copy of the logger with the same settings. +func (l Logger) clone() Logger { + return Logger{ + logger: l.logger, + level: l.level, + callDepth: l.callDepth, + } +} diff --git a/internal/olog/olog_test.go b/internal/olog/olog_test.go new file mode 100644 index 0000000..08f3ebd --- /dev/null +++ b/internal/olog/olog_test.go @@ -0,0 +1,89 @@ +package olog_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/dannyhinshaw/converge/internal/olog" +) + +func TestLogger_Debug(t *testing.T) { + a := assert.New(t) + + var buf bytes.Buffer + logger := olog.NewLogger(olog.LevelDebug, olog.WithWriter(&buf)). + WithName("TestLogger") + + logger.Debug("debug message") + + expected := fmt.Sprintf("[%s] [TestLogger]: debug message\n", olog.LevelDebug) + a.Contains(buf.String(), expected) +} + +func TestLogger_Debugf(t *testing.T) { + a := assert.New(t) + + var buf bytes.Buffer + logger := olog.NewLogger(olog.LevelDebug, olog.WithWriter(&buf)). + WithName("TestLogger") + + logger.Debugf("debug message %d", 1) + + expected := fmt.Sprintf("[%s] [TestLogger]: debug message 1\n", olog.LevelDebug) + a.Contains(buf.String(), expected) +} + +func TestLogger_Info(t *testing.T) { + a := assert.New(t) + + var buf bytes.Buffer + logger := olog.NewLogger(olog.LevelInfo, olog.WithWriter(&buf)). + WithName("TestLogger") + + logger.Info("info message") + + expected := fmt.Sprintf("[%s] [TestLogger]: info message\n", olog.LevelInfo) + a.Contains(buf.String(), expected) +} + +func TestLogger_Infof(t *testing.T) { + a := assert.New(t) + + var buf bytes.Buffer + logger := olog.NewLogger(olog.LevelInfo, olog.WithWriter(&buf)). + WithName("TestLogger") + + logger.Infof("info message %d", 1) + + expected := fmt.Sprintf("[%s] [TestLogger]: info message 1\n", olog.LevelInfo) + a.Contains(buf.String(), expected) +} + +func TestLogger_Error(t *testing.T) { + a := assert.New(t) + + var buf bytes.Buffer + logger := olog.NewLogger(olog.LevelError, olog.WithWriter(&buf)). + WithName("TestLogger") + + logger.Error("error message") + + expected := fmt.Sprintf("[%s] [TestLogger]: error message\n", olog.LevelError) + a.Contains(buf.String(), expected) +} + +func TestLogger_Errorf(t *testing.T) { + a := assert.New(t) + + var buf bytes.Buffer + logger := olog.NewLogger(olog.LevelError, olog.WithWriter(&buf)). + WithName("TestLogger") + + logger.Errorf("error message %d", 1) + + expected := fmt.Sprintf("[%s] [TestLogger]: error message 1\n", olog.LevelError) + a.Contains(buf.String(), expected) +} diff --git a/main.go b/main.go index 173d952..7b99308 100644 --- a/main.go +++ b/main.go @@ -1,114 +1,19 @@ +// Package main is the executable entrypoint for running the converge tool. package main import ( - "context" - "flag" - "fmt" - "log" + "io" "os" - "strings" - "time" "github.com/dannyhinshaw/converge/cmd" - "github.com/dannyhinshaw/converge/internal/cli" - "github.com/dannyhinshaw/converge/internal/gonverge" - "github.com/dannyhinshaw/converge/internal/logger" ) -// versions is set by the linker at build time. -// Defaults to "(dev)" if not set. +// version is the version of the converge tool. +// This is set at build time using the -ldflags flag. var version = "(dev)" func main() { - app := cli.NewApp() - - // Handle version flag - if app.ShowVersion { - fmt.Println("converge version:", version) //nolint:forbidigo // not debugging - return - } - - // Create logger and set verbose logging. - clog := logger.New("converge", logger.WithVerbose(app.VerboseLog)) - if app.VerboseLog { - clog.Debug("Verbose logging enabled") - } - - srcDir, err := app.ParseSrcDir() - if err != nil { - clog.Error(err.Error()) - flag.Usage() - os.Exit(1) - } - - var ( - converger = createConverger(clog, app.Workers, app.Packages, app.Exclude) - command = createCommand(converger, srcDir, app.OutFile) - ctx, cancel = newCancelContext(context.Background(), app.Timeout) - ) - defer cancel() - - if err = command.Run(ctx); err != nil { - log.Printf("Error: %v\n", err) - return - } - - if app.OutFile != "" { - log.Printf("Files from '%s' have been successfully merged into '%s'.\n", srcDir, app.OutFile) - } -} - -// createCommand creates a new Converge command with the given options. -func createCommand(converger cmd.FileConverger, src, outFile string) *cmd.Converge { - var cmdOpts []cmd.Option - if outFile != "" { - cmdOpts = append(cmdOpts, cmd.WithDstFile(outFile)) - } - - return cmd.NewCommand(converger, src, cmdOpts...) -} - -// createConverger creates a new GoFileConverger with the given options. -func createConverger(ll logger.LevelLogger, workers int, packages, exclude string) *gonverge.GoFileConverger { - var gonvOpts []gonverge.Option - if ll != nil { - gonvOpts = append(gonvOpts, gonverge.WithLogger(ll)) - } - - if workers > 0 { - gonvOpts = append(gonvOpts, gonverge.WithMaxWorkers(workers)) + if err := cmd.NewRoot(version).Execute(); err != nil { + _, _ = io.WriteString(os.Stderr, err.Error()) } - - if packages != "" { - var pkgs []string - for _, pkg := range strings.Split(packages, ",") { - pkgs = append(pkgs, strings.TrimSpace(pkg)) - } - - gonvOpts = append(gonvOpts, gonverge.WithPackages(pkgs)) - } - - if exclude != "" { - var excludeFiles []string - for _, excludeFile := range strings.Split(exclude, ",") { - excludeFiles = append(excludeFiles, strings.TrimSpace(excludeFile)) - } - - gonvOpts = append(gonvOpts, gonverge.WithExcludes(excludeFiles)) - } - - return gonverge.NewGoFileConverger(gonvOpts...) -} - -// newCancelContext returns a new cancellable context -// with the given timeout if one is specified. -// -// If no timeout is specified, the context will not have a -// timeout, but a cancel function will still be returned. -func newCancelContext(ctx context.Context, timeout int) (context.Context, context.CancelFunc) { - if timeout > 0 { - return context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - } - - return context.WithCancel(ctx) }