diff --git a/.envrc b/.envrc index a5dbbcb..0a9cd1d 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ +nix_direnv_manual_reload use flake . diff --git a/.github/actionlint-matcher.json b/.github/actionlint-matcher.json new file mode 100644 index 0000000..4613e16 --- /dev/null +++ b/.github/actionlint-matcher.json @@ -0,0 +1,17 @@ +{ + "problemMatcher": [ + { + "owner": "actionlint", + "pattern": [ + { + "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", + "file": 1, + "line": 2, + "column": 3, + "message": 4, + "code": 5 + } + ] + } + ] +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index ce2c6f5..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Build - -on: - push: - branches: [ main ] - paths: - - '**.go' - - 'go.mod' - - 'go.sum' - - '.github/workflows/build.yml' - pull_request: - branches: [ main ] - paths: - - '**.go' - - 'go.mod' - - 'go.sum' - - '.github/workflows/build.yml' - workflow_dispatch: - -jobs: - build: - name: Build ${{ matrix.os }} ${{ matrix.arch }} - runs-on: ${{ matrix.runner }} - strategy: - matrix: - include: - - os: linux - arch: amd64 - runner: ubuntu-24.04 - - os: linux - arch: arm64 - runner: ubuntu-24.04-arm - - os: darwin - arch: amd64 - runner: macos-15-intel - - os: darwin - arch: arm64 - runner: macos-15 - fail-fast: false - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Download ffmpeg-statigo libraries - run: | - cd third_party/ffmpeg-statigo - go run ./cmd/download-lib - - - name: Run tests - run: go test -v ./... - - - name: Build ${{ matrix.os }} ${{ matrix.arch }} - run: | - echo "Building for ${{ matrix.os }}/${{ matrix.arch }}..." - go build -v -o jivedrop-${{ matrix.os }}-${{ matrix.arch }} ./cmd/jivedrop - ls -lh jivedrop-${{ matrix.os }}-${{ matrix.arch }} - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: jivedrop-${{ matrix.os }}-${{ matrix.arch }} - path: jivedrop-${{ matrix.os }}-${{ matrix.arch }} diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml new file mode 100644 index 0000000..9111267 --- /dev/null +++ b/.github/workflows/builder.yml @@ -0,0 +1,278 @@ +name: Builder 👷 + +on: + push: + branches: [main] + tags: ["*.*.*"] + pull_request: + branches: [main] + schedule: + - cron: "0 9 * * 0" # Weekly + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint-code: + name: Lint Code 🧹 + runs-on: ubuntu-slim + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: go vet ./... + - name: Check cyclomatic complexity + run: | + go install github.com/fzipp/gocyclo/cmd/gocyclo@latest + gocyclo -top 20 -ignore '_test\.go$' -avg . + - name: Check ineffectual assignments + run: | + go install github.com/gordonklaus/ineffassign@latest + ineffassign ./... + - uses: golangci/golangci-lint-action@v9 + + lint-actions: + name: Lint Action ⚙️ + runs-on: ubuntu-slim + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: freerangebytes/setup-actionlint@v0.1.1 + - name: Run actionlint + run: | + echo "::add-matcher::.github/actionlint-matcher.json" + actionlint -color + + coverage: + name: Coverage 📊 + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Download ffmpeg-statigo libraries + run: | + cd third_party/ffmpeg-statigo + go run ./cmd/download-lib + - uses: robherley/go-test-action@v0 + with: + testArguments: -coverprofile=coverage.out -covermode=atomic ./... + omit: untested + + test: + name: Test 🧪 + strategy: + fail-fast: false + matrix: + include: + - os: linux + arch: amd64 + runner: ubuntu-24.04 + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + - os: darwin + arch: amd64 + runner: macos-15-intel + - os: darwin + arch: arm64 + runner: macos-15 + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Download ffmpeg-statigo libraries + run: | + cd third_party/ffmpeg-statigo + go run ./cmd/download-lib + - name: Test + run: go test ./... + + security: + name: Security 🔒 + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + security-events: write + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: actions/dependency-review-action@v4 + if: github.event_name == 'pull_request' + with: + fail-on-severity: moderate + allow-licenses: 0BSD, AFL-2.0, AFL-2.1, AFL-3.0, ANTLR-PD, ANTLR-PD-fallback, Apache-2.0, Artistic-2.0, BlueOak-1.0.0, BSD-1-Clause, BSD-2-Clause, BSD-2-Clause-FreeBSD, BSD-2-Clause-NetBSD, BSD-2-Clause-Patent, BSD-2-Clause-Views, BSD-3-Clause, BSD-3-Clause-Attribution, BSD-3-Clause-Clear, BSD-3-Clause-HP, BSD-3-Clause-LBNL, BSD-3-Clause-Modification, BSD-3-Clause-No-Nuclear-License-2014, BSD-3-Clause-No-Nuclear-Warranty, BSD-3-Clause-Open-MPI, BSD-3-Clause-Sun, BSD-Source-Code, BSL-1.0, bzip2-1.0.5, bzip2-1.0.6, CC0-1.0, CNRI-Python-GPL-Compatible, curl, ECL-2.0, FTL, GPL-2.0-or-later, GPL-3.0-only, GPL-3.0-or-later, HPND-Fenneberg-Livingston, HPND-sell-regexpr, HTMLTIDY, ICU, ImageMagick, Info-ZIP, Intel, Intel-ACPI, ISC, JasPer-2.0, LGPL-2.1-only, LGPL-2.1-or-later, LGPL-3.0-only, LGPL-3.0-or-later, Libpng, libpng-2.0, libtiff, Linux-OpenIB, LZMA-SDK-9.22, MIT, MIT-0, MIT-advertising, MIT-CMU, MIT-Modern-Variant, MIT-open-group, MITNFA, MPL-2.0, MulanPSL-1.0, MulanPSL-2.0, Multics, NCSA, Net-SNMP, NetCDF, NIST-Software, NTP, OLDAP-2.7, OLDAP-2.8, PostgreSQL, PSF-2.0, SGI-B-2.0, SHL-0.5, Spencer-99, SunPro, TCL, TCP-wrappers, UCAR, Unicode-DFS-2015, Unicode-DFS-2016, UnixCrypt, Unlicense, UPL-1.0, W3C, X11, XFree86-1.1, Xnet, Zlib, zlib-acknowledgement, ZPL-2.0, ZPL-2.1 + fail-on-scopes: runtime + comment-summary-in-pr: on-failure + + - name: Run govulncheck + uses: golang/govulncheck-action@master + with: + repo-checkout: false + go-version-file: go.mod + output-format: sarif + output-file: govulncheck.sarif + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v4.32.6 + if: always() + with: + sarif_file: govulncheck.sarif + category: govulncheck + + sentinel: + name: Sentinel 👁️ + needs: [lint-code, lint-actions, coverage, test, security] + if: always() + runs-on: ubuntu-slim + permissions: {} + steps: + - name: Check results + run: | + results='${{ toJSON(needs.*.result) }}' + echo "Job results: ${results}" + if echo "${results}" | jq -e 'any(. == "failure" or . == "cancelled")' > /dev/null 2>&1; then + echo "One or more jobs failed or were cancelled" + exit 1 + fi + echo "All jobs passed" + + build: + name: Build ${{ matrix.os }} ${{ matrix.arch }} + needs: [sentinel] + if: always() && needs.sentinel.result == 'success' + strategy: + fail-fast: false + matrix: + include: + - os: linux + arch: amd64 + runner: ubuntu-24.04 + - os: linux + arch: arm64 + runner: ubuntu-24.04-arm + - os: darwin + arch: amd64 + runner: macos-15-intel + - os: darwin + arch: arm64 + runner: macos-15 + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Download ffmpeg-statigo libraries + run: | + cd third_party/ffmpeg-statigo + go run ./cmd/download-lib + - name: Get version + id: version + run: | + VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building jivedrop $VERSION for ${{ matrix.os }}/${{ matrix.arch }}" + - name: Build binary + run: | + go build -ldflags="-X main.version=${{ steps.version.outputs.version }}" -o jivedrop-${{ matrix.os }}-${{ matrix.arch }} ./cmd/jivedrop + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: jivedrop-${{ matrix.os }}-${{ matrix.arch }} + path: jivedrop-${{ matrix.os }}-${{ matrix.arch }} + + release: + name: Release 📦 + needs: [build] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Get version from tag + id: version + run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + - name: Generate changelog + id: changelog + run: | + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + + if [ -n "$PREV_TAG" ]; then + echo "## Changes since $PREV_TAG" > CHANGELOG.md + git log "$PREV_TAG"..HEAD --pretty=format:"* %s (%h)" >> CHANGELOG.md + else + echo "## Initial Release" > CHANGELOG.md + git log --pretty=format:"* %s (%h)" >> CHANGELOG.md + fi + + cat >> CHANGELOG.md << 'NOTES' + + ## Installation + + Download the appropriate binary for your platform below, make it executable, and move it to your PATH: + + ```bash + # Linux (amd64) + chmod +x jivedrop-linux-amd64 + sudo mv jivedrop-linux-amd64 /usr/local/bin/jivedrop + + # macOS (Apple Silicon) + chmod +x jivedrop-darwin-arm64 + sudo mv jivedrop-darwin-arm64 /usr/local/bin/jivedrop + ``` + + ## Checksums + + SHA256 checksums are provided below for verification. + NOTES + + cat CHANGELOG.md + - name: Download all artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + pattern: jivedrop-* + merge-multiple: false + - name: Create release + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.version.outputs.version }} + name: Jivedrop ${{ steps.version.outputs.version }} + body_path: CHANGELOG.md + draft: false + prerelease: false + files: artifacts/jivedrop-*/jivedrop-* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 86d2d55..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,131 +0,0 @@ -name: Release - -on: - push: - tags: - - '*.*.*' - -permissions: - contents: write - -jobs: - create-release: - name: Create Release - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Get version from tag - id: get_version - run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - - name: Generate changelog - id: changelog - run: | - # Get previous tag - PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - - # Generate changelog between tags - if [ -n "$PREV_TAG" ]; then - echo "## Changes since $PREV_TAG" > CHANGELOG.md - git log $PREV_TAG..HEAD --pretty=format:"* %s (%h)" >> CHANGELOG.md - else - echo "## Initial Release" > CHANGELOG.md - git log --pretty=format:"* %s (%h)" >> CHANGELOG.md - fi - - # Add release notes - cat >> CHANGELOG.md << 'EOF' - - ## Installation - - Download the appropriate binary for your platform below, make it executable, and move it to your PATH: - - ```bash - # Linux (amd64) - chmod +x jivedrop-linux-amd64 - sudo mv jivedrop-linux-amd64 /usr/local/bin/jivedrop - - # macOS (Apple Silicon) - chmod +x jivedrop-darwin-arm64 - sudo mv jivedrop-darwin-arm64 /usr/local/bin/jivedrop - ``` - - ## Checksums - - SHA256 checksums are provided below for verification. - EOF - - cat CHANGELOG.md - - - name: Create Release - uses: softprops/action-gh-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.get_version.outputs.version }} - name: Jivedrop ${{ steps.get_version.outputs.version }} - body_path: CHANGELOG.md - draft: false - prerelease: false - - build: - name: Build ${{ matrix.os }} ${{ matrix.arch }} - runs-on: ${{ matrix.runner }} - needs: create-release - strategy: - matrix: - include: - - os: linux - arch: amd64 - runner: ubuntu-24.04 - - os: linux - arch: arm64 - runner: ubuntu-24.04-arm - - os: darwin - arch: amd64 - runner: macos-15-intel - - os: darwin - arch: arm64 - runner: macos-15 - fail-fast: false - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: recursive - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Download ffmpeg-statigo libraries - run: | - cd third_party/ffmpeg-statigo - go run ./cmd/download-lib - - - name: Get version from tag - id: get_version - run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - - name: Build binary - run: | - VERSION=${{ steps.get_version.outputs.version }} - echo "Building jivedrop $VERSION for ${{ matrix.os }}/${{ matrix.arch }}" - go build -ldflags="-X main.version=$VERSION" -o jivedrop-${{ matrix.os }}-${{ matrix.arch }} ./cmd/jivedrop - ls -lh jivedrop-${{ matrix.os }}-${{ matrix.arch }} - - - name: Upload Assets to Release - uses: softprops/action-gh-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ steps.get_version.outputs.version }} - files: | - jivedrop-${{ matrix.os }}-${{ matrix.arch }} diff --git a/.gitignore b/.gitignore index a9cc9e1..cdd1ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .direnv -jivedrop +/jivedrop testdata/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..482acf5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,67 @@ +version: "2" +linters: + enable: + - bodyclose + - copyloopvar + - dupword + - errorlint + - gocritic + - gosec + - misspell + - modernize + - noctx + - revive + - staticcheck + - unconvert + - unparam + - usestdlibvars + - wastedassign + settings: + gosec: + excludes: + - G306 + govet: + enable-all: true + disable: + - fieldalignment + - shadow + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-naming + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofumpt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4a73cfb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +Read AGENTS.md diff --git a/cmd/jivedrop/main.go b/cmd/jivedrop/main.go index c448ef2..1fe6c03 100644 --- a/cmd/jivedrop/main.go +++ b/cmd/jivedrop/main.go @@ -77,7 +77,7 @@ func detectMode() WorkflowMode { func validateHugoMode() error { // In Hugo mode, episode markdown is required if CLI.EpisodeMD == "" { - return fmt.Errorf("Hugo mode requires episode markdown file as second argument") + return fmt.Errorf("hugo mode requires episode markdown file as second argument") } if !strings.HasSuffix(strings.ToLower(CLI.EpisodeMD), ".md") { @@ -184,7 +184,7 @@ func resolveOutputPath(mode WorkflowMode, num, artist string) (string, error) { func promptAndUpdateFrontmatter(markdownPath, promptMsg, duration string, bytes int64) { fmt.Print(promptMsg) var response string - fmt.Scanln(&response) + _, _ = fmt.Scanln(&response) if strings.ToLower(strings.TrimSpace(response)) == "y" { if err := encoder.UpdateFrontmatter(markdownPath, duration, bytes); err != nil { @@ -198,6 +198,10 @@ func promptAndUpdateFrontmatter(markdownPath, promptMsg, duration string, bytes } func main() { + os.Exit(run()) +} + +func run() int { ctx := kong.Parse(&CLI, kong.Name("jivedrop"), kong.Description("Drop the mix, ship the show—metadata, cover art, and all."), @@ -209,13 +213,13 @@ func main() { // Handle version flag if CLI.Version { cli.PrintVersion(version) - os.Exit(0) + return 0 } // If no audio file provided, show help if CLI.AudioFile == "" { _ = ctx.PrintUsage(false) - os.Exit(0) + return 0 } // Detect workflow mode and validate arguments @@ -225,12 +229,12 @@ func main() { if mode == HugoMode { if err := validateHugoMode(); err != nil { cli.PrintError(err.Error()) - os.Exit(1) + return 1 } } else { if err := validateStandaloneMode(); err != nil { cli.PrintError(err.Error()) - os.Exit(1) + return 1 } } @@ -240,7 +244,7 @@ func main() { if _, err := os.Stat(CLI.AudioFile); os.IsNotExist(err) { cli.PrintError(fmt.Sprintf("Audio file not found: %s", CLI.AudioFile)) cli.PrintInfo("Make sure the audio file exists.") - os.Exit(1) + return 1 } // Validate episode markdown file exists (Hugo mode) @@ -248,7 +252,7 @@ func main() { if _, err := os.Stat(CLI.EpisodeMD); os.IsNotExist(err) { cli.PrintError(fmt.Sprintf("Episode file not found: %s", CLI.EpisodeMD)) cli.PrintInfo("Make sure the episode markdown file exists.") - os.Exit(1) + return 1 } } @@ -257,7 +261,7 @@ func main() { if _, err := os.Stat(CLI.Cover); os.IsNotExist(err) { cli.PrintError(fmt.Sprintf("Cover art not found: %s", CLI.Cover)) cli.PrintInfo("Make sure the cover art file exists.") - os.Exit(1) + return 1 } } @@ -271,7 +275,7 @@ func main() { hugoMetadata, err = encoder.ParseEpisodeMetadata(CLI.EpisodeMD) if err != nil { cli.PrintError(fmt.Sprintf("Failed to parse episode metadata: %v", err)) - os.Exit(1) + return 1 } // Apply Hugo defaults @@ -311,7 +315,7 @@ func main() { if err != nil { cli.PrintError(fmt.Sprintf("Failed to resolve cover art: %v", err)) cli.PrintInfo("Use --cover flag to specify a custom cover art path.") - os.Exit(1) + return 1 } } } else { @@ -334,7 +338,7 @@ func main() { outputPath, err := resolveOutputPath(mode, episodeNum, artist) if err != nil { cli.PrintError(fmt.Sprintf("Failed to resolve output path: %v", err)) - os.Exit(1) + return 1 } // Display encoding info @@ -358,14 +362,14 @@ func main() { }) if err != nil { cli.PrintError(fmt.Sprintf("Failed to create encoder: %v", err)) - os.Exit(1) + return 1 } defer enc.Close() // Initialize encoder if err := enc.Initialize(); err != nil { cli.PrintError(fmt.Sprintf("Failed to initialize encoder: %v", err)) - os.Exit(1) + return 1 } // Get input info @@ -402,7 +406,7 @@ func main() { finalModel, err := p.Run() if err != nil { cli.PrintError(fmt.Sprintf("UI error: %v", err)) - os.Exit(1) + return 1 } // Check for encoding errors @@ -411,7 +415,7 @@ func main() { cli.PrintError(fmt.Sprintf("Encoding failed: %v", encModel.Error())) // Clean up partial output file os.Remove(outputPath) - os.Exit(1) + return 1 } } @@ -420,7 +424,7 @@ func main() { if coverResult.err != nil { cli.PrintError(fmt.Sprintf("Failed to process cover art: %v", coverResult.err)) cli.PrintInfo(fmt.Sprintf("MP3 file created but missing cover art: %s", outputPath)) - os.Exit(1) + return 1 } // Write ID3v2 tags @@ -439,7 +443,7 @@ func main() { if err := id3.WriteTags(outputPath, tagInfo); err != nil { cli.PrintError(fmt.Sprintf("Failed to write ID3 tags: %v", err)) cli.PrintInfo(fmt.Sprintf("MP3 file created but missing metadata: %s", outputPath)) - os.Exit(1) + return 1 } cli.PrintSuccessLabel("Complete:", outputPath) @@ -449,7 +453,7 @@ func main() { stats, err := encoder.GetFileStats(outputPath, durationSecs) if err != nil { cli.PrintWarning(fmt.Sprintf("Could not extract file statistics: %v", err)) - return + return 0 } // Display podcast statistics (both modes) @@ -459,7 +463,7 @@ func main() { // Only handle frontmatter updates in Hugo mode if mode == StandaloneMode { - return + return 0 } // Hugo mode: check and update frontmatter if needed @@ -483,4 +487,6 @@ func main() { // If frontmatter is missing these fields, offer to add them promptAndUpdateFrontmatter(CLI.EpisodeMD, "\nAdd podcast_duration and podcast_bytes to frontmatter? [y/N]: ", stats.DurationString, stats.FileSizeBytes) } + + return 0 } diff --git a/cmd/jivedrop/main_test.go b/cmd/jivedrop/main_test.go index 3b1a1c6..9408130 100644 --- a/cmd/jivedrop/main_test.go +++ b/cmd/jivedrop/main_test.go @@ -455,7 +455,7 @@ func TestResolveOutputPath_FileOverwrite(t *testing.T) { existingFile := filepath.Join(tmpDir, "existing.mp3") // Create an existing file - if err := os.WriteFile(existingFile, []byte("dummy"), 0644); err != nil { + if err := os.WriteFile(existingFile, []byte("dummy"), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -466,7 +466,6 @@ func TestResolveOutputPath_FileOverwrite(t *testing.T) { // Resolve the same path again CLI.OutputPath = existingFile result, err := resolveOutputPath(HugoMode, "1", "") - if err != nil { t.Errorf("resolveOutputPath() with existing file: got unexpected error: %v", err) } @@ -492,13 +491,13 @@ func TestResolveOutputPath_GeneratedFilenameInTempDir(t *testing.T) { CLI.Artist = "Test Show" result, err := resolveOutputPath(StandaloneMode, "42", "Test Show") - if err != nil { t.Errorf("resolveOutputPath() unexpected error: %v", err) } // Verify result is in the temp directory and has correct filename - if !filepath.HasPrefix(result, tmpDir) { + rel, relErr := filepath.Rel(tmpDir, result) + if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { t.Errorf("resolveOutputPath() = %q; not in temp directory %q", result, tmpDir) } diff --git a/flake.nix b/flake.nix index 515a074..c0282c9 100644 --- a/flake.nix +++ b/flake.nix @@ -21,11 +21,16 @@ { devShells.default = pkgs.mkShell { packages = with pkgs; [ + actionlint + cosign curl ffmpeg gnugrep gcc go_1_26 + gocyclo + golangci-lint + ineffassign just lame mediainfo diff --git a/internal/cli/help.go b/internal/cli/help.go index 516445f..d4291e8 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -58,11 +58,11 @@ func StyledHelpPrinter(options kong.HelpOptions) kong.HelpPrinter { sb.WriteString("\n ") sb.WriteString(helpModeStyle.Render("Hugo mode:")) sb.WriteString("\n ") - sb.WriteString(fmt.Sprintf("%s [flags]", ctx.Model.Name)) + fmt.Fprintf(&sb, "%s [flags]", ctx.Model.Name) sb.WriteString("\n ") sb.WriteString(helpModeStyle.Render("Standalone mode:")) sb.WriteString("\n ") - sb.WriteString(fmt.Sprintf("%s --title TEXT --num NUMBER --cover PATH [flags]", ctx.Model.Name)) + fmt.Fprintf(&sb, "%s --title TEXT --num NUMBER --cover PATH [flags]", ctx.Model.Name) sb.WriteString("\n") // Arguments section @@ -127,7 +127,7 @@ func getArguments(ctx *kong.Context) []argument { var args []argument // Parse arguments from the model - for _, arg := range ctx.Model.Node.Positional { + for _, arg := range ctx.Model.Positional { name := arg.Summary() help := arg.Help args = append(args, argument{name: name, help: help}) @@ -147,12 +147,12 @@ func getFlags(ctx *kong.Context) []flag { }) // Parse flags from the model - for _, f := range ctx.Model.Node.Flags { + for _, f := range ctx.Model.Flags { if f.Name == "help" { continue // Already added } - flagStr := "" + var flagStr string if f.Short != 0 { flagStr = fmt.Sprintf("-%c, --%s", f.Short, f.Name) } else { diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go index db10f0f..bf20495 100644 --- a/internal/encoder/encoder.go +++ b/internal/encoder/encoder.go @@ -107,7 +107,7 @@ func (e *Encoder) openInput() error { } e.streamIndex = streamIdx - stream := e.ifmtCtx.Streams().Get(uintptr(e.streamIndex)) + stream := e.ifmtCtx.Streams().Get(uintptr(e.streamIndex)) //nolint:gosec // streamIndex is validated by AVFindBestStream codecPar := stream.Codecpar() // Find decoder diff --git a/internal/encoder/encoder_test.go b/internal/encoder/encoder_test.go index 0cca1db..7482bfc 100644 --- a/internal/encoder/encoder_test.go +++ b/internal/encoder/encoder_test.go @@ -133,7 +133,6 @@ func TestEncoder_InvalidInput(t *testing.T) { OutputPath: tt.outputPath, Stereo: false, }) - // Check for immediate config errors if err != nil { if !tt.wantErr { @@ -398,10 +397,7 @@ func TestEncoder_ProgressCallback(t *testing.T) { lastUpdate := progressUpdates[len(progressUpdates)-1] if lastUpdate.samplesProcessed < totalSamples { // Allow small tolerance (within 1% or 100 samples, whichever is larger) - tolerance := int64(totalSamples / 100) - if tolerance < 100 { - tolerance = 100 - } + tolerance := max(totalSamples/100, 100) if totalSamples-lastUpdate.samplesProcessed > tolerance { t.Errorf("Final update does not reach totalSamples: %d vs %d (diff: %d)", lastUpdate.samplesProcessed, totalSamples, diff --git a/internal/encoder/metadata.go b/internal/encoder/metadata.go index ded782c..e504290 100644 --- a/internal/encoder/metadata.go +++ b/internal/encoder/metadata.go @@ -74,9 +74,10 @@ func findFrontmatterBounds(lines []string) (start, end int, err error) { for i, line := range lines { if strings.TrimSpace(line) == "---" { delimiterCount++ - if delimiterCount == 1 { + switch delimiterCount { + case 1: start = i + 1 - } else if delimiterCount == 2 { + case 2: end = i return start, end, nil } @@ -213,7 +214,7 @@ func UpdateFrontmatter(markdownPath, duration string, bytes int64) error { // Write back to file output := strings.Join(lines, "\n") - if err := os.WriteFile(markdownPath, []byte(output), 0644); err != nil { + if err := os.WriteFile(markdownPath, []byte(output), 0o644); err != nil { //nolint:gosec // markdownPath is user-provided input path, not tainted return fmt.Errorf("failed to write file: %w", err) } diff --git a/internal/encoder/metadata_test.go b/internal/encoder/metadata_test.go index e338422..d046470 100644 --- a/internal/encoder/metadata_test.go +++ b/internal/encoder/metadata_test.go @@ -98,7 +98,7 @@ No closing delimiter // Create temporary test file tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -145,7 +145,7 @@ Episode content. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -187,7 +187,7 @@ More content. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -267,7 +267,7 @@ Episode content. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -317,7 +317,7 @@ Episode content. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -362,7 +362,7 @@ Episode content. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -439,7 +439,7 @@ Some content without proper closing t.Run(tt.name, func(t *testing.T) { tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(tt.content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(tt.content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -468,7 +468,7 @@ Episode content. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -510,7 +510,7 @@ Episode content. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -547,7 +547,7 @@ episode_image: "/img/test.png" tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -591,7 +591,7 @@ Episode content. tmpDir := t.TempDir() tmpFile := filepath.Join(tmpDir, "test.md") - if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -628,12 +628,12 @@ func TestResolveCoverArtPath_RelativePath(t *testing.T) { // Create cover art file imagesDir := filepath.Join(tmpDir, "images") - if err := os.MkdirAll(imagesDir, 0755); err != nil { + if err := os.MkdirAll(imagesDir, 0o755); err != nil { t.Fatalf("Failed to create images directory: %v", err) } coverPath := filepath.Join(imagesDir, "cover.png") - if err := os.WriteFile(coverPath, []byte("fake png"), 0644); err != nil { + if err := os.WriteFile(coverPath, []byte("fake png"), 0o644); err != nil { t.Fatalf("Failed to create cover art file: %v", err) } @@ -645,7 +645,7 @@ title: "Test" episode_image: "./images/cover.png" --- ` - if err := os.WriteFile(markdownPath, []byte(markdownContent), 0644); err != nil { + if err := os.WriteFile(markdownPath, []byte(markdownContent), 0o644); err != nil { t.Fatalf("Failed to create markdown file: %v", err) } @@ -670,13 +670,13 @@ func TestResolveCoverArtPath_RelativePathSubdirectory(t *testing.T) { // tmpDir/content/episodes/cover.png nestedDir := filepath.Join(tmpDir, "content", "episodes") - if err := os.MkdirAll(nestedDir, 0755); err != nil { + if err := os.MkdirAll(nestedDir, 0o755); err != nil { t.Fatalf("Failed to create nested directory: %v", err) } // Create cover art file in same directory as markdown coverPath := filepath.Join(nestedDir, "cover.png") - if err := os.WriteFile(coverPath, []byte("fake png"), 0644); err != nil { + if err := os.WriteFile(coverPath, []byte("fake png"), 0o644); err != nil { t.Fatalf("Failed to create cover art file: %v", err) } @@ -688,7 +688,7 @@ title: "Test" episode_image: "./cover.png" --- ` - if err := os.WriteFile(markdownPath, []byte(markdownContent), 0644); err != nil { + if err := os.WriteFile(markdownPath, []byte(markdownContent), 0o644); err != nil { t.Fatalf("Failed to create markdown file: %v", err) } @@ -712,19 +712,19 @@ func TestResolveCoverArtPath_AbsolutePath(t *testing.T) { // Create Hugo static directory structure staticDir := filepath.Join(tmpDir, "static", "img") - if err := os.MkdirAll(staticDir, 0755); err != nil { + if err := os.MkdirAll(staticDir, 0o755); err != nil { t.Fatalf("Failed to create static directory: %v", err) } // Create cover art file coverPath := filepath.Join(staticDir, "cover.png") - if err := os.WriteFile(coverPath, []byte("fake png"), 0644); err != nil { + if err := os.WriteFile(coverPath, []byte("fake png"), 0o644); err != nil { t.Fatalf("Failed to create cover art file: %v", err) } // Create episode markdown file in nested content directory contentDir := filepath.Join(tmpDir, "content", "episodes") - if err := os.MkdirAll(contentDir, 0755); err != nil { + if err := os.MkdirAll(contentDir, 0o755); err != nil { t.Fatalf("Failed to create content directory: %v", err) } @@ -735,7 +735,7 @@ title: "Test" episode_image: "/img/cover.png" --- ` - if err := os.WriteFile(markdownPath, []byte(markdownContent), 0644); err != nil { + if err := os.WriteFile(markdownPath, []byte(markdownContent), 0o644); err != nil { t.Fatalf("Failed to create markdown file: %v", err) } @@ -759,17 +759,17 @@ func TestResolveCoverArtPath_AbsolutePathNested(t *testing.T) { // tmpDir/static/media/podcasts/cover.png // tmpDir/content/blog/post.md staticDir := filepath.Join(tmpDir, "static", "media", "podcasts") - if err := os.MkdirAll(staticDir, 0755); err != nil { + if err := os.MkdirAll(staticDir, 0o755); err != nil { t.Fatalf("Failed to create static directory: %v", err) } coverPath := filepath.Join(staticDir, "cover.png") - if err := os.WriteFile(coverPath, []byte("fake png"), 0644); err != nil { + if err := os.WriteFile(coverPath, []byte("fake png"), 0o644); err != nil { t.Fatalf("Failed to create cover art file: %v", err) } contentDir := filepath.Join(tmpDir, "content", "blog") - if err := os.MkdirAll(contentDir, 0755); err != nil { + if err := os.MkdirAll(contentDir, 0o755); err != nil { t.Fatalf("Failed to create content directory: %v", err) } @@ -780,7 +780,7 @@ title: "Test" episode_image: "/media/podcasts/cover.png" --- ` - if err := os.WriteFile(markdownPath, []byte(markdownContent), 0644); err != nil { + if err := os.WriteFile(markdownPath, []byte(markdownContent), 0o644); err != nil { t.Fatalf("Failed to create markdown file: %v", err) } @@ -807,7 +807,7 @@ title: "Test" episode_image: "./missing.png" --- ` - if err := os.WriteFile(markdownPath, []byte(markdownContent), 0644); err != nil { + if err := os.WriteFile(markdownPath, []byte(markdownContent), 0o644); err != nil { t.Fatalf("Failed to create markdown file: %v", err) } @@ -827,7 +827,7 @@ func TestResolveCoverArtPath_FileNotFound_Absolute(t *testing.T) { // Create Hugo structure without the cover file staticDir := filepath.Join(tmpDir, "static") - if err := os.MkdirAll(staticDir, 0755); err != nil { + if err := os.MkdirAll(staticDir, 0o755); err != nil { t.Fatalf("Failed to create static directory: %v", err) } @@ -838,7 +838,7 @@ title: "Test" episode_image: "/img/missing.png" --- ` - if err := os.WriteFile(markdownPath, []byte(markdownContent), 0644); err != nil { + if err := os.WriteFile(markdownPath, []byte(markdownContent), 0o644); err != nil { t.Fatalf("Failed to create markdown file: %v", err) } @@ -864,7 +864,7 @@ title: "Test" episode_image: "/img/cover.png" --- ` - if err := os.WriteFile(markdownPath, []byte(markdownContent), 0644); err != nil { + if err := os.WriteFile(markdownPath, []byte(markdownContent), 0o644); err != nil { t.Fatalf("Failed to create markdown file: %v", err) } diff --git a/internal/encoder/stats_test.go b/internal/encoder/stats_test.go index 46792e7..6f08093 100644 --- a/internal/encoder/stats_test.go +++ b/internal/encoder/stats_test.go @@ -45,10 +45,6 @@ func TestGetFileStats(t *testing.T) { t.Fatalf("GetFileStats() error = %v", err) } - if stats == nil { - t.Fatal("GetFileStats() returned nil stats") - } - // Verify duration format (should be HH:MM:SS) if len(stats.DurationString) != 8 { t.Errorf("Duration format incorrect: got %s, want HH:MM:SS format", stats.DurationString) diff --git a/internal/id3/artwork.go b/internal/id3/artwork.go index 5564980..533eb5e 100644 --- a/internal/id3/artwork.go +++ b/internal/id3/artwork.go @@ -62,7 +62,7 @@ func ScaleCoverArt(inputPath string) ([]byte, error) { // If no scaling needed and format is PNG, return original file bytes if !needsScaling && format == "png" { cli.PrintCover(fmt.Sprintf("%dx%d %s (no scaling needed)", width, height, format)) - + // Re-open and read entire original PNG file file.Close() file, err = os.Open(inputPath) @@ -70,12 +70,12 @@ func ScaleCoverArt(inputPath string) ([]byte, error) { return nil, fmt.Errorf("failed to re-open cover art: %w", err) } defer file.Close() - + var buf bytes.Buffer if _, err := buf.ReadFrom(file); err != nil { return nil, fmt.Errorf("failed to read cover art: %w", err) } - + return buf.Bytes(), nil } diff --git a/internal/id3/artwork_test.go b/internal/id3/artwork_test.go index 851b8e9..d8e104b 100644 --- a/internal/id3/artwork_test.go +++ b/internal/id3/artwork_test.go @@ -13,9 +13,9 @@ import ( // TestScaleCoverArt_ValidSquareImage tests scaling of valid square images func TestScaleCoverArt_ValidSquareImage(t *testing.T) { tests := []struct { - name string - size int - expectSize int + name string + size int + expectSize int shouldScale bool }{ { @@ -191,7 +191,7 @@ func TestScaleCoverArt_CorruptFile(t *testing.T) { // Write corrupt PNG data corruptData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic but incomplete - if err := os.WriteFile(corruptPath, corruptData, 0644); err != nil { + if err := os.WriteFile(corruptPath, corruptData, 0o644); err != nil { t.Fatalf("Failed to create corrupt file: %v", err) } @@ -211,7 +211,7 @@ func TestScaleCoverArt_TextFile(t *testing.T) { textPath := filepath.Join(tmpDir, "notanimage.txt") // Write text file - if err := os.WriteFile(textPath, []byte("This is not an image"), 0644); err != nil { + if err := os.WriteFile(textPath, []byte("This is not an image"), 0o644); err != nil { t.Fatalf("Failed to create text file: %v", err) } @@ -485,12 +485,12 @@ func createTestPNG(path string, width, height int) error { img := image.NewRGBA(image.Rect(0, 0, width, height)) // Fill with a gradient pattern for visual distinctiveness - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { + for y := range height { + for x := range width { // Create a simple gradient pattern - r := uint8((x * 255) / width) - g := uint8((y * 255) / height) - b := uint8(((x + y) * 255) / (width + height)) + r := uint8((x * 255) / width) //nolint:gosec // test code, values bounded by image dimensions + g := uint8((y * 255) / height) //nolint:gosec // test code, values bounded by image dimensions + b := uint8(((x + y) * 255) / (width + height)) //nolint:gosec // test code, values bounded by image dimensions img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: 255}) } } diff --git a/internal/id3/writer_test.go b/internal/id3/writer_test.go index eb8a2fc..2be1ef4 100644 --- a/internal/id3/writer_test.go +++ b/internal/id3/writer_test.go @@ -20,7 +20,7 @@ func TestWriteTags(t *testing.T) { if err != nil { t.Skipf("Test MP3 not found at %s: %v", testMP3, err) } - if err := os.WriteFile(mp3Path, input, 0644); err != nil { + if err := os.WriteFile(mp3Path, input, 0o644); err != nil { //nolint:gosec // test file path from t.TempDir t.Fatalf("Failed to create test MP3: %v", err) } @@ -101,7 +101,7 @@ func TestWriteTags_WithDate(t *testing.T) { if err != nil { t.Skipf("Test MP3 not found: %v", err) } - if err := os.WriteFile(mp3Path, input, 0644); err != nil { + if err := os.WriteFile(mp3Path, input, 0o644); err != nil { //nolint:gosec // test file path from t.TempDir t.Fatalf("Failed to create test MP3: %v", err) } @@ -139,7 +139,7 @@ func TestWriteTags_WithCoverArt(t *testing.T) { if err != nil { t.Skipf("Test MP3 not found at %s: %v", testMP3, err) } - if err := os.WriteFile(mp3Path, input, 0644); err != nil { + if err := os.WriteFile(mp3Path, input, 0o644); err != nil { //nolint:gosec // test file path from t.TempDir t.Fatalf("Failed to create test MP3: %v", err) } @@ -222,7 +222,7 @@ func TestWriteTags_WithCoverArt_InvalidPath(t *testing.T) { if err != nil { t.Skipf("Test MP3 not found at %s: %v", testMP3, err) } - if err := os.WriteFile(mp3Path, input, 0644); err != nil { + if err := os.WriteFile(mp3Path, input, 0o644); err != nil { //nolint:gosec // test file path from t.TempDir t.Fatalf("Failed to create test MP3: %v", err) } @@ -251,7 +251,7 @@ func TestWriteTags_WithCoverArt_AllMetadata(t *testing.T) { if err != nil { t.Skipf("Test MP3 not found: %v", err) } - if err := os.WriteFile(mp3Path, input, 0644); err != nil { + if err := os.WriteFile(mp3Path, input, 0o644); err != nil { //nolint:gosec // test file path from t.TempDir t.Fatalf("Failed to create test MP3: %v", err) } @@ -361,7 +361,7 @@ func TestWriteTags_CoverArt_NoOtherMetadata(t *testing.T) { if err != nil { t.Skipf("Test MP3 not found: %v", err) } - if err := os.WriteFile(mp3Path, input, 0644); err != nil { + if err := os.WriteFile(mp3Path, input, 0o644); err != nil { //nolint:gosec // test file path from t.TempDir t.Fatalf("Failed to create test MP3: %v", err) } diff --git a/internal/ui/views.go b/internal/ui/views.go index 9f56f75..386da52 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -18,7 +18,7 @@ func progressView(m *EncodeModel) string { // Progress bar using bubbles/progress with gradient progress := m.calculateProgress() / 100.0 // Convert to 0.0-1.0 range b.WriteString(m.progressBar.ViewAs(progress)) - b.WriteString(fmt.Sprintf(" %s", highlightStyle.Render(fmt.Sprintf("%3.0f%%", progress*100)))) + fmt.Fprintf(&b, " %s", highlightStyle.Render(fmt.Sprintf("%3.0f%%", progress*100))) b.WriteString("\n\n") // Time and speed info @@ -51,14 +51,14 @@ func progressView(m *EncodeModel) string { m.outputBitrate, ) - b.WriteString(fmt.Sprintf("%s %s\n", + fmt.Fprintf(&b, "%s %s\n", keyStyle.Render("Input:"), valueStyle.Render(inputSpec), - )) - b.WriteString(fmt.Sprintf("%s %s\n", + ) + fmt.Fprintf(&b, "%s %s\n", keyStyle.Render("Output:"), valueStyle.Render(outputSpec), - )) + ) return boxStyle.Render(b.String()) } diff --git a/justfile b/justfile index a906167..5490465 100644 --- a/justfile +++ b/justfile @@ -114,6 +114,14 @@ vhs: build test-encoder: build @echo n | ./jivedrop testdata/LMP67.flac testdata/67.md --output-path testdata/ +# Run linters +lint: + @go vet ./... + @gocyclo -top 20 -avg -ignore '_test\.go$' . + @ineffassign ./... + @golangci-lint run + @actionlint + # Run tests test: go test ./...