diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e63156a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test-and-build: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Run Tests + run: go test -v ./... + + - name: Build Binary + run: go build -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ac692f3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index aaadf73..8dc1c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,13 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# # Binaries for programs and plugins +gophersnap +gophersnap.exe *.exe *.exe~ *.dll *.so *.dylib +*.zip +*.tar.gz # Test binary, built with `go test -c` *.test @@ -17,7 +18,7 @@ coverage.* *.coverprofile profile.cov -# Dependency directories (remove the comment below to include it) +# Dependency directories # vendor/ # Go workspace file @@ -26,7 +27,3 @@ go.work.sum # env file .env - -# Editor/IDE -# .idea/ -# .vscode/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..298b454 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,52 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: gophersnap + main: ./main.go + binary: gophersnap + env: + - CGO_ENABLED=0 + goos: + - windows + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X github.com/benjaminjvdm/GopherSnap/cmd.Version={{.Version}} -X github.com/benjaminjvdm/GopherSnap/cmd.Commit={{.Commit}} -X github.com/benjaminjvdm/GopherSnap/cmd.Date={{.Date}} + +universal_binaries: + - id: gophersnap + replace: true + +archives: + - id: default + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: + - README.md + - LICENSE + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..51a3e22 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: all build test clean release + +BINARY_NAME=gophersnap +WINDOWS_BINARY=gophersnap.exe + +all: build + +build: + CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BINARY_NAME) main.go + +test: + go test -v -cover ./... + +clean: + rm -f $(BINARY_NAME) $(WINDOWS_BINARY) gophersnap_*.zip gophersnap_*.tar.gz coverage.out + +release: + go run github.com/tc-hib/go-winres@latest make --arch amd64,arm64,386 + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o $(WINDOWS_BINARY) main.go + zip -q gophersnap_windows_x64.zip $(WINDOWS_BINARY) README.md LICENSE diff --git a/README.md b/README.md index 9370b94..dccd20f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GopherSnap -GopherSnap is a high-performance, concurrent CLI image converter written in Go. It allows you to batch process images with efficiency, supporting modern formats like WebP and AVIF. +GopherSnap is a high-performance, concurrent CLI image converter written in Go. It allows you to batch process images with efficiency, supporting modern formats like WebP and AVIF with zero external runtime C dependencies. ## Features @@ -8,23 +8,85 @@ GopherSnap is a high-performance, concurrent CLI image converter written in Go. - **Recursive Directory Mirroring**: Preserves input directory structure in the output. - **Concurrent Processing**: Leverages Go's goroutines for fast, parallel execution. - **Modern Formats**: Supports JPG, PNG, WebP, and AVIF. -- **No CGO**: Uses pure-Go/WASM implementations for WebP and AVIF for easy portability. +- **Pure-Go & Zero CGO**: Compiles cleanly with `CGO_ENABLED=0` using WASM/pure-Go encoders for maximum cross-platform portability. +- **Standalone Windows Executable**: Embedded application icons and PE version manifests for Windows (`.exe`). - **Interactive Progress**: Real-time feedback with a styled progress bar. +## Architecture Overview + +GopherSnap is architected for zero-CGO portability and parallel performance: + +- **Core Converter Pipeline**: Pure Go image decoding (`image/jpeg`, `image/png`, `image/gif`) combined with pure-Go and WASM-backed WebP (`github.com/gen2brain/webp`) and AVIF (`github.com/gen2brain/avif`) encoders via Wazero. +- **Concurrency Model**: Worker pool pattern (`internal/converter/batch.go`) distributing file transformation jobs across customizable goroutine workers. +- **Windows Embedding**: Windows application icon and PE metadata are compiled into native `rsrc_windows_*.syso` resources using `go-winres`. +- **Cross-Platform Utility**: Clean path sanitization and case-insensitive image extension matching (`.jpg`, `.jpeg`, `.png`, `.webp`, `.avif`, `.gif`). + ## Installation -Ensure you have Go installed on your system. Then, you can install GopherSnap using: +### Windows (.EXE) Standalone Quickstart + +Download `gophersnap_windows_x64.zip` from the latest GitHub Release, extract `gophersnap.exe`, and run it directly in PowerShell or Command Prompt: + +```powershell +# Extract gophersnap.exe and test version +.\gophersnap.exe version + +# Convert images in PowerShell +.\gophersnap.exe convert -i C:\Images\Photos -o C:\Images\Output -f webp -q 85 +``` + +### Via Go Install + +Ensure Go is installed on your system: ```bash go install github.com/benjaminjvdm/GopherSnap@latest ``` -Alternatively, clone the repository and build it manually: +### Build From Source + +Clone the repository and build using Makefile or PowerShell build automation: ```bash git clone https://github.com/benjaminjvdm/GopherSnap.git cd GopherSnap -go build -o gophersnap + +# Linux / macOS (Unix) +make build + +# Windows PowerShell +.\build.ps1 +``` + +## Build Automation & PowerShell Scripts + +### Windows PowerShell (`build.ps1`) + +```powershell +# Default build: compiles standalone gophersnap.exe with stripped symbols +.\build.ps1 + +# Run full test suite with coverage report +.\build.ps1 -Test + +# Generate Windows PE resources and package release ZIP +.\build.ps1 -Package +``` + +### Unix Makefile (`Makefile`) + +```bash +# Build standalone binary +make build + +# Run test suite +make test + +# Generate Windows PE resources and release package +make release + +# Clean binaries and build artifacts +make clean ``` ## Usage @@ -70,3 +132,38 @@ gophersnap convert -i ./images --width 1024 --height 1024 - `--height int`: Target height (maintaining aspect ratio) - `-j, --jobs int`: Number of concurrent jobs (Default: `4`) - `--overwrite`: Overwrite existing files if they exist in the output directory + +## Version & Release Pipeline + +GopherSnap features automated cross-platform releases powered by GoReleaser and GitHub Actions. + +### Version Command & Linker Injection + +Build-time metadata can be injected via ldflags: + +```bash +go build -ldflags="-s -w \ + -X 'github.com/benjaminjvdm/GopherSnap/cmd.Version=v0.1.0' \ + -X 'github.com/benjaminjvdm/GopherSnap/cmd.Commit=1a2b3c4' \ + -X 'github.com/benjaminjvdm/GopherSnap/cmd.Date=2026-03-30'" \ + -o gophersnap main.go +``` + +Check installed version details: + +```bash +gophersnap version +# or +gophersnap --version +``` + +### Release Process & GoReleaser Configuration + +To trigger an automated release: +1. Commit all changes and create a semantic version tag: `git tag -a v0.1.0 -m "Release v0.1.0"`. +2. Push the tag: `git push origin v0.1.0`. +3. GitHub Actions (`.github/workflows/release.yml`) executes `.goreleaser.yaml` to build cross-platform binaries: + - **Windows**: `windows/amd64`, `windows/arm64` (.zip with `gophersnap.exe`, `README.md`, `LICENSE`) + - **Linux**: `linux/amd64`, `linux/arm64` (.tar.gz) + - **macOS**: `darwin/amd64`, `darwin/arm64` (Universal binary .tar.gz) + - **Checksums**: SHA-256 (`checksums.txt`) diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..37a6ba6 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,37 @@ +[CmdletBinding()] +param( + [switch]$Test, + [switch]$Package +) + +$ErrorActionPreference = "Stop" + +if ($Test) { + Write-Host "Running full test suite..." -ForegroundColor Cyan + go test -v -cover ./... + exit $LASTEXITCODE +} + +if ($Package) { + Write-Host "Generating Windows PE resources..." -ForegroundColor Cyan + go run github.com/tc-hib/go-winres@latest make --arch amd64,arm64,386 + + Write-Host "Building gophersnap.exe..." -ForegroundColor Cyan + $env:CGO_ENABLED = "0" + $env:GOOS = "windows" + $env:GOARCH = "amd64" + go build -ldflags="-s -w" -o gophersnap.exe main.go + + Write-Host "Packaging gophersnap_windows_x64.zip..." -ForegroundColor Cyan + if (Test-Path "gophersnap_windows_x64.zip") { + Remove-Item "gophersnap_windows_x64.zip" -Force + } + Compress-Archive -Path "gophersnap.exe", "README.md", "LICENSE" -DestinationPath "gophersnap_windows_x64.zip" + Write-Host "Successfully packaged gophersnap_windows_x64.zip" -ForegroundColor Green + exit 0 +} + +Write-Host "Building standalone gophersnap.exe..." -ForegroundColor Cyan +$env:CGO_ENABLED = "0" +go build -ldflags="-s -w" -o gophersnap.exe main.go +Write-Host "Build complete: gophersnap.exe" -ForegroundColor Green diff --git a/cmd/convert.go b/cmd/convert.go index d51e79f..d0b4eb1 100644 --- a/cmd/convert.go +++ b/cmd/convert.go @@ -70,7 +70,7 @@ var convertCmd = &cobra.Command{ if err != nil { relPath = "." } - jobOutputDir := filepath.Join(outputDir, relPath) + jobOutputDir := filepath.Clean(filepath.Join(outputDir, relPath)) jobsList = append(jobsList, converter.ConverterJob{ InputPath: file, OutputDir: jobOutputDir, @@ -176,7 +176,7 @@ func findFiles(path string) ([]string, error) { if !info.IsDir() { ext := strings.ToLower(filepath.Ext(p)) if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".webp" || ext == ".avif" || ext == ".gif" { - files = append(files, p) + files = append(files, filepath.Clean(p)) } } return nil diff --git a/cmd/convert_test.go b/cmd/convert_test.go index fb900a1..6bf6903 100644 --- a/cmd/convert_test.go +++ b/cmd/convert_test.go @@ -1,23 +1,53 @@ package cmd import ( + "image" + "image/color" + "image/png" "os" "path/filepath" "testing" + + "github.com/benjaminjvdm/GopherSnap/internal/converter" ) +func createTestPNG(t *testing.T, dir, filename string, width, height int) string { + t.Helper() + imgPath := filepath.Join(dir, filename) + if err := os.MkdirAll(filepath.Dir(imgPath), 0755); err != nil { + t.Fatalf("failed to create dir: %v", err) + } + + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for x := 0; x < width; x++ { + for y := 0; y < height; y++ { + img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255}) + } + } + + f, err := os.Create(imgPath) + if err != nil { + t.Fatalf("failed to create image file: %v", err) + } + defer f.Close() + + if err := png.Encode(f, img); err != nil { + t.Fatalf("failed to encode png: %v", err) + } + + return imgPath +} + func TestFindFilesSanitization(t *testing.T) { - // Create a temporary directory structure for testing tempDir, err := os.MkdirTemp("", "gophersnap-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) - // Create some files filesToCreate := []string{ "test1.jpg", - filepath.Join("subdir", "test2.png"), + filepath.Join("subdir", "test2.PNG"), } for _, f := range filesToCreate { @@ -35,7 +65,7 @@ func TestFindFilesSanitization(t *testing.T) { tests := []struct { name string inputPath string - expectedFiles []string // base names + expectedFiles []string expectError bool }{ { @@ -43,7 +73,7 @@ func TestFindFilesSanitization(t *testing.T) { inputPath: tempDir + string(filepath.Separator) + "subdir" + string(filepath.Separator) + "..", expectedFiles: []string{ "test1.jpg", - "test2.png", + "test2.PNG", }, expectError: false, }, @@ -52,7 +82,7 @@ func TestFindFilesSanitization(t *testing.T) { inputPath: tempDir + string(filepath.Separator) + ".", expectedFiles: []string{ "test1.jpg", - "test2.png", + "test2.PNG", }, expectError: false, }, @@ -77,7 +107,6 @@ func TestFindFilesSanitization(t *testing.T) { t.Fatalf("expected %d files, got %d", len(tt.expectedFiles), len(files)) } - // Verify basenames foundMap := make(map[string]bool) for _, f := range files { foundMap[filepath.Base(f)] = true @@ -91,3 +120,192 @@ func TestFindFilesSanitization(t *testing.T) { }) } } + +func TestFormatConversions(t *testing.T) { + tempDir, err := os.MkdirTemp("", "gophersnap-fmt-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + srcPath := createTestPNG(t, tempDir, "sample.png", 200, 200) + outputDir := filepath.Join(tempDir, "output") + + formats := []converter.Format{ + converter.FormatJPG, + converter.FormatPNG, + converter.FormatWebP, + converter.FormatAVIF, + } + + for _, fmt := range formats { + t.Run("Convert to "+string(fmt), func(t *testing.T) { + opts := converter.Options{ + Format: fmt, + Quality: 80, + Overwrite: true, + } + res := converter.Convert(srcPath, outputDir, opts) + if res.Error != nil { + t.Fatalf("conversion to %s failed: %v", fmt, res.Error) + } + + if _, err := os.Stat(res.OutputPath); os.IsNotExist(err) { + t.Fatalf("output file for %s does not exist: %s", fmt, res.OutputPath) + } + }) + } +} + +func TestSizingAndQualityConstraints(t *testing.T) { + tempDir, err := os.MkdirTemp("", "gophersnap-size-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + srcPath := createTestPNG(t, tempDir, "large.png", 200, 200) + outputDir := filepath.Join(tempDir, "output") + + t.Run("Width and Height Resizing", func(t *testing.T) { + opts := converter.Options{ + Format: converter.FormatJPG, + Quality: 80, + Width: 100, + Height: 100, + Overwrite: true, + } + res := converter.Convert(srcPath, outputDir, opts) + if res.Error != nil { + t.Fatalf("resizing failed: %v", res.Error) + } + + img, _, err := converter.LoadImage(res.OutputPath) + if err != nil { + t.Fatalf("failed to load resized image: %v", err) + } + bounds := img.Bounds() + if bounds.Dx() != 100 || bounds.Dy() != 100 { + t.Errorf("expected 100x100 resized image, got %dx%d", bounds.Dx(), bounds.Dy()) + } + }) + + t.Run("MaxSize Optimization", func(t *testing.T) { + optsLimited := converter.Options{ + Format: converter.FormatJPG, + Quality: 95, + MaxSize: 2000, + Overwrite: true, + } + resLimited := converter.Convert(srcPath, filepath.Join(outputDir, "limited"), optsLimited) + if resLimited.Error != nil { + t.Fatalf("max-size conversion failed: %v", resLimited.Error) + } + + optsUnlimited := converter.Options{ + Format: converter.FormatJPG, + Quality: 95, + MaxSize: 0, + Overwrite: true, + } + resUnlimited := converter.Convert(srcPath, filepath.Join(outputDir, "unlimited"), optsUnlimited) + if resUnlimited.Error != nil { + t.Fatalf("unlimited conversion failed: %v", resUnlimited.Error) + } + + infoLimited, _ := os.Stat(resLimited.OutputPath) + infoUnlimited, _ := os.Stat(resUnlimited.OutputPath) + + if infoLimited.Size() >= infoUnlimited.Size() { + t.Errorf("expected limited size (%d) to be smaller than unlimited high-quality size (%d)", infoLimited.Size(), infoUnlimited.Size()) + } + }) + + t.Run("Quality Setting Comparison", func(t *testing.T) { + optsLow := converter.Options{Format: converter.FormatJPG, Quality: 20, Overwrite: true} + optsHigh := converter.Options{Format: converter.FormatJPG, Quality: 95, Overwrite: true} + + outLowDir := filepath.Join(outputDir, "low") + outHighDir := filepath.Join(outputDir, "high") + + resLow := converter.Convert(srcPath, outLowDir, optsLow) + resHigh := converter.Convert(srcPath, outHighDir, optsHigh) + + if resLow.Error != nil || resHigh.Error != nil { + t.Fatalf("quality conversion error: low=%v, high=%v", resLow.Error, resHigh.Error) + } + + infoLow, _ := os.Stat(resLow.OutputPath) + infoHigh, _ := os.Stat(resHigh.OutputPath) + + if infoLow.Size() >= infoHigh.Size() { + t.Errorf("expected low quality size (%d) to be smaller than high quality size (%d)", infoLow.Size(), infoHigh.Size()) + } + }) +} + +func TestBatchAndSubdirectoryConversion(t *testing.T) { + tempDir, err := os.MkdirTemp("", "gophersnap-batch-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + src1 := createTestPNG(t, tempDir, "img1.png", 100, 100) + src2 := createTestPNG(t, tempDir, filepath.Join("sub", "img2.PNG"), 150, 150) + + outputDir := filepath.Join(tempDir, "output") + + jobs := []converter.ConverterJob{ + {InputPath: src1, OutputDir: outputDir}, + {InputPath: src2, OutputDir: filepath.Join(outputDir, "sub")}, + } + + opts := converter.Options{ + Format: converter.FormatWebP, + Quality: 80, + Overwrite: true, + } + + progress := make(chan converter.Result) + go converter.BatchConvert(jobs, opts, 2, progress) + + count := 0 + for res := range progress { + if res.Error != nil { + t.Errorf("batch convert job failed for %s: %v", res.InputPath, res.Error) + } + count++ + } + + if count != 2 { + t.Fatalf("expected 2 converted results, got %d", count) + } +} + +func TestErrorHandling(t *testing.T) { + tempDir, err := os.MkdirTemp("", "gophersnap-err-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + t.Run("Non-existent path in findFiles", func(t *testing.T) { + _, err := findFiles(filepath.Join(tempDir, "does-not-exist")) + if err == nil { + t.Error("expected error for non-existent path, got nil") + } + }) + + t.Run("Corrupted image conversion", func(t *testing.T) { + corruptPath := filepath.Join(tempDir, "corrupt.jpg") + if err := os.WriteFile(corruptPath, []byte("this is not an image file"), 0644); err != nil { + t.Fatalf("failed to write corrupted file: %v", err) + } + + res := converter.Convert(corruptPath, filepath.Join(tempDir, "out"), converter.Options{Format: converter.FormatWebP}) + if res.Error == nil { + t.Error("expected error when converting corrupted file, got nil") + } + }) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..6514a0f --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + Version = "v0.1.0" + Commit = "dev" + Date = "unknown" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information for GopherSnap", + Long: `Print version information including version number, git commit hash, and build date.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("GopherSnap %s (commit: %s, built at: %s)\n", Version, Commit, Date) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) + rootCmd.Version = Version + rootCmd.SetVersionTemplate("GopherSnap {{.Version}}\n") +} diff --git a/rsrc_windows_386.syso b/rsrc_windows_386.syso new file mode 100644 index 0000000..c276def Binary files /dev/null and b/rsrc_windows_386.syso differ diff --git a/rsrc_windows_amd64.syso b/rsrc_windows_amd64.syso new file mode 100644 index 0000000..54f0b0f Binary files /dev/null and b/rsrc_windows_amd64.syso differ diff --git a/rsrc_windows_arm64.syso b/rsrc_windows_arm64.syso new file mode 100644 index 0000000..33d643c Binary files /dev/null and b/rsrc_windows_arm64.syso differ diff --git a/winres/icon.png b/winres/icon.png new file mode 100644 index 0000000..c590e4f Binary files /dev/null and b/winres/icon.png differ diff --git a/winres/winres.json b/winres/winres.json new file mode 100644 index 0000000..2316d25 --- /dev/null +++ b/winres/winres.json @@ -0,0 +1,42 @@ +{ + "RT_GROUP_ICON": { + "APP": { + "0000": [ + "icon.png" + ] + } + }, + "RT_MANIFEST": { + "1": { + "0000": { + "identity": { + "name": "GopherSnap", + "version": "0.1.0.0" + }, + "description": "High-Performance Image Converter CLI", + "minimum-os": "win7" + } + } + }, + "RT_VERSION": { + "1": { + "0000": { + "fixed": { + "file_version": "0.1.0.0", + "product_version": "0.1.0.0" + }, + "info": { + "0409": { + "CompanyName": "OpsGen Cloud", + "FileDescription": "High-Performance Image Converter CLI", + "FileVersion": "0.1.0", + "LegalCopyright": "Copyright (c) 2026 OpsGen Cloud", + "OriginalFilename": "gophersnap.exe", + "ProductName": "GopherSnap", + "ProductVersion": "0.1.0" + } + } + } + } + } +}