From ebd4f986f5151fb737ad8c74a79f3368e70081b5 Mon Sep 17 00:00:00 2001 From: Kevin Perreau Date: Thu, 18 Apr 2024 18:16:58 +0200 Subject: [PATCH] feat: init project (#1) * feat: init project --- .dockerignore | 17 + .github/auto_assign.yml | 2 + .github/workflows/auto-clean-cache.yaml | 29 ++ .github/workflows/main.yaml | 49 +++ .gitignore | 5 + .goacproject.yaml | 18 + .semver.yaml | 4 + Dockerfile | 21 + LICENSE | 2 +- Makefile | 24 ++ README.md | 161 +++++++- _scripts/build-image.sh | 33 ++ cmd/affected.go | 66 +++ cmd/list.go | 47 +++ cmd/root.go | 61 +++ go.mod | 23 ++ go.sum | 41 ++ main.go | 9 + pkg/hasher/hasher.go | 61 +++ pkg/hasher/hasher_test.go | 70 ++++ pkg/printer/printer.go | 26 ++ pkg/project/affected.go | 109 +++++ pkg/project/affected_test.go | 523 ++++++++++++++++++++++++ pkg/project/build.go | 60 +++ pkg/project/build_test.go | 192 +++++++++ pkg/project/cache.go | 79 ++++ pkg/project/cache_test.go | 304 ++++++++++++++ pkg/project/hash.go | 103 +++++ pkg/project/hash_test.go | 248 +++++++++++ pkg/project/list.go | 15 + pkg/project/list_test.go | 86 ++++ pkg/project/message.go | 15 + pkg/project/message_test.go | 91 +++++ pkg/project/modules.go | 104 +++++ pkg/project/modules_test.go | 203 +++++++++ pkg/project/project.go | 237 +++++++++++ pkg/project/project_test.go | 46 +++ pkg/project/rule.go | 33 ++ pkg/project/rule_test.go | 54 +++ pkg/scan/scan.go | 77 ++++ pkg/scan/scan_test.go | 158 +++++++ pkg/utils/utils.go | 41 ++ pkg/utils/utils_test.go | 155 +++++++ 43 files changed, 3700 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/auto_assign.yml create mode 100644 .github/workflows/auto-clean-cache.yaml create mode 100644 .github/workflows/main.yaml create mode 100644 .goacproject.yaml create mode 100644 .semver.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100755 _scripts/build-image.sh create mode 100644 cmd/affected.go create mode 100644 cmd/list.go create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/hasher/hasher.go create mode 100644 pkg/hasher/hasher_test.go create mode 100644 pkg/printer/printer.go create mode 100644 pkg/project/affected.go create mode 100644 pkg/project/affected_test.go create mode 100644 pkg/project/build.go create mode 100644 pkg/project/build_test.go create mode 100644 pkg/project/cache.go create mode 100644 pkg/project/cache_test.go create mode 100644 pkg/project/hash.go create mode 100644 pkg/project/hash_test.go create mode 100644 pkg/project/list.go create mode 100644 pkg/project/list_test.go create mode 100644 pkg/project/message.go create mode 100644 pkg/project/message_test.go create mode 100644 pkg/project/modules.go create mode 100644 pkg/project/modules_test.go create mode 100644 pkg/project/project.go create mode 100644 pkg/project/project_test.go create mode 100644 pkg/project/rule.go create mode 100644 pkg/project/rule_test.go create mode 100644 pkg/scan/scan.go create mode 100644 pkg/scan/scan_test.go create mode 100644 pkg/utils/utils.go create mode 100644 pkg/utils/utils_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1176519 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.gitignore +.dockerignore +Dockerfile +.github/ +.git/ +*.log +.goac/ +.goacproject.yaml +.semver.yaml +coverage.out +goac +*_test.go +README.md +LICENSE +Makefile +.DS_Store +.idea \ No newline at end of file diff --git a/.github/auto_assign.yml b/.github/auto_assign.yml new file mode 100644 index 0000000..5f07017 --- /dev/null +++ b/.github/auto_assign.yml @@ -0,0 +1,2 @@ +addReviewers: false +addAssignees: author diff --git a/.github/workflows/auto-clean-cache.yaml b/.github/workflows/auto-clean-cache.yaml new file mode 100644 index 0000000..b37e58c --- /dev/null +++ b/.github/workflows/auto-clean-cache.yaml @@ -0,0 +1,29 @@ +name: Cleanup branch caches +on: + pull_request: + types: + - closed + +jobs: + autoCleanup: + runs-on: ubuntu-latest + steps: + - name: Cleanup + run: | + gh extension install actions/gh-actions-cache + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR + do + gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..133e793 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,49 @@ +name: Main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - "**" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: false + check-latest: true + + - name: Verify dependencies + run: | + go mod verify + git diff --exit-code + + - name: golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: "v1.55.2" + args: --timeout=10m + + - name: Vet + run: make vet + + - name: Test Format + run: | + go install mvdan.cc/gofumpt@latest + make format + + - name: Unit Test + run: make ci-test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3b735ec..9f57f20 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ # Go workspace file go.work + +.goac +goac +.idea +.DS_Store \ No newline at end of file diff --git a/.goacproject.yaml b/.goacproject.yaml new file mode 100644 index 0000000..33fe2d3 --- /dev/null +++ b/.goacproject.yaml @@ -0,0 +1,18 @@ +version: 1.0 +name: goac +target: + build: + exec: + cmd: go + params: + - build + - -ldflags=-s -w + - -o + - "{{project-path}}/{{project-name}}" + - "{{project-path}}" + build-image: + envs: + - key: PROJECT_PATH + value: "{{project-path}}" + exec: + cmd: ./_scripts/build-image.sh \ No newline at end of file diff --git a/.semver.yaml b/.semver.yaml new file mode 100644 index 0000000..36a2acc --- /dev/null +++ b/.semver.yaml @@ -0,0 +1,4 @@ +alpha: 0 +beta: 0 +rc: 0 +release: v1.0.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30333a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.22-bookworm AS builder + +# Set Go env +ENV GOOS=linux CGO_ENABLED=0 + +WORKDIR /workspace + +# Build Go binary +COPY . . +RUN --mount=type=cache,mode=0755,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go mod download +RUN --mount=type=cache,mode=0755,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -ldflags="-s -w" -o /workspace/goac + +# Deployment container +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /workspace/goac /goac +ENTRYPOINT ["/goac"] diff --git a/LICENSE b/LICENSE index 4e4f3a5..c3fe4a9 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..495ca44 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +tidy: + @go mod tidy + @gofumpt -l -w . + +test: + @go test ./... + +test-coverage: + go test ./... -coverprofile=coverage.out + +dep: + go mod download + +vet: + @go vet -unsafeptr=false ./... + +lint: + @golangci-lint run + +ci-test: + @go test -race -vet=off ./... + +format: + @gofumpt -l . \ No newline at end of file diff --git a/README.md b/README.md index b2b3cc2..e740f49 100644 --- a/README.md +++ b/README.md @@ -1 +1,160 @@ -# goac \ No newline at end of file +# Go Affected Cache (GOAC) + +## About +Go Affected Cache (GOAC) is a tool tailored to optimize the build process for binaries and Docker images in monorepo setups through caching. + +It generates and caches a hash based on the list of dependencies and files imported by each project within the monorepo. This functionality prevents unnecessary builds by detecting changes accurately, thus conserving time and resources. + +Originally developed to address specific needs in my own project's architecture, GOAC might not directly align with every user's project structure and requirements, making its utility somewhat experimental. + +## Features + Monorepo Efficiency: Specifically designed for monorepos, ensuring efficient handling of multiple interconnected projects. + Intelligent Change Detection: Utilizes hashes of dependencies and files to determine the necessity of builds. + Docker Integration: Optimizes Docker image creation by avoiding unnecessary builds and pushes, preventing redundant deployments. + +## Installation +Follow these steps to install GOAC in your environment. Adjust as necessary for your specific setup. + +```bash +go install github.com/kperreau/goac +``` + +## Configuration +Configuring GOAC is straightforward. You need to create a `.goacproject.yaml` file and place it at the root of each project/directory that requires a build. + +### File example: +```yaml +# .goacproject.yaml + +version: 1.0 # Please do not modify this value; keep it set to 1.0 +name: goac # Specify the name of your project, service, or application here +target: # GOAC currently supports two targets: 'build' and 'build-image' + build: # This target compiles the Go binary + exec: + cmd: go # The command to execute for compilation; 'go' in this case + params: # Parameters to be added; the final command will be: go build -ldflags="-s -w" -o ./goac goac + - build + - -ldflags=-s -w + - -o + - "{{project-path}}/{{project-name}}" + - "{{project-path}}" + build-image: # This target builds the Docker image + envs: + - key: PROJECT_PATH + value: "{{project-path}}" + exec: + cmd: ./_scripts/build-image.sh # Shell script to execute for building the image +``` + +To see what the script that builds the image of this project looks like, take a look at this example: [build-image.sh](./_scripts/build-image.sh) + +### Variables +The configuration file interprets variables that will automatically be replaced by their values. + +Currently, there are two: +``` +{{project-name}} # The name of the project +{{project-path}} # The path of the project +``` + +### Environnement +You can pass environment variables when executing GOAC, which will naturally be transmitted to the commands. +However, two environment variables are reserved, initialized, and transmitted by GOAC: +``` +BUILD_NAME={{project-name}} +PROJECT_PATH={{project-path}} +``` +If you run a shell script, you can use these 2 environment variables. + +## Usage +GOAC offers commands such as affected and list to manage your monorepo effectively. + +``` +Usage: + goac [command] + +Available Commands: + affected List affected projects + help Help about any command + list List projects + +Flags: + -c, --concurrency int Max Concurrency (default 4) + --debug string Debug files loaded/hashed + -h, --help help for goac + -p, --projects string Filter by projects name +``` + +### Checking / Building Affected Projects +``` +Usage: + goac affected [flags] + +Examples: +goac affected -t build + +Flags: + --binarycheck Affected if binary is missing + --dockerignore Read docker ignore (default true) + --dryrun Dry & run + -f, --force Force build + -h, --help help for affected + --stdout Print stdout of exec command + -t, --target string Target + +Global Flags: + -c, --concurrency int Max Concurrency (default 4) + --debug string Debug files loaded/hashed + -p, --projects string Filter by projects name + +``` +Exemples: +```bash +goac affected -t build # build binary of affected project +goac affected -t build -p auth-service,docs # build binaries for auth-service and docs +goac affected -t build --force # build all binaries without checking affected projects +goac affected -t build --debug=name,hashed -p docs # build project docs with debug to display project name and hashed files +``` + +### Listing Projects +To list all projects configured in your monorepo based on the `.goacproject.yaml`: + +``` +Usage: + goac list [flags] + +Examples: +goac list + +Flags: + -h, --help help for list + +Global Flags: + -c, --concurrency int Max Concurrency (default 4) + --debug string Debug files loaded/hashed + -p, --projects string Filter by projects name +``` + +Exemples: +```bash +goac list +``` + +### Common Options + --debug [types]: Controls the verbosity of command output, useful for debugging. + Available types: name,includes,excludes,local,dependencies + +## Contribution +Contributions are welcome! If you'd like to contribute, please follow these steps: + + Fork the project + Create your feature branch (git checkout -b feature/AmazingFeature) + Commit your changes (git commit -m 'Add some AmazingFeature') + Push to the branch (git push origin feature/AmazingFeature) + Open a Pull Request + +## Authors +- [@kperreau](https://www.github.com/kperreau) + +## License +Distributed under the MIT License. See [LICENSE](./LICENSE) for more information. \ No newline at end of file diff --git a/_scripts/build-image.sh b/_scripts/build-image.sh new file mode 100755 index 0000000..18f44ef --- /dev/null +++ b/_scripts/build-image.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e + +: "${PUSH_IMAGE="false"}" + +: "${REPOSITORY="kperreau/goac"}" + +: "${PROJECT_PATH="."}" # project path (must be where the Dockerfile is) + +DOCKERFILE="${PROJECT_PATH}/Dockerfile" + +GIT_VERSION=$(git rev-parse --short=7 HEAD) + +dockerCmd=(docker buildx build --platform="linux/amd64,linux/arm64" --network host) + +if [[ "${PUSH_IMAGE}" == "true" ]]; then + dockerCmd+=(--push); +fi + +# print cmd +echo "${dockerCmd[@]}" \ + -t "${REPOSITORY}:latest" \ + -t "${REPOSITORY}:${GIT_VERSION}" \ + -f "${DOCKERFILE}" \ + . + +# run docker build +"${dockerCmd[@]}" \ + -t "${REPOSITORY}:latest" \ + -t "${REPOSITORY}:${GIT_VERSION}" \ + -f "${DOCKERFILE}" \ + . \ No newline at end of file diff --git a/cmd/affected.go b/cmd/affected.go new file mode 100644 index 0000000..b6ed567 --- /dev/null +++ b/cmd/affected.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "errors" + + "github.com/kperreau/goac/pkg/project" + "github.com/spf13/cobra" +) + +// affectedCmd represents the affected command +var affectedCmd = &cobra.Command{ + Use: "affected", + Short: "List affected projects", + Long: `List projects affected by recent changes based on GOAC cache.`, + Example: "goac affected -t build", + RunE: func(cmd *cobra.Command, args []string) error { + debugArgs, err := debugCmd(debug) + if err != nil { + return err + } + + t := project.StringToTarget(target) + if project.StringToTarget(target) != project.TargetNone { + projectsList, err := project.NewProjectsList(&project.Options{ + Target: t, + DryRun: dryrun, + MaxConcurrency: concurrency, + BinaryCheck: binaryCheck, + Force: force, + DockerIgnore: dockerignore, + Debug: debugArgs, + ProjectsName: projectsCmd(projects), + PrintStdout: stdout, + }) + if err != nil { + return err + } + if err := projectsList.Affected(); err != nil { + return err + } + return nil + } + + return errors.New("bad argument") + }, +} + +var ( + target string + dryrun bool + force bool + binaryCheck bool + dockerignore bool + stdout bool +) + +func init() { + rootCmd.AddCommand(affectedCmd) + + affectedCmd.Flags().StringVarP(&target, "target", "t", "", "Target") + affectedCmd.Flags().BoolVar(&stdout, "stdout", false, "Print stdout of exec command") + affectedCmd.Flags().BoolVar(&dockerignore, "dockerignore", true, "Read docker ignore") + affectedCmd.Flags().BoolVar(&binaryCheck, "binarycheck", false, "Affected if binary is missing") + affectedCmd.Flags().BoolVar(&dryrun, "dryrun", false, "Dry & run") + affectedCmd.Flags().BoolVarP(&force, "force", "f", false, "Force build") +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..e0a7995 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "errors" + + "github.com/kperreau/goac/pkg/project" + + "github.com/spf13/cobra" +) + +// listCmd represents the project command +var listCmd = &cobra.Command{ + Use: "list", + Example: "goac list", + Short: "List projects", + Long: `Use it to list all your projects configured with GOAC.`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return errors.New("bad args number") + } + + debugArgs, err := debugCmd(debug) + if err != nil { + return err + } + + listProject, err := project.NewProjectsList(&project.Options{ + Target: project.TargetNone, + MaxConcurrency: concurrency, + BinaryCheck: binaryCheck, + DockerIgnore: dockerignore, + Debug: debugArgs, + ProjectsName: projectsCmd(projects), + }) + if err != nil { + return err + } + + listProject.List() + + return nil + }, +} + +func init() { + rootCmd.AddCommand(listCmd) +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..3d26b28 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "os" + "slices" + "strings" + + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "goac", + Short: "Go Affected Cache", + Long: `GOAC is a CLI library for Go that empowers builds. +This application is a tool to check if an app is affected by recent change. +This way it improve build and deployment.`, +} + +var ( + concurrency int + debug string + projects string +) + +var validDebugValues = []string{"name", "includes", "excludes", "dependencies", "local", "hashed"} + +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVarP(&projects, "projects", "p", "", "Filter by projects name") + rootCmd.PersistentFlags().StringVar(&debug, "debug", "", "Debug files loaded/hashed") + rootCmd.PersistentFlags().IntVarP(&concurrency, "concurrency", "c", 4, "Max Concurrency") +} + +func debugCmd(arg string) ([]string, error) { + if arg == "" { + return []string{}, nil + } + args := strings.Split(arg, ",") + for _, elem := range args { + if !slices.Contains(validDebugValues, elem) { + return []string{}, fmt.Errorf("bad debug value: %s\nvalid values are: %s\n", elem, strings.Join(validDebugValues, ",")) + } + } + + return args, nil +} + +func projectsCmd(arg string) []string { + if arg == "" { + return []string{} + } + return strings.Split(arg, ",") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a6fb33a --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/kperreau/goac + +go 1.22 + +require ( + github.com/codeskyblue/dockerignore v0.0.0-20151214070507-de82dee623d9 + github.com/fatih/color v1.16.0 + github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.9.0 + golang.org/x/mod v0.17.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/smartystreets/goconvey v1.8.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.19.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4c44a07 --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/codeskyblue/dockerignore v0.0.0-20151214070507-de82dee623d9 h1:c9axcChJwkLuSl9AvwTHi8jiBa6+VX4gGgERhABgv2E= +github.com/codeskyblue/dockerignore v0.0.0-20151214070507-de82dee623d9/go.mod h1:XNZkUhPf+qgRnhY/ecS3B73ODJ2NXCzDMJHXM069IMg= +github.com/cpuguy83/go-md2man/v2 v2.0.3/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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +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= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/main.go b/main.go new file mode 100644 index 0000000..0279c79 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/kperreau/goac/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/pkg/hasher/hasher.go b/pkg/hasher/hasher.go new file mode 100644 index 0000000..5611b5c --- /dev/null +++ b/pkg/hasher/hasher.go @@ -0,0 +1,61 @@ +package hasher + +import ( + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "hash" + "io" + "os" + "sort" + "strings" + "sync" +) + +func Files(files []string, hashPool *sync.Pool) (string, error) { + h := hashPool.Get().(hash.Hash) + defer hashPool.Put(h) + h.Reset() + + hf := hashPool.Get().(hash.Hash) + defer hashPool.Put(hf) + + files = append([]string(nil), files...) + sort.Strings(files) + for _, file := range files { + if strings.Contains(file, "\n") { + return "", errors.New("filenames with newlines are not supported") + } + r, err := os.Open(file) + if err != nil { + return "", err + } + + hf.Reset() + _, err = io.Copy(hf, r) + r.Close() + if err != nil { + return "", err + } + fmt.Fprintf(h, "%x %s\n", hf.Sum(nil), file) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func WithPool(hashPool *sync.Pool, s string) (string, error) { + h := hashPool.Get().(hash.Hash) + defer hashPool.Put(h) + h.Reset() + _, err := h.Write([]byte(s)) + if err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func NewPool() *sync.Pool { + return &sync.Pool{ + New: func() any { return sha1.New() }, + } +} diff --git a/pkg/hasher/hasher_test.go b/pkg/hasher/hasher_test.go new file mode 100644 index 0000000..e77254e --- /dev/null +++ b/pkg/hasher/hasher_test.go @@ -0,0 +1,70 @@ +package hasher + +import ( + "crypto/sha1" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFiles_HashOfAllFilesContentsInAlphabeticalOrder(t *testing.T) { + files, err := listFiles("../project") + assert.NoError(t, err) + + // Call the Files function + hashPool := NewPool() + result, err := Files(files, hashPool) + + // Verify the result + assert.NoError(t, err) + assert.NotEmpty(t, result) + + // Cal 2 times to be sure that the result is identical + result2, err2 := Files(files, hashPool) + + // Verify the result + assert.NoError(t, err2) + assert.NotEmpty(t, result2) +} + +func listFiles(dir string) ([]string, error) { + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var fileNames []string + for _, file := range files { + if !file.IsDir() { + fileNames = append(fileNames, filepath.Join(dir, file.Name())) + } + } + return fileNames, nil +} + +func TestWithPool_ReturnsHashOfInputString(t *testing.T) { + // Create a new hashPool + hashPool := NewPool() + + // Create the input string + input := "test string" + + // Call the function under test + result, err := WithPool(hashPool, input) + + // Assert that no error occurred + assert.NoError(t, err) + + // Assert that the result matches the expected hash + assert.Equal(t, "661295c9cbf9d6b2f6428414504a8deed3020641", result) +} + +func TestNewPool_ReturnsSyncPoolWithSha1New(t *testing.T) { + pool := NewPool() + + assert.NotNil(t, pool) + assert.NotNil(t, pool.New) + assert.IsType(t, sha1.New(), pool.New()) +} diff --git a/pkg/printer/printer.go b/pkg/printer/printer.go new file mode 100644 index 0000000..345ea2f --- /dev/null +++ b/pkg/printer/printer.go @@ -0,0 +1,26 @@ +package printer + +import ( + "fmt" + + "github.com/fatih/color" +) + +func Printf(format string, a ...any) { + fmt.Printf(format, a...) +} + +func Errorf(format string, a ...any) { + red := color.New(color.FgRed).SprintFunc() + fmt.Printf("%s", red(fmt.Sprintf(format, a...))) +} + +func Warnf(format string, a ...any) { + yellow := color.New(color.FgYellow).SprintFunc() + fmt.Printf("%s", yellow(fmt.Sprintf(format, a...))) +} + +func BoldGreen(s string) string { + c := color.New(color.Bold).Add(color.FgGreen).SprintFunc() + return c(s) +} diff --git a/pkg/project/affected.go b/pkg/project/affected.go new file mode 100644 index 0000000..640af16 --- /dev/null +++ b/pkg/project/affected.go @@ -0,0 +1,109 @@ +package project + +import ( + "path" + "sync" + + "github.com/fatih/color" + "github.com/kperreau/goac/pkg/printer" + "github.com/kperreau/goac/pkg/utils" +) + +type Target string + +const ( + TargetNone Target = "none" + TargetBuild Target = "build" + TargetBuildImage Target = "build-image" +) + +func (t Target) String() string { return string(t) } + +type processAffectedOptions struct { + wg *sync.WaitGroup + sem chan bool +} + +func (l *List) Affected() error { + l.printAffected() + + // init process options + sem := make(chan bool, l.Options.MaxConcurrency+1) + wg := sync.WaitGroup{} + pOpts := &processAffectedOptions{ + wg: &wg, + sem: sem, + } + + for _, p := range l.Projects { + sem <- true // acquire + wg.Add(1) + go processAffected(p, pOpts) + } + + wg.Wait() + + return nil +} + +func processAffected(p *Project, opts *processAffectedOptions) { + defer opts.wg.Done() + defer func() { + <-opts.sem // release + }() + + isAffected := p.isAffected() + + if isAffected && p.CMDOptions.DryRun { + printer.Printf("%s %s %s\n", color.BlueString(p.Name), color.YellowString("=>"), p.CleanPath) + } + + if p.CMDOptions.DryRun || !isAffected { + return + } + + if err := p.build(); err != nil { + printer.Errorf("error building: %s\n", err.Error()) + return + } + + if err := p.writeCache(); err != nil { + printer.Errorf("%v\n", err) + return + } +} + +func (l *List) countAffected() (n int) { + for _, p := range l.Projects { + if p.isAffected() { + n++ + } + } + return n +} + +func (p *Project) isAffected() bool { + if p.CMDOptions.Force { + return true + } + + if p.Cache.Target[p.CMDOptions.Target] == nil || !p.Cache.Target[p.CMDOptions.Target].isMetadataMatch(p.Metadata) { + return true + } + + if p.CMDOptions.BinaryCheck && !utils.FileExist(path.Join(p.CleanPath, p.Name)) { + return true + } + + return false +} + +func StringToTarget(s string) Target { + switch s { + case TargetBuild.String(): + return TargetBuild + case TargetBuildImage.String(): + return TargetBuildImage + } + return TargetNone +} diff --git a/pkg/project/affected_test.go b/pkg/project/affected_test.go new file mode 100644 index 0000000..225585d --- /dev/null +++ b/pkg/project/affected_test.go @@ -0,0 +1,523 @@ +package project + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "sync" + "testing" + + "github.com/fatih/color" + "github.com/stretchr/testify/assert" +) + +func TestIsAffected_ForceTrue(t *testing.T) { + p := &Project{ + CMDOptions: &Options{ + Force: true, + Target: TargetBuild, + }, + } + result := p.isAffected() + + // Assert + assert.True(t, result) +} + +func TestIsAffected_TargetNotInCache(t *testing.T) { + p := &Project{ + CMDOptions: &Options{ + Target: TargetBuild, + }, + Cache: &Cache{ + Target: make(map[Target]*Metadata), + }, + } + result := p.isAffected() + + // Assert + assert.True(t, result) +} + +func TestIsAffected_BinaryCheckFalse_ReturnFalse(t *testing.T) { + p := &Project{ + CMDOptions: &Options{ + BinaryCheck: false, + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + } + result := p.isAffected() + + // Assert + assert.False(t, result) +} + +func TestIsAffected_BinaryCheckTrue_ReturnTrue(t *testing.T) { + p := &Project{ + CMDOptions: &Options{ + BinaryCheck: true, + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + } + result := p.isAffected() + + // Assert + assert.True(t, result) +} + +func TestIsAffected_DiffHash_ReturnTrue(t *testing.T) { + p := &Project{ + CMDOptions: &Options{ + BinaryCheck: true, + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "new-hash", + DirHash: "new-hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + } + result := p.isAffected() + + // Assert + assert.True(t, result) +} + +func TestStringToTarget_Build(t *testing.T) { + result := StringToTarget(TargetBuild.String()) + assert.Equal(t, TargetBuild, result) +} + +func TestStringToTarget_BuildImage(t *testing.T) { + result := StringToTarget(TargetBuildImage.String()) + assert.Equal(t, TargetBuildImage, result) +} + +func TestStringToTarget_EmptyString(t *testing.T) { + result := StringToTarget("") + assert.Equal(t, TargetNone, result) +} + +func TestStringToTarget_WhitespaceString(t *testing.T) { + result := StringToTarget(" ") + assert.Equal(t, TargetNone, result) +} + +func TestCountAffected_NoAffectedProjects(t *testing.T) { + // Initialize the List object + l := &List{ + Projects: []*Project{}, + Options: &Options{}, + } + + // Invoke the countAffected method + result := l.countAffected() + + // Assert that the result is 0 + assert.Equal(t, 0, result) +} + +func TestCountAffected_OneProject(t *testing.T) { + // Initialize the List object + l := &List{ + Projects: []*Project{ + { + CMDOptions: &Options{ + Force: true, + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + }, + }, + Options: &Options{}, + } + + // Invoke the countAffected method + result := l.countAffected() + + // Assert that the result is 0 + assert.Equal(t, 1, result) +} + +func TestCountAffected_OneProjectOfTwo(t *testing.T) { + // Initialize the List object + l := &List{ + Projects: []*Project{ + { + CMDOptions: &Options{ + Force: true, + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + }, + { + CMDOptions: &Options{ + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + }, + }, + Options: &Options{}, + } + + // Invoke the countAffected method + result := l.countAffected() + + // Assert that the result is 0 + assert.Equal(t, 1, result) +} + +func TestCountAffected_TwoProjects(t *testing.T) { + // Initialize the List object + l := &List{ + Projects: []*Project{ + { + CMDOptions: &Options{ + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "new-hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + }, + { + CMDOptions: &Options{ + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "new-hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + }, + }, + Options: &Options{}, + } + + // Invoke the countAffected method + result := l.countAffected() + + // Assert that the result is 0 + assert.Equal(t, 2, result) +} + +func TestAffected_Prints0AffectedProjects(t *testing.T) { + // Initialize the List object + l := &List{ + Projects: []*Project{}, + Options: &Options{}, + } + + output, err := redirectAffectedStdout(l.Affected) + assert.NoError(t, err) + + // Assert that the correct output is printed + expectedOutput := fmt.Sprintf("Affected: %s/%s\n", color.HiBlueString("%d", 0), color.HiBlueString("%d", len(l.Projects))) + assert.Equal(t, expectedOutput, output.String()) +} + +func TestAffected_Prints1AffectedProjects(t *testing.T) { + // Initialize the List object + // Initialize the List object + l := &List{ + Projects: []*Project{ + { + CMDOptions: &Options{ + Force: true, + DryRun: true, + Target: TargetBuild, + MaxConcurrency: 2, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + }, + { + CMDOptions: &Options{ + DryRun: true, + Target: TargetBuild, + MaxConcurrency: 2, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + }, + }, + Options: &Options{ + MaxConcurrency: 2, + Target: TargetBuild, + DryRun: true, + }, + } + + output, err := redirectAffectedStdout(l.Affected) + assert.NoError(t, err) + + // Assert that the correct output is printed + expectedOutput := fmt.Sprintf("Affected: %s/%s\ngoac => .\n", color.HiBlueString("%d", 1), color.HiBlueString("%d", len(l.Projects))) + assert.Equal(t, expectedOutput, output.String()) +} + +func TestAffected_MaxConcurrencyZero(t *testing.T) { + // Initialize the List object + l := &List{ + Projects: []*Project{ + { + CMDOptions: &Options{ + Force: true, + DryRun: true, + Target: TargetBuild, + MaxConcurrency: 1, + }, + Metadata: &Metadata{ + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + }, + }, + Options: &Options{ + MaxConcurrency: 0, + DryRun: true, + }, + } + + // Call the Affected method + _, err := redirectAffectedStdout(l.Affected) + assert.NoError(t, err) +} + +func TestProcessAffected_BuildAffectedProject(t *testing.T) { + // Create a project with affected flag set to true and dry run flag set to false + p := &Project{ + CMDOptions: &Options{ + DryRun: true, + Target: TargetBuild, + MaxConcurrency: 4, + }, + Metadata: &Metadata{ + DependenciesHash: "new-hash", + DirHash: "hash", + Date: "date", + }, + CleanPath: ".", + Name: "goac", + Cache: &Cache{ + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + }, + } + + // Create a processAffectedOptions with a wait group and semaphore + opts := &processAffectedOptions{ + wg: &sync.WaitGroup{}, + sem: make(chan bool, 1), + } + + // Add a wait group counter + opts.wg.Add(1) + + // Acquire the semaphore + opts.sem <- true + + // Redirect stdout to a buffer + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the processAffected function + processAffected(p, opts) + + // Restore stdout + if err := w.Close(); err != nil { + log.Fatal(err) + } + os.Stdout = old + + // Read from the buffer and assert the output + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + log.Fatal(err) + } + + assert.Equal(t, "goac => .\n", buf.String()) +} + +func redirectAffectedStdout(f func() error) (*bytes.Buffer, error) { + // Redirect stdout to a buffer + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the func + err := f() + + // Restore stdout + if err := w.Close(); err != nil { + log.Fatal(err) + } + os.Stdout = old + + // Read from the buffer and assert the output + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + log.Fatal(err) + } + + return &buf, err +} diff --git a/pkg/project/build.go b/pkg/project/build.go new file mode 100644 index 0000000..e2a6f99 --- /dev/null +++ b/pkg/project/build.go @@ -0,0 +1,60 @@ +package project + +import ( + "bytes" + "fmt" + "github.com/fatih/color" + "github.com/kperreau/goac/pkg/printer" + "os" + "os/exec" + "strings" +) + +func (p *Project) build() error { + printer.Printf("Building %s...\n", color.HiBlueString(p.Name)) + + // replace variables env and params to proper values + replaceAllVariables(p) + + var stderr bytes.Buffer + cmd := exec.Command(p.Target[p.CMDOptions.Target].Exec.CMD, p.Target[p.CMDOptions.Target].Exec.Params...) + setEnv(p, cmd) + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("%s: %s", err, stderr.String()) + } + + if p.CMDOptions.PrintStdout { + fmt.Print(string(output)) + } + + return nil +} + +func setEnv(p *Project, cmd *exec.Cmd) { + cmd.Env = os.Environ() + for _, env := range p.Target[p.CMDOptions.Target].Envs { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", env.Key, env.Value)) + } +} + +func replaceAllVariables(p *Project) { + // init variables + variables := map[string]string{ + "{{project-name}}": p.Name, + "{{project-path}}": p.Path, + } + + for i := range p.Target[p.CMDOptions.Target].Envs { + for search, replace := range variables { + p.Target[p.CMDOptions.Target].Envs[i].Value = strings.ReplaceAll(p.Target[p.CMDOptions.Target].Envs[i].Value, search, replace) + } + } + + for i := range p.Target[p.CMDOptions.Target].Exec.Params { + for search, replace := range variables { + p.Target[p.CMDOptions.Target].Exec.Params[i] = strings.ReplaceAll(p.Target[p.CMDOptions.Target].Exec.Params[i], search, replace) + } + } +} diff --git a/pkg/project/build_test.go b/pkg/project/build_test.go new file mode 100644 index 0000000..93435b1 --- /dev/null +++ b/pkg/project/build_test.go @@ -0,0 +1,192 @@ +package project + +import ( + "bytes" + "io" + "log" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuild_WithDefaultParameters(t *testing.T) { + tmp, _ := os.MkdirTemp("", "test-build") + p := &Project{ + Name: "test-project", + Path: tmp, + Target: map[Target]*TargetConfig{ + TargetBuild: { + Exec: &Exec{ + CMD: "echo", + Params: []string{"go build"}, + }, + }, + }, + CMDOptions: &Options{ + Target: TargetBuild, + }, + } + + output, err := redirectBuildStdout(p.build) + + assert.NoError(t, err) + assert.Contains(t, output.String(), "Building test-project...") +} + +func TestBuild_PrintStdout(t *testing.T) { + p := &Project{ + Name: "test-project", + Target: map[Target]*TargetConfig{ + TargetBuild: { + Exec: &Exec{ + CMD: "echo", + Params: []string{"should print this"}, + }, + }, + }, + CMDOptions: &Options{ + Target: TargetBuild, + PrintStdout: true, + }, + } + + output, err := redirectBuildStdout(p.build) + + assert.NoError(t, err) + assert.Contains(t, output.String(), "Building test-project...") + assert.Contains(t, output.String(), "should print this") +} + +func TestBuild_WithoutPrintStdout(t *testing.T) { + p := &Project{ + Name: "test-project", + Target: map[Target]*TargetConfig{ + TargetBuild: { + Exec: &Exec{ + CMD: "echo", + Params: []string{"should not print this"}, + }, + }, + }, + CMDOptions: &Options{ + Target: TargetBuild, + }, + } + + output, err := redirectBuildStdout(p.build) + + assert.NoError(t, err) + assert.Contains(t, output.String(), "Building test-project...") + assert.NotContains(t, output.String(), "should not print this") +} + +func TestBuild_CommandFails(t *testing.T) { + p := &Project{ + Name: "test-project", + Target: map[Target]*TargetConfig{ + TargetBuild: { + Exec: &Exec{ + CMD: "cat", + Params: []string{"not-found"}, + }, + }, + }, + CMDOptions: &Options{ + Target: TargetBuild, + }, + } + + output, err := redirectBuildStdout(p.build) + + assert.Error(t, err) + assert.Contains(t, output.String(), "Building test-project...") +} + +func redirectBuildStdout(f func() error) (*bytes.Buffer, error) { + // Redirect stdout to a buffer + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the func + err := f() + + // Restore stdout + if err := w.Close(); err != nil { + log.Fatal(err) + } + os.Stdout = old + + // Read from the buffer and assert the output + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + log.Fatal(err) + } + + return &buf, err +} + +func TestSetEnv_SetEnvironmentVariables(t *testing.T) { + p := &Project{ + Name: "goac", + Path: ".", + Target: map[Target]*TargetConfig{ + TargetBuild: { + Envs: []Env{ + {Key: "ENV_TEST", Value: "hello"}, + {Key: "BUILD_NAME", Value: "goac"}, + {Key: "PROJECT_PATH", Value: "."}, + }, + }, + }, + CMDOptions: &Options{ + Target: TargetBuild, + }, + } + cmd := &exec.Cmd{} + setEnv(p, cmd) + + expectedEnv := append( + os.Environ(), + "ENV_TEST=hello", + "BUILD_NAME=goac", + "PROJECT_PATH=.", + ) + assert.Equal(t, expectedEnv, cmd.Env) +} + +func TestReplaceAllVariables_ReplaceVariables(t *testing.T) { + p := &Project{ + Name: "goac", + Path: ".", + Target: map[Target]*TargetConfig{ + TargetBuild: { + Envs: []Env{ + {Key: "PROJECT_NAME", Value: "{{project-name}}"}, + {Key: "PROJECT_PATH", Value: "{{project-path}}"}, + }, + Exec: &Exec{ + CMD: "echo", + Params: []string{"{{project-name}}", "{{project-path}}"}, + }, + }, + }, + CMDOptions: &Options{ + Target: TargetBuild, + }, + } + + replaceAllVariables(p) + + expectedEnvName := p.Name + expectedEnvPath := p.Path + assert.Equal(t, expectedEnvName, p.Target[TargetBuild].Envs[0].Value) + assert.Equal(t, expectedEnvPath, p.Target[TargetBuild].Envs[1].Value) + + expectedParamName := p.Name + expectedParamPath := p.Path + assert.Equal(t, expectedParamName, p.Target[TargetBuild].Exec.Params[0]) + assert.Equal(t, expectedParamPath, p.Target[TargetBuild].Exec.Params[1]) +} diff --git a/pkg/project/cache.go b/pkg/project/cache.go new file mode 100644 index 0000000..661af23 --- /dev/null +++ b/pkg/project/cache.go @@ -0,0 +1,79 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +type Cache struct { + Target map[Target]*Metadata + Path string +} + +var DefaultCachePath = ".goac/cache/" + +func (p *Project) LoadCache() error { + cacheFilePath := fmt.Sprintf("%s%s.yaml", DefaultCachePath, p.HashPath) + + // init a default basic cache + cacheData := Cache{Path: p.CleanPath, Target: map[Target]*Metadata{}} + + if _, err := os.Stat(cacheFilePath); os.IsNotExist(err) { + p.Cache = &cacheData + return nil + } + + if err := readCacheFromFile(cacheFilePath, &cacheData); err != nil { + return fmt.Errorf("error loading cache: %w", err) + } + + p.Cache = &cacheData + + return nil +} + +func readCacheFromFile(path string, cache *Cache) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + if err := yaml.Unmarshal(data, cache); err != nil { + return fmt.Errorf("error unmarshaling cache data: %w", err) + } + + return nil +} + +func (cm *Metadata) isMetadataMatch(m *Metadata) bool { + return cm.DependenciesHash == m.DependenciesHash && + cm.DirHash == m.DirHash +} + +func (p *Project) writeCache() error { + cacheFilePath := filepath.Join(DefaultCachePath, fmt.Sprintf("%s.yaml", p.HashPath)) + + if err := os.MkdirAll(DefaultCachePath, 0o755); err != nil { + return fmt.Errorf("error creating cache directory: %v", err) + } + + p.Cache.Target[p.CMDOptions.Target] = &Metadata{ + DependenciesHash: p.Metadata.DependenciesHash, + DirHash: p.Metadata.DirHash, + Date: time.Now().Format(time.RFC3339), + } + + cacheData, err := yaml.Marshal(p.Cache) + if err != nil { + return fmt.Errorf("error encoding yaml data: %v", err) + } + + if err := os.WriteFile(cacheFilePath, cacheData, 0o644); err != nil { + return fmt.Errorf("error writing cache file %s: %v", cacheFilePath, err) + } + return nil +} diff --git a/pkg/project/cache_test.go b/pkg/project/cache_test.go new file mode 100644 index 0000000..8be0091 --- /dev/null +++ b/pkg/project/cache_test.go @@ -0,0 +1,304 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestLoadCache_LoadsCacheDataFromFileIfExists(t *testing.T) { + // Initialize the class object + p := &Project{ + Cache: &Cache{}, + HashPath: "hash", + CleanPath: "path", + } + + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "goac-cache") + assert.NoError(t, err) + DefaultCachePath = tmpDir // override DefaultCachePath + + // Clean up the cache file + defer os.RemoveAll(tmpDir) + + // Create a temporary cache file + cacheFilePath := fmt.Sprintf("%s%s.yaml", DefaultCachePath, p.HashPath) + cacheData := Cache{ + Path: p.CleanPath, + Target: map[Target]*Metadata{ + TargetBuild: { + DependenciesHash: "hash", + DirHash: "hash", + Date: "date", + }, + }, + } + data, _ := yaml.Marshal(cacheData) + err = os.WriteFile(cacheFilePath, data, 0o644) + assert.NoError(t, err) + + // Call the method under test + err = p.LoadCache() + + // Assert that the cache data is loaded from file + assert.NoError(t, err) + assert.Equal(t, cacheData, *p.Cache) +} + +func TestLoadCache_InitializesDefaultCacheIfFileDoesNotExist(t *testing.T) { + // Initialize the class object + p := &Project{ + Cache: &Cache{}, + HashPath: "hash", + CleanPath: "path", + } + + DefaultCachePath = "not-exist" // override DefaultCachePath + + // Call the method under test + err := p.LoadCache() + + fmt.Println("cache 1:", p.Cache.Target[TargetBuild]) + + // Assert that a default cache is initialized + assert.NoError(t, err) + assert.Equal(t, p.CleanPath, p.Cache.Path) + assert.Empty(t, p.Cache.Target) +} + +func TestLoadCache_ReturnsErrorIfCacheFileCannotBeRead(t *testing.T) { + // Initialize the class object + p := &Project{ + Cache: &Cache{}, + HashPath: "hash", + CleanPath: "path", + } + + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "goac-cache") + assert.NoError(t, err) + DefaultCachePath = tmpDir // override DefaultCachePath + + // Clean up the cache file + defer os.RemoveAll(tmpDir) + + // Create a cache file with no read permissions + cacheFilePath := fmt.Sprintf("%s%s.yaml", DefaultCachePath, p.HashPath) + err = os.WriteFile(cacheFilePath, []byte{}, 0o000) + assert.NoError(t, err) + + // Call the method under test + err = p.LoadCache() + + // Assert that an error is returned + assert.Error(t, err) +} + +func TestLoadCache_ReturnsErrorIfCacheFileCannotBeUnmarshaled(t *testing.T) { + // Initialize the class object + p := &Project{ + Cache: &Cache{}, + HashPath: "hash", + CleanPath: "path", + } + + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "goac-cache") + assert.NoError(t, err) + DefaultCachePath = tmpDir // override DefaultCachePath + + // Clean up the cache file + defer os.RemoveAll(tmpDir) + + // Create a cache file with invalid YAML data + cacheFilePath := fmt.Sprintf("%s%s.yaml", DefaultCachePath, p.HashPath) + err = os.WriteFile(cacheFilePath, []byte("invalid_yaml"), 0o644) + assert.NoError(t, err) + + // Call the method under test + err = p.LoadCache() + + // Assert that an error is returned + assert.Error(t, err) +} + +func TestReadCacheFromFile_SuccessfulRead(t *testing.T) { + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "goac-cache") + assert.NoError(t, err) + DefaultCachePath = tmpDir // override DefaultCachePath + + // Clean up the cache file + defer os.RemoveAll(tmpDir) + + // Write cache data to the file + cacheFilePath := fmt.Sprintf("%s%s.yaml", DefaultCachePath, "hash") + err = os.WriteFile(cacheFilePath, []byte("target:\n build:\n dependencieshash: hash1\n dirhash: hash2\n date: \"2024-04-15T15:39:47+02:00\""), 0o644) + assert.NoError(t, err) + + // Create a cache object + cache := &Cache{ + Target: make(map[Target]*Metadata), + Path: cacheFilePath, + } + + // Call the function under test + err = readCacheFromFile(cache.Path, cache) + assert.NoError(t, err) + + // Assert that the cache data was successfully read + expectedTarget := TargetBuild + expectedMetadata := &Metadata{ + DependenciesHash: "hash1", + DirHash: "hash2", + Date: "2024-04-15T15:39:47+02:00", + } + + assert.NotEmpty(t, cache.Target[expectedTarget]) + assert.Equal(t, expectedMetadata, cache.Target[TargetBuild]) +} + +func TestReadCacheFromFile_InvalidDataTypes(t *testing.T) { + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "goac-cache") + assert.NoError(t, err) + DefaultCachePath = tmpDir // override DefaultCachePath + + // Clean up the cache file + defer os.RemoveAll(tmpDir) + + // Write cache data to the file + cacheFilePath := fmt.Sprintf("%s%s.yaml", DefaultCachePath, "hash") + err = os.WriteFile(cacheFilePath, []byte("target:\n invalid"), 0o644) + assert.NoError(t, err) + + // Create a cache object + cache := &Cache{ + Target: make(map[Target]*Metadata), + Path: cacheFilePath, + } + + // Call the function under test + err = readCacheFromFile(cache.Path, cache) + assert.Error(t, err) +} + +func TestIsMetadataMatch_ReturnsTrueWhenDependenciesHashAndDirHashMatch(t *testing.T) { + // Arrange + cachem := &Metadata{ + DependenciesHash: "hash1", + DirHash: "hash2", + } + m := &Metadata{ + DependenciesHash: "hash1", + DirHash: "hash2", + } + + // Act + result := cachem.isMetadataMatch(m) + + // Assert + assert.True(t, result) +} + +func TestIsMetadataMatch_ReturnsFalseWhenDependenciesHashOrDirHashDoNotMatch(t *testing.T) { + // Arrange + cachem := &Metadata{ + DependenciesHash: "hash1", + DirHash: "hash2", + } + m := &Metadata{ + DependenciesHash: "hash3", + DirHash: "hash2", + } + + // Act + result := cachem.isMetadataMatch(m) + + // Assert + assert.False(t, result) +} + +func TestIsMetadataMatch_PanicWhenBothMetadataObjectsAreNil(t *testing.T) { + // Arrange + var cachem *Metadata + var m *Metadata + + // Assert + assert.Panics(t, func() { cachem.isMetadataMatch(m) }) +} + +func TestWriteCache_ValidData(t *testing.T) { + // Initialize the project object + p := &Project{ + // Initialize the necessary fields + Cache: &Cache{ + Target: make(map[Target]*Metadata), + Path: DefaultCachePath, + }, + CMDOptions: &Options{ + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "dependenciesHash", + DirHash: "dirHash", + }, + } + + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "goac-cache") + assert.NoError(t, err) + DefaultCachePath = tmpDir // override DefaultCachePath + + // Clean up the cache file + defer os.RemoveAll(tmpDir) + + // Call the writeCache method + err = p.writeCache() + + // Assert that there is no error + assert.NoError(t, err) + + // Assert that the cache file exists + _, err = os.Stat(filepath.Join(DefaultCachePath, p.HashPath, ".yaml")) + assert.NoError(t, err) +} + +func TestWriteCache_ErrorCreatingCacheDirectory(t *testing.T) { + // Initialize the project object + p := &Project{ + // Initialize the necessary fields + Cache: &Cache{ + Target: make(map[Target]*Metadata), + Path: DefaultCachePath, + }, + CMDOptions: &Options{ + Target: TargetBuild, + }, + Metadata: &Metadata{ + DependenciesHash: "dependenciesHash", + DirHash: "dirHash", + }, + } + + // Create a temporary directory + tmpDir, err := os.MkdirTemp("", "goac-cache") + assert.NoError(t, err) + err = os.Mkdir(filepath.Join(tmpDir, "read"), 0o444) + assert.NoError(t, err) + DefaultCachePath = filepath.Join(tmpDir, "read") // override DefaultCachePath + + // Clean up the cache file + defer os.RemoveAll(tmpDir) + + // Call the writeCache method + err = p.writeCache() + + // Assert that the expected error is returned + assert.Error(t, err) +} diff --git a/pkg/project/hash.go b/pkg/project/hash.go new file mode 100644 index 0000000..f80ab69 --- /dev/null +++ b/pkg/project/hash.go @@ -0,0 +1,103 @@ +package project + +import ( + "encoding/hex" + "hash" + "slices" + "strings" + + "github.com/fatih/color" + "github.com/kperreau/goac/pkg/hasher" + "github.com/kperreau/goac/pkg/printer" + "github.com/kperreau/goac/pkg/scan" +) + +type Metadata struct { + DependenciesHash string + DirHash string + Date string +} + +func (p *Project) LoadHashs() error { + depsHash, err := processDependenciesHash(p) + if err != nil { + return err + } + + dirHash, err := processDirectoryHash(p) + if err != nil { + return err + } + + p.Metadata = &Metadata{ + DependenciesHash: depsHash, + DirHash: dirHash, + } + + return nil +} + +func processDependenciesHash(p *Project) (string, error) { + joinedDeps := strings.Join(p.Module.ExternalDeps, ",") + + h := p.HashPool.Get().(hash.Hash) + defer p.HashPool.Put(h) + h.Reset() + + if _, err := h.Write([]byte(joinedDeps)); err != nil { + return "", err + } + + hashBytes := h.Sum(nil) + hashStr := hex.EncodeToString(hashBytes) + + return hashStr, nil +} + +func processDirectoryHash(p *Project) (string, error) { + files, err := scan.Dirs(p.Module.LocalDirs, p.Rule) + if err != nil { + return "", err + } + + if len(p.CMDOptions.Debug) > 0 { + debug(p, files) + } + + hashDir, err := hasher.Files(files, p.HashPool) + if err != nil { + return "", err + } + + return hashDir, nil +} + +func debug(p *Project, files []string) { + if slices.Contains(p.CMDOptions.Debug, "name") { + printer.Warnf("Name: %s\n", printer.BoldGreen(p.Name)) + } + + if slices.Contains(p.CMDOptions.Debug, "includes") { + printer.Printf("%s\n%s\n", color.YellowString("Includes"), strings.Join(p.Rule.Includes, "\n")) + } + + if slices.Contains(p.CMDOptions.Debug, "excludes") { + printer.Printf("%s\n%s\n", color.YellowString("Excludes"), strings.Join(p.Rule.Excludes, "\n")) + } + + if slices.Contains(p.CMDOptions.Debug, "hashed") { + printer.Printf("%s\n%s\n", color.YellowString("Hashed files"), strings.Join(files, "\n")) + } + + if slices.Contains(p.CMDOptions.Debug, "dependencies") { + printer.Printf("%s\n%s\n", color.YellowString("Dependencies"), strings.Join(p.Module.ExternalDeps, "\n")) + } + + if slices.Contains(p.CMDOptions.Debug, "local") { + printer.Printf("%s\n%s\n", color.YellowString("Local Imports"), strings.Join(p.Module.LocalDirs, "\n")) + } + + if len(p.CMDOptions.Debug) > 0 { + printer.Printf("\n") + } +} diff --git a/pkg/project/hash_test.go b/pkg/project/hash_test.go new file mode 100644 index 0000000..780b1e8 --- /dev/null +++ b/pkg/project/hash_test.go @@ -0,0 +1,248 @@ +package project + +import ( + "bytes" + "crypto/sha1" + "io" + "log" + "os" + "sync" + "testing" + + "github.com/kperreau/goac/pkg/scan" + "github.com/stretchr/testify/assert" +) + +func TestLoadHashs_Success(t *testing.T) { + p := &Project{ + Version: "1.0", + Name: "TestProject", + CleanPath: ".", + Target: make(map[Target]*TargetConfig), + Path: ".", + HashPath: ".", + Module: &Module{ + LocalDirs: []string{"."}, + ExternalDeps: []string{"dep1", "dep2"}, + }, + HashPool: &sync.Pool{ + New: func() any { return sha1.New() }, + }, + Metadata: &Metadata{}, + Cache: &Cache{}, + Rule: &scan.Rule{}, + CMDOptions: &Options{}, + } + + err := p.LoadHashs() + if err != nil { + t.Errorf("Expected no error, but got %v", err) + } + + assert.Equal(t, "35380d4ef74486ae75fa80d5f4ba2c3321bf6530", p.Metadata.DependenciesHash) + + if p.Metadata.DirHash == "" { + t.Error("Expected DirHash to be set, but it was empty") + } +} + +func TestProcessDependenciesHash_ValidProjectWithEmptyExternalDeps(t *testing.T) { + p := &Project{ + Module: &Module{ + ExternalDeps: []string{}, + }, + HashPool: &sync.Pool{ + New: func() any { return sha1.New() }, + }, + } + + _, err := processDependenciesHash(p) + + assert.NoError(t, err) +} + +func TestProcessDependenciesHash_ValidProjectWithExternalDeps(t *testing.T) { + p := &Project{ + Module: &Module{ + ExternalDeps: []string{"dep1", "dep2"}, + }, + HashPool: &sync.Pool{ + New: func() any { return sha1.New() }, + }, + } + + hash, err := processDependenciesHash(p) + + assert.NoError(t, err) + assert.Equal(t, "35380d4ef74486ae75fa80d5f4ba2c3321bf6530", hash) +} + +func TestProcessDirectoryHash_ValidProject_ReturnsHash(t *testing.T) { + // Arrange + p := &Project{ + Module: &Module{ + LocalDirs: []string{"../hasher", "../project"}, + }, + Rule: &scan.Rule{}, + CMDOptions: &Options{ + Debug: []string{}, + }, + HashPool: &sync.Pool{ + New: func() any { return sha1.New() }, + }, + } + + // Act + result, err := processDirectoryHash(p) + + // Assert + assert.NoError(t, err) + assert.NotEmpty(t, result) +} + +func TestProcessDirectoryHash_MultipleCallsWithSameProject_ReturnsSameHash(t *testing.T) { + // Arrange + p := &Project{ + Module: &Module{ + LocalDirs: []string{"../hasher", "../project"}, + }, + Rule: &scan.Rule{}, + CMDOptions: &Options{ + Debug: []string{}, + }, + HashPool: &sync.Pool{ + New: func() any { return sha1.New() }, + }, + } + + // Act + result1, err1 := processDirectoryHash(p) + result2, err2 := processDirectoryHash(p) + + // Assert + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.Equal(t, result1, result2) +} + +func TestProcessDirectoryHash_UnableToScanDirectories_ReturnsError(t *testing.T) { + // Arrange + p := &Project{ + Module: &Module{ + LocalDirs: []string{"invalid_dir"}, + }, + Rule: &scan.Rule{}, + CMDOptions: &Options{ + Debug: []string{}, + }, + HashPool: &sync.Pool{ + New: func() any { return sha1.New() }, + }, + } + + // Act + result, err := processDirectoryHash(p) + + // Assert + assert.Error(t, err) + assert.Empty(t, result) +} + +func TestDebug_ValidProjectAndFiles_PrintsDebugInformation(t *testing.T) { + p := &Project{ + Name: "TestProject", + CMDOptions: &Options{ + Debug: []string{"name", "includes", "excludes", "hashed", "dependencies", "local"}, + }, + Rule: &scan.Rule{ + Includes: []string{"include1", "include2"}, + Excludes: []string{"exclude1", "exclude2"}, + }, + Module: &Module{ + ExternalDeps: []string{"dep1", "dep2"}, + LocalDirs: []string{"local1", "local2"}, + }, + } + files := []string{"file1", "file2"} + + output := redirectHashStdout(debug, p, files) + + expectedOutput := "Name: TestProject\n" + + "Includes\ninclude1\ninclude2\n" + + "Excludes\nexclude1\nexclude2\n" + + "Hashed files\nfile1\nfile2\n" + + "Dependencies\ndep1\ndep2\n" + + "Local Imports\nlocal1\nlocal2\n\n" + + assert.Equal(t, expectedOutput, output.String()) +} + +func TestDebug_NoDebugOptionsSpecified_DoesNotPrintDebugInformation(t *testing.T) { + p := &Project{ + Name: "TestProject", + CMDOptions: &Options{ + Debug: []string{}, + }, + Rule: &scan.Rule{ + Includes: []string{"include1", "include2"}, + Excludes: []string{"exclude1", "exclude2"}, + }, + Module: &Module{ + ExternalDeps: []string{"dep1", "dep2"}, + LocalDirs: []string{"local1", "local2"}, + }, + } + files := []string{"file1", "file2"} + + output := redirectHashStdout(debug, p, files) + expectedOutput := "" + + assert.Equal(t, expectedOutput, output.String()) +} + +func TestDebug_EmptyFilesList_PrintsNoHashedFiles(t *testing.T) { + p := &Project{ + Name: "TestProject", + CMDOptions: &Options{ + Debug: []string{"hashed"}, + }, + Rule: &scan.Rule{ + Includes: []string{"include1", "include2"}, + Excludes: []string{"exclude1", "exclude2"}, + }, + Module: &Module{ + ExternalDeps: []string{"dep1", "dep2"}, + LocalDirs: []string{"local1", "local2"}, + }, + } + var files []string + + output := redirectHashStdout(debug, p, files) + expectedOutput := "Hashed files\n\n\n" + + assert.Equal(t, expectedOutput, output.String()) +} + +func redirectHashStdout(f func(*Project, []string), p *Project, files []string) *bytes.Buffer { + // Redirect stdout to a buffer + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the func + f(p, files) + + // Restore stdout + if err := w.Close(); err != nil { + log.Fatal(err) + } + os.Stdout = old + + // Read from the buffer and assert the output + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + log.Fatal(err) + } + + return &buf +} diff --git a/pkg/project/list.go b/pkg/project/list.go new file mode 100644 index 0000000..48c91d5 --- /dev/null +++ b/pkg/project/list.go @@ -0,0 +1,15 @@ +package project + +import ( + "github.com/fatih/color" + "github.com/kperreau/goac/pkg/printer" +) + +const configFileName = ".goacproject.yaml" + +func (l *List) List() { + printer.Printf("Found %s projects\n", color.YellowString("%d", len(l.Projects))) + for _, project := range l.Projects { + printer.Printf("%s %s %s\n", color.BlueString(project.Name), color.YellowString("=>"), project.CleanPath) + } +} diff --git a/pkg/project/list_test.go b/pkg/project/list_test.go new file mode 100644 index 0000000..b28f6b0 --- /dev/null +++ b/pkg/project/list_test.go @@ -0,0 +1,86 @@ +package project + +import ( + "bytes" + "io" + "log" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestList_PrintsNumberOfProjects(t *testing.T) { + // Initialize the List object + l := &List{ + Projects: []*Project{ + {Name: "Project1", CleanPath: "/path/to/project1"}, + {Name: "Project2", CleanPath: "/path/to/project2"}, + }, + Options: &Options{}, + } + + // Call the List method and return output + buf := redirectStdout(l.List) + + // Assert that the output matches the expected value + expectedOutput := "Found 2 projects\n" + + "Project1 => /path/to/project1\n" + + "Project2 => /path/to/project2\n" + assert.Equal(t, expectedOutput, buf.String()) +} + +func TestList_HandlesEmptyProjectsList(t *testing.T) { + // Initialize the List object with an empty list of projects + l := &List{ + Projects: []*Project{}, + Options: &Options{}, + } + + // Call the List method and return output + buf := redirectStdout(l.List) + + // Assert that the output is empty + assert.Equal(t, "Found 0 projects\n", buf.String()) +} + +func TestList_HandlesProjectsWithEmptyName(t *testing.T) { + // Initialize the List object with a project with empty name + l := &List{ + Projects: []*Project{ + {Name: "", CleanPath: "."}, + }, + Options: &Options{}, + } + + // Call the List method and return output + buf := redirectStdout(l.List) + + // Assert that the output matches the expected result + expected := "Found 1 projects\n => .\n" + assert.Equal(t, expected, buf.String()) +} + +func redirectStdout(f func()) bytes.Buffer { + // Redirect stdout to a buffer + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the func + f() + + // Restore stdout + if err := w.Close(); err != nil { + log.Fatal(err) + } + os.Stdout = old + + // Read from the buffer and assert the output + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + log.Fatal(err) + } + + return buf +} diff --git a/pkg/project/message.go b/pkg/project/message.go new file mode 100644 index 0000000..cb33a3b --- /dev/null +++ b/pkg/project/message.go @@ -0,0 +1,15 @@ +package project + +import ( + "github.com/fatih/color" + "github.com/kperreau/goac/pkg/printer" +) + +func (l *List) printAffected() { + affectedCounter := l.countAffected() + affected := color.HiBlueString("%d", affectedCounter) + if affectedCounter == 0 { + affected = color.HiBlackString("%d", affectedCounter) + } + printer.Printf("Affected: %s/%s\n", affected, color.HiBlueString("%d", len(l.Projects))) +} diff --git a/pkg/project/message_test.go b/pkg/project/message_test.go new file mode 100644 index 0000000..01f6861 --- /dev/null +++ b/pkg/project/message_test.go @@ -0,0 +1,91 @@ +package project + +import ( + "bytes" + "io" + "log" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrintAffected_1AffectedAnd1Project(t *testing.T) { + // Initialize the List object + l := &List{ + Projects: []*Project{ + {Name: "Project1", CleanPath: "/path/to/project1", CMDOptions: &Options{DryRun: true, Force: true}}, + }, + Options: &Options{ + DryRun: true, + Force: true, + }, + } + + // Call the List method and return output + buf := redirectStdoutMessage(l.printAffected) + + // Assert the printed output + expectedOutput := "Affected: 1/1\n" + assert.Equal(t, expectedOutput, buf.String()) +} + +func TestPrintAffected_0AffectedAnd1Project(t *testing.T) { + // Initialize the List object + opts := &Options{ + DryRun: true, + BinaryCheck: false, + Force: false, + Target: TargetBuild, + } + l := &List{ + Projects: []*Project{ + { + Name: "Project1", CleanPath: "/path/to/project1", + CMDOptions: opts, + Metadata: &Metadata{ + DependenciesHash: "a", + DirHash: "b", + Date: "", + }, + Cache: &Cache{Target: map[Target]*Metadata{TargetBuild: { + DependenciesHash: "a", + DirHash: "b", + Date: "", + }}}, + }, + }, + Options: opts, + } + + // Call the List method and return output + buf := redirectStdoutMessage(l.printAffected) + + // Assert the printed output + expectedOutput := "Affected: 0/1\n" + assert.Equal(t, expectedOutput, buf.String()) +} + +func redirectStdoutMessage(f func()) bytes.Buffer { + // Redirect stdout to a buffer + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Call the func + f() + + // Restore stdout + if err := w.Close(); err != nil { + log.Fatal(err) + } + os.Stdout = old + + // Read from the buffer and assert the output + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + log.Fatal(err) + } + + return buf +} diff --git a/pkg/project/modules.go b/pkg/project/modules.go new file mode 100644 index 0000000..7347f71 --- /dev/null +++ b/pkg/project/modules.go @@ -0,0 +1,104 @@ +package project + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "golang.org/x/mod/modfile" +) + +type Module struct { + LocalDirs []string + ExternalDeps []string + IgnoredGoFiles []string +} + +type toolData struct { + ImportPath string + Module struct { + Path string + Dir string + } + GoFiles []string + IgnoredGoFiles []string + Imports []string + Deps []string +} + +func (p *Project) LoadGOModules(gomod *modfile.File) error { + cmd := exec.Command("go", "list", "-json", p.Path) + output, err := cmd.Output() + if err != nil { + return err + } + + var rawData toolData + if err := json.Unmarshal(output, &rawData); err != nil { + return err + } + + localDir, extDeps := cleanDeps(&rawData, p.Path) + + p.Module = &Module{ + LocalDirs: localDir, + ExternalDeps: getDependencies(gomod, extDeps), + IgnoredGoFiles: rawData.IgnoredGoFiles, + } + + return nil +} + +func loadGOModFile(path string) (*modfile.File, error) { + data, err := os.ReadFile(filepath.Join(path, "go.mod")) + if err != nil { + return nil, fmt.Errorf("error reading go.mod: %v", err) + } + + modFile, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return nil, fmt.Errorf("error parsing go.mod: %v", err) + } + + return modFile, nil +} + +func cleanDeps(rawData *toolData, localDir string) (localDeps []string, extDeps []string) { + localDeps = []string{filepath.Clean(localDir)} + deps := append(rawData.Deps, rawData.Imports...) + for _, dep := range deps { + if strings.Contains(dep, rawData.Module.Path) { + path := strings.Replace(dep, rawData.Module.Path, ".", 1) + if !slices.Contains(localDeps, path) { + localDeps = append(localDeps, path) + } + } else { + extDeps = append(extDeps, dep) + } + } + + return localDeps, extDeps +} + +func getDependencies(gomod *modfile.File, rawDeps []string) (deps []string) { + slices.Sort(rawDeps) + for _, dep := range rawDeps { + if depWithVersion := findVersion(gomod.Require, dep); depWithVersion != "" { + deps = append(deps, depWithVersion) + } + } + return deps +} + +func findVersion(dependencies []*modfile.Require, val string) string { + for _, item := range dependencies { + if strings.Contains(val, item.Mod.Path) { + return fmt.Sprintf("%s %s", val, item.Mod.Version) + } + } + return val +} diff --git a/pkg/project/modules_test.go b/pkg/project/modules_test.go new file mode 100644 index 0000000..b4e5aa6 --- /dev/null +++ b/pkg/project/modules_test.go @@ -0,0 +1,203 @@ +package project + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +func TestLoadGOModules_LoadsModuleData(t *testing.T) { + // Initialize the class object + p := &Project{ + Name: "test-project", + Path: "./../..", + } + + mfile, _ := loadGOModFile("../..") + + // Call the method under test + err := p.LoadGOModules(mfile) + + // Assert that there is no error + assert.NoError(t, err) + + // Assert that the module data is correctly parsed + assert.Contains(t, p.Module.LocalDirs, "../..") + assert.Contains(t, p.Module.LocalDirs, "./cmd") + assert.Contains(t, p.Module.LocalDirs, "./pkg/project") + assert.Contains(t, p.Module.LocalDirs, "./pkg/hasher") + assert.NotEmpty(t, p.Module.ExternalDeps) +} + +func TestLoadGOModFile_Valid(t *testing.T) { + // Create a temporary directory + tempDir := t.TempDir() + + // Create a valid go.mod file + goModContent := "module example.com\n\ngo 1.22" + goModPath := filepath.Join(tempDir, "go.mod") + err := os.WriteFile(goModPath, []byte(goModContent), 0o644) + assert.NoError(t, err) + + // Call the loadGOModFile function + modFile, err := loadGOModFile(tempDir) + assert.NoError(t, err) + + // Assert that the modFile is not empty + assert.NotEmpty(t, modFile) +} + +func TestLoadGOModFile_GOAC(t *testing.T) { + // Call the loadGOModFile function + modFile, err := loadGOModFile("../..") + assert.NoError(t, err) + + // Assert that the modFile is not empty + assert.NotEmpty(t, modFile) +} + +func TestLoadGOModFile_LocalDependencies(t *testing.T) { + // Create a temporary directory + tempDir := t.TempDir() + + // Create a go.mod file with local dependencies + goModContent := "module example.com\n\ngo 1.22\n\nrequire (\n\texample.com/localdep v1.0.0\n)" + goModPath := filepath.Join(tempDir, "go.mod") + err := os.WriteFile(goModPath, []byte(goModContent), 0o644) + assert.NoError(t, err) + + // Call the loadGOModFile function + modFile, err := loadGOModFile(tempDir) + assert.NoError(t, err) + + // Assert that the modFile is not nil + assert.NotEmpty(t, modFile) + + // Assert that the Require slice is not empty + assert.NotEmpty(t, modFile.Require) + assert.Len(t, modFile.Require, 1) +} + +func TestLoadGOModFile_InvalidPath(t *testing.T) { + // Call the loadGOModFile function + modFile, err := loadGOModFile("invalid") + + // assert error and modfile empty + assert.Error(t, err) + assert.Empty(t, modFile) +} + +func TestCleanDeps_IncludesAllDeps(t *testing.T) { + rawData := &toolData{ + Module: struct { + Path string + Dir string + }{ + Path: "github.com/kperreau/goac", + }, + Deps: []string{"dep1 v1", "dep2 v1.1"}, + Imports: []string{"github.com/kperreau/goac/scan", "github.com/kperreau/goac/hasher", "anotherlib v1"}, + } + localDir := "" + + localDeps, extDeps := cleanDeps(rawData, localDir) + + assert.Equal(t, []string{".", "./scan", "./hasher"}, localDeps) + assert.Equal(t, []string{"dep1 v1", "dep2 v1.1", "anotherlib v1"}, extDeps) +} + +func TestCleanDeps_EmptyDepsAndImports(t *testing.T) { + rawData := &toolData{ + Deps: []string{}, + Imports: []string{}, + } + localDir := "../.." + + localDeps, extDeps := cleanDeps(rawData, localDir) + + assert.Equal(t, []string{"../.."}, localDeps) + assert.Empty(t, extDeps) +} + +func TestGetDependencies_ValidModfileAndDependencies(t *testing.T) { + gomod := &modfile.File{ + Require: []*modfile.Require{ + {Mod: module.Version{Path: "github.com/pkg1", Version: "v1.0.0"}}, + {Mod: module.Version{Path: "github.com/pkg2", Version: "v2.0.0"}}, + }, + } + rawDeps := []string{"github.com/pkg1", "github.com/pkg2"} + expected := []string{"github.com/pkg1 v1.0.0", "github.com/pkg2 v2.0.0"} + + deps := getDependencies(gomod, rawDeps) + + if !reflect.DeepEqual(deps, expected) { + t.Errorf("Expected %v, but got %v", expected, deps) + } +} + +func TestGetDependencies_EmptyDependenciesList(t *testing.T) { + gomod := &modfile.File{ + Require: []*modfile.Require{ + {Mod: module.Version{Path: "github.com/pkg1", Version: "v1.0.0"}}, + {Mod: module.Version{Path: "github.com/pkg2", Version: "v2.0.0"}}, + }, + } + var rawDeps []string + + deps := getDependencies(gomod, rawDeps) + + assert.Empty(t, deps) +} + +func TestGetDependencies_EmptyModfile(t *testing.T) { + gomod := &modfile.File{} + rawDeps := []string{"github.com/pkg1", "github.com/pkg2"} + expected := rawDeps + + deps := getDependencies(gomod, rawDeps) + + assert.Equal(t, expected, deps) +} + +func TestFindVersion_ModuleFound(t *testing.T) { + dependencies := []*modfile.Require{ + {Mod: module.Version{Path: "module1", Version: "v1.0.0"}}, + {Mod: module.Version{Path: "module2", Version: "v2.0.0"}}, + {Mod: module.Version{Path: "module3", Version: "v3.0.0"}}, + } + val := "module2" + expected := "module2 v2.0.0" + + result := findVersion(dependencies, val) + + assert.Equal(t, expected, result) +} + +func TestFindVersion_EmptyDependencies(t *testing.T) { + var dependencies []*modfile.Require + val := "module1" + expected := val + + result := findVersion(dependencies, val) + + assert.Equal(t, expected, result) +} + +func TestFindVersion_EmptyString(t *testing.T) { + dependencies := []*modfile.Require{ + {Mod: module.Version{Path: "module1", Version: "v1.0.0"}}, + {Mod: module.Version{Path: "module2", Version: "v2.0.0"}}, + {Mod: module.Version{Path: "module3", Version: "v3.0.0"}}, + } + val := "" + + result := findVersion(dependencies, val) + + assert.Empty(t, result) +} diff --git a/pkg/project/project.go b/pkg/project/project.go new file mode 100644 index 0000000..fa9b8c5 --- /dev/null +++ b/pkg/project/project.go @@ -0,0 +1,237 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + + "golang.org/x/mod/modfile" + + "github.com/kperreau/goac/pkg/hasher" + "github.com/kperreau/goac/pkg/scan" + "github.com/kperreau/goac/pkg/utils" + "gopkg.in/yaml.v3" + + "github.com/kperreau/goac/pkg/printer" +) + +type Env struct { + Key string + Value string +} + +type Exec struct { + CMD string + Params []string +} + +type TargetConfig struct { + Envs []Env + Exec *Exec +} + +type Project struct { + Version string + Name string + Path string + CleanPath string + Target map[Target]*TargetConfig + HashPath string + Module *Module + HashPool *sync.Pool + Metadata *Metadata + Cache *Cache + Rule *scan.Rule + CMDOptions *Options +} + +type IList interface { + List() + Affected() error +} + +type List struct { + Projects []*Project + Options *Options +} + +type Options struct { + Target Target + DryRun bool + MaxConcurrency int + BinaryCheck bool + Force bool + DockerIgnore bool + ProjectsName []string + Debug []string + PrintStdout bool +} + +var RootPath = "." + +func NewProjectsList(opt *Options) (IList, error) { + projects, err := getProjects(opt) + if err != nil { + return nil, err + } + + return &List{ + Projects: projects, + Options: opt, + }, err +} + +func find(path string, projectFileName string) (files []string, err error) { + err = filepath.Walk(path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && strings.HasSuffix(info.Name(), projectFileName) { + files = append(files, path) + return filepath.SkipDir + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("error finding config files: %w", err) + } + + return files, nil +} + +func loadConfig(file string, opts *processProjectOptions) (*Project, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("error opening project config: %w", err) + } + + var project Project + if err = yaml.Unmarshal(data, &project); err != nil { + printer.Errorf("failed to unmarshal project config: %s", err.Error()) + return nil, err + } + project.CleanPath = utils.CleanPath(file, configFileName) + project.Path = utils.AddCurrentDirPrefix(project.CleanPath) + project.HashPool = opts.hashPool + project.CMDOptions = opts.Options + + hashPath, err := hasher.WithPool(opts.hashPool, project.CleanPath) + if err != nil { + return nil, fmt.Errorf("error hashing files: %w", err) + } + project.HashPath = hashPath + + return &project, nil +} + +type processProjectOptions struct { + *Options + gomod *modfile.File + projectCh chan *Project + errorsCh chan error + hashPool *sync.Pool + wg *sync.WaitGroup + sem chan bool +} + +func getProjects(opt *Options) (projects []*Project, err error) { + projectsFiles, err := find(RootPath, configFileName) + if err != nil { + return nil, err + } + + // preload go mod file dependencies + gomod, err := loadGOModFile(RootPath) + if err != nil { + return nil, err + } + + // init process options + sem := make(chan bool, opt.MaxConcurrency+1) + projectsCh := make(chan *Project) + errorsCh := make(chan error) + wg := sync.WaitGroup{} + pOpts := &processProjectOptions{ + Options: opt, + projectCh: projectsCh, + errorsCh: errorsCh, + hashPool: hasher.NewPool(), + wg: &wg, + sem: sem, + gomod: gomod, + } + + for _, projectFile := range projectsFiles { + sem <- true // acquire + wg.Add(1) + go processProject(pOpts, projectFile) + } + + wg.Wait() + for i := 0; i < len(projectsFiles); i++ { + select { + case project := <-projectsCh: + if project != nil { + projects = append(projects, project) + } + case err := <-errorsCh: + return nil, err + } + } + + return projects, nil +} + +func processProject(opt *processProjectOptions, projectFile string) { + defer opt.wg.Done() + defer func() { + <-opt.sem // release + }() + + // load config file .goacproject.yaml + project, err := loadConfig(projectFile, opt) + if err != nil { + go func() { opt.errorsCh <- fmt.Errorf("error loading config: %w", err) }() + return + } + + // Skip if option cli cmd --projects is set and not match this project name + if len(opt.Options.ProjectsName) > 0 && !slices.Contains(opt.Options.ProjectsName, project.Name) { + go func() { opt.projectCh <- nil }() + return + } + + // load go modules with go list cmd cli (list imports and dependencies) + if err := project.LoadGOModules(opt.gomod); err != nil { + go func() { opt.errorsCh <- fmt.Errorf("error loading modules: %w", err) }() + return + } + + // no need affected data, return project (list for example) + if opt.Target == TargetNone { + go func() { opt.projectCh <- project }() + return + } + + // load caches data + if err := project.LoadCache(); err != nil { + go func() { opt.errorsCh <- err }() + return + } + + if opt.DockerIgnore { + // load includes/excludes rule + project.LoadRule(opt.Target) + } + + // load hashs + if err := project.LoadHashs(); err != nil { + go func() { opt.errorsCh <- err }() + return + } + + go func() { opt.projectCh <- project }() +} diff --git a/pkg/project/project_test.go b/pkg/project/project_test.go new file mode 100644 index 0000000..e646ebf --- /dev/null +++ b/pkg/project/project_test.go @@ -0,0 +1,46 @@ +package project + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewProjectsList_ValidOptions(t *testing.T) { + opt := &Options{ + Target: "goac", + DryRun: false, + MaxConcurrency: 2, + BinaryCheck: true, + Force: false, + DockerIgnore: true, + ProjectsName: []string{"project1", "project2"}, + Debug: []string{"debug1", "debug2"}, + PrintStdout: true, + } + + RootPath = "./../.." + list, err := NewProjectsList(opt) + + assert.NoError(t, err) + assert.NotNil(t, list) +} + +func TestNewProjectsList_InvalidPath(t *testing.T) { + opt := &Options{ + Target: "goac", + DryRun: false, + MaxConcurrency: 2, + BinaryCheck: true, + Force: false, + DockerIgnore: true, + ProjectsName: []string{"project1", "project2"}, + Debug: []string{"debug1", "debug2"}, + PrintStdout: true, + } + + RootPath = "invalid-path" + list, err := NewProjectsList(opt) + + assert.Error(t, err) + assert.Nil(t, list) +} diff --git a/pkg/project/rule.go b/pkg/project/rule.go new file mode 100644 index 0000000..a0e5a66 --- /dev/null +++ b/pkg/project/rule.go @@ -0,0 +1,33 @@ +package project + +import ( + "fmt" + "path/filepath" + + "github.com/codeskyblue/dockerignore" + "github.com/kperreau/goac/pkg/scan" + "github.com/kperreau/goac/pkg/utils" +) + +var DefaultFilesToInclude = map[Target][]string{ + TargetBuild: {"*.go"}, + TargetBuildImage: {}, +} + +var DefaultFilesToExclude = map[Target][]string{ + TargetBuild: {".goacproject.yaml", "*_test.go"}, + TargetBuildImage: {".goacproject.yaml", "*_test.go"}, +} + +func (p *Project) LoadRule(target Target) { + p.Rule = &scan.Rule{ + Includes: DefaultFilesToInclude[target], + Excludes: append(p.Module.IgnoredGoFiles, DefaultFilesToExclude[target]...), + } + + // add .dockerignore entries to the exclude files rules + dockerIgnoreFiles, err := dockerignore.ReadIgnoreFile(filepath.Clean(fmt.Sprintf("%s/.dockerignore", p.CleanPath))) + if err == nil { + p.Rule.Excludes = utils.AppendIfNotExist(p.Rule.Excludes, dockerIgnoreFiles...) + } +} diff --git a/pkg/project/rule_test.go b/pkg/project/rule_test.go new file mode 100644 index 0000000..1ca83cc --- /dev/null +++ b/pkg/project/rule_test.go @@ -0,0 +1,54 @@ +package project + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadRule_LoadsDefaultRule(t *testing.T) { + // Initialize the project object + p := &Project{ + Module: &Module{ + IgnoredGoFiles: []string{"file1.go", "file2.go"}, + }, + } + + // Invoke the LoadRule method + p.LoadRule(TargetBuild) + + // Assert that the rule is loaded correctly + assert.Equal(t, DefaultFilesToInclude[TargetBuild], p.Rule.Includes) + assert.Equal(t, append(p.Module.IgnoredGoFiles, DefaultFilesToExclude[TargetBuild]...), p.Rule.Excludes) +} + +func TestLoadRule_AppendsIgnoredGoFiles(t *testing.T) { + // Initialize the project object + p := &Project{ + Module: &Module{ + IgnoredGoFiles: []string{"file1.go", "file2.go"}, + }, + } + + // Invoke the LoadRule method + p.LoadRule(TargetBuild) + + // Assert that the ignored go files are appended to the exclude list + expectedExcludes := append(p.Module.IgnoredGoFiles, DefaultFilesToExclude[TargetBuild]...) + assert.Equal(t, expectedExcludes, p.Rule.Excludes) +} + +func TestLoadRule_HandlesEmptyIgnoredGoFiles(t *testing.T) { + // Initialize the project object with an empty ignored go files list + p := &Project{ + Module: &Module{ + IgnoredGoFiles: []string{}, + }, + } + + // Invoke the LoadRule method + p.LoadRule(TargetBuild) + + // Assert that the exclude list only contains the default excludes + assert.Equal(t, DefaultFilesToExclude[TargetBuild], p.Rule.Excludes) +} diff --git a/pkg/scan/scan.go b/pkg/scan/scan.go new file mode 100644 index 0000000..7127dbc --- /dev/null +++ b/pkg/scan/scan.go @@ -0,0 +1,77 @@ +package scan + +import ( + "os" + "path/filepath" +) + +type Rule struct { + Excludes []string + Includes []string +} + +func Dirs(dirs []string, rule *Rule) (files []string, err error) { + for _, dir := range dirs { + filesScanned, err := subDir(dir, rule) + if err != nil { + return []string{}, err + } + files = append(files, filesScanned...) + } + + return files, nil +} + +func subDir(dir string, rule *Rule) (files []string, err error) { + dir = filepath.Clean(dir) + err = filepath.Walk(dir, func(file string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip if we match excludes patterns + if fileMatch(info.Name(), rule.Excludes) { + //fmt.Println("dir1", info.IsDir(), info.Name()) + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + if info.IsDir() { + return nil + } else if file == dir { + files = append(files, filepath.ToSlash(file)) + return filepath.SkipDir + } + + // Skip if we have includes patterns, and we don't match it + if len(rule.Includes) > 0 && !fileMatch(info.Name(), rule.Includes) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + files = append(files, filepath.ToSlash(file)) + return nil + }) + if err != nil { + return nil, err + } + return files, nil +} + +func fileMatch(filename string, patterns []string) bool { + for _, pattern := range patterns { + match, err := filepath.Match(pattern, filename) + if err != nil { + return false + } + if match { + return true + } + } + + return false +} diff --git a/pkg/scan/scan_test.go b/pkg/scan/scan_test.go new file mode 100644 index 0000000..abce4fb --- /dev/null +++ b/pkg/scan/scan_test.go @@ -0,0 +1,158 @@ +package scan + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDirs_ReturnsListOfFiles(t *testing.T) { + // Create a mock rule + rule := &Rule{ + Excludes: []string{}, + Includes: []string{}, + } + + // Set Directories + dirs := []string{"."} + + // Call the Dirs function + files, err := Dirs(dirs, rule) + + // Assert that the function returns the expected result + assert.Nil(t, err) + assert.Equal(t, []string{"scan.go", "scan_test.go"}, files) +} + +func TestDirs_ReturnsErrorWhenDirectoryDoesNotExist(t *testing.T) { + // Create a mock rule + rule := &Rule{ + Excludes: []string{}, + Includes: []string{}, + } + + // Directory that does not exist + dirs := []string{"/path/to/nonexistent/directory"} + + // Call the Dirs function + files, err := Dirs(dirs, rule) + + // Assert that the function returns the expected error + assert.NotNil(t, err) + assert.Equal(t, "lstat /path/to/nonexistent/directory: no such file or directory", err.Error()) + + // Assert that the function does not return any files + assert.Empty(t, files) +} + +func TestSubDir_ValidDirectoryAndRule_ReturnsListOfFiles(t *testing.T) { + // Create a mock rule + rule := &Rule{ + Excludes: []string{".DS_Store"}, + Includes: []string{"*.go"}, + } + + // Call the subDir function + files, err := subDir(".", rule) + fmt.Println("files", files) + + // Assert that the function returns the expected result + assert.NoError(t, err) + assert.Equal(t, []string{"scan.go", "scan_test.go"}, files) +} + +func TestSubDir_ValidDirectoryAndRuleExcludeTestFiles_ReturnsListOfFiles(t *testing.T) { + // Create a mock rule + rule := &Rule{ + Excludes: []string{".DS_Store", "*_test.go"}, + Includes: []string{"*.go"}, + } + + // Call the subDir function + files, err := subDir(".", rule) + + // Assert that the function returns the expected result + assert.NoError(t, err) + assert.Equal(t, []string{"scan.go"}, files) +} + +func TestSubDir_ValidDirectoryAndRuleOnlyGO_ReturnsListOfFiles(t *testing.T) { + // Create a mock rule + rule := &Rule{ + Excludes: []string{".DS_Store", "*_test.go"}, + Includes: []string{"*.go"}, + } + + // Call the subDir function + files, err := subDir("../../", rule) + + // Assert that the function returns the expected result + assert.NoError(t, err) + assert.Contains(t, files, "../../main.go") + assert.NotContains(t, files, "../../.goacproject.yaml") + assert.NotContains(t, files, "../../go.mod") +} + +func TestSubDir_InvalidDirectoryPath_ReturnsError(t *testing.T) { + // Create a mock rule + rule := &Rule{ + Excludes: []string{".*"}, + Includes: []string{"*.go"}, + } + + // Call the subDir function with an invalid directory path + files, err := subDir("/invalid/path", rule) + + // Assert that the function returns an error + assert.Error(t, err) + assert.Nil(t, files) +} + +func TestFileMatch_MatchesPattern_ReturnsTrue(t *testing.T) { + // Arrange + filename := "example.txt" + patterns := []string{"*.txt"} + + // Act + result := fileMatch(filename, patterns) + + // Assert + assert.True(t, result) +} + +func TestFileMatch_MatchesMultiplePatterns_ReturnsTrue(t *testing.T) { + // Arrange + filename := "scan.go" + patterns := []string{"go", ".", "*.go"} + + // Act + result := fileMatch(filename, patterns) + + // Assert + assert.True(t, result) +} + +func TestFileMatch_MatchesMultiplePatterns_ReturnsFalse(t *testing.T) { + // Arrange + filename := "scan.go.yo" + patterns := []string{"go", ".", "*.go", "scan"} + + // Act + result := fileMatch(filename, patterns) + + // Assert + assert.False(t, result) +} + +func TestFileMatch_EmptyFilename_ReturnsFalse(t *testing.T) { + // Arrange + filename := "" + patterns := []string{"*.txt"} + + // Act + result := fileMatch(filename, patterns) + + // Assert + assert.False(t, result) +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..786c99b --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,41 @@ +package utils + +import ( + "os" + "path/filepath" + "slices" + "strings" +) + +func CleanPath(path string, filename string) string { + return filepath.Clean(strings.TrimSuffix(path, filename)) +} + +func AddCurrentDirPrefix(path string) string { + if path != "." && !strings.HasPrefix(path, "./") && !strings.HasPrefix(path, "/") { + return "./" + path + } + return path +} + +func FileExist(filePath string) bool { + _, err := os.Stat(filePath) + if err != nil { + if os.IsNotExist(err) { + return false + } + return false + } + return true +} + +// AppendIfNotExist append new values to the existing slice only if the values are not already in. +// This avoids duplication. +func AppendIfNotExist(slice []string, elems ...string) []string { + for _, elem := range elems { + if elem != "" && !slices.Contains(slice, elem) { + slice = append(slice, elem) + } + } + return slice +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go new file mode 100644 index 0000000..e60d865 --- /dev/null +++ b/pkg/utils/utils_test.go @@ -0,0 +1,155 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCleanPath_ValidPathAndFilename(t *testing.T) { + // Arrange + path := "/path/to/config.yaml" + filename := "config.yaml" + + // Act + result := CleanPath(path, filename) + + // Assert + assert.Equal(t, "/path/to", result) +} + +func TestCleanPath_ValidDirtyPathAndFilename(t *testing.T) { + // Arrange + path := "./path///to//config.yaml" + filename := "config.yaml" + + // Act + result := CleanPath(path, filename) + + // Assert + assert.Equal(t, "path/to", result) +} + +func TestCleanPath_EmptyPathAndFilename(t *testing.T) { + // Arrange + path := "" + filename := "" + + // Act + result := CleanPath(path, filename) + + // Assert + assert.Equal(t, ".", result) +} + +func TestCleanPath_NoChange(t *testing.T) { + // Arrange + path := "/path/to/config.yaml" + filename := "nop" + + // Act + result := CleanPath(path, filename) + + // Assert + assert.Equal(t, "/path/to/config.yaml", result) +} + +func TestCleanPath_NoChange2(t *testing.T) { + // Arrange + path := "/path/to/nop" + filename := "config.yaml" + + // Act + result := CleanPath(path, filename) + + // Assert + assert.Equal(t, "/path/to/nop", result) +} + +func TestAddCurrentDirPrefix_InputPathStartsWithSlash_ReturnsInputPath(t *testing.T) { + // Arrange + path := "/test/path" + + // Act + result := AddCurrentDirPrefix(path) + + // Assert + assert.Equal(t, path, result) +} + +func TestAddCurrentDirPrefix_InputPathStartsWithoutSlash_ReturnsDotSlash(t *testing.T) { + // Arrange + path := "test/path" + + // Act + result := AddCurrentDirPrefix(path) + + // Assert + assert.Equal(t, "./test/path", result) +} + +func TestAddCurrentDirPrefix_InputPathIsEmptyString_ReturnsDotSlash(t *testing.T) { + // Arrange + path := "" + + // Act + result := AddCurrentDirPrefix(path) + + // Assert + assert.Equal(t, "./", result) +} + +func TestFileExist_ValidFilePath(t *testing.T) { + // Arrange + filePath := "utils.go" + + // Act + result := FileExist(filePath) + + // Assert + assert.True(t, result) +} + +func TestFileExist_BadFilePath(t *testing.T) { + // Arrange + filePath := "file-not-exist.go" + + // Act + result := FileExist(filePath) + + // Assert + assert.False(t, result) +} + +func TestAppendIfNotExist_SingleElementToEmptySlice(t *testing.T) { + // Create a new empty slice + var slice []string + + // Call the AppendIfNotExist function with a single element + slice = AppendIfNotExist(slice, "element") + + // Assert that the slice contains the appended element + assert.Contains(t, slice, "element") +} + +func TestAppendIfNotExist_ExistingElementToNonEmptySlice(t *testing.T) { + // Create a new non-empty slice + slice := []string{"existing"} + + // Call the AppendIfNotExist function with an existing element + slice = AppendIfNotExist(slice, "existing") + + // Assert that the slice remains unchanged + assert.Equal(t, []string{"existing"}, slice) +} + +func TestAppendIfNotExist_AddElementsToNonEmptySlice(t *testing.T) { + // Create a new non-empty slice + slice := []string{"1", "2", "3"} + + // Call the AppendIfNotExist function with elements + slice = AppendIfNotExist(slice, []string{"3", "1", "4", ""}...) + + // Assert that the slice remains changed without duplication + assert.Equal(t, []string{"1", "2", "3", "4"}, slice) +}